feat: cadastro manual de acesso remoto e ajustes de horas
This commit is contained in:
parent
8e3cbc7a9a
commit
f3a7045691
16 changed files with 1549 additions and 207 deletions
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue