feat: enhance tickets portal and admin flows

This commit is contained in:
Esdras Renan 2025-10-07 02:26:09 -03:00
parent 9cdd8763b4
commit c15f0a5b09
67 changed files with 1101 additions and 338 deletions

View file

@ -1,12 +1,11 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
import { format, formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
import { IconClock, IconPlayerPause, IconPlayerPlay } from "@tabler/icons-react"
import { IconClock, IconFileDownload, IconPlayerPause, IconPlayerPlay } from "@tabler/icons-react"
import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner"
// @ts-expect-error Convex generates JS module without TS definitions
import { api } from "@/convex/_generated/api"
import { useAuth } from "@/lib/auth-client"
@ -20,6 +19,9 @@ import { StatusSelect } from "@/components/tickets/status-select"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Textarea } from "@/components/ui/textarea"
import { Spinner } from "@/components/ui/spinner"
import { useTicketCategories } from "@/hooks/use-ticket-categories"
import { useDefaultQueues } from "@/hooks/use-default-queues"
@ -44,6 +46,11 @@ const subtleBadgeClass =
const EMPTY_CATEGORY_VALUE = "__none__"
const EMPTY_SUBCATEGORY_VALUE = "__none__"
const PAUSE_REASONS = [
{ value: "NO_CONTACT", label: "Falta de contato" },
{ value: "WAITING_THIRD_PARTY", label: "Aguardando terceiro" },
{ value: "IN_PROCEDURE", label: "Em procedimento" },
]
function formatDuration(durationMs: number) {
if (durationMs <= 0) return "0s"
@ -104,6 +111,11 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
}
)
const [saving, setSaving] = useState(false)
const [pauseDialogOpen, setPauseDialogOpen] = useState(false)
const [pauseReason, setPauseReason] = useState<string>(PAUSE_REASONS[0]?.value ?? "NO_CONTACT")
const [pauseNote, setPauseNote] = useState("")
const [pausing, setPausing] = useState(false)
const [exportingPdf, setExportingPdf] = useState(false)
const selectedCategoryId = categorySelection.categoryId
const selectedSubcategoryId = categorySelection.subcategoryId
const dirty = useMemo(
@ -272,6 +284,14 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
return () => clearInterval(interval)
}, [workSummary?.activeSession])
useEffect(() => {
if (!pauseDialogOpen) {
setPauseReason(PAUSE_REASONS[0]?.value ?? "NO_CONTACT")
setPauseNote("")
setPausing(false)
}
}, [pauseDialogOpen])
const currentSessionMs = workSummary?.activeSession ? Math.max(0, now - workSummary.activeSession.startedAt) : 0
const totalWorkedMs = workSummary ? workSummary.totalWorkedMs + currentSessionMs : 0
@ -281,6 +301,74 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
[ticket.updatedAt]
)
const handleStartWork = async () => {
if (!convexUserId) return
toast.dismiss("work")
toast.loading("Iniciando atendimento...", { id: "work" })
try {
const result = await startWork({ ticketId: ticket.id as Id<"tickets">, actorId: convexUserId as Id<"users"> })
if (result?.status === "already_started") {
toast.info("O atendimento já estava em andamento", { id: "work" })
} else {
toast.success("Atendimento iniciado", { id: "work" })
}
} catch {
toast.error("Não foi possível atualizar o atendimento", { id: "work" })
}
}
const handlePauseConfirm = async () => {
if (!convexUserId) return
toast.dismiss("work")
toast.loading("Pausando atendimento...", { id: "work" })
setPausing(true)
try {
const result = await pauseWork({
ticketId: ticket.id as Id<"tickets">,
actorId: convexUserId as Id<"users">,
reason: pauseReason,
note: pauseNote.trim() ? pauseNote.trim() : undefined,
})
if (result?.status === "already_paused") {
toast.info("O atendimento já estava pausado", { id: "work" })
} else {
toast.success("Atendimento pausado", { id: "work" })
}
setPauseDialogOpen(false)
} catch {
toast.error("Não foi possível atualizar o atendimento", { id: "work" })
} finally {
setPausing(false)
}
}
const handleExportPdf = useCallback(async () => {
try {
setExportingPdf(true)
toast.dismiss("ticket-export")
toast.loading("Gerando PDF...", { id: "ticket-export" })
const response = await fetch(`/api/tickets/${ticket.id}/export/pdf`)
if (!response.ok) {
throw new Error(`failed: ${response.status}`)
}
const blob = await response.blob()
const url = URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = url
link.download = `ticket-${ticket.reference}.pdf`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
toast.success("PDF exportado com sucesso!", { id: "ticket-export" })
} catch (error) {
console.error(error)
toast.error("Não foi possível exportar o PDF.", { id: "ticket-export" })
} finally {
setExportingPdf(false)
}
}, [ticket.id, ticket.reference])
return (
<div className={cardClass}>
<div className="absolute right-6 top-6 flex items-center gap-3">
@ -294,6 +382,16 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
Editar
</Button>
) : null}
<Button
size="sm"
variant="outline"
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm font-semibold text-neutral-800 hover:bg-slate-50"
onClick={handleExportPdf}
disabled={exportingPdf}
>
{exportingPdf ? <Spinner className="size-3 text-neutral-700" /> : <IconFileDownload className="size-4" />}
Exportar PDF
</Button>
<DeleteTicketDialog ticketId={ticket.id as Id<"tickets">} />
</div>
<div className="flex flex-wrap items-start justify-between gap-3">
@ -305,28 +403,12 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
<Button
size="sm"
className={isPlaying ? pauseButtonClass : startButtonClass}
onClick={async () => {
onClick={() => {
if (!convexUserId) return
toast.dismiss("work")
toast.loading(isPlaying ? "Pausando atendimento..." : "Iniciando atendimento...", { id: "work" })
try {
if (isPlaying) {
const result = await pauseWork({ ticketId: ticket.id as Id<"tickets">, actorId: convexUserId as Id<"users"> })
if (result?.status === "already_paused") {
toast.info("O atendimento já estava pausado", { id: "work" })
} else {
toast.success("Atendimento pausado", { id: "work" })
}
} else {
const result = await startWork({ ticketId: ticket.id as Id<"tickets">, actorId: convexUserId as Id<"users"> })
if (result?.status === "already_started") {
toast.info("O atendimento já estava em andamento", { id: "work" })
} else {
toast.success("Atendimento iniciado", { id: "work" })
}
}
} catch {
toast.error("Não foi possível atualizar o atendimento", { id: "work" })
if (isPlaying) {
setPauseDialogOpen(true)
} else {
void handleStartWork()
}
}}
>
@ -539,6 +621,53 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
</div>
) : null}
</div>
<Dialog open={pauseDialogOpen} onOpenChange={setPauseDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Registrar pausa</DialogTitle>
<DialogDescription>Informe o motivo da pausa para registrar no histórico do chamado.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Motivo</span>
<Select value={pauseReason} onValueChange={setPauseReason}>
<SelectTrigger className={selectTriggerClass}>
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
{PAUSE_REASONS.map((reason) => (
<SelectItem key={reason.value} value={reason.value}>
{reason.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<span className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Observações</span>
<Textarea
value={pauseNote}
onChange={(event) => setPauseNote(event.target.value)}
rows={3}
placeholder="Adicione detalhes opcionais (visível apenas internamente)."
className="min-h-[96px]"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setPauseDialogOpen(false)} disabled={pausing}>
Cancelar
</Button>
<Button
className={pauseButtonClass}
onClick={handlePauseConfirm}
disabled={pausing || !pauseReason}
>
{pausing ? <Spinner className="size-4 text-white" /> : "Registrar pausa"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}