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
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue