feat: CSV exports, PDF improvements, play internal/external with hour split, roles cleanup, admin companies with 'Cliente avulso', ticket list spacing/alignment fixes, status translations and mappings

This commit is contained in:
Esdras Renan 2025-10-07 13:42:45 -03:00
parent addd4ce6e8
commit 3bafcc5a0a
45 changed files with 1401 additions and 256 deletions

View file

@ -3,7 +3,7 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { format, formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
import { IconClock, IconFileDownload, IconPlayerPause, IconPlayerPlay } from "@tabler/icons-react"
import { IconClock, IconFileTypePdf, IconPlayerPause, IconPlayerPlay } from "@tabler/icons-react"
import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner"
import { api } from "@/convex/_generated/api"
@ -24,6 +24,12 @@ 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"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
interface TicketHeaderProps {
ticket: TicketWithDetails
@ -128,6 +134,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
return selectedCategoryId !== currentCategoryId || selectedSubcategoryId !== currentSubcategoryId
}, [selectedCategoryId, selectedSubcategoryId, currentCategoryId, currentSubcategoryId])
const currentQueueName = ticket.queue ?? ""
const isAvulso = Boolean((ticket as any).company?.isAvulso ?? false)
const [queueSelection, setQueueSelection] = useState(currentQueueName)
const queueDirty = useMemo(() => queueSelection !== currentQueueName, [queueSelection, currentQueueName])
const formDirty = dirty || categoryDirty || queueDirty
@ -263,11 +270,14 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
return {
ticketId: ticket.id as Id<"tickets">,
totalWorkedMs: ticket.workSummary.totalWorkedMs,
internalWorkedMs: ticket.workSummary.internalWorkedMs ?? 0,
externalWorkedMs: ticket.workSummary.externalWorkedMs ?? 0,
activeSession: ticket.workSummary.activeSession
? {
id: ticket.workSummary.activeSession.id as Id<"ticketWorkSessions">,
agentId: ticket.workSummary.activeSession.agentId as Id<"users">,
startedAt: ticket.workSummary.activeSession.startedAt.getTime(),
workType: (ticket.workSummary.activeSession as any).workType ?? "INTERNAL",
}
: null,
}
@ -294,6 +304,12 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const currentSessionMs = workSummary?.activeSession ? Math.max(0, now - workSummary.activeSession.startedAt) : 0
const totalWorkedMs = workSummary ? workSummary.totalWorkedMs + currentSessionMs : 0
const internalWorkedMs = workSummary
? (((workSummary as any).internalWorkedMs ?? 0) + (((workSummary?.activeSession as any)?.workType === "INTERNAL") ? currentSessionMs : 0))
: 0
const externalWorkedMs = workSummary
? (((workSummary as any).externalWorkedMs ?? 0) + (((workSummary?.activeSession as any)?.workType === "EXTERNAL") ? currentSessionMs : 0))
: 0
const formattedTotalWorked = useMemo(() => formatDuration(totalWorkedMs), [totalWorkedMs])
const updatedRelative = useMemo(
@ -301,12 +317,12 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
[ticket.updatedAt]
)
const handleStartWork = async () => {
const handleStartWork = async (workType: "INTERNAL" | "EXTERNAL") => {
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"> })
const result = await startWork({ ticketId: ticket.id as Id<"tickets">, actorId: convexUserId as Id<"users">, workType } as any)
if (result?.status === "already_started") {
toast.info("O atendimento já estava em andamento", { id: "work" })
} else {
@ -347,7 +363,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
setExportingPdf(true)
toast.dismiss("ticket-export")
toast.loading("Gerando PDF...", { id: "ticket-export" })
const response = await fetch(`/api/tickets/${ticket.id}/export/pdf`)
const response = await fetch(`/api/tickets/${ticket.id}/export/pdf`, { credentials: "include" })
if (!response.ok) {
throw new Error(`failed: ${response.status}`)
}
@ -373,9 +389,17 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
<div className={cardClass}>
<div className="absolute right-6 top-6 flex items-center gap-3">
{workSummary ? (
<Badge className="inline-flex h-9 items-center gap-2 rounded-full border border-slate-200 bg-white px-3 text-sm font-semibold text-neutral-700">
<IconClock className="size-4 text-neutral-700" /> Tempo total: {formattedTotalWorked}
</Badge>
<>
<Badge className="inline-flex h-9 items-center gap-2 rounded-full border border-slate-200 bg-white px-3 text-sm font-semibold text-neutral-700">
<IconClock className="size-4 text-neutral-700" /> Interno: {formatDuration(internalWorkedMs)}
</Badge>
<Badge className="inline-flex h-9 items-center gap-2 rounded-full border border-slate-200 bg-white px-3 text-sm font-semibold text-neutral-700">
<IconClock className="size-4 text-neutral-700" /> Externo: {formatDuration(externalWorkedMs)}
</Badge>
<Badge className="inline-flex h-9 items-center gap-2 rounded-full border border-slate-200 bg-white px-3 text-sm font-semibold text-neutral-700">
<IconClock className="size-4 text-neutral-700" /> Total: {formattedTotalWorked}
</Badge>
</>
) : null}
{!editing ? (
<Button size="sm" className={editButtonClass} onClick={() => setEditing(true)}>
@ -383,45 +407,53 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
</Button>
) : null}
<Button
size="sm"
size="icon"
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"
aria-label="Exportar PDF"
className="inline-flex items-center justify-center rounded-lg border border-slate-200 bg-white text-neutral-800 hover:bg-slate-50"
onClick={handleExportPdf}
disabled={exportingPdf}
title="Exportar PDF"
>
{exportingPdf ? <Spinner className="size-3 text-neutral-700" /> : <IconFileDownload className="size-4" />}
Exportar PDF
{exportingPdf ? <Spinner className="size-4 text-neutral-700" /> : <IconFileTypePdf className="size-5" />}
</Button>
<DeleteTicketDialog ticketId={ticket.id as Id<"tickets">} />
</div>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-3">
<Badge className={referenceBadgeClass}>#{ticket.reference}</Badge>
<PrioritySelect ticketId={ticket.id} value={ticket.priority} />
<StatusSelect ticketId={ticket.id} value={status} />
<Button
size="sm"
className={isPlaying ? pauseButtonClass : startButtonClass}
onClick={() => {
if (!convexUserId) return
if (isPlaying) {
<div className="flex flex-wrap items-center gap-3">
<Badge className={referenceBadgeClass}>#{ticket.reference}</Badge>
{isAvulso ? (
<Badge className="inline-flex h-9 items-center gap-2 rounded-full border border-rose-200 bg-rose-50 px-3 text-sm font-semibold text-rose-700">
Cliente avulso
</Badge>
) : null}
<PrioritySelect ticketId={ticket.id} value={ticket.priority} />
<StatusSelect ticketId={ticket.id} value={status} />
{isPlaying ? (
<Button
size="sm"
className={pauseButtonClass}
onClick={() => {
if (!convexUserId) return
setPauseDialogOpen(true)
} else {
void handleStartWork()
}
}}
>
{isPlaying ? (
<>
<IconPlayerPause className="size-4 text-white" /> Pausar
</>
) : (
<>
<IconPlayerPlay className="size-4 text-white" /> Iniciar
</>
)}
</Button>
}}
>
<IconPlayerPause className="size-4 text-white" /> Pausar
</Button>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" className={startButtonClass}>
<IconPlayerPlay className="size-4 text-white" /> Iniciar
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
<DropdownMenuItem onSelect={() => void handleStartWork("INTERNAL")}>Iniciar (interno)</DropdownMenuItem>
<DropdownMenuItem onSelect={() => void handleStartWork("EXTERNAL")}>Iniciar (externo)</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{editing ? (
<div className="space-y-2">