chore: sync staging
This commit is contained in:
parent
c5ddd54a3e
commit
561b19cf66
610 changed files with 105285 additions and 1206 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue