chore: sync staging

This commit is contained in:
Esdras Renan 2025-11-10 01:57:45 -03:00
parent c5ddd54a3e
commit 561b19cf66
610 changed files with 105285 additions and 1206 deletions

View file

@ -1,6 +1,7 @@
"use client"
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"
import { CheckCircle2, Eraser } from "lucide-react"
import { useMutation, useQuery } from "convex/react"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
@ -15,6 +16,7 @@ import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import type { TicketStatus } from "@/lib/schemas/ticket"
import { cn } from "@/lib/utils"
type ClosingTemplate = { id: string; title: string; body: string }
@ -83,6 +85,30 @@ const DEFAULT_CLOSING_TEMPLATES: ClosingTemplate[] = [
},
]
const WIZARD_STEPS = [
{ key: "message", title: "Mensagem", description: "Personalize o texto enviado ao cliente." },
{ key: "time", title: "Tempo", description: "Revise e ajuste o esforço registrado." },
{ key: "confirm", title: "Confirmações", description: "Vincule tickets e defina reabertura." },
] as const
type WizardStepKey = (typeof WIZARD_STEPS)[number]["key"]
const DRAFT_STORAGE_PREFIX = "close-ticket-draft:"
type CloseTicketDraft = {
selectedTemplateId: string | null
message: string
shouldAdjustTime: boolean
internalHours: string
internalMinutes: string
externalHours: string
externalMinutes: string
adjustReason: string
linkedReference: string
reopenWindowDays: string
step: number
}
function applyTemplatePlaceholders(html: string, customerName?: string | null, agentName?: string | null) {
const normalizedCustomer = customerName?.trim()
const customerFallback = normalizedCustomer && normalizedCustomer.length > 0 ? normalizedCustomer : "cliente"
@ -196,6 +222,11 @@ export function CloseTicketDialog({
const linkedReferenceInputRef = useRef<HTMLInputElement | null>(null)
const suggestionHideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [reopenWindowDays, setReopenWindowDays] = useState<string>("14")
const [currentStep, setCurrentStep] = useState(0)
const [draftLoaded, setDraftLoaded] = useState(false)
const [hasStoredDraft, setHasStoredDraft] = useState(false)
const draftStorageKey = useMemo(() => `${DRAFT_STORAGE_PREFIX}${ticketId}`, [ticketId])
const digitsOnlyReference = linkedReference.replace(/[^0-9]/g, "").trim()
@ -223,8 +254,7 @@ export function CloseTicketDialog({
return stripLeadingEmptyParagraphs(sanitizeEditorHtml(withPlaceholders))
}, [requesterName, agentName])
useEffect(() => {
if (open) return
const resetFormState = useCallback(() => {
setSelectedTemplateId(null)
setMessage("")
setIsSubmitting(false)
@ -238,7 +268,99 @@ export function CloseTicketDialog({
setLinkedTicketSelection(null)
setLinkSuggestions([])
setShowLinkSuggestions(false)
}, [open])
setReopenWindowDays("14")
setCurrentStep(0)
}, [])
const loadDraft = useCallback(() => {
if (typeof window === "undefined") {
setHasStoredDraft(false)
return
}
const stored = window.localStorage.getItem(draftStorageKey)
if (!stored) {
setHasStoredDraft(false)
setCurrentStep(0)
resetFormState()
return
}
try {
const parsed = JSON.parse(stored) as CloseTicketDraft
setSelectedTemplateId(parsed.selectedTemplateId ?? null)
setMessage(parsed.message ?? "")
setShouldAdjustTime(Boolean(parsed.shouldAdjustTime))
setInternalHours(parsed.internalHours ?? "0")
setInternalMinutes(parsed.internalMinutes ?? "0")
setExternalHours(parsed.externalHours ?? "0")
setExternalMinutes(parsed.externalMinutes ?? "0")
setAdjustReason(parsed.adjustReason ?? "")
setLinkedReference(parsed.linkedReference ?? "")
setLinkedTicketSelection(null)
setLinkSuggestions([])
setShowLinkSuggestions(false)
setReopenWindowDays(parsed.reopenWindowDays ?? "14")
setCurrentStep(Math.min(parsed.step ?? 0, WIZARD_STEPS.length - 1))
setHasStoredDraft(true)
} catch (error) {
console.error("Erro ao carregar rascunho de encerramento", error)
window.localStorage.removeItem(draftStorageKey)
setHasStoredDraft(false)
resetFormState()
}
}, [draftStorageKey, resetFormState])
const handleSaveDraft = useCallback(() => {
if (typeof window === "undefined") return
const payload: CloseTicketDraft = {
selectedTemplateId,
message,
shouldAdjustTime,
internalHours,
internalMinutes,
externalHours,
externalMinutes,
adjustReason,
linkedReference,
reopenWindowDays,
step: currentStep,
}
window.localStorage.setItem(draftStorageKey, JSON.stringify(payload))
setHasStoredDraft(true)
toast.success("Rascunho salvo.")
}, [
adjustReason,
currentStep,
draftStorageKey,
externalHours,
externalMinutes,
internalHours,
internalMinutes,
linkedReference,
message,
reopenWindowDays,
selectedTemplateId,
shouldAdjustTime,
])
const handleDiscardDraft = useCallback(() => {
if (typeof window !== "undefined") {
window.localStorage.removeItem(draftStorageKey)
}
setHasStoredDraft(false)
resetFormState()
toast.success("Rascunho descartado.")
}, [draftStorageKey, resetFormState])
useEffect(() => {
if (open && !draftLoaded) {
loadDraft()
setDraftLoaded(true)
}
if (!open && draftLoaded) {
setDraftLoaded(false)
resetFormState()
}
}, [open, draftLoaded, loadDraft, resetFormState])
useEffect(() => {
return () => {
@ -403,6 +525,25 @@ export function CloseTicketDialog({
}
}
const hasFormChanges =
Boolean(message.trim().length) ||
Boolean(selectedTemplateId) ||
shouldAdjustTime ||
adjustReason.trim().length > 0 ||
linkedReference.trim().length > 0 ||
reopenWindowDays !== "14"
const canSaveDraft = hasFormChanges && !isSubmitting
const goToStep = (index: number) => {
if (index < 0 || index >= WIZARD_STEPS.length) return
setCurrentStep(index)
}
const goToNextStep = () => setCurrentStep((prev) => Math.min(prev + 1, WIZARD_STEPS.length - 1))
const goToPreviousStep = () => setCurrentStep((prev) => Math.max(prev - 1, 0))
const isLastStep = currentStep === WIZARD_STEPS.length - 1
const handleSubmit = async () => {
if (!actorId) {
toast.error("É necessário estar autenticado para encerrar o ticket.")
@ -502,6 +643,10 @@ export function CloseTicketDialog({
})
}
toast.success("Ticket encerrado com sucesso!", { id: "close-ticket" })
if (typeof window !== "undefined") {
window.localStorage.removeItem(draftStorageKey)
}
setHasStoredDraft(false)
onOpenChange(false)
onSuccess()
} catch (error) {
@ -514,52 +659,143 @@ export function CloseTicketDialog({
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Encerrar ticket</DialogTitle>
<DialogDescription>
Confirme a mensagem de encerramento que será enviada ao cliente. Você pode personalizar o texto antes de concluir.
</DialogDescription>
</DialogHeader>
const renderStepContent = () => {
const stepKey = WIZARD_STEPS[currentStep]?.key ?? "message"
if (stepKey === "time") {
if (!enableAdjustment) {
return (
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50 px-4 py-6 text-sm text-neutral-600">
Os ajustes de tempo não estão disponíveis para o seu perfil. Apenas administradores e agentes podem alterar o tempo registrado
antes de encerrar um ticket.
</div>
)
}
return (
<div className="rounded-2xl border border-slate-200 bg-white px-4 py-5 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold text-neutral-900">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>
)
}
if (stepKey === "confirm") {
return (
<div className="space-y-4">
<div className="space-y-2">
<p className="text-sm font-medium text-neutral-800">Modelos rápidos</p>
{templatesLoading ? (
<div className="flex items-center gap-2 text-sm text-neutral-600">
<Spinner className="size-4" /> Carregando templates...
</div>
) : (
<div className="flex flex-wrap gap-2">
{templates.map((template) => (
<Button
key={template.id}
type="button"
variant={selectedTemplateId === template.id ? "default" : "outline"}
size="sm"
onClick={() => handleTemplateSelect(template)}
>
{template.title}
</Button>
))}
</div>
)}
<p className="text-xs text-neutral-500">
Use <code className="rounded bg-slate-100 px-1 py-0.5">{"{{cliente}}"}</code> dentro do template para inserir automaticamente o nome do solicitante.
</p>
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-neutral-800">Mensagem de encerramento</p>
<RichTextEditor
value={message}
onChange={setMessage}
minHeight={220}
placeholder="Escreva uma mensagem final para o cliente..."
/>
<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 className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-4">
<div className="rounded-2xl border border-slate-200 bg-white px-4 py-5 shadow-sm">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_220px]">
<div className="space-y-2">
<Label htmlFor="linked-reference" className="text-sm font-medium text-neutral-800">
@ -596,9 +832,7 @@ export function CloseTicketDialog({
onClick={() => handleSelectLinkSuggestion(suggestion)}
className="flex w-full flex-col gap-1 px-3 py-2 text-left text-sm transition hover:bg-slate-100 focus:bg-slate-100"
>
<span className="font-semibold text-neutral-900">
#{suggestion.reference}
</span>
<span className="font-semibold text-neutral-900">#{suggestion.reference}</span>
{suggestion.subject ? (
<span className="text-xs text-neutral-600">{suggestion.subject}</span>
) : null}
@ -617,10 +851,13 @@ export function CloseTicketDialog({
<Spinner className="size-3" /> Procurando ticket #{normalizedReference}...
</p>
) : linkNotFound ? (
<p className="text-xs text-red-500">Ticket não encontrado ou sem acesso permitido. Verifique o número informado.</p>
<p className="text-xs text-red-500">
Ticket não encontrado ou sem acesso permitido. Verifique o número informado.
</p>
) : linkedTicketCandidate ? (
<p className="text-xs text-emerald-600">
Será registrado vínculo com o ticket #{linkedTicketCandidate.reference} {linkedTicketCandidate.subject ?? "Sem assunto"}
Será registrado vínculo com o ticket #{linkedTicketCandidate.reference} {" "}
{linkedTicketCandidate.subject ?? "Sem assunto"}
</p>
) : null}
</div>
@ -642,157 +879,163 @@ export function CloseTicketDialog({
</div>
</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>
)
}
return (
<div className="space-y-4">
<div className="space-y-2">
<p className="text-sm font-medium text-neutral-800">Modelos rápidos</p>
{templatesLoading ? (
<div className="flex items-center gap-2 text-sm text-neutral-600">
<Spinner className="size-4" /> Carregando templates...
</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.
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancelar
</Button>
) : (
<div className="flex flex-wrap gap-2">
{templates.map((template) => (
<Button
key={template.id}
type="button"
variant={selectedTemplateId === template.id ? "default" : "outline"}
size="sm"
onClick={() => handleTemplateSelect(template)}
disabled={isSubmitting}
>
{template.title}
</Button>
))}
</div>
)}
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-neutral-500">
<span>
Use <code className="rounded bg-slate-100 px-1 py-0.5">{"{{cliente}}"}</code> dentro do template para inserir automaticamente o nome do solicitante.
</span>
<Button
type="button"
variant="outline"
size="icon"
title="Limpar mensagem"
aria-label="Limpar mensagem"
onClick={() => {
setMessage("")
setSelectedTemplateId(null)
}}
disabled={isSubmitting}
>
Limpar mensagem
</Button>
<Button type="button" onClick={handleSubmit} disabled={isSubmitting || !actorId}>
{isSubmitting ? <Spinner className="size-4 text-white" /> : "Encerrar ticket"}
<Eraser className="size-4" />
<span className="sr-only">Limpar mensagem</span>
</Button>
</div>
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-neutral-800">Mensagem de encerramento</p>
<RichTextEditor
value={message}
onChange={setMessage}
minHeight={220}
placeholder="Escreva uma mensagem final para o cliente..."
/>
<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. O comentário será público e ficará registrado no histórico do ticket.
</p>
</div>
</div>
)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Encerrar ticket</DialogTitle>
<DialogDescription>
Complete as etapas abaixo para encerrar o ticket e registrar o comunicado final.
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
<div className="flex flex-col gap-4 sm:flex-row sm:flex-wrap">
{WIZARD_STEPS.map((step, index) => {
const isActive = index === currentStep
const isCompleted = index < currentStep
const canNavigate = index <= currentStep
return (
<div
key={step.key}
className="flex min-w-[200px] flex-1 items-center gap-3"
>
<button
type="button"
onClick={() => (canNavigate && !isSubmitting ? goToStep(index) : undefined)}
disabled={!canNavigate || isSubmitting}
className={cn(
"flex size-9 items-center justify-center rounded-full border text-sm font-semibold transition",
isCompleted
? "border-emerald-500 bg-emerald-500 text-white"
: isActive
? "border-slate-900 bg-slate-900 text-white"
: "border-slate-200 bg-white text-neutral-500"
)}
>
{isCompleted ? <CheckCircle2 className="size-4" /> : index + 1}
</button>
<div>
<p className={cn("text-sm font-semibold", isActive ? "text-neutral-900" : "text-neutral-600")}>
{step.title}
</p>
<p className="text-xs text-neutral-500">{step.description}</p>
</div>
</div>
)
})}
</div>
</div>
{renderStepContent()}
</div>
<DialogFooter className="mt-4 w-full border-t border-slate-100 pt-4">
{hasStoredDraft ? (
<div className="w-full text-xs text-neutral-500">Rascunho salvo localmente.</div>
) : null}
<div className="flex w-full flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancelar
</Button>
<Button
type="button"
variant="outline"
onClick={handleSaveDraft}
disabled={!canSaveDraft}
>
Salvar rascunho
</Button>
{hasStoredDraft ? (
<Button type="button" variant="ghost" onClick={handleDiscardDraft} disabled={isSubmitting}>
Descartar rascunho
</Button>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
{currentStep > 0 ? (
<Button type="button" variant="outline" onClick={goToPreviousStep} disabled={isSubmitting}>
Voltar
</Button>
) : null}
{!isLastStep ? (
<Button type="button" onClick={goToNextStep} disabled={isSubmitting}>
Próximo passo
</Button>
) : (
<Button type="button" onClick={handleSubmit} disabled={isSubmitting || !actorId}>
{isSubmitting ? <Spinner className="size-4 text-white" /> : "Encerrar ticket"}
</Button>
)}
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>