feat: cadastro manual de acesso remoto e ajustes de horas

This commit is contained in:
Esdras Renan 2025-10-24 23:52:58 -03:00
parent 8e3cbc7a9a
commit f3a7045691
16 changed files with 1549 additions and 207 deletions

View file

@ -1,9 +1,9 @@
"use client"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react"
import { format, formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
import { IconClock, IconDownload, IconPlayerPause, IconPlayerPlay, IconPencil } from "@tabler/icons-react"
import { IconClock, IconDownload, IconPlayerPause, IconPlayerPlay, IconPencil, IconAdjustmentsHorizontal } from "@tabler/icons-react"
import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner"
import { api } from "@/convex/_generated/api"
@ -21,6 +21,7 @@ import { CheckCircle2 } from "lucide-react"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Textarea } from "@/components/ui/textarea"
import { Spinner } from "@/components/ui/spinner"
@ -133,6 +134,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const normalizedRole = (role ?? "").toLowerCase()
const isManager = normalizedRole === "manager"
const isAdmin = normalizedRole === "admin"
const canAdjustWork = isAdmin || normalizedRole === "agent"
const sessionName = session?.user?.name?.trim()
const machineAssignedName = machineContext?.assignedUserName?.trim()
const agentName =
@ -163,6 +165,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const updateSummary = useMutation(api.tickets.updateSummary)
const startWork = useMutation(api.tickets.startWork)
const pauseWork = useMutation(api.tickets.pauseWork)
const adjustWorkSummaryMutation = useMutation(api.tickets.adjustWorkSummary)
const updateCategories = useMutation(api.tickets.updateCategories)
const agents = (useQuery(api.users.listAgents, { tenantId: ticket.tenantId }) as Doc<"users">[] | undefined) ?? []
const queuesEnabled = Boolean(isStaff && convexUserId)
@ -248,6 +251,11 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const [pausing, setPausing] = useState(false)
const [exportingPdf, setExportingPdf] = useState(false)
const [closeOpen, setCloseOpen] = useState(false)
const [adjustDialogOpen, setAdjustDialogOpen] = useState(false)
const [adjustInternalHours, setAdjustInternalHours] = useState("")
const [adjustExternalHours, setAdjustExternalHours] = useState("")
const [adjustReason, setAdjustReason] = useState("")
const [adjusting, setAdjusting] = useState(false)
const [assigneeChangeReason, setAssigneeChangeReason] = useState("")
const [assigneeReasonError, setAssigneeReasonError] = useState<string | null>(null)
const [companySelection, setCompanySelection] = useState<string>(NO_COMPANY_VALUE)
@ -644,6 +652,25 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
])
const [workSummary, setWorkSummary] = useState<WorkSummarySnapshot | null>(initialWorkSummary)
const formatHoursInput = useCallback((ms: number) => {
if (!Number.isFinite(ms) || ms <= 0) {
return "0"
}
const hours = ms / 3600000
const rounded = Math.round(hours * 100) / 100
return rounded.toString()
}, [])
const effectiveWorkSummary = workSummary ?? initialWorkSummary
useEffect(() => {
if (!adjustDialogOpen) return
const internalMs = effectiveWorkSummary?.internalWorkedMs ?? 0
const externalMs = effectiveWorkSummary?.externalWorkedMs ?? 0
setAdjustInternalHours(formatHoursInput(internalMs))
setAdjustExternalHours(formatHoursInput(externalMs))
setAdjustReason("")
}, [adjustDialogOpen, effectiveWorkSummary, formatHoursInput])
const serverOffsetRef = useRef<number>(0)
const calibrateServerOffset = useCallback(
@ -944,6 +971,116 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
}
}
const handleAdjustSubmit = useCallback(
async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!convexUserId) {
toast.error("Sessão expirada. Faça login novamente.")
return
}
const parseHours = (value: string) => {
const normalized = value.replace(",", ".").trim()
if (normalized.length === 0) return 0
const numeric = Number.parseFloat(normalized)
if (!Number.isFinite(numeric) || numeric < 0) return null
return numeric
}
const internalHoursParsed = parseHours(adjustInternalHours)
if (internalHoursParsed === null) {
toast.error("Informe um valor válido para horas internas.")
return
}
const externalHoursParsed = parseHours(adjustExternalHours)
if (externalHoursParsed === null) {
toast.error("Informe um valor válido para horas externas.")
return
}
const trimmedReason = adjustReason.trim()
if (trimmedReason.length < 5) {
toast.error("Descreva o motivo do ajuste (mínimo de 5 caracteres).")
return
}
toast.dismiss("adjust-hours")
toast.loading("Ajustando horas...", { id: "adjust-hours" })
setAdjusting(true)
try {
const targetInternalMs = Math.round(internalHoursParsed * 3600000)
const targetExternalMs = Math.round(externalHoursParsed * 3600000)
const result = (await adjustWorkSummaryMutation({
ticketId: ticket.id as Id<"tickets">,
actorId: convexUserId as Id<"users">,
internalWorkedMs: targetInternalMs,
externalWorkedMs: targetExternalMs,
reason: trimmedReason,
})) as {
ticketId: Id<"tickets">
totalWorkedMs: number
internalWorkedMs: number
externalWorkedMs: number
serverNow?: number
perAgentTotals?: Array<{
agentId: string
agentName: string | null
agentEmail: string | null
avatarUrl: string | null
totalWorkedMs: number
internalWorkedMs: number
externalWorkedMs: number
}>
}
calibrateServerOffset(result?.serverNow ?? null)
setWorkSummary((prev) => {
const base: WorkSummarySnapshot =
prev ??
({
ticketId: ticket.id as Id<"tickets">,
totalWorkedMs: 0,
internalWorkedMs: 0,
externalWorkedMs: 0,
serverNow: result?.serverNow ?? null,
activeSession: null,
perAgentTotals: [],
} satisfies WorkSummarySnapshot)
return {
...base,
totalWorkedMs: result?.totalWorkedMs ?? base.totalWorkedMs,
internalWorkedMs: result?.internalWorkedMs ?? base.internalWorkedMs,
externalWorkedMs: result?.externalWorkedMs ?? base.externalWorkedMs,
serverNow: result?.serverNow ?? base.serverNow,
perAgentTotals: result?.perAgentTotals
? result.perAgentTotals.map((item) => ({
agentId: item.agentId,
agentName: item.agentName ?? null,
agentEmail: item.agentEmail ?? null,
avatarUrl: item.avatarUrl ?? null,
totalWorkedMs: item.totalWorkedMs,
internalWorkedMs: item.internalWorkedMs,
externalWorkedMs: item.externalWorkedMs,
}))
: base.perAgentTotals,
}
})
toast.success("Horas ajustadas com sucesso.", { id: "adjust-hours" })
setAdjustDialogOpen(false)
setAdjustReason("")
} catch (error) {
const message = error instanceof Error ? error.message : "Não foi possível ajustar as horas."
toast.error(message, { id: "adjust-hours" })
} finally {
setAdjusting(false)
}
},
[
adjustInternalHours,
adjustExternalHours,
adjustReason,
adjustWorkSummaryMutation,
calibrateServerOffset,
convexUserId,
ticket.id,
],
)
const handleExportPdf = useCallback(async () => {
try {
setExportingPdf(true)
@ -999,6 +1136,17 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
</TooltipContent>
</Tooltip>
) : null}
{canAdjustWork && workSummary ? (
<Button
type="button"
variant="outline"
size="sm"
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm font-semibold text-neutral-700 hover:bg-slate-50"
onClick={() => setAdjustDialogOpen(true)}
>
<IconAdjustmentsHorizontal className="size-4" /> Ajustar horas
</Button>
) : null}
{!editing ? (
<Button
size="icon"
@ -1033,6 +1181,71 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
agentName={agentName}
onSuccess={() => setStatus("RESOLVED")}
/>
<Dialog open={adjustDialogOpen} onOpenChange={setAdjustDialogOpen}>
<DialogContent className="max-w-lg">
<form onSubmit={handleAdjustSubmit} className="space-y-6">
<DialogHeader>
<DialogTitle>Ajustar horas do chamado</DialogTitle>
<DialogDescription>
Atualize os tempos registrados e descreva o motivo do ajuste. Apenas agentes e administradores visualizam este log.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="adjust-internal">Horas internas</Label>
<Input
id="adjust-internal"
type="number"
min={0}
step={0.25}
value={adjustInternalHours}
onChange={(event) => setAdjustInternalHours(event.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="adjust-external">Horas externas</Label>
<Input
id="adjust-external"
type="number"
min={0}
step={0.25}
value={adjustExternalHours}
onChange={(event) => setAdjustExternalHours(event.target.value)}
required
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="adjust-reason">Motivo do ajuste</Label>
<Textarea
id="adjust-reason"
value={adjustReason}
onChange={(event) => setAdjustReason(event.target.value)}
placeholder="Descreva por que o tempo precisa ser ajustado..."
rows={4}
required
/>
</div>
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => {
setAdjustDialogOpen(false)
setAdjusting(false)
}}
disabled={adjusting}
>
Cancelar
</Button>
<Button type="submit" disabled={adjusting}>
{adjusting ? "Salvando..." : "Salvar ajuste"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-3">