Merge pull request #5 from esdrasrenan/feat/convex-tickets-core
Feat/convex tickets core
This commit is contained in:
commit
f6026f83a5
22 changed files with 1104 additions and 799 deletions
32
agents.md
32
agents.md
|
|
@ -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 dropdown‑badge (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é (bottom‑center) 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 dropdown‑badge 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.
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
67
web/src/components/tickets/status-select.tsx
Normal file
67
web/src/components/tickets/status-select.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,15 +20,19 @@ 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)
|
||||
|
|
@ -40,52 +43,70 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
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,51 +114,57 @@ 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) {
|
||||
{comment.attachments.map((attachment) => {
|
||||
const isImage = (attachment?.url ?? "").match(/\.(png|jpe?g|gif|webp|svg)$/i)
|
||||
if (isImage && attachment.url) {
|
||||
return (
|
||||
<button
|
||||
key={att.id}
|
||||
key={attachment.id}
|
||||
type="button"
|
||||
onClick={() => setPreview(att.url || null)}
|
||||
className="group overflow-hidden rounded-lg border bg-card p-0.5 hover:shadow"
|
||||
onClick={() => setPreview(attachment.url || null)}
|
||||
className="group overflow-hidden rounded-lg border border-slate-200 bg-white p-0.5 hover:border-slate-400"
|
||||
>
|
||||
{/* 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}
|
||||
<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={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
|
||||
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>
|
||||
)
|
||||
})}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4 rounded-xl border bg-card p-6 shadow-sm">
|
||||
<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
|
||||
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("Atualizando status…", { id: "status" })
|
||||
toast.loading("Salvando alterações...", { id: "save-header" })
|
||||
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" })
|
||||
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={cardClass}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Badge className={referenceBadgeClass}>#{ticket.reference}</Badge>
|
||||
<PrioritySelect ticketId={ticket.id} value={ticket.priority} />
|
||||
<StatusSelect ticketId={ticket.id} value={status} />
|
||||
<Button
|
||||
size="sm"
|
||||
className={isPlaying ? pauseButtonClass : startButtonClass}
|
||||
onClick={async () => {
|
||||
if (!userId) return
|
||||
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>
|
||||
<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>
|
||||
{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 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>
|
||||
</div>
|
||||
<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 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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 há {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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue