feat: enhance tickets portal and admin flows
This commit is contained in:
parent
9cdd8763b4
commit
c15f0a5b09
67 changed files with 1101 additions and 338 deletions
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue