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>
|
||||
|
|
|
|||
171
src/components/tickets/my-tickets-panel.tsx
Normal file
171
src/components/tickets/my-tickets-panel.tsx
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useQuery } from "convex/react"
|
||||
import { formatDistanceToNow } from "date-fns"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
import { Inbox, ChevronLeft, ChevronRight } from "lucide-react"
|
||||
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||
import { mapTicketsFromServerList } from "@/lib/mappers/ticket"
|
||||
import type { Ticket } from "@/lib/schemas/ticket"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { TicketStatusBadge } from "@/components/tickets/status-badge"
|
||||
import { TicketPriorityPill } from "@/components/tickets/priority-pill"
|
||||
|
||||
const PAGE_SIZE = 4
|
||||
|
||||
export function MyTicketsPanel() {
|
||||
const { convexUserId, isStaff, session } = useAuth()
|
||||
const tenantId = session?.user?.tenantId ?? DEFAULT_TENANT_ID
|
||||
const [page, setPage] = useState(0)
|
||||
const enabled = Boolean(convexUserId && isStaff)
|
||||
const ticketsResult = useQuery(
|
||||
api.tickets.list,
|
||||
enabled
|
||||
? {
|
||||
tenantId,
|
||||
viewerId: convexUserId as Id<"users">,
|
||||
assigneeId: convexUserId as Id<"users">,
|
||||
limit: 60,
|
||||
}
|
||||
: "skip"
|
||||
)
|
||||
const tickets = useMemo(() => {
|
||||
if (!Array.isArray(ticketsResult)) return []
|
||||
const parsed = mapTicketsFromServerList(ticketsResult as unknown[]).filter(
|
||||
(ticket) => ticket.status !== "RESOLVED"
|
||||
)
|
||||
return parsed
|
||||
}, [ticketsResult])
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(tickets.length / PAGE_SIZE))
|
||||
const currentPage = Math.min(page, totalPages - 1)
|
||||
const paginated = tickets.slice(currentPage * PAGE_SIZE, currentPage * PAGE_SIZE + PAGE_SIZE)
|
||||
|
||||
return (
|
||||
<Card className="rounded-3xl border border-border/60 shadow-sm">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">Minhas tarefas</CardTitle>
|
||||
<CardDescription>Chamados atribuídos a você e ainda em progresso</CardDescription>
|
||||
</div>
|
||||
{convexUserId ? (
|
||||
<Link
|
||||
href={`/tickets?assignee=${String(convexUserId)}`}
|
||||
className="text-sm font-semibold text-[#006879] underline-offset-4 transition-colors hover:text-[#004d5a] hover:underline"
|
||||
>
|
||||
Ver todos
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{!enabled ? (
|
||||
<div className="flex flex-col items-center gap-3 rounded-2xl border border-dashed border-border/60 bg-muted/40 px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
<Inbox className="size-6" />
|
||||
<p>Disponível apenas para usuários internos.</p>
|
||||
</div>
|
||||
) : ticketsResult === undefined ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: PAGE_SIZE }).map((_, index) => (
|
||||
<Skeleton key={`mytickets-skeleton-${index}`} className="h-20 w-full rounded-2xl" />
|
||||
))}
|
||||
</div>
|
||||
) : paginated.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/60 bg-muted/40 px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
Nenhum ticket atribuído para hoje. Aproveite para ajudar na triagem ou revisar filas em risco.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{paginated.map((ticket) => (
|
||||
<TicketRow key={ticket.id} ticket={ticket} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
{enabled && tickets.length > PAGE_SIZE ? (
|
||||
<CardFooter className="flex items-center justify-between border-t border-border/60 px-6 py-4 text-sm text-muted-foreground">
|
||||
<span>
|
||||
Página {currentPage + 1} de {totalPages}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setPage((prev) => Math.max(0, prev - 1))}
|
||||
disabled={currentPage === 0}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setPage((prev) => Math.min(totalPages - 1, prev + 1))}
|
||||
disabled={currentPage >= totalPages - 1}
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardFooter>
|
||||
) : null}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function TicketRow({ ticket }: { ticket: Ticket }) {
|
||||
const queueLabel = ticket.queue ?? "Sem fila"
|
||||
const updatedLabel = formatDistanceToNow(ticket.updatedAt, { addSuffix: true, locale: ptBR })
|
||||
const categoryBadges = [ticket.category?.name, ticket.subcategory?.name].filter(
|
||||
(value): value is string => Boolean(value)
|
||||
)
|
||||
const badgeClass =
|
||||
"rounded-lg border border-slate-300 px-3.5 py-1.5 text-sm font-medium text-slate-600 transition-colors"
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/tickets/${ticket.id}`}
|
||||
className="group relative flex flex-col gap-3 rounded-2xl border border-border/70 bg-white/90 px-5 py-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-border hover:shadow-md"
|
||||
>
|
||||
<div className="absolute right-5 top-4 flex flex-col items-end gap-2 sm:flex-row sm:items-center">
|
||||
<TicketStatusBadge status={ticket.status} className="h-8 px-3.5 text-sm" />
|
||||
<TicketPriorityPill priority={ticket.priority} className="h-8 px-3.5 text-sm" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 pr-0">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||
#{ticket.reference} • {queueLabel}
|
||||
</span>
|
||||
<span className="line-clamp-1 pr-32 text-base font-semibold text-neutral-900">{ticket.subject}</span>
|
||||
</div>
|
||||
<p className="line-clamp-2 pr-32 text-sm text-neutral-600">{ticket.summary ?? "Sem descrição"}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-neutral-600">
|
||||
{categoryBadges.length > 0 ? (
|
||||
categoryBadges.map((label) => (
|
||||
<span key={label} className={badgeClass}>
|
||||
{label}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className={badgeClass}>Sem categoria</span>
|
||||
)}
|
||||
<span className={badgeClass}>Atualizado {updatedLabel}</span>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,12 +2,14 @@
|
|||
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
import { NewTicketDialog } from "./new-ticket-dialog"
|
||||
|
||||
export function NewTicketDialogDeferred({ triggerClassName }: { triggerClassName?: string } = {}) {
|
||||
type DeferredProps = {
|
||||
triggerClassName?: string
|
||||
triggerVariant?: "button" | "card"
|
||||
}
|
||||
|
||||
export function NewTicketDialogDeferred({ triggerClassName, triggerVariant = "button" }: DeferredProps = {}) {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -16,19 +18,15 @@ export function NewTicketDialogDeferred({ triggerClassName }: { triggerClassName
|
|||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
className={cn(
|
||||
"rounded-lg border border-black bg-black px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30",
|
||||
triggerClassName
|
||||
)}
|
||||
disabled
|
||||
aria-disabled
|
||||
>
|
||||
Novo ticket
|
||||
</Button>
|
||||
<div
|
||||
className={
|
||||
triggerVariant === "card"
|
||||
? `h-28 min-w-[220px] rounded-2xl border border-slate-900 bg-neutral-900/80 text-white shadow-sm ${triggerClassName ?? ""}`
|
||||
: `rounded-lg border border-black bg-black px-3 py-1.5 text-sm font-semibold text-white opacity-60 ${triggerClassName ?? ""}`
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <NewTicketDialog triggerClassName={triggerClassName} />
|
||||
return <NewTicketDialog triggerClassName={triggerClassName} triggerVariant={triggerVariant} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ import { normalizeCustomFieldInputs } from "@/lib/ticket-form-helpers"
|
|||
import type { TicketFormDefinition, TicketFormFieldDefinition } from "@/lib/ticket-form-types"
|
||||
import { Calendar as CalendarIcon } from "lucide-react"
|
||||
|
||||
type TriggerVariant = "button" | "card"
|
||||
|
||||
type CustomerOption = {
|
||||
id: string
|
||||
name: string
|
||||
|
|
@ -113,7 +115,13 @@ const schema = z.object({
|
|||
subcategoryId: z.string().min(1, "Selecione uma categoria secundária"),
|
||||
})
|
||||
|
||||
export function NewTicketDialog({ triggerClassName }: { triggerClassName?: string } = {}) {
|
||||
export function NewTicketDialog({
|
||||
triggerClassName,
|
||||
triggerVariant = "button",
|
||||
}: {
|
||||
triggerClassName?: string
|
||||
triggerVariant?: TriggerVariant
|
||||
} = {}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const calendarTimeZone = useLocalTimeZone()
|
||||
|
|
@ -558,18 +566,41 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
|
|||
}
|
||||
}
|
||||
|
||||
const cardTrigger = (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"rounded-2xl border border-slate-900 bg-neutral-950 px-4 py-4 text-left text-white shadow-lg shadow-black/30 transition hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40",
|
||||
"flex h-28 min-w-[220px] flex-1 flex-col justify-between",
|
||||
triggerClassName
|
||||
)}
|
||||
>
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-white/60">Atalho</span>
|
||||
<div className="space-y-1">
|
||||
<p className="text-lg font-semibold leading-tight">Novo ticket</p>
|
||||
<p className="text-xs text-white/70">Abrir chamado manualmente</p>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
|
||||
const buttonTrigger = (
|
||||
<Button
|
||||
size="sm"
|
||||
className={cn(
|
||||
"rounded-lg border border-black bg-black px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30",
|
||||
triggerClassName
|
||||
)}
|
||||
>
|
||||
Novo ticket
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
className={cn(
|
||||
"rounded-lg border border-black bg-black px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30",
|
||||
triggerClassName
|
||||
)}
|
||||
>
|
||||
Novo ticket
|
||||
</Button>
|
||||
{triggerVariant === "card"
|
||||
? cardTrigger
|
||||
: buttonTrigger}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-4xl gap-0 overflow-hidden rounded-3xl border border-slate-200 bg-white p-0 shadow-2xl lg:max-w-5xl">
|
||||
<div className="max-h-[88vh] overflow-y-auto">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery } from "convex/react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { DEFAULT_TENANT_ID } from "@/lib/constants";
|
||||
import { mapTicketWithDetailsFromServer } from "@/lib/mappers/ticket";
|
||||
|
|
@ -8,6 +11,7 @@ import type { Id } from "@/convex/_generated/dataModel";
|
|||
import type { TicketWithDetails } from "@/lib/schemas/ticket";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TicketComments } from "@/components/tickets/ticket-comments.rich";
|
||||
import { TicketDetailsPanel } from "@/components/tickets/ticket-details-panel";
|
||||
import { TicketSummaryHeader } from "@/components/tickets/ticket-summary-header";
|
||||
|
|
@ -89,8 +93,16 @@ export function TicketDetailView({ id }: { id: string }) {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handlePrioritizeClick = () => {
|
||||
if (typeof window === "undefined") return;
|
||||
const anchor = document.getElementById("ticket-summary-header");
|
||||
anchor?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 px-4 lg:px-6">
|
||||
<TicketSlaBanner ticket={ticket} onPrioritize={handlePrioritizeClick} />
|
||||
<TicketSummaryHeader ticket={ticket} />
|
||||
<TicketCsatCard ticket={ticket} />
|
||||
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
|
||||
|
|
@ -104,3 +116,51 @@ export function TicketDetailView({ id }: { id: string }) {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SLA_WARNING_THRESHOLD_MS = 1000 * 60 * 60 * 4;
|
||||
|
||||
function TicketSlaBanner({ ticket, onPrioritize }: { ticket: TicketWithDetails; onPrioritize: () => void }) {
|
||||
const [now, setNow] = useState(Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), 60000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const dueAtDate = ticket.dueAt ? new Date(ticket.dueAt) : null;
|
||||
if (!dueAtDate || ticket.status === "RESOLVED") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const diff = dueAtDate.getTime() - now;
|
||||
const isOverdue = diff <= 0;
|
||||
const isNearThreshold = diff > 0 && diff <= SLA_WARNING_THRESHOLD_MS;
|
||||
|
||||
if (!isOverdue && !isNearThreshold) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = isOverdue
|
||||
? `SLA vencido ${formatDistanceToNow(dueAtDate, { addSuffix: true, locale: ptBR })}`
|
||||
: `Faltam ${formatDistanceToNow(dueAtDate, { locale: ptBR })} para o SLA`;
|
||||
|
||||
const containerClasses = isOverdue
|
||||
? "border-rose-200 bg-rose-50 text-rose-900"
|
||||
: "border-amber-200 bg-amber-50 text-amber-900";
|
||||
|
||||
const buttonVariant = isOverdue ? "destructive" : "outline";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col gap-3 rounded-2xl border px-4 py-4 shadow-sm sm:flex-row sm:items-center sm:justify-between ${containerClasses}`}
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{isOverdue ? "SLA em atraso" : "SLA em risco"}</p>
|
||||
<p className="text-sm">{label}</p>
|
||||
</div>
|
||||
<Button variant={buttonVariant as "destructive" | "outline"} onClick={onPrioritize} className="w-full sm:w-auto">
|
||||
Priorizar atendimento
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1081,7 +1081,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
}, [reopenTicket, ticket.id, viewerId])
|
||||
|
||||
return (
|
||||
<div className={cardClass}>
|
||||
<div id="ticket-summary-header" className={cardClass}>
|
||||
<div className="absolute right-6 top-6 flex items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue