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>

View 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>
)
}

View file

@ -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} />
}

View file

@ -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">

View file

@ -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>
);
}

View file

@ -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"