Fix Excel export XML order and gate time adjustments on close
This commit is contained in:
parent
be9816a3a8
commit
9d569d987d
4 changed files with 379 additions and 254 deletions
|
|
@ -1,9 +1,9 @@
|
|||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { format, formatDistanceToNow } from "date-fns"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
import { IconClock, IconDownload, IconPlayerPause, IconPlayerPlay, IconPencil, IconAdjustmentsHorizontal } from "@tabler/icons-react"
|
||||
import { IconClock, IconDownload, IconPlayerPause, IconPlayerPlay, IconPencil } from "@tabler/icons-react"
|
||||
import { useMutation, useQuery } from "convex/react"
|
||||
import { toast } from "sonner"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
|
|
@ -16,12 +16,11 @@ import { Separator } from "@/components/ui/separator"
|
|||
import { PrioritySelect } from "@/components/tickets/priority-select"
|
||||
import { DeleteTicketDialog } from "@/components/tickets/delete-ticket-dialog"
|
||||
import { StatusSelect } from "@/components/tickets/status-select"
|
||||
import { CloseTicketDialog } from "@/components/tickets/close-ticket-dialog"
|
||||
import { CloseTicketDialog, type AdjustWorkSummaryResult } from "@/components/tickets/close-ticket-dialog"
|
||||
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"
|
||||
|
|
@ -165,7 +164,6 @@ 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)
|
||||
|
|
@ -251,11 +249,6 @@ 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)
|
||||
|
|
@ -652,25 +645,7 @@ 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(
|
||||
|
|
@ -971,114 +946,41 @@ 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({
|
||||
const handleWorkSummaryAdjusted = useCallback(
|
||||
(result: AdjustWorkSummaryResult) => {
|
||||
calibrateServerOffset(result?.serverNow ?? null)
|
||||
setWorkSummary((prev) => {
|
||||
const fallback: WorkSummarySnapshot = {
|
||||
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
|
||||
}>
|
||||
totalWorkedMs: 0,
|
||||
internalWorkedMs: 0,
|
||||
externalWorkedMs: 0,
|
||||
serverNow: result?.serverNow ?? null,
|
||||
activeSession: null,
|
||||
perAgentTotals: [],
|
||||
}
|
||||
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)
|
||||
}
|
||||
const base = prev ?? fallback
|
||||
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,
|
||||
}
|
||||
})
|
||||
},
|
||||
[
|
||||
adjustInternalHours,
|
||||
adjustExternalHours,
|
||||
adjustReason,
|
||||
adjustWorkSummaryMutation,
|
||||
calibrateServerOffset,
|
||||
convexUserId,
|
||||
ticket.id,
|
||||
],
|
||||
[calibrateServerOffset, ticket.id],
|
||||
)
|
||||
|
||||
const handleExportPdf = useCallback(async () => {
|
||||
|
|
@ -1136,17 +1038,6 @@ 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"
|
||||
|
|
@ -1179,73 +1070,19 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
actorId={convexUserId as Id<"users"> | null}
|
||||
requesterName={ticket.requester?.name ?? ticket.requester?.email ?? null}
|
||||
agentName={agentName}
|
||||
workSummary={
|
||||
effectiveWorkSummary
|
||||
? {
|
||||
totalWorkedMs: effectiveWorkSummary.totalWorkedMs,
|
||||
internalWorkedMs: effectiveWorkSummary.internalWorkedMs,
|
||||
externalWorkedMs: effectiveWorkSummary.externalWorkedMs,
|
||||
}
|
||||
: null
|
||||
}
|
||||
canAdjustTime={canAdjustWork && Boolean(effectiveWorkSummary)}
|
||||
onWorkSummaryAdjusted={handleWorkSummaryAdjusted}
|
||||
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