Merge pull request #5 from esdrasrenan/feat/convex-tickets-core

Feat/convex tickets core
This commit is contained in:
esdrasrenan 2025-10-04 20:10:44 -03:00 committed by GitHub
commit f6026f83a5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1104 additions and 799 deletions

View file

@ -265,3 +265,35 @@ Médio prazo:
- Prioridade: alterar no cabeçalho; observar evento de timeline e toasts.
- Exclusão: acionar modal no cabeçalho e confirmar; conferir redirecionamento para `/tickets`.
- Novo ticket: usar Dialog; assunto com menos de 3 chars deve bloquear submit com erro no campo.
---
## Atualizações recentes (abr/2025)
Resumo do que foi integrado nesta rodada para o núcleo de tickets e UX:
- Header do ticket
- Status como dropdownbadge (padrão visual alinhado às badges existentes).
- Edição inline de Assunto/Resumo com Cancelar/Salvar e toasts.
- Ação de Play/Pause (toggle de atendimento) com eventos WORK_STARTED/WORK_PAUSED na timeline.
- Layout dos campos reorganizado: labels acima e controles abaixo (evita redundância do valor + dropdown lado a lado).
- Tabela e comentários
- Empty states padronizados com Empty + CTA de novo ticket.
- Notificações
- Toaster centralizado no rodapé (bottomcenter) com estilo consistente.
- Título do app
- Atualizado para “Sistema de chamados”.
Backend Convex
- ickets.updateSubject e ickets.updateSummary adicionadas para edição do cabeçalho.
- ickets.toggleWork adicionada; campo opcional working no schema de ickets.
Próximos passos sugeridos
- Status dropdownbadge também na tabela (edição rápida opcional com confirmação).
- Combobox (command) para busca de responsável no select.
- Tokens de cor: manter badges padrão do design atual; quando migração completa para paleta Rever estiver definida, aplicar via globals.css para herdar em todos os componentes.
- Testes (Vitest): adicionar casos de mappers e smoke tests de páginas.
Observações de codificação
- Evitar ny; usar TicketStatus/TicketPriority e Id<>/Doc<> do Convex.
- Não retornar Date do Convex; sempre epoch (number) e converter via mappers Zod.

View file

@ -48,6 +48,7 @@ export default defineSchema({
queueId: v.optional(v.id("queues")),
requesterId: v.id("users"),
assigneeId: v.optional(v.id("users")),
working: v.optional(v.boolean()),
slaPolicyId: v.optional(v.id("slaPolicies")),
dueAt: v.optional(v.number()), // ms since epoch
firstResponseAt: v.optional(v.number()),

View file

@ -220,6 +220,7 @@ export const create = mutation({
queueId: args.queueId,
requesterId: args.requesterId,
assigneeId: undefined,
working: false,
createdAt: now,
updatedAt: now,
firstResponseAt: undefined,
@ -348,6 +349,59 @@ export const updatePriority = mutation({
},
});
export const toggleWork = mutation({
args: { ticketId: v.id("tickets"), actorId: v.id("users") },
handler: async (ctx, { ticketId, actorId }) => {
const t = await ctx.db.get(ticketId)
if (!t) return
const now = Date.now()
const next = !(t.working ?? false)
await ctx.db.patch(ticketId, { working: next, updatedAt: now })
const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null
await ctx.db.insert("ticketEvents", {
ticketId,
type: next ? "WORK_STARTED" : "WORK_PAUSED",
payload: { actorId, actorName: actor?.name, actorAvatar: actor?.avatarUrl },
createdAt: now,
})
return next
},
})
export const updateSubject = mutation({
args: { ticketId: v.id("tickets"), subject: v.string(), actorId: v.id("users") },
handler: async (ctx, { ticketId, subject, actorId }) => {
const now = Date.now();
const t = await ctx.db.get(ticketId);
if (!t) return;
await ctx.db.patch(ticketId, { subject, updatedAt: now });
const actor = await ctx.db.get(actorId);
await ctx.db.insert("ticketEvents", {
ticketId,
type: "SUBJECT_CHANGED",
payload: { from: t.subject, to: subject, actorId, actorName: (actor as Doc<"users"> | null)?.name, actorAvatar: (actor as Doc<"users"> | null)?.avatarUrl },
createdAt: now,
});
},
});
export const updateSummary = mutation({
args: { ticketId: v.id("tickets"), summary: v.optional(v.string()), actorId: v.id("users") },
handler: async (ctx, { ticketId, summary, actorId }) => {
const now = Date.now();
const t = await ctx.db.get(ticketId);
if (!t) return;
await ctx.db.patch(ticketId, { summary, updatedAt: now });
const actor = await ctx.db.get(actorId);
await ctx.db.insert("ticketEvents", {
ticketId,
type: "SUMMARY_CHANGED",
payload: { actorId, actorName: (actor as Doc<"users"> | null)?.name, actorAvatar: (actor as Doc<"users"> | null)?.avatarUrl },
createdAt: now,
});
},
});
export const playNext = mutation({
args: {
tenantId: v.string(),

View file

@ -19,8 +19,8 @@ const jetBrainsMono = JetBrains_Mono({
})
export const metadata: Metadata = {
title: "Atlas Support",
description: "Plataforma omnichannel de gestão de chamados",
title: "Sistema de chamados",
description: "Plataforma de chamados da Rever",
}
export default async function RootLayout({
@ -43,7 +43,7 @@ export default async function RootLayout({
<ConvexClientProvider>
<AuthProvider demoUser={demoUser} tenantId={tenantId}>
{children}
<Toaster position="top-right" richColors />
<Toaster position="bottom-center" richColors />
</AuthProvider>
</ConvexClientProvider>
</body>

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"
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { api } from "@/convex/_generated/api"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
@ -26,6 +25,10 @@ interface PlayNextTicketCardProps {
context?: TicketPlayContext
}
const queueBadgeClass = "inline-flex items-center rounded-full border border-slate-200 bg-white px-2.5 py-1 text-xs font-semibold text-neutral-700"
const startButtonClass = "inline-flex items-center gap-2 rounded-lg border border-black bg-[#00e8ff] px-3 py-2 text-sm font-semibold text-black transition hover:bg-[#00d6eb]"
const secondaryButtonClass = "inline-flex items-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-semibold text-neutral-700 hover:bg-slate-100"
export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
const router = useRouter()
const { userId } = useAuth()
@ -43,16 +46,29 @@ export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
})?.[0]
const nextTicketUi = nextTicketFromServer ? mapTicketFromServer(nextTicketFromServer as unknown) : null
const cardContext: TicketPlayContext | null = context ?? (nextTicketUi ? { queue: { id: "default", name: "Geral", pending: queueSummary.reduce((a, b) => a + b.pending, 0), waiting: queueSummary.reduce((a, b) => a + b.waiting, 0), breached: 0 }, nextTicket: nextTicketUi } : null)
const cardContext: TicketPlayContext | null =
context ??
(nextTicketUi
? {
queue: {
id: "default",
name: "Geral",
pending: queueSummary.reduce((acc, item) => acc + item.pending, 0),
waiting: queueSummary.reduce((acc, item) => acc + item.waiting, 0),
breached: 0,
},
nextTicket: nextTicketUi,
}
: null)
if (!cardContext || !cardContext.nextTicket) {
return (
<Card className="border-dashed">
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardHeader>
<CardTitle>Fila sem tickets pendentes</CardTitle>
<CardTitle className="text-lg font-semibold text-neutral-900">Fila sem tickets pendentes</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
Nenhum ticket disponivel no momento. Excelente trabalho!
<CardContent className="text-sm text-neutral-600">
Nenhum ticket disponível no momento. Excelente trabalho!
</CardContent>
</Card>
)
@ -61,61 +77,73 @@ export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
const ticket = cardContext.nextTicket
return (
<Card className="border-dashed">
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardHeader className="flex flex-row items-center justify-between gap-2">
<CardTitle className="text-lg font-semibold">
Proximo ticket #{ticket.reference}
<CardTitle className="text-lg font-semibold text-neutral-900">
Próximo ticket #{ticket.reference}
</CardTitle>
<TicketPriorityPill priority={ticket.priority} />
</CardHeader>
<CardContent className="flex flex-col gap-4">
<CardContent className="flex flex-col gap-4 text-sm text-neutral-700">
<div className="flex items-center justify-end gap-2">
<span className="text-sm text-muted-foreground">Fila:</span>
<Select value={selectedQueueId ?? "ALL"} onValueChange={(v) => setSelectedQueueId(v === "ALL" ? undefined : v)}>
<SelectTrigger className="h-8 w-[180px]"><SelectValue placeholder="Todas" /></SelectTrigger>
<SelectContent>
<span className="text-xs uppercase tracking-wide text-neutral-500">Fila</span>
<Select value={selectedQueueId ?? "ALL"} onValueChange={(value) => setSelectedQueueId(value === "ALL" ? undefined : value)}>
<SelectTrigger className="h-8 w-[180px] rounded-lg border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]">
<SelectValue placeholder="Todas" />
</SelectTrigger>
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
<SelectItem value="ALL">Todas</SelectItem>
{queueSummary.map((q) => (
<SelectItem key={q.id} value={q.id}>{q.name}</SelectItem>
{queueSummary.map((queue) => (
<SelectItem key={queue.id} value={queue.id}>
{queue.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<h2 className="text-xl font-medium text-foreground">{ticket.subject}</h2>
<p className="text-sm text-muted-foreground">{ticket.summary}</p>
<h2 className="text-xl font-semibold text-neutral-900">{ticket.subject}</h2>
<p className="text-sm text-neutral-600">{ticket.summary}</p>
</div>
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
<Badge variant="outline">{ticket.queue ?? "Sem fila"}</Badge>
<div className="flex flex-wrap gap-2 text-xs text-neutral-600">
<Badge className={queueBadgeClass}>{ticket.queue ?? "Sem fila"}</Badge>
<TicketStatusBadge status={ticket.status} />
<span>Solicitante: {ticket.requester.name}</span>
<span className="font-medium text-neutral-900">Solicitante: {ticket.requester.name}</span>
</div>
<Separator />
<div className="flex flex-col gap-3 text-sm text-muted-foreground">
<Separator className="bg-slate-200" />
<div className="flex flex-col gap-3 text-sm text-neutral-700">
<div className="flex items-center justify-between">
<span>Pendentes na fila</span>
<span className="font-medium text-foreground">{cardContext.queue.pending}</span>
<span className="font-semibold text-neutral-900">{cardContext.queue.pending}</span>
</div>
<div className="flex items-center justify-between">
<span>Em espera</span>
<span className="font-medium text-foreground">{cardContext.queue.waiting}</span>
<span className="font-semibold text-neutral-900">{cardContext.queue.waiting}</span>
</div>
<div className="flex items-center justify-between">
<span>SLA violado</span>
<span className="font-medium text-destructive">{cardContext.queue.breached}</span>
<span className="font-semibold text-red-600">{cardContext.queue.breached}</span>
</div>
</div>
<Button
className="gap-2"
className={startButtonClass}
onClick={async () => {
if (!userId) return
const chosen = await playNext({ tenantId: DEFAULT_TENANT_ID, queueId: (selectedQueueId as Id<"queues">) || undefined, agentId: userId as Id<"users"> })
if (chosen?.id) router.push(`/tickets/${chosen.id}`)
}}
>
{userId ? (<><IconPlayerPlayFilled className="size-4" /> Iniciar atendimento</>) : (<><Spinner className="me-2" /> Carregando</>)}
{userId ? (
<>
<IconPlayerPlayFilled className="size-4 text-black" /> Iniciar atendimento
</>
) : (
<>
<Spinner className="me-2" /> Carregando...
</>
)}
</Button>
<Button variant="ghost" asChild className="gap-2 text-sm">
<Button variant="ghost" asChild className={secondaryButtonClass}>
<Link href="/tickets">
Ver lista completa
<IconArrowRight className="size-4" />

View file

@ -1,40 +1,21 @@
import { cn } from "@/lib/utils"
import { type TicketPriority } from "@/lib/schemas/ticket"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
const priorityConfig = {
LOW: {
label: "Baixa",
className: "bg-slate-100 text-slate-600 border-transparent",
},
MEDIUM: {
label: "Media",
className: "bg-blue-100 text-blue-600 border-transparent",
},
HIGH: {
label: "Alta",
className: "bg-amber-100 text-amber-700 border-transparent",
},
URGENT: {
label: "Urgente",
className: "bg-red-100 text-red-700 border-transparent",
},
} satisfies Record<string, { label: string; className: string }>
type TicketPriorityPillProps = {
priority: keyof typeof priorityConfig
const priorityStyles: Record<TicketPriority, { label: string; className: string }> = {
LOW: { label: "Baixa", className: "border border-slate-200 bg-slate-100 text-slate-700" },
MEDIUM: { label: "Média", className: "border border-slate-200 bg-[#dff1fb] text-[#0a4760]" },
HIGH: { label: "Alta", className: "border border-slate-200 bg-[#fde8d1] text-[#7d3b05]" },
URGENT: { label: "Urgente", className: "border border-slate-200 bg-[#fbd9dd] text-[#8b0f1c]" },
}
export function TicketPriorityPill({ priority }: TicketPriorityPillProps) {
const config = priorityConfig[priority]
const baseClass = "inline-flex items-center rounded-full px-3 py-0.5 text-xs font-semibold"
export function TicketPriorityPill({ priority }: { priority: TicketPriority }) {
const styles = priorityStyles[priority]
return (
<Badge
variant="outline"
className={cn(
"rounded-full px-2.5 py-1 text-xs font-medium",
config?.className ?? ""
)}
>
{config?.label ?? priority}
<Badge className={cn(baseClass, styles?.className)}>
{styles?.label ?? priority}
</Badge>
)
}

View file

@ -5,61 +5,71 @@ import { useMutation } from "convex/react"
// @ts-ignore
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import type { TicketPriority } from "@/lib/schemas/ticket"
import { useAuth } from "@/lib/auth-client"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Badge } from "@/components/ui/badge"
import { toast } from "sonner"
import { ArrowDown, ArrowRight, ArrowUp, ChevronsUp } from "lucide-react"
import { cn } from "@/lib/utils"
const labels: Record<string, string> = {
LOW: "Baixa",
MEDIUM: "Média",
HIGH: "Alta",
URGENT: "Urgente",
const priorityStyles: Record<TicketPriority, { label: string; badgeClass: string }> = {
LOW: { label: "Baixa", badgeClass: "border border-slate-200 bg-slate-100 text-slate-700" },
MEDIUM: { label: "Média", badgeClass: "border border-slate-200 bg-[#dff1fb] text-[#0a4760]" },
HIGH: { label: "Alta", badgeClass: "border border-slate-200 bg-[#fde8d1] text-[#7d3b05]" },
URGENT: { label: "Urgente", badgeClass: "border border-slate-200 bg-[#fbd9dd] text-[#8b0f1c]" },
}
function badgeClass(p: string) {
switch (p) {
case "URGENT":
return "bg-red-100 text-red-700"
case "HIGH":
return "bg-amber-100 text-amber-700"
case "MEDIUM":
return "bg-blue-100 text-blue-700"
default:
return "bg-slate-100 text-slate-700"
}
const triggerClass = "h-8 w-[160px] rounded-full border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]"
const itemClass = "flex items-center gap-2 rounded-md px-2 py-2 text-sm text-neutral-800 transition data-[state=checked]:bg-[#00e8ff]/15 data-[state=checked]:text-neutral-900 focus:bg-[#00e8ff]/10"
const iconClass = "size-4 text-neutral-700"
const baseBadgeClass = "inline-flex items-center gap-2 rounded-full px-3 py-0.5 text-xs font-semibold"
function PriorityIcon({ value }: { value: TicketPriority }) {
if (value === "LOW") return <ArrowDown className={iconClass} />
if (value === "MEDIUM") return <ArrowRight className={iconClass} />
if (value === "HIGH") return <ArrowUp className={iconClass} />
return <ChevronsUp className={iconClass} />
}
export function PrioritySelect({ ticketId, value }: { ticketId: Id<"tickets">; value: "LOW" | "MEDIUM" | "HIGH" | "URGENT" }) {
export function PrioritySelect({ ticketId, value }: { ticketId: string; value: TicketPriority }) {
const updatePriority = useMutation(api.tickets.updatePriority)
const [priority, setPriority] = useState(value)
const [priority, setPriority] = useState<TicketPriority>(value)
const { userId } = useAuth()
return (
<Select
value={priority}
onValueChange={async (val) => {
const prev = priority
setPriority(val as typeof priority)
toast.loading("Atualizando prioridade...", { id: "prio" })
onValueChange={async (selected) => {
const previous = priority
const next = selected as TicketPriority
setPriority(next)
toast.loading("Atualizando prioridade...", { id: "priority" })
try {
if (!userId) throw new Error("No user")
await updatePriority({ ticketId, priority: val as any, actorId: userId as Id<"users"> })
toast.success("Prioridade atualizada!", { id: "prio" })
if (!userId) throw new Error("missing user")
await updatePriority({ ticketId: ticketId as unknown as Id<"tickets">, priority: next, actorId: userId as Id<"users"> })
toast.success("Prioridade atualizada!", { id: "priority" })
} catch {
setPriority(prev)
toast.error("Não foi possível atualizar a prioridade.", { id: "prio" })
setPriority(previous)
toast.error("Não foi possível atualizar a prioridade.", { id: "priority" })
}
}}
>
<SelectTrigger className="h-7 w-[140px] border-transparent bg-muted/50 px-2">
<SelectTrigger className={triggerClass}>
<SelectValue>
<Badge className={`rounded-full px-2 py-0.5 ${badgeClass(priority)}`}>{labels[priority]}</Badge>
<Badge className={cn(baseBadgeClass, priorityStyles[priority]?.badgeClass)}>
<PriorityIcon value={priority} />
{priorityStyles[priority]?.label ?? priority}
</Badge>
</SelectValue>
</SelectTrigger>
<SelectContent>
{(["LOW","MEDIUM","HIGH","URGENT"] as const).map((p) => (
<SelectItem key={p} value={p}>
<span className="inline-flex items-center gap-2"><span className={`h-2 w-2 rounded-full ${p==="URGENT"?"bg-red-500":p==="HIGH"?"bg-amber-500":p==="MEDIUM"?"bg-blue-500":"bg-slate-400"}`}></span>{labels[p]}</span>
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
{(["LOW", "MEDIUM", "HIGH", "URGENT"] as const).map((option) => (
<SelectItem key={option} value={option} className={itemClass}>
<span className="inline-flex items-center gap-2">
<PriorityIcon value={option} />
{priorityStyles[option].label}
</span>
</SelectItem>
))}
</SelectContent>

View file

@ -6,19 +6,17 @@ import { api } from "@/convex/_generated/api";
import { DEFAULT_TENANT_ID } from "@/lib/constants";
import { mapTicketsFromServerList } from "@/lib/mappers/ticket";
import { TicketsTable } from "@/components/tickets/tickets-table";
import { Spinner } from "@/components/ui/spinner";
import type { Ticket } from "@/lib/schemas/ticket";
export function RecentTicketsPanel() {
const ticketsRaw = useQuery(api.tickets.list, { tenantId: DEFAULT_TENANT_ID, limit: 10 });
if (ticketsRaw === undefined) {
return (
<div className="rounded-xl border bg-card p-4">
<div className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="grid gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center justify-between gap-3">
<div className="h-4 w-56 animate-pulse rounded bg-muted" />
<div className="h-4 w-20 animate-pulse rounded bg-muted" />
<div className="h-4 w-56 animate-pulse rounded bg-slate-100" />
<div className="h-4 w-20 animate-pulse rounded bg-slate-100" />
</div>
))}
</div>
@ -27,7 +25,7 @@ export function RecentTicketsPanel() {
}
const tickets = mapTicketsFromServerList((ticketsRaw ?? []) as unknown[]);
return (
<div className="rounded-xl border bg-card">
<div className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<TicketsTable tickets={tickets} />
</div>
);

View file

@ -2,26 +2,25 @@
import { ticketStatusSchema, type TicketStatus } from "@/lib/schemas/ticket"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
const statusConfig = {
NEW: { label: "Novo", className: "bg-slate-100 text-slate-700 border-transparent" },
OPEN: { label: "Aberto", className: "bg-blue-100 text-blue-700 border-transparent" },
PENDING: { label: "Pendente", className: "bg-amber-100 text-amber-700 border-transparent" },
ON_HOLD: { label: "Em espera", className: "bg-purple-100 text-purple-700 border-transparent" },
RESOLVED: { label: "Resolvido", className: "bg-emerald-100 text-emerald-700 border-transparent" },
CLOSED: { label: "Fechado", className: "bg-slate-100 text-slate-700 border-transparent" },
} satisfies Record<TicketStatus, { label: string; className: string }>
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]" },
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" },
}
type TicketStatusBadgeProps = { status: TicketStatus }
export function TicketStatusBadge({ status }: TicketStatusBadgeProps) {
const config = statusConfig[status]
const parsed = ticketStatusSchema.parse(status)
const styles = statusStyles[parsed]
return (
<Badge
variant="outline"
className={`rounded-full px-2.5 py-1 text-xs font-medium ${config?.className ?? ""}`}
>
{config?.label ?? status}
<Badge className={cn('inline-flex items-center rounded-full px-3 py-0.5 text-xs font-semibold', styles?.className)}>
{styles?.label ?? parsed}
</Badge>
)
}

View file

@ -0,0 +1,67 @@
"use client"
import { useState } from "react"
import { useMutation } from "convex/react"
// @ts-ignore
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import type { TicketStatus } from "@/lib/schemas/ticket"
import { useAuth } from "@/lib/auth-client"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Badge } from "@/components/ui/badge"
import { toast } from "sonner"
import { cn } from "@/lib/utils"
const statusStyles: Record<TicketStatus, { label: string; badgeClass: string }> = {
NEW: { label: "Novo", badgeClass: "border border-slate-200 bg-slate-100 text-slate-700" },
OPEN: { label: "Aberto", badgeClass: "border border-slate-200 bg-[#dff1fb] text-[#0a4760]" },
PENDING: { label: "Pendente", badgeClass: "border border-slate-200 bg-[#fdebd6] text-[#7b4107]" },
ON_HOLD: { label: "Em espera", badgeClass: "border border-slate-200 bg-[#ede8ff] text-[#4f2f96]" },
RESOLVED: { label: "Resolvido", badgeClass: "border border-slate-200 bg-[#dcf4eb] text-[#1f6a45]" },
CLOSED: { label: "Fechado", badgeClass: "border border-slate-200 bg-slate-200 text-slate-700" },
}
const triggerClass = "h-8 w-[180px] rounded-full border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]"
const itemClass = "rounded-md px-2 py-2 text-sm text-neutral-800 transition hover:bg-slate-100 data-[state=checked]:bg-[#00e8ff]/15 data-[state=checked]:text-neutral-900 focus:bg-[#00e8ff]/10"
const baseBadgeClass = "inline-flex items-center rounded-full px-3 py-0.5 text-xs font-semibold"
export function StatusSelect({ ticketId, value }: { ticketId: string; value: TicketStatus }) {
const updateStatus = useMutation(api.tickets.updateStatus)
const [status, setStatus] = useState<TicketStatus>(value)
const { userId } = useAuth()
return (
<Select
value={status}
onValueChange={async (selected) => {
const previous = status
const next = selected as TicketStatus
setStatus(next)
toast.loading("Atualizando status...", { id: "status" })
try {
if (!userId) throw new Error("missing user")
await updateStatus({ ticketId: ticketId as unknown as Id<"tickets">, status: next, actorId: userId as Id<"users"> })
toast.success("Status alterado para " + (statusStyles[next]?.label ?? next) + ".", { id: "status" })
} catch {
setStatus(previous)
toast.error("Não foi possível atualizar o status.", { id: "status" })
}
}}
>
<SelectTrigger className={triggerClass}>
<SelectValue>
<Badge className={cn(baseBadgeClass, statusStyles[status]?.badgeClass)}>
{statusStyles[status]?.label ?? status}
</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) => (
<SelectItem key={option} value={option} className={itemClass}>
{statusStyles[option].label}
</SelectItem>
))}
</SelectContent>
</Select>
)
}

View file

@ -5,8 +5,7 @@ import { formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
import { IconLock, IconMessage } from "@tabler/icons-react"
import { Download, FileIcon } from "lucide-react"
import { useAction, useMutation } from "convex/react"
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
import { useMutation } from "convex/react"
// @ts-ignore
import { api } from "@/convex/_generated/api"
import { useAuth } from "@/lib/auth-client"
@ -21,71 +20,93 @@ import { Dropzone } from "@/components/ui/dropzone"
import { RichTextEditor, RichTextContent, sanitizeEditorHtml } from "@/components/ui/rich-text-editor"
import { Dialog, DialogContent } from "@/components/ui/dialog"
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
interface TicketCommentsProps {
ticket: TicketWithDetails
}
const badgeInternal = "gap-1 rounded-full border border-slate-300 bg-neutral-900 px-2 py-0.5 text-xs font-semibold uppercase tracking-wide text-white"
const selectTriggerClass = "h-8 w-[140px] rounded-lg border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]"
const submitButtonClass = "inline-flex items-center gap-2 rounded-lg border border-black bg-[#00e8ff] px-3 py-2 text-sm font-semibold text-black transition hover:bg-[#00d6eb]"
export function TicketComments({ ticket }: TicketCommentsProps) {
const { userId } = useAuth()
const addComment = useMutation(api.tickets.addComment)
const generateUploadUrl = useAction(api.files.generateUploadUrl)
const [body, setBody] = useState("")
const [attachmentsToSend, setAttachmentsToSend] = useState<Array<{ storageId: string; name: string; size?: number; type?: string; previewUrl?: string }>>([])
const [preview, setPreview] = useState<string | null>(null)
const [pending, setPending] = useState<Pick<TicketWithDetails["comments"][number], "id"|"author"|"visibility"|"body"|"attachments"|"createdAt"|"updatedAt">[]>([])
const [pending, setPending] = useState<Pick<TicketWithDetails["comments"][number], "id" | "author" | "visibility" | "body" | "attachments" | "createdAt" | "updatedAt">[]>([])
const [visibility, setVisibility] = useState<"PUBLIC" | "INTERNAL">("PUBLIC")
const commentsAll = useMemo(() => {
return [...pending, ...ticket.comments]
}, [pending, ticket.comments])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
if (!userId) return
const attachments = attachmentsToSend
const now = new Date()
const attachments = attachmentsToSend
const optimistic = {
id: `temp-${now.getTime()}`,
author: ticket.requester,
visibility,
body: sanitizeEditorHtml(body),
attachments: attachments.map((a) => ({ id: a.storageId, name: a.name, url: a.previewUrl })),
attachments: attachments.map((attachment) => ({
id: attachment.storageId,
name: attachment.name,
url: attachment.previewUrl,
})),
createdAt: now,
updatedAt: now,
}
setPending((p) => [optimistic, ...p])
setPending((current) => [optimistic, ...current])
setBody("")
setAttachmentsToSend([])
toast.loading("Enviando comentário.", { id: "comment" })
toast.loading("Enviando comentário...", { id: "comment" })
try {
const typedAttachments = attachments.map((a) => ({
storageId: a.storageId as unknown as Id<"_storage">,
name: a.name,
size: a.size,
type: a.type,
const payload = attachments.map((attachment) => ({
storageId: attachment.storageId as unknown as Id<"_storage">,
name: attachment.name,
size: attachment.size,
type: attachment.type,
}))
await addComment({ ticketId: ticket.id as Id<"tickets">, authorId: userId as Id<"users">, visibility, body: optimistic.body, attachments: typedAttachments })
await addComment({
ticketId: ticket.id as Id<"tickets">,
authorId: userId as Id<"users">,
visibility,
body: optimistic.body,
attachments: payload,
})
setPending([])
toast.success("Comentário enviado!", { id: "comment" })
} catch (err) {
} catch {
setPending([])
toast.error("Falha ao enviar comentário.", { id: "comment" })
}
}
return (
<Card className="rounded-xl border bg-card shadow-sm">
<CardHeader className="px-4">
<CardTitle className="flex items-center gap-2 text-lg font-semibold">
<IconMessage className="size-5" /> Conversa
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardHeader className="px-4 pb-3">
<CardTitle className="flex items-center gap-2 text-lg font-semibold text-neutral-900">
<IconMessage className="size-5 text-neutral-900" /> Conversa
</CardTitle>
</CardHeader>
<CardContent className="space-y-6 px-4 pb-6">
{commentsAll.length === 0 ? (
<p className="text-sm text-muted-foreground">
Ainda sem comentários. Que tal registrar o próximo passo?
</p>
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<IconMessage className="size-5 text-neutral-900" />
</EmptyMedia>
<EmptyTitle className="text-neutral-900">Nenhum comentário ainda</EmptyTitle>
<EmptyDescription className="text-neutral-600">Registre o próximo passo abaixo.</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
commentsAll.map((comment) => {
const initials = comment.author.name
@ -93,56 +114,62 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join("")
return (
<div key={comment.id} className="flex gap-3">
<Avatar className="size-9">
<Avatar className="size-9 border border-slate-200">
<AvatarImage src={comment.author.avatarUrl} alt={comment.author.name} />
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="font-medium text-foreground">{comment.author.name}</span>
<span className="font-semibold text-neutral-900">{comment.author.name}</span>
{comment.visibility === "INTERNAL" ? (
<Badge variant="outline" className="gap-1">
<IconLock className="size-3" /> Interno
<Badge className={badgeInternal}>
<IconLock className="size-3 text-[#00e8ff]" /> Interno
</Badge>
) : null}
<span className="text-xs text-muted-foreground">
<span className="text-xs text-neutral-500">
{formatDistanceToNow(comment.createdAt, { addSuffix: true, locale: ptBR })}
</span>
</div>
<div className="rounded-lg border border-dashed bg-card px-3 py-2 text-sm leading-relaxed text-muted-foreground break-words">
<div className="break-words rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm leading-relaxed text-neutral-700">
<RichTextContent html={comment.body} />
</div>
{comment.attachments?.length ? (
<div className="grid max-w-xl grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-3">
{comment.attachments.map((att) => {
const isImg = (att?.url ?? "").match(/\.(png|jpe?g|gif|webp|svg)$/i)
if (isImg && att.url) {
return (
<button
key={att.id}
type="button"
onClick={() => setPreview(att.url || null)}
className="group overflow-hidden rounded-lg border bg-card p-0.5 hover:shadow"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={att.url} alt={att.name} className="h-24 w-24 rounded-md object-cover" />
<div className="mt-1 line-clamp-1 w-24 text-ellipsis text-center text-[11px] text-muted-foreground">
{att.name}
</div>
</button>
)
}
return (
<a key={att.id} href={att.url} download={att.name} target="_blank" className="flex items-center gap-2 rounded-md border px-2 py-1 text-xs hover:bg-muted">
<FileIcon className="size-3.5" /> {att.name}
{att.url ? <Download className="size-3.5" /> : null}
</a>
)
})}
</div>
) : null}
{comment.attachments?.length ? (
<div className="grid max-w-xl grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-3">
{comment.attachments.map((attachment) => {
const isImage = (attachment?.url ?? "").match(/\.(png|jpe?g|gif|webp|svg)$/i)
if (isImage && attachment.url) {
return (
<button
key={attachment.id}
type="button"
onClick={() => setPreview(attachment.url || null)}
className="group overflow-hidden rounded-lg border border-slate-200 bg-white p-0.5 hover:border-slate-400"
>
<img src={attachment.url} alt={attachment.name} className="h-24 w-24 rounded-md object-cover" />
<div className="mt-1 line-clamp-1 w-24 text-ellipsis text-center text-[11px] text-neutral-500">
{attachment.name}
</div>
</button>
)
}
return (
<a
key={attachment.id}
href={attachment.url}
download={attachment.name}
target="_blank"
className="flex items-center gap-2 rounded-md border border-slate-200 px-2 py-1 text-xs text-neutral-800 hover:border-slate-400"
>
<FileIcon className="size-3.5 text-neutral-700" /> {attachment.name}
{attachment.url ? <Download className="size-3.5 text-neutral-700" /> : null}
</a>
)
})}
</div>
) : null}
</div>
</div>
)
@ -152,25 +179,26 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
<RichTextEditor value={body} onChange={setBody} placeholder="Escreva um comentário..." />
<Dropzone onUploaded={(files) => setAttachmentsToSend((prev) => [...prev, ...files])} />
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<div className="flex items-center gap-2 text-xs text-neutral-600">
Visibilidade:
<Select value={visibility} onValueChange={(v) => setVisibility(v as "PUBLIC" | "INTERNAL")}>
<SelectTrigger className="h-8 w-[140px]"><SelectValue placeholder="Visibilidade" /></SelectTrigger>
<SelectContent>
<Select value={visibility} onValueChange={(value) => setVisibility(value as "PUBLIC" | "INTERNAL")}>
<SelectTrigger className={selectTriggerClass}>
<SelectValue placeholder="Visibilidade" />
</SelectTrigger>
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
<SelectItem value="PUBLIC">Pública</SelectItem>
<SelectItem value="INTERNAL">Interna</SelectItem>
</SelectContent>
</Select>
</div>
<Button type="submit" size="sm">Enviar</Button>
<Button type="submit" size="sm" className={submitButtonClass}>
Enviar
</Button>
</div>
</form>
<Dialog open={!!preview} onOpenChange={(o) => !o && setPreview(null)}>
<Dialog open={!!preview} onOpenChange={(open) => !open && setPreview(null)}>
<DialogContent className="max-w-3xl p-0">
{preview ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={preview} alt="Preview" className="h-auto w-full rounded-xl" />
) : null}
{preview ? <img src={preview} alt="Preview" className="h-auto w-full rounded-xl" /> : null}
</DialogContent>
</Dialog>
</CardContent>

View file

@ -10,7 +10,6 @@ import type { Id } from "@/convex/_generated/dataModel";
import type { TicketWithDetails } from "@/lib/schemas/ticket";
import { getTicketById } from "@/lib/mocks/tickets";
import { Card, CardContent } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { TicketComments } from "@/components/tickets/ticket-comments.rich";
import { TicketDetailsPanel } from "@/components/tickets/ticket-details-panel";
@ -28,7 +27,7 @@ export function TicketDetailView({ id }: { id: string }) {
}
if (!ticket) return (
<div className="flex flex-col gap-6 px-4 lg:px-6">
<Card className="rounded-xl border bg-card shadow-sm">
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardContent className="space-y-3 p-6">
<div className="flex items-center gap-2"><Skeleton className="h-5 w-24" /><Skeleton className="h-5 w-20" /></div>
<Skeleton className="h-7 w-2/3" />
@ -36,7 +35,7 @@ export function TicketDetailView({ id }: { id: string }) {
</CardContent>
</Card>
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
<Card className="rounded-xl border bg-card shadow-sm">
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardContent className="space-y-4 p-6">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="space-y-2">
@ -46,7 +45,7 @@ export function TicketDetailView({ id }: { id: string }) {
))}
</CardContent>
</Card>
<Card className="rounded-xl border bg-card shadow-sm">
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardContent className="space-y-3 p-6">
{Array.from({ length: 5 }).map((_, i) => (<Skeleton key={i} className="h-3 w-full" />))}
</CardContent>

View file

@ -11,75 +11,75 @@ interface TicketDetailsPanelProps {
ticket: TicketWithDetails
}
const queueBadgeClass = "inline-flex items-center rounded-full border border-slate-200 bg-white px-2.5 py-1 text-xs font-semibold text-neutral-700"
const tagBadgeClass = "inline-flex items-center gap-1 rounded-full border border-slate-200 bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-neutral-700"
const iconAccentClass = "size-3 text-neutral-700"
export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
return (
<Card className="rounded-xl border bg-card shadow-sm">
<CardHeader className="px-4">
<CardTitle className="text-lg font-semibold">Detalhes</CardTitle>
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardHeader className="px-4 pb-3">
<CardTitle className="text-lg font-semibold text-neutral-900">Detalhes</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-5 px-4 pb-6 text-sm text-muted-foreground">
<CardContent className="flex flex-col gap-5 px-4 pb-6 text-sm text-neutral-700">
<div className="space-y-1 break-words">
<p className="text-xs font-semibold uppercase tracking-wide">Fila</p>
<Badge variant="outline" className="max-w-full truncate">{ticket.queue ?? "Sem fila"}</Badge>
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Fila</p>
<Badge className={queueBadgeClass}>{ticket.queue ?? "Sem fila"}</Badge>
</div>
<Separator />
<Separator className="bg-slate-200" />
<div className="space-y-2 break-words">
<p className="text-xs font-semibold uppercase tracking-wide">SLA</p>
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">SLA</p>
{ticket.slaPolicy ? (
<div className="flex flex-col gap-2 rounded-lg border border-dashed bg-card px-3 py-2">
<span className="text-foreground text-sm font-medium leading-tight">{ticket.slaPolicy.name}</span>
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
<div className="flex flex-col gap-2 rounded-xl border border-slate-200 bg-white px-3 py-2 shadow-sm">
<span className="text-sm font-semibold text-neutral-900">{ticket.slaPolicy.name}</span>
<div className="flex flex-col gap-1 text-xs text-neutral-600">
{ticket.slaPolicy.targetMinutesToFirstResponse ? (
<span className="leading-normal">
Resposta inicial: {ticket.slaPolicy.targetMinutesToFirstResponse} min
</span>
<span>Resposta inicial: {ticket.slaPolicy.targetMinutesToFirstResponse} min</span>
) : null}
{ticket.slaPolicy.targetMinutesToResolution ? (
<span className="leading-normal">
Resolução: {ticket.slaPolicy.targetMinutesToResolution} min
</span>
<span>Resolução: {ticket.slaPolicy.targetMinutesToResolution} min</span>
) : null}
</div>
</div>
) : (
<span>Sem política atribuída.</span>
<span className="text-neutral-600">Sem política atribuída.</span>
)}
</div>
<Separator />
<Separator className="bg-slate-200" />
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide">Métricas</p>
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Métricas</p>
{ticket.metrics ? (
<div className="flex flex-col gap-2 text-xs text-muted-foreground">
<div className="flex flex-col gap-2 text-xs text-neutral-700">
<span className="flex items-center gap-2">
<IconClockHour4 className="size-3" /> Tempo aguardando: {ticket.metrics.timeWaitingMinutes ?? "-"} min
<IconClockHour4 className={iconAccentClass} /> Tempo aguardando: {ticket.metrics.timeWaitingMinutes ?? "-"} min
</span>
<span className="flex items-center gap-2">
<IconAlertTriangle className="size-3" /> Tempo aberto: {ticket.metrics.timeOpenedMinutes ?? "-"} min
<IconAlertTriangle className={iconAccentClass} /> Tempo aberto: {ticket.metrics.timeOpenedMinutes ?? "-"} min
</span>
</div>
) : (
<span>Sem dados de SLA ainda.</span>
<span className="text-neutral-600">Sem dados de SLA ainda.</span>
)}
</div>
<Separator />
<Separator className="bg-slate-200" />
<div className="space-y-2 break-words">
<p className="text-xs font-semibold uppercase tracking-wide">Tags</p>
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Tags</p>
<div className="flex flex-wrap gap-2">
{ticket.tags?.length ? (
ticket.tags.map((tag) => (
<Badge key={tag} variant="outline" className="gap-1">
<IconTags className="size-3" /> {tag}
<Badge key={tag} className={tagBadgeClass}>
<IconTags className={iconAccentClass} /> {tag}
</Badge>
))
) : (
<span>Sem tags.</span>
<span className="text-neutral-600">Sem tags.</span>
)}
</div>
</div>
<Separator />
<Separator className="bg-slate-200" />
<div className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-wide">Histórico</p>
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Histórico</p>
<div className="flex flex-col gap-1 text-xs text-neutral-600">
<span>Criado: {format(ticket.createdAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
<span>Atualizado: {format(ticket.updatedAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
{ticket.resolvedAt ? (

View file

@ -1,7 +1,6 @@
"use client"
import { useQuery } from "convex/react"
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { api } from "@/convex/_generated/api"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
@ -16,45 +15,47 @@ interface TicketQueueSummaryProps {
export function TicketQueueSummaryCards({ queues }: TicketQueueSummaryProps) {
const fromServer = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID })
const data: TicketQueueSummary[] = (queues ?? (fromServer as TicketQueueSummary[] | undefined) ?? [])
if (!queues && fromServer === undefined) {
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="rounded-xl border bg-card p-4">
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
<div className="mt-4 h-3 w-full animate-pulse rounded bg-muted" />
{Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="h-4 w-24 animate-pulse rounded bg-slate-100" />
<div className="mt-4 h-3 w-full animate-pulse rounded bg-slate-100" />
</div>
))}
</div>
)
}
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{data.map((queue) => {
const total = queue.pending + queue.waiting
const breachPercent = total === 0 ? 0 : Math.round((queue.breached / total) * 100)
return (
<Card key={queue.id} className="border border-border/60 bg-gradient-to-br from-background to-card p-4">
<Card key={queue.id} className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
<CardHeader className="pb-2">
<CardDescription>Fila</CardDescription>
<CardTitle className="text-lg font-semibold">{queue.name}</CardTitle>
<CardDescription className="text-xs uppercase tracking-wide text-neutral-500">Fila</CardDescription>
<CardTitle className="text-lg font-semibold text-neutral-900">{queue.name}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
<div className="flex justify-between text-muted-foreground">
<CardContent className="flex flex-col gap-3 text-sm text-neutral-600">
<div className="flex justify-between">
<span>Pendentes</span>
<span className="font-medium text-foreground">{queue.pending}</span>
<span className="font-semibold text-neutral-900">{queue.pending}</span>
</div>
<div className="flex justify-between text-muted-foreground">
<div className="flex justify-between">
<span>Aguardando resposta</span>
<span className="font-medium text-foreground">{queue.waiting}</span>
<span className="font-semibold text-neutral-900">{queue.waiting}</span>
</div>
<div className="flex items-center justify-between text-muted-foreground">
<div className="flex items-center justify-between">
<span>Violados</span>
<span className="font-medium text-destructive">{queue.breached}</span>
<span className="font-semibold text-red-600">{queue.breached}</span>
</div>
<div className="pt-1.5">
<Progress value={breachPercent} className="h-1.5" />
<span className="mt-2 block text-xs text-muted-foreground">
<Progress value={breachPercent} className="h-1.5 bg-slate-100" indicatorClassName="bg-[#00e8ff]" />
<span className="mt-2 block text-xs text-neutral-500">
{breachPercent}% com SLA violado nesta fila
</span>
</div>

View file

@ -1,9 +1,9 @@
"use client"
import { useState } from "react"
import { useMemo, useState } from "react"
import { format } from "date-fns"
import { ptBR } from "date-fns/locale"
import { IconClock, IconUserCircle } from "@tabler/icons-react"
import { IconPlayerPause, IconPlayerPlay } from "@tabler/icons-react"
import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner"
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
@ -15,91 +15,154 @@ import type { Doc, Id } from "@/convex/_generated/dataModel"
import type { TicketWithDetails, TicketQueueSummary, TicketStatus } from "@/lib/schemas/ticket"
import { Badge } from "@/components/ui/badge"
import { Separator } from "@/components/ui/separator"
import { TicketPriorityPill } from "@/components/tickets/priority-pill"
import { PrioritySelect } from "@/components/tickets/priority-select"
import { DeleteTicketDialog } from "@/components/tickets/delete-ticket-dialog"
import { TicketStatusBadge } from "@/components/tickets/status-badge"
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"
interface TicketHeaderProps {
ticket: TicketWithDetails
}
const cardClass = "space-y-4 rounded-2xl border border-slate-200 bg-white p-6 shadow-sm"
const referenceBadgeClass = "inline-flex items-center gap-1 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-semibold text-neutral-700"
const startButtonClass = "inline-flex items-center gap-1 rounded-lg border border-black bg-[#00e8ff] px-3 py-1.5 text-sm font-semibold text-black transition hover:bg-[#00d6eb]"
const pauseButtonClass = "inline-flex items-center gap-1 rounded-lg border border-black bg-black px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-black/90"
const editButtonClass = "inline-flex items-center gap-1 rounded-lg border border-black bg-black px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-black/90"
const selectTriggerClass = "h-8 w-[220px] rounded-lg border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]"
const smallSelectTriggerClass = "h-8 w-[180px] rounded-lg border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0 data-[state=open]:border-[#00d6eb]"
const sectionLabelClass = "text-xs font-semibold uppercase tracking-wide text-neutral-500"
const sectionValueClass = "font-medium text-neutral-900"
export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const { userId } = useAuth()
const updateStatus = useMutation(api.tickets.updateStatus)
const changeAssignee = useMutation(api.tickets.changeAssignee)
const changeQueue = useMutation(api.tickets.changeQueue)
const updateSubject = useMutation(api.tickets.updateSubject)
const updateSummary = useMutation(api.tickets.updateSummary)
const toggleWork = useMutation(api.tickets.toggleWork)
const agents = (useQuery(api.users.listAgents, { tenantId: ticket.tenantId }) as Doc<"users">[] | undefined) ?? []
const queues = (useQuery(api.queues.summary, { tenantId: ticket.tenantId }) as TicketQueueSummary[] | undefined) ?? []
const [status, setStatus] = useState<TicketStatus>(ticket.status)
const statusPt: Record<string, string> = {
NEW: "Novo",
OPEN: "Aberto",
PENDING: "Pendente",
ON_HOLD: "Em espera",
RESOLVED: "Resolvido",
CLOSED: "Fechado",
const [status] = useState<TicketStatus>(ticket.status)
const [editing, setEditing] = useState(false)
const [subject, setSubject] = useState(ticket.subject)
const [summary, setSummary] = useState(ticket.summary ?? "")
const dirty = useMemo(
() => subject !== ticket.subject || (summary ?? "") !== (ticket.summary ?? ""),
[subject, summary, ticket.subject, ticket.summary]
)
async function handleSave() {
if (!userId) return
toast.loading("Salvando alterações...", { id: "save-header" })
try {
if (subject !== ticket.subject) {
await updateSubject({ ticketId: ticket.id as Id<"tickets">, subject: subject.trim(), actorId: userId as Id<"users"> })
}
if ((summary ?? "") !== (ticket.summary ?? "")) {
await updateSummary({ ticketId: ticket.id as Id<"tickets">, summary: (summary ?? "").trim(), actorId: userId as Id<"users"> })
}
toast.success("Cabeçalho atualizado!", { id: "save-header" })
setEditing(false)
} catch {
toast.error("Não foi possível salvar.", { id: "save-header" })
}
}
function handleCancel() {
setSubject(ticket.subject)
setSummary(ticket.summary ?? "")
setEditing(false)
}
const lastWork = [...ticket.timeline].reverse().find((e) => e.type === "WORK_STARTED" || e.type === "WORK_PAUSED")
const isPlaying = lastWork?.type === "WORK_STARTED"
return (
<div className="space-y-4 rounded-xl border bg-card p-6 shadow-sm">
<div className={cardClass}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-2">
<div className="flex items-center gap-3">
<Badge variant="outline" className="rounded-full px-3 py-1 text-xs uppercase tracking-wide">
#{ticket.reference}
</Badge>
<PrioritySelect ticketId={ticket.id as any} value={ticket.priority as any} />
<TicketStatusBadge status={status} />
<Select
value={status}
onValueChange={async (value) => {
const prev = status
setStatus(value as import("@/lib/schemas/ticket").TicketStatus) // otimista
<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={async () => {
if (!userId) return
toast.loading("Atualizando status…", { id: "status" })
try {
await updateStatus({ ticketId: ticket.id as Id<"tickets">, status: value, actorId: userId as Id<"users"> })
toast.success(`Status alterado para ${statusPt[value]}.`, { id: "status" })
} catch (e) {
setStatus(prev)
toast.error("Não foi possível alterar o status.", { id: "status" })
}
const next = await toggleWork({ ticketId: ticket.id as Id<"tickets">, actorId: userId as Id<"users"> })
if (next) toast.success("Atendimento iniciado", { id: "work" })
else toast.success("Atendimento pausado", { id: "work" })
}}
>
<SelectTrigger className="h-8 w-[150px]">
<SelectValue placeholder="Alterar status" />
</SelectTrigger>
<SelectContent>
{(["NEW","OPEN","PENDING","ON_HOLD","RESOLVED","CLOSED"]).map((s) => (
<SelectItem key={s} value={s}>{statusPt[s]}</SelectItem>
))}
</SelectContent>
</Select>
{isPlaying ? (
<>
<IconPlayerPause className="size-4 text-white" /> Pausar
</>
) : (
<>
<IconPlayerPlay className="size-4 text-black" /> Iniciar
</>
)}
</Button>
</div>
<h1 className="text-2xl font-semibold text-foreground break-words">{ticket.subject}</h1>
<p className="max-w-2xl text-sm text-muted-foreground">{ticket.summary}</p>
<div className="ms-auto flex items-center gap-2">
<DeleteTicketDialog ticketId={ticket.id as any} />
{editing ? (
<div className="space-y-2">
<Input
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="h-10 rounded-lg border border-slate-300 text-lg font-semibold text-neutral-900"
/>
<textarea
value={summary}
onChange={(e) => setSummary(e.target.value)}
rows={3}
className="w-full rounded-lg border border-slate-300 bg-white p-3 text-sm text-neutral-800 shadow-sm"
placeholder="Adicione um resumo opcional"
/>
</div>
) : (
<div className="space-y-1">
<h1 className="break-words text-2xl font-semibold text-neutral-900">{subject}</h1>
{summary ? <p className="max-w-2xl text-sm text-neutral-600">{summary}</p> : null}
</div>
)}
<div className="flex items-center gap-2">
{editing ? (
<>
<Button variant="ghost" size="sm" className="text-sm font-semibold text-neutral-700" onClick={handleCancel}>
Cancelar
</Button>
<Button size="sm" className={startButtonClass} onClick={handleSave} disabled={!dirty}>
Salvar
</Button>
</>
) : (
<Button size="sm" className={editButtonClass} onClick={() => setEditing(true)}>
Editar
</Button>
)}
<DeleteTicketDialog ticketId={ticket.id as Id<"tickets">} />
</div>
</div>
</div>
<Separator />
<div className="grid gap-4 text-sm text-muted-foreground sm:grid-cols-2 lg:grid-cols-3">
<div className="flex items-center gap-2">
<IconUserCircle className="size-4" />
Solicitante:
<span className="font-medium text-foreground">{ticket.requester.name}</span>
<Separator className="bg-slate-200" />
<div className="grid gap-4 text-sm text-neutral-600 sm:grid-cols-2 lg:grid-cols-3">
<div className="flex flex-col gap-1">
<span className={sectionLabelClass}>Solicitante</span>
<span className={sectionValueClass}>{ticket.requester.name}</span>
</div>
<div className="flex items-center gap-2">
<IconUserCircle className="size-4" />
Responsável:
<span className="font-medium text-foreground">{ticket.assignee?.name ?? "Aguardando atribuição"}</span>
<div className="flex flex-col gap-1">
<span className={sectionLabelClass}>Responsável</span>
<Select
value={ticket.assignee?.id ?? ""}
onValueChange={async (value) => {
if (!userId) return
toast.loading("Atribuindo responsável…", { id: "assignee" })
toast.loading("Atribuindo responsável...", { id: "assignee" })
try {
await changeAssignee({ ticketId: ticket.id as Id<"tickets">, assigneeId: value as Id<"users">, actorId: userId as Id<"users"> })
toast.success("Responsável atualizado!", { id: "assignee" })
@ -108,77 +171,65 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
}
}}
>
<SelectTrigger className="h-8 w-[220px]"><SelectValue placeholder="Selecionar" /></SelectTrigger>
<SelectContent>
{agents.map((a) => (
<SelectItem key={a._id} value={a._id}>
<span className="inline-flex items-center gap-2">
<span className="inline-flex size-6 items-center justify-center overflow-hidden rounded-full bg-muted">
{/* eslint-disable-next-line @next/next/no-img-element */}
{a.avatarUrl ? <img src={a.avatarUrl} alt={a.name} className="h-6 w-6 rounded-full object-cover" /> : <span className="text-[10px] font-medium">{a.name.split(' ').slice(0,2).map(p=>p[0]).join('').toUpperCase()}</span>}
</span>
{a.name}
</span>
<SelectTrigger className={selectTriggerClass}>
<SelectValue placeholder="Selecionar" />
</SelectTrigger>
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
{agents.map((agent) => (
<SelectItem key={agent._id} value={agent._id}>
{agent.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<IconClock className="size-4" />
Fila:
<span className="font-medium text-foreground">{ticket.queue ?? "Sem fila"}</span>
<div className="flex flex-col gap-1">
<span className={sectionLabelClass}>Fila</span>
<Select
value={ticket.queue ?? ""}
onValueChange={async (value) => {
if (!userId) return
const q = queues.find((qq) => qq.name === value)
if (!q) return
toast.loading("Atualizando fila", { id: "queue" })
const queue = queues.find((item) => item.name === value)
if (!queue) return
toast.loading("Atualizando fila...", { id: "queue" })
try {
await changeQueue({ ticketId: ticket.id as Id<"tickets">, queueId: q.id as Id<"queues">, actorId: userId as Id<"users"> })
await changeQueue({ ticketId: ticket.id as Id<"tickets">, queueId: queue.id as Id<"queues">, actorId: userId as Id<"users"> })
toast.success("Fila atualizada!", { id: "queue" })
} catch {
toast.error("Não foi possível atualizar a fila.", { id: "queue" })
}
}}
>
<SelectTrigger className="h-8 w-[180px]"><SelectValue placeholder="Selecionar" /></SelectTrigger>
<SelectContent>
{queues.map((q) => (
<SelectItem key={q.id} value={q.name}>{q.name}</SelectItem>
<SelectTrigger className={smallSelectTriggerClass}>
<SelectValue placeholder="Selecionar" />
</SelectTrigger>
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
{queues.map((queue) => (
<SelectItem key={queue.id} value={queue.name}>
{queue.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<IconClock className="size-4" />
Atualizado em:
<span className="font-medium text-foreground">
{format(ticket.updatedAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}
</span>
<div className="flex flex-col gap-1">
<span className={sectionLabelClass}>Atualizado em</span>
<span className={sectionValueClass}>{format(ticket.updatedAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
</div>
<div className="flex items-center gap-2">
<IconClock className="size-4" />
Criado em:
<span className="font-medium text-foreground">
{format(ticket.createdAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}
</span>
<div className="flex flex-col gap-1">
<span className={sectionLabelClass}>Criado em</span>
<span className={sectionValueClass}>{format(ticket.createdAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
</div>
{ticket.dueAt ? (
<div className="flex items-center gap-2">
<IconClock className="size-4" />
SLA ate:
<span className="font-medium text-foreground">
{format(ticket.dueAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}
</span>
<div className="flex flex-col gap-1">
<span className={sectionLabelClass}>SLA até</span>
<span className={sectionValueClass}>{format(ticket.dueAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
</div>
) : null}
{ticket.slaPolicy ? (
<div className="flex items-center gap-2">
<IconClock className="size-4" />
Politica:
<span className="font-medium text-foreground">{ticket.slaPolicy.name}</span>
<div className="flex flex-col gap-1">
<span className={sectionLabelClass}>Política</span>
<span className={sectionValueClass}>{ticket.slaPolicy.name}</span>
</div>
) : null}
</div>

View file

@ -9,7 +9,6 @@ import {
} from "@tabler/icons-react"
import type { TicketWithDetails } from "@/lib/schemas/ticket"
import { cn } from "@/lib/utils"
import { Card, CardContent } from "@/components/ui/card"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Separator } from "@/components/ui/separator"
@ -19,6 +18,11 @@ const timelineIcons: Record<string, ComponentType<{ className?: string }>> = {
STATUS_CHANGED: IconSquareCheck,
ASSIGNEE_CHANGED: IconUserCircle,
COMMENT_ADDED: IconNote,
WORK_STARTED: IconClockHour4,
WORK_PAUSED: IconClockHour4,
SUBJECT_CHANGED: IconNote,
SUMMARY_CHANGED: IconNote,
QUEUE_CHANGED: IconSquareCheck,
}
const timelineLabels: Record<string, string> = {
@ -26,6 +30,10 @@ const timelineLabels: Record<string, string> = {
STATUS_CHANGED: "Status alterado",
ASSIGNEE_CHANGED: "Responsável alterado",
COMMENT_ADDED: "Comentário adicionado",
WORK_STARTED: "Atendimento iniciado",
WORK_PAUSED: "Atendimento pausado",
SUBJECT_CHANGED: "Assunto atualizado",
SUMMARY_CHANGED: "Resumo atualizado",
QUEUE_CHANGED: "Fila alterada",
}
@ -35,50 +43,79 @@ interface TicketTimelineProps {
export function TicketTimeline({ ticket }: TicketTimelineProps) {
return (
<Card className="rounded-xl border bg-card shadow-sm">
<CardContent className="space-y-6 px-4 pb-6">
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardContent className="space-y-5 px-4 pb-6">
{ticket.timeline.map((entry, index) => {
const Icon = timelineIcons[entry.type] ?? IconClockHour4
const isLast = index === ticket.timeline.length - 1
return (
<div key={entry.id} className="relative pl-10">
<div key={entry.id} className="relative pl-11">
{!isLast && (
<span className="absolute left-[17px] top-6 h-full w-px bg-border" aria-hidden />
<span className="absolute left-[14px] top-6 h-full w-px bg-slate-200" aria-hidden />
)}
<span className="absolute left-0 top-0 flex size-8 items-center justify-center rounded-full bg-muted text-muted-foreground">
<span className="absolute left-0 top-0 flex size-8 items-center justify-center rounded-full border border-slate-200 bg-white text-neutral-700 shadow-sm">
<Icon className="size-4" />
</span>
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="text-sm font-medium text-foreground">
<span className="text-sm font-semibold text-neutral-900">
{timelineLabels[entry.type] ?? entry.type}
</span>
{entry.payload?.actorName ? (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Avatar className="size-5">
<span className="flex items-center gap-1 text-xs text-neutral-500">
<Avatar className="size-5 border border-slate-200">
<AvatarImage src={entry.payload?.actorAvatar as string | undefined} alt={String(entry.payload?.actorName ?? "")} />
<AvatarFallback>
{String(entry.payload?.actorName ?? '').split(' ').slice(0,2).map((p:string)=>p[0]).join('').toUpperCase()}
{String(entry.payload?.actorName ?? "").split(" ").slice(0, 2).map((part: string) => part[0]).join("").toUpperCase()}
</AvatarFallback>
</Avatar>
por {String(entry.payload?.actorName ?? '')}
por {String(entry.payload?.actorName ?? "")}
</span>
) : null}
<span className="text-xs text-muted-foreground">
<span className="text-xs text-neutral-500">
{format(entry.createdAt, "dd MMM yyyy HH:mm", { locale: ptBR })}
</span>
</div>
{(() => {
const p = (entry.payload || {}) as { toLabel?: string; to?: string; assigneeName?: string; assigneeId?: string; queueName?: string; queueId?: string; requesterName?: string; authorName?: string; authorId?: string }
const payload = (entry.payload || {}) as {
toLabel?: string
to?: string
assigneeName?: string
assigneeId?: string
queueName?: string
queueId?: string
requesterName?: string
authorName?: string
authorId?: string
from?: string
}
let message: string | null = null
if (entry.type === "STATUS_CHANGED" && (p.toLabel || p.to)) message = `Status alterado para ${p.toLabel || p.to}`
if (entry.type === "ASSIGNEE_CHANGED" && (p.assigneeName || p.assigneeId)) message = `Responsável alterado${p.assigneeName ? ` para ${p.assigneeName}` : ""}`
if (entry.type === "QUEUE_CHANGED" && (p.queueName || p.queueId)) message = `Fila alterada${p.queueName ? ` para ${p.queueName}` : ""}`
if (entry.type === "CREATED" && (p.requesterName)) message = `Criado por ${p.requesterName}`
if (entry.type === "COMMENT_ADDED" && (p.authorName || p.authorId)) message = `Comentário adicionado${p.authorName ? ` por ${p.authorName}` : ""}`
if (entry.type === "STATUS_CHANGED" && (payload.toLabel || payload.to)) {
message = "Status alterado para " + (payload.toLabel || payload.to)
}
if (entry.type === "ASSIGNEE_CHANGED" && (payload.assigneeName || payload.assigneeId)) {
message = "Responsável alterado" + (payload.assigneeName ? " para " + payload.assigneeName : "")
}
if (entry.type === "QUEUE_CHANGED" && (payload.queueName || payload.queueId)) {
message = "Fila alterada" + (payload.queueName ? " para " + payload.queueName : "")
}
if (entry.type === "CREATED" && payload.requesterName) {
message = "Criado por " + payload.requesterName
}
if (entry.type === "COMMENT_ADDED" && (payload.authorName || payload.authorId)) {
message = "Comentário adicionado" + (payload.authorName ? " por " + payload.authorName : "")
}
if (entry.type === "SUBJECT_CHANGED" && (payload.to || payload.toLabel)) {
message = "Assunto alterado" + (payload.to ? " para \"" + payload.to + "\"" : "")
}
if (entry.type === "SUMMARY_CHANGED") {
message = "Resumo atualizado"
}
if (!message) return null
return (
<div className="rounded-lg border border-dashed bg-card px-3 py-2 text-sm text-muted-foreground">
<div className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-neutral-600">
{message}
</div>
)
@ -87,8 +124,8 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
</div>
)
})}
<Separator className="bg-slate-200" />
</CardContent>
</Card>
)
}

View file

@ -40,7 +40,7 @@ const priorityOptions = ticketPrioritySchema.options.map((priority) => ({
value: priority,
label: {
LOW: "Baixa",
MEDIUM: "Media",
MEDIUM: "Média",
HIGH: "Alta",
URGENT: "Urgente",
}[priority],
@ -90,7 +90,7 @@ export function TicketsFilters({ onChange, queues = [] }: TicketsFiltersProps) {
setFilters((prev) => ({ ...prev, ...partial }))
}
// Propaga as mudanças de filtros para o pai sem disparar durante render
// Propaga as mudancas de filtros para o componente pai sem disparar durante a renderizacao
useEffect(() => {
onChange?.(filters)
}, [filters, onChange])
@ -134,14 +134,18 @@ export function TicketsFilters({ onChange, queues = [] }: TicketsFiltersProps) {
<div className="flex items-center gap-2">
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<IconFilter className="size-4" />
<Button
variant="outline"
size="sm"
className="gap-2 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-neutral-800 shadow-sm hover:bg-slate-50"
>
<IconFilter className="size-4 text-neutral-800" />
Filtros
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 space-y-4">
<div className="space-y-2">
<p className="text-xs font-semibold uppercase text-muted-foreground">
<p className="text-xs font-semibold uppercase text-neutral-500">
Status
</p>
<Select
@ -162,7 +166,7 @@ export function TicketsFilters({ onChange, queues = [] }: TicketsFiltersProps) {
</Select>
</div>
<div className="space-y-2">
<p className="text-xs font-semibold uppercase text-muted-foreground">
<p className="text-xs font-semibold uppercase text-neutral-500">
Prioridade
</p>
<Select
@ -183,7 +187,7 @@ export function TicketsFilters({ onChange, queues = [] }: TicketsFiltersProps) {
</Select>
</div>
<div className="space-y-2">
<p className="text-xs font-semibold uppercase text-muted-foreground">
<p className="text-xs font-semibold uppercase text-neutral-500">
Canal
</p>
<Select
@ -208,7 +212,7 @@ export function TicketsFilters({ onChange, queues = [] }: TicketsFiltersProps) {
<Button
variant="ghost"
size="sm"
className="gap-2"
className="gap-2 rounded-lg px-3 py-2 text-sm font-medium text-neutral-700 hover:bg-slate-100"
onClick={() => setPartial(defaultTicketFilters)}
>
<IconRefresh className="size-4" />
@ -219,7 +223,7 @@ export function TicketsFilters({ onChange, queues = [] }: TicketsFiltersProps) {
{activeFilters.length > 0 && (
<div className="flex flex-wrap gap-2">
{activeFilters.map((chip) => (
<Badge key={chip} variant="secondary" className="rounded-full px-3 py-1 text-xs">
<Badge key={chip} className="rounded-full border border-slate-300 bg-slate-100 px-3 py-1 text-xs font-medium text-neutral-700">
{chip}
</Badge>
))}

View file

@ -7,6 +7,8 @@ import type { Ticket } from "@/lib/schemas/ticket"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent } from "@/components/ui/card"
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
import { NewTicketDialog } from "@/components/tickets/new-ticket-dialog"
import {
Table,
TableBody,
@ -28,10 +30,13 @@ const channelLabel: Record<string, string> = {
}
const cellClass = "py-4 align-top"
const queueBadgeClass = "inline-flex items-center rounded-full border border-slate-200 bg-white px-2.5 py-1 text-xs font-semibold text-neutral-700"
const channelBadgeClass = "inline-flex items-center gap-2 rounded-full border border-[#00c4d7]/40 bg-[#00e8ff]/15 px-2.5 py-1 text-xs font-semibold text-neutral-900"
const tagBadgeClass = "inline-flex items-center rounded-full border border-slate-200 bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-neutral-700"
function AssigneeCell({ ticket }: { ticket: Ticket }) {
if (!ticket.assignee) {
return <span className="text-sm text-muted-foreground">Sem responsável</span>
return <span className="text-sm text-neutral-600">Sem responsável</span>
}
const initials = ticket.assignee.name
@ -42,15 +47,15 @@ function AssigneeCell({ ticket }: { ticket: Ticket }) {
return (
<div className="flex items-center gap-2">
<Avatar className="size-8">
<Avatar className="size-8 border border-slate-200">
<AvatarImage src={ticket.assignee.avatarUrl} alt={ticket.assignee.name} />
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<span className="text-sm font-medium leading-none text-foreground">
<span className="text-sm font-semibold leading-none text-neutral-900">
{ticket.assignee.name}
</span>
<span className="text-xs text-muted-foreground">
<span className="text-xs text-neutral-600">
{ticket.assignee.teams?.[0] ?? "Agente"}
</span>
</div>
@ -58,17 +63,17 @@ function AssigneeCell({ ticket }: { ticket: Ticket }) {
)
}
type TicketsTableProps = {
export type TicketsTableProps = {
tickets?: Ticket[]
}
export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
return (
<Card className="border bg-card/90 shadow-sm">
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
<CardContent className="px-4 py-4 sm:px-6">
<Table className="min-w-full">
<TableHeader>
<TableRow className="text-xs uppercase tracking-wide text-muted-foreground">
<TableRow className="text-[11px] uppercase tracking-wide text-neutral-500">
<TableHead className="w-[110px]">Ticket</TableHead>
<TableHead>Assunto</TableHead>
<TableHead className="hidden lg:table-cell">Fila</TableHead>
@ -81,16 +86,16 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
</TableHeader>
<TableBody>
{tickets.map((ticket) => (
<TableRow key={ticket.id} className="group hover:bg-muted/40">
<TableRow key={ticket.id} className="group border-b border-slate-100 transition hover:bg-[#00e8ff]/8">
<TableCell className={cellClass}>
<div className="flex flex-col gap-0.5">
<Link
href={`/tickets/${ticket.id}`}
className="font-semibold tracking-tight text-primary hover:underline"
className="font-semibold tracking-tight text-neutral-900 hover:text-[#00b8ce]"
>
#{ticket.reference}
</Link>
<span className="text-xs text-muted-foreground">
<span className="text-xs text-neutral-500">
{ticket.queue ?? "Sem fila"}
</span>
</div>
@ -99,21 +104,17 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
<div className="flex flex-col gap-1">
<Link
href={`/tickets/${ticket.id}`}
className="line-clamp-1 font-medium text-foreground hover:underline"
className="line-clamp-1 font-semibold text-neutral-900 hover:text-[#00b8ce]"
>
{ticket.subject}
</Link>
<span className="line-clamp-1 text-sm text-muted-foreground">
<span className="line-clamp-1 text-sm text-neutral-600">
{ticket.summary ?? "Sem resumo"}
</span>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>{ticket.requester.name}</span>
<div className="flex flex-wrap gap-2 text-xs text-neutral-500">
<span className="font-medium text-neutral-900">{ticket.requester.name}</span>
{ticket.tags?.map((tag) => (
<Badge
key={tag}
variant="outline"
className="rounded-full border-transparent bg-slate-100 px-2 py-1 text-xs text-slate-600"
>
<Badge key={tag} className={tagBadgeClass}>
{tag}
</Badge>
))}
@ -121,12 +122,11 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
</div>
</TableCell>
<TableCell className={`${cellClass} hidden lg:table-cell`}>
<Badge className="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600">
{ticket.queue ?? "Sem fila"}
</Badge>
<Badge className={queueBadgeClass}>{ticket.queue ?? "Sem fila"}</Badge>
</TableCell>
<TableCell className={`${cellClass} hidden md:table-cell`}>
<Badge className="rounded-full bg-blue-50 px-2.5 py-1 text-xs font-medium text-blue-600">
<Badge className={channelBadgeClass}>
<span className="inline-block size-2 rounded-full border border-[#009bb1] bg-[#00e8ff]" />
{channelLabel[ticket.channel] ?? ticket.channel}
</Badge>
</TableCell>
@ -137,8 +137,8 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
<div className="flex flex-col gap-1">
<TicketStatusBadge status={ticket.status} />
{ticket.metrics?.timeWaitingMinutes ? (
<span className="text-xs text-muted-foreground">
Espera {ticket.metrics.timeWaitingMinutes} min
<span className="text-xs text-neutral-500">
Em espera {ticket.metrics.timeWaitingMinutes} min
</span>
) : null}
</div>
@ -147,11 +147,8 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
<AssigneeCell ticket={ticket} />
</TableCell>
<TableCell className={cellClass}>
<span className="text-sm text-muted-foreground">
{formatDistanceToNow(ticket.updatedAt, {
addSuffix: true,
locale: ptBR,
})}
<span className="text-sm text-neutral-500">
{formatDistanceToNow(ticket.updatedAt, { addSuffix: true, locale: ptBR })}
</span>
</TableCell>
</TableRow>
@ -159,12 +156,20 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
</TableBody>
</Table>
{tickets.length === 0 && (
<div className="flex flex-col items-center justify-center gap-2 py-10 text-center text-sm">
<p className="text-sm font-medium">Nenhum ticket encontrado</p>
<p className="text-sm text-muted-foreground">
Ajuste os filtros ou selecione outra fila.
</p>
</div>
<Empty className="my-6">
<EmptyHeader>
<EmptyMedia variant="icon">
<span className="inline-block size-3 rounded-full border border-slate-300 bg-[#00e8ff]" />
</EmptyMedia>
<EmptyTitle className="text-neutral-900">Nenhum ticket encontrado</EmptyTitle>
<EmptyDescription className="text-neutral-600">
Ajuste os filtros ou crie um novo ticket.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<NewTicketDialog />
</EmptyContent>
</Empty>
)}
</CardContent>
</Card>

View file

@ -10,7 +10,6 @@ import { mapTicketsFromServerList } from "@/lib/mappers/ticket"
import type { Ticket, TicketQueueSummary } from "@/lib/schemas/ticket"
import { TicketsFilters, TicketFiltersState, defaultTicketFilters } from "@/components/tickets/tickets-filters"
import { TicketsTable } from "@/components/tickets/tickets-table"
import { Spinner } from "@/components/ui/spinner"
export function TicketsView() {
const [filters, setFilters] = useState<TicketFiltersState>(defaultTicketFilters)
@ -36,12 +35,12 @@ export function TicketsView() {
<div className="flex flex-col gap-6 px-4 lg:px-6">
<TicketsFilters onChange={setFilters} queues={(queues ?? []).map((q) => q.name)} />
{ticketsRaw === undefined ? (
<div className="rounded-xl border bg-card p-4">
<div className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="grid gap-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex items-center justify-between gap-3">
<div className="h-4 w-48 animate-pulse rounded bg-muted" />
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
<div className="h-4 w-48 animate-pulse rounded bg-slate-100" />
<div className="h-4 w-24 animate-pulse rounded bg-slate-100" />
</div>
))}
</div>

View file

@ -4,10 +4,11 @@ import { cn } from "@/lib/utils"
type ProgressProps = React.HTMLAttributes<HTMLDivElement> & {
value?: number
indicatorClassName?: string
}
export const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
({ className, value = 0, ...props }, ref) => {
({ className, value = 0, indicatorClassName, ...props }, ref) => {
const clamped = Math.min(100, Math.max(0, value))
return (
<div
@ -19,7 +20,7 @@ export const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
{...props}
>
<div
className="h-full w-full flex-1 bg-primary transition-all"
className={cn("h-full w-full flex-1 bg-primary transition-all", indicatorClassName)}
style={{ transform: `translateX(-${100 - clamped}%)` }}
/>
</div>

View file

@ -10,6 +10,16 @@ const Toaster = ({ ...props }: ToasterProps) => {
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast: "border border-black bg-black text-white shadow-md",
title: "font-medium",
description: "text-white/80",
icon: "text-cyan-400",
actionButton: "bg-white text-black border border-black",
cancelButton: "bg-transparent text-white border border-white/40",
},
}}
style={
{
"--normal-bg": "var(--popover)",