Fix Excel export XML order and gate time adjustments on close

This commit is contained in:
codex-bot 2025-10-31 14:47:37 -03:00
parent be9816a3a8
commit 9d569d987d
4 changed files with 379 additions and 254 deletions

View file

@ -9,6 +9,10 @@ import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
import { RichTextEditor, sanitizeEditorHtml, stripLeadingEmptyParagraphs } from "@/components/ui/rich-text-editor"
import { toast } from "sonner"
import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
type ClosingTemplate = { id: string; title: string; body: string }
@ -17,6 +21,23 @@ const DEFAULT_COMPANY_NAME = "Rever Tecnologia"
const sanitizeTemplate = (html: string) => stripLeadingEmptyParagraphs(sanitizeEditorHtml(html.trim()))
export type AdjustWorkSummaryResult = {
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
}>
}
const DEFAULT_CLOSING_TEMPLATES: ClosingTemplate[] = [
{
id: "default-standard",
@ -63,6 +84,21 @@ function applyTemplatePlaceholders(html: string, customerName?: string | null, a
.replace(/{{\s*(empresa|company|companhia)\s*}}/gi, DEFAULT_COMPANY_NAME)
}
const splitDuration = (ms: number) => {
const safeMs = Number.isFinite(ms) && ms > 0 ? ms : 0
const totalMinutes = Math.round(safeMs / 60000)
const hours = Math.floor(totalMinutes / 60)
const minutes = totalMinutes % 60
return { hours, minutes }
}
const formatDurationLabel = (ms: number) => {
const { hours, minutes } = splitDuration(ms)
if (hours > 0 && minutes > 0) return `${hours}h ${minutes}min`
if (hours > 0) return `${hours}h`
return `${minutes}min`
}
export function CloseTicketDialog({
open,
onOpenChange,
@ -72,6 +108,9 @@ export function CloseTicketDialog({
requesterName,
agentName,
onSuccess,
workSummary,
onWorkSummaryAdjusted,
canAdjustTime = false,
}: {
open: boolean
onOpenChange: (open: boolean) => void
@ -81,9 +120,17 @@ export function CloseTicketDialog({
requesterName?: string | null
agentName?: string | null
onSuccess: () => void
workSummary?: {
totalWorkedMs: number
internalWorkedMs: number
externalWorkedMs: number
} | null
onWorkSummaryAdjusted?: (result: AdjustWorkSummaryResult) => void
canAdjustTime?: boolean
}) {
const updateStatus = useMutation(api.tickets.updateStatus)
const addComment = useMutation(api.tickets.addComment)
const adjustWorkSummary = useMutation(api.tickets.adjustWorkSummary)
const closingTemplates = useQuery(
actorId && open ? api.commentTemplates.list : "skip",
@ -101,6 +148,13 @@ export function CloseTicketDialog({
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null)
const [message, setMessage] = useState<string>("")
const [isSubmitting, setIsSubmitting] = useState(false)
const [shouldAdjustTime, setShouldAdjustTime] = useState<boolean>(false)
const [internalHours, setInternalHours] = useState<string>("0")
const [internalMinutes, setInternalMinutes] = useState<string>("0")
const [externalHours, setExternalHours] = useState<string>("0")
const [externalMinutes, setExternalMinutes] = useState<string>("0")
const [adjustReason, setAdjustReason] = useState<string>("")
const enableAdjustment = Boolean(canAdjustTime && workSummary)
const hydrateTemplateBody = useCallback((templateHtml: string) => {
const withPlaceholders = applyTemplatePlaceholders(templateHtml, requesterName, agentName)
@ -108,12 +162,20 @@ export function CloseTicketDialog({
}, [requesterName, agentName])
useEffect(() => {
if (!open) {
setSelectedTemplateId(null)
setMessage("")
setIsSubmitting(false)
return
}
if (open) return
setSelectedTemplateId(null)
setMessage("")
setIsSubmitting(false)
setShouldAdjustTime(false)
setAdjustReason("")
setInternalHours("0")
setInternalMinutes("0")
setExternalHours("0")
setExternalMinutes("0")
}, [open])
useEffect(() => {
if (!open) return
if (templates.length > 0 && !selectedTemplateId && !message) {
const first = templates[0]
const hydrated = hydrateTemplateBody(first.body)
@ -122,6 +184,28 @@ export function CloseTicketDialog({
}
}, [open, templates, selectedTemplateId, message, hydrateTemplateBody])
useEffect(() => {
if (!open || !enableAdjustment || !shouldAdjustTime) return
const internal = splitDuration(workSummary?.internalWorkedMs ?? 0)
const external = splitDuration(workSummary?.externalWorkedMs ?? 0)
setInternalHours(internal.hours.toString())
setInternalMinutes(internal.minutes.toString())
setExternalHours(external.hours.toString())
setExternalMinutes(external.minutes.toString())
}, [
open,
enableAdjustment,
shouldAdjustTime,
workSummary?.internalWorkedMs,
workSummary?.externalWorkedMs,
])
useEffect(() => {
if (!shouldAdjustTime) {
setAdjustReason("")
}
}, [shouldAdjustTime])
const handleTemplateSelect = (template: ClosingTemplate) => {
setSelectedTemplateId(template.id)
setMessage(hydrateTemplateBody(template.body))
@ -132,9 +216,65 @@ export function CloseTicketDialog({
toast.error("É necessário estar autenticado para encerrar o ticket.")
return
}
const applyAdjustment = enableAdjustment && shouldAdjustTime
let targetInternalMs = 0
let targetExternalMs = 0
let trimmedReason = ""
if (applyAdjustment) {
const parsePart = (value: string, label: string) => {
const trimmed = value.trim()
if (trimmed.length === 0) return 0
if (!/^\d+$/u.test(trimmed)) {
toast.error(`Informe um número válido para ${label}.`)
return null
}
return Number.parseInt(trimmed, 10)
}
const internalHoursValue = parsePart(internalHours, "horas internas")
if (internalHoursValue === null) return
const internalMinutesValue = parsePart(internalMinutes, "minutos internos")
if (internalMinutesValue === null) return
if (internalMinutesValue >= 60) {
toast.error("Os minutos internos devem estar entre 0 e 59.")
return
}
const externalHoursValue = parsePart(externalHours, "horas externas")
if (externalHoursValue === null) return
const externalMinutesValue = parsePart(externalMinutes, "minutos externos")
if (externalMinutesValue === null) return
if (externalMinutesValue >= 60) {
toast.error("Os minutos externos devem estar entre 0 e 59.")
return
}
targetInternalMs = (internalHoursValue * 60 + internalMinutesValue) * 60000
targetExternalMs = (externalHoursValue * 60 + externalMinutesValue) * 60000
trimmedReason = adjustReason.trim()
if (trimmedReason.length < 5) {
toast.error("Descreva o motivo do ajuste (mínimo de 5 caracteres).")
return
}
}
toast.dismiss("close-ticket")
setIsSubmitting(true)
toast.loading("Encerrando ticket...", { id: "close-ticket" })
toast.loading(applyAdjustment ? "Ajustando tempo e encerrando ticket..." : "Encerrando ticket...", { id: "close-ticket" })
try {
if (applyAdjustment) {
const result = (await adjustWorkSummary({
ticketId: ticketId as unknown as Id<"tickets">,
actorId,
internalWorkedMs: targetInternalMs,
externalWorkedMs: targetExternalMs,
reason: trimmedReason,
})) as AdjustWorkSummaryResult
onWorkSummaryAdjusted?.(result)
}
await updateStatus({ ticketId: ticketId as unknown as Id<"tickets">, status: "RESOLVED", actorId })
const withPlaceholders = applyTemplatePlaceholders(message, requesterName, agentName)
const sanitized = stripLeadingEmptyParagraphs(sanitizeEditorHtml(withPlaceholders))
@ -153,7 +293,9 @@ export function CloseTicketDialog({
onSuccess()
} catch (error) {
console.error(error)
toast.error("Não foi possível encerrar o ticket.", { id: "close-ticket" })
toast.error(applyAdjustment ? "Não foi possível ajustar o tempo ou encerrar o ticket." : "Não foi possível encerrar o ticket.", {
id: "close-ticket",
})
} finally {
setIsSubmitting(false)
}
@ -205,6 +347,129 @@ export function CloseTicketDialog({
<p className="text-xs text-neutral-500">Você pode editar o conteúdo antes de enviar. Deixe em branco para encerrar sem comentário adicional.</p>
</div>
</div>
{enableAdjustment ? (
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold text-neutral-800">Ajustar tempo antes de encerrar</p>
<p className="text-xs text-neutral-500">
Atualize o tempo registrado e informe um motivo para o ajuste. O comentário fica visível apenas para a equipe.
</p>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="toggle-time-adjustment"
checked={shouldAdjustTime}
onCheckedChange={(checked) => setShouldAdjustTime(Boolean(checked))}
disabled={isSubmitting}
/>
<Label htmlFor="toggle-time-adjustment" className="text-sm font-medium text-neutral-800">
Incluir ajuste
</Label>
</div>
</div>
{shouldAdjustTime ? (
<div className="mt-4 space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Tempo interno</p>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label htmlFor="adjust-internal-hours" className="text-xs text-neutral-600">
Horas
</Label>
<Input
id="adjust-internal-hours"
type="number"
min={0}
step={1}
inputMode="numeric"
value={internalHours}
onChange={(event) => setInternalHours(event.target.value)}
disabled={isSubmitting}
/>
</div>
<div className="space-y-1">
<Label htmlFor="adjust-internal-minutes" className="text-xs text-neutral-600">
Minutos
</Label>
<Input
id="adjust-internal-minutes"
type="number"
min={0}
max={59}
step={1}
inputMode="numeric"
value={internalMinutes}
onChange={(event) => setInternalMinutes(event.target.value)}
disabled={isSubmitting}
/>
</div>
</div>
<p className="text-xs text-neutral-500">
Atual: {formatDurationLabel(workSummary?.internalWorkedMs ?? 0)}
</p>
</div>
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Tempo externo</p>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label htmlFor="adjust-external-hours" className="text-xs text-neutral-600">
Horas
</Label>
<Input
id="adjust-external-hours"
type="number"
min={0}
step={1}
inputMode="numeric"
value={externalHours}
onChange={(event) => setExternalHours(event.target.value)}
disabled={isSubmitting}
/>
</div>
<div className="space-y-1">
<Label htmlFor="adjust-external-minutes" className="text-xs text-neutral-600">
Minutos
</Label>
<Input
id="adjust-external-minutes"
type="number"
min={0}
max={59}
step={1}
inputMode="numeric"
value={externalMinutes}
onChange={(event) => setExternalMinutes(event.target.value)}
disabled={isSubmitting}
/>
</div>
</div>
<p className="text-xs text-neutral-500">
Atual: {formatDurationLabel(workSummary?.externalWorkedMs ?? 0)}
</p>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="adjust-reason" className="text-xs text-neutral-600">
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={3}
disabled={isSubmitting}
/>
<p className="text-xs text-neutral-500">
Registre o motivo para fins de auditoria interna. Informe valores em minutos quando menor que 1 hora.
</p>
</div>
</div>
) : null}
</div>
) : null}
<DialogFooter className="flex flex-wrap justify-between gap-3 pt-4">
<div className="text-xs text-neutral-500">
O comentário será público e ficará registrado no histórico do ticket.