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:
parent
addd4ce6e8
commit
3bafcc5a0a
45 changed files with 1401 additions and 256 deletions
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue