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

@ -3,7 +3,6 @@
import { useRouter } from "next/navigation"
import { useState } from "react"
import { useMutation } from "convex/react"
// @ts-expect-error Convex runtime API lacks TS declarations until build
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogTrigger } from "@/components/ui/dialog"

View file

@ -5,7 +5,6 @@ import { useEffect, useMemo, useState } from "react"
import type { Doc, Id } from "@/convex/_generated/dataModel"
import type { TicketPriority, TicketQueueSummary } from "@/lib/schemas/ticket"
import { useMutation, useQuery } from "convex/react"
// @ts-expect-error Convex runtime API lacks TypeScript definitions
import { api } from "@/convex/_generated/api"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { useAuth } from "@/lib/auth-client"

View file

@ -5,7 +5,6 @@ import { useState } from "react"
import { useRouter } from "next/navigation"
import { IconArrowRight, IconPlayerPlayFilled } from "@tabler/icons-react"
import { useMutation, useQuery } from "convex/react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { useAuth } from "@/lib/auth-client"

View file

@ -2,7 +2,6 @@
import { useState } from "react"
import { useMutation } from "convex/react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import type { TicketPriority } from "@/lib/schemas/ticket"

View file

@ -5,7 +5,6 @@ import Link from "next/link"
import { formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
import { useQuery } from "convex/react"
// @ts-expect-error Convex runtime API lacks TS declarations
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { DEFAULT_TENANT_ID } from "@/lib/constants"

View file

@ -5,10 +5,9 @@ import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
const statusStyles: Record<TicketStatus, { label: string; className: string }> = {
NEW: { label: "Novo", className: "border border-slate-200 bg-slate-100 text-slate-700" },
OPEN: { label: "Aberto", className: "border border-slate-200 bg-[#dff1fb] text-[#0a4760]" },
PENDING: { label: "Pendente", className: "border border-slate-200 bg-[#fdebd6] text-[#7b4107]" },
ON_HOLD: { label: "Em espera", className: "border border-slate-200 bg-[#ede8ff] text-[#4f2f96]" },
PENDING: { label: "Pendente", className: "border border-slate-200 bg-slate-100 text-slate-700" },
AWAITING_ATTENDANCE: { label: "Aguardando atendimento", className: "border border-slate-200 bg-[#dff1fb] text-[#0a4760]" },
PAUSED: { label: "Pausado", className: "border border-slate-200 bg-[#ede8ff] text-[#4f2f96]" },
RESOLVED: { label: "Resolvido", className: "border border-slate-200 bg-[#dcf4eb] text-[#1f6a45]" },
CLOSED: { label: "Fechado", className: "border border-slate-200 bg-slate-200 text-slate-700" },
}

View file

@ -2,7 +2,6 @@
import { useState } from "react"
import { useMutation } from "convex/react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import type { TicketStatus } from "@/lib/schemas/ticket"
@ -13,14 +12,20 @@ import { toast } from "sonner"
import { cn } from "@/lib/utils"
import { ChevronDown } from "lucide-react"
const statusStyles: Record<TicketStatus, { label: string; badgeClass: string }> = {
NEW: { label: "Novo", badgeClass: "bg-slate-100 text-slate-700" },
OPEN: { label: "Aberto", badgeClass: "bg-[#dff1fb] text-[#0a4760]" },
PENDING: { label: "Pendente", badgeClass: "bg-[#fdebd6] text-[#7b4107]" },
ON_HOLD: { label: "Em espera", badgeClass: "bg-[#ede8ff] text-[#4f2f96]" },
type StatusKey = TicketStatus | "NEW" | "OPEN" | "ON_HOLD";
const STATUS_OPTIONS: TicketStatus[] = ["PENDING", "AWAITING_ATTENDANCE", "PAUSED", "RESOLVED", "CLOSED"];
const statusStyles: Record<StatusKey, { label: string; badgeClass: string }> = {
PENDING: { label: "Pendente", badgeClass: "bg-slate-100 text-slate-700" },
AWAITING_ATTENDANCE: { label: "Aguardando atendimento", badgeClass: "bg-[#dff1fb] text-[#0a4760]" },
PAUSED: { label: "Pausado", badgeClass: "bg-[#ede8ff] text-[#4f2f96]" },
RESOLVED: { label: "Resolvido", badgeClass: "bg-[#dcf4eb] text-[#1f6a45]" },
CLOSED: { label: "Fechado", badgeClass: "bg-slate-200 text-slate-700" },
}
NEW: { label: "Pendente", badgeClass: "bg-slate-100 text-slate-700" },
OPEN: { label: "Aguardando atendimento", badgeClass: "bg-[#dff1fb] text-[#0a4760]" },
ON_HOLD: { label: "Pausado", badgeClass: "bg-[#ede8ff] text-[#4f2f96]" },
};
const triggerClass =
"group inline-flex h-auto w-auto items-center justify-center rounded-full border border-transparent bg-transparent p-0 shadow-none ring-0 ring-offset-0 ring-offset-transparent focus-visible:outline-none focus-visible:border-transparent focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:shadow-none hover:bg-transparent data-[state=open]:bg-transparent data-[state=open]:border-transparent data-[state=open]:shadow-none data-[state=open]:ring-0 data-[state=open]:ring-offset-0 data-[state=open]:ring-offset-transparent [&>*:last-child]:hidden"
@ -53,14 +58,14 @@ export function StatusSelect({ ticketId, value }: { ticketId: string; value: Tic
>
<SelectTrigger className={triggerClass} aria-label="Atualizar status">
<SelectValue asChild>
<Badge className={cn(baseBadgeClass, statusStyles[status]?.badgeClass)}>
{statusStyles[status]?.label ?? status}
<Badge className={cn(baseBadgeClass, statusStyles[status]?.badgeClass ?? statusStyles.PENDING.badgeClass)}>
{statusStyles[status]?.label ?? statusStyles.PENDING.label}
<ChevronDown className="size-3 text-current transition group-data-[state=open]:rotate-180" />
</Badge>
</SelectValue>
</SelectTrigger>
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
{(["NEW", "OPEN", "PENDING", "ON_HOLD", "RESOLVED", "CLOSED"] as const).map((option) => (
{STATUS_OPTIONS.map((option) => (
<SelectItem key={option} value={option} className={itemClass}>
{statusStyles[option].label}
</SelectItem>

View file

@ -6,7 +6,6 @@ import { ptBR } from "date-fns/locale"
import { IconLock, IconMessage, IconFileText } from "@tabler/icons-react"
import { FileIcon, Image as ImageIcon, PencilLine, Trash2, X } from "lucide-react"
import { useAction, useMutation, useQuery } from "convex/react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import { useAuth } from "@/lib/auth-client"
import type { Id } from "@/convex/_generated/dataModel"

View file

@ -1,7 +1,6 @@
"use client";
import { useQuery } from "convex/react";
// @ts-expect-error Convex runtime API lacks TypeScript definitions
import { api } from "@/convex/_generated/api";
import { DEFAULT_TENANT_ID } from "@/lib/constants";
import { mapTicketWithDetailsFromServer } from "@/lib/mappers/ticket";

View file

@ -1,7 +1,6 @@
"use client"
import { useQuery } from "convex/react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import type { TicketQueueSummary } from "@/lib/schemas/ticket"

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

View file

@ -1,5 +1,5 @@
import { format } from "date-fns"
import type { ComponentType } from "react"
import type { ComponentType, ReactNode } from "react"
import { ptBR } from "date-fns/locale"
import {
IconClockHour4,
@ -119,9 +119,12 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
sessionDurationMs?: number
categoryName?: string
subcategoryName?: string
pauseReason?: string
pauseReasonLabel?: string
pauseNote?: string
}
let message: string | null = null
let message: ReactNode = null
if (entry.type === "STATUS_CHANGED" && (payload.toLabel || payload.to)) {
message = "Status alterado para " + (payload.toLabel || payload.to)
}
@ -153,8 +156,22 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
if (entry.type === "ATTACHMENT_REMOVED" && payload.attachmentName) {
message = `Anexo removido: ${payload.attachmentName}`
}
if (entry.type === "WORK_PAUSED" && typeof payload.sessionDurationMs === "number") {
message = `Tempo registrado: ${formatDuration(payload.sessionDurationMs)}`
if (entry.type === "WORK_PAUSED") {
const parts: string[] = []
if (payload.pauseReasonLabel || payload.pauseReason) {
parts.push(`Motivo: ${payload.pauseReasonLabel ?? payload.pauseReason}`)
}
if (typeof payload.sessionDurationMs === "number") {
parts.push(`Tempo registrado: ${formatDuration(payload.sessionDurationMs)}`)
}
message = (
<div className="space-y-1">
<span>{parts.length > 0 ? parts.join(" • ") : "Atendimento pausado"}</span>
{payload.pauseNote ? (
<span className="block text-xs text-neutral-500">Observação: {payload.pauseNote}</span>
) : null}
</div>
)
}
if (entry.type === "CATEGORY_CHANGED") {
if (payload.categoryName || payload.subcategoryName) {
@ -168,9 +185,7 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
if (!message) return null
return (
<div className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-neutral-600">
{message}
</div>
<div className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-neutral-600">{message}</div>
)
})()}
</div>

View file

@ -6,7 +6,7 @@ import { IconFilter, IconRefresh } from "@tabler/icons-react"
import {
ticketChannelSchema,
ticketPrioritySchema,
ticketStatusSchema,
type TicketStatus,
} from "@/lib/schemas/ticket"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
@ -24,17 +24,18 @@ import {
SelectValue,
} from "@/components/ui/select"
const statusOptions = ticketStatusSchema.options.map((status) => ({
value: status,
label: {
NEW: "Novo",
OPEN: "Aberto",
PENDING: "Pendente",
ON_HOLD: "Em espera",
RESOLVED: "Resolvido",
CLOSED: "Fechado",
}[status],
}))
const statusOptions: Array<{ value: TicketStatus; label: string }> = [
{ value: "PENDING", label: "Pendente" },
{ value: "AWAITING_ATTENDANCE", label: "Aguardando atendimento" },
{ value: "PAUSED", label: "Pausado" },
{ value: "RESOLVED", label: "Resolvido" },
{ value: "CLOSED", label: "Fechado" },
]
const statusLabelMap = statusOptions.reduce<Record<TicketStatus, string>>((acc, option) => {
acc[option.value] = option.label
return acc
}, {} as Record<TicketStatus, string>)
const priorityOptions = ticketPrioritySchema.options.map((priority) => ({
value: priority,
@ -62,10 +63,11 @@ type QueueOption = string
export type TicketFiltersState = {
search: string
status: string | null
status: TicketStatus | null
priority: string | null
queue: string | null
channel: string | null
view: "active" | "completed"
}
export const defaultTicketFilters: TicketFiltersState = {
@ -74,6 +76,7 @@ export const defaultTicketFilters: TicketFiltersState = {
priority: null,
queue: null,
channel: null,
view: "active",
}
interface TicketsFiltersProps {
@ -97,10 +100,11 @@ export function TicketsFilters({ onChange, queues = [] }: TicketsFiltersProps) {
const activeFilters = useMemo(() => {
const chips: string[] = []
if (filters.status) chips.push(`Status: ${filters.status}`)
if (filters.status) chips.push(`Status: ${statusLabelMap[filters.status] ?? filters.status}`)
if (filters.priority) chips.push(`Prioridade: ${filters.priority}`)
if (filters.queue) chips.push(`Fila: ${filters.queue}`)
if (filters.channel) chips.push(`Canal: ${filters.channel}`)
if (!filters.status && filters.view === "completed") chips.push("Exibindo concluídos")
return chips
}, [filters])
@ -132,6 +136,18 @@ export function TicketsFilters({ onChange, queues = [] }: TicketsFiltersProps) {
</Select>
</div>
<div className="flex items-center gap-2">
<Select
value={filters.view}
onValueChange={(value) => setPartial({ view: value as TicketFiltersState["view"] })}
>
<SelectTrigger className="w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">Em andamento</SelectItem>
<SelectItem value="completed">Concluídos</SelectItem>
</SelectContent>
</Select>
<Popover>
<PopoverTrigger asChild>
<Button
@ -150,7 +166,9 @@ export function TicketsFilters({ onChange, queues = [] }: TicketsFiltersProps) {
</p>
<Select
value={filters.status ?? ALL_VALUE}
onValueChange={(value) => setPartial({ status: value === ALL_VALUE ? null : value })}
onValueChange={(value) =>
setPartial({ status: value === ALL_VALUE ? null : (value as TicketStatus) })
}
>
<SelectTrigger>
<SelectValue placeholder="Todos" />

View file

@ -49,19 +49,17 @@ const tableRowClass =
"group border-b border-slate-100 text-sm transition-colors hover:bg-slate-100/70 last:border-none"
const statusLabel: Record<TicketStatus, string> = {
NEW: "Novo",
OPEN: "Aberto",
PENDING: "Pendente",
ON_HOLD: "Em espera",
AWAITING_ATTENDANCE: "Aguardando atendimento",
PAUSED: "Pausado",
RESOLVED: "Resolvido",
CLOSED: "Fechado",
}
const statusTone: Record<TicketStatus, string> = {
NEW: "text-slate-700",
OPEN: "text-sky-700",
PENDING: "text-amber-700",
ON_HOLD: "text-violet-700",
PENDING: "text-slate-700",
AWAITING_ATTENDANCE: "text-sky-700",
PAUSED: "text-violet-700",
RESOLVED: "text-emerald-700",
CLOSED: "text-slate-600",
}

View file

@ -2,7 +2,6 @@
import { useMemo, useState } from "react"
import { useQuery } from "convex/react"
// @ts-expect-error Convex runtime API lacks TypeScript definitions
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
@ -42,9 +41,23 @@ export function TicketsView() {
const tickets = useMemo(() => mapTicketsFromServerList((ticketsRaw ?? []) as unknown[]), [ticketsRaw])
const filteredTickets = useMemo(() => {
if (!filters.queue) return tickets
return tickets.filter((t: Ticket) => t.queue === filters.queue)
}, [tickets, filters.queue])
const completedStatuses = new Set<Ticket["status"]>(["RESOLVED", "CLOSED"])
let working = tickets
if (!filters.status) {
if (filters.view === "active") {
working = working.filter((t) => !completedStatuses.has(t.status))
} else if (filters.view === "completed") {
working = working.filter((t) => completedStatuses.has(t.status))
}
}
if (filters.queue) {
working = working.filter((t) => t.queue === filters.queue)
}
return working
}, [tickets, filters.queue, filters.status, filters.view])
return (
<div className="flex flex-col gap-6 px-4 lg:px-6">