style: refresh ticket ui components
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
parent
5c16ab75a6
commit
744d5933d4
16 changed files with 718 additions and 650 deletions
|
|
@ -5,7 +5,6 @@ import { useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { IconArrowRight, IconPlayerPlayFilled } from "@tabler/icons-react"
|
import { IconArrowRight, IconPlayerPlayFilled } from "@tabler/icons-react"
|
||||||
import { useMutation, useQuery } from "convex/react"
|
import { useMutation, useQuery } from "convex/react"
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { api } from "@/convex/_generated/api"
|
import { api } from "@/convex/_generated/api"
|
||||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
|
|
@ -26,6 +25,10 @@ interface PlayNextTicketCardProps {
|
||||||
context?: TicketPlayContext
|
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) {
|
export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { userId } = useAuth()
|
const { userId } = useAuth()
|
||||||
|
|
@ -43,16 +46,29 @@ export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
|
||||||
})?.[0]
|
})?.[0]
|
||||||
const nextTicketUi = nextTicketFromServer ? mapTicketFromServer(nextTicketFromServer as unknown) : null
|
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) {
|
if (!cardContext || !cardContext.nextTicket) {
|
||||||
return (
|
return (
|
||||||
<Card className="rounded-xl border bg-card shadow-sm">
|
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Fila sem tickets pendentes</CardTitle>
|
<CardTitle className="text-lg font-semibold text-neutral-900">Fila sem tickets pendentes</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="text-sm text-muted-foreground">
|
<CardContent className="text-sm text-neutral-600">
|
||||||
Nenhum ticket disponivel no momento. Excelente trabalho!
|
Nenhum ticket disponível no momento. Excelente trabalho!
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|
@ -61,61 +77,73 @@ export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
|
||||||
const ticket = cardContext.nextTicket
|
const ticket = cardContext.nextTicket
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="rounded-xl border bg-card shadow-sm">
|
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||||
<CardHeader className="flex flex-row items-center justify-between gap-2">
|
<CardHeader className="flex flex-row items-center justify-between gap-2">
|
||||||
<CardTitle className="text-lg font-semibold">
|
<CardTitle className="text-lg font-semibold text-neutral-900">
|
||||||
Proximo ticket • #{ticket.reference}
|
Próximo ticket • #{ticket.reference}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<TicketPriorityPill priority={ticket.priority} />
|
<TicketPriorityPill priority={ticket.priority} />
|
||||||
</CardHeader>
|
</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">
|
<div className="flex items-center justify-end gap-2">
|
||||||
<span className="text-sm text-muted-foreground">Fila:</span>
|
<span className="text-xs uppercase tracking-wide text-neutral-500">Fila</span>
|
||||||
<Select value={selectedQueueId ?? "ALL"} onValueChange={(v) => setSelectedQueueId(v === "ALL" ? undefined : v)}>
|
<Select value={selectedQueueId ?? "ALL"} onValueChange={(value) => setSelectedQueueId(value === "ALL" ? undefined : value)}>
|
||||||
<SelectTrigger className="h-8 w-[180px]"><SelectValue placeholder="Todas" /></SelectTrigger>
|
<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]">
|
||||||
<SelectContent>
|
<SelectValue placeholder="Todas" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
|
||||||
<SelectItem value="ALL">Todas</SelectItem>
|
<SelectItem value="ALL">Todas</SelectItem>
|
||||||
{queueSummary.map((q) => (
|
{queueSummary.map((queue) => (
|
||||||
<SelectItem key={q.id} value={q.id}>{q.name}</SelectItem>
|
<SelectItem key={queue.id} value={queue.id}>
|
||||||
|
{queue.name}
|
||||||
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h2 className="text-xl font-medium text-foreground">{ticket.subject}</h2>
|
<h2 className="text-xl font-semibold text-neutral-900">{ticket.subject}</h2>
|
||||||
<p className="text-sm text-muted-foreground">{ticket.summary}</p>
|
<p className="text-sm text-neutral-600">{ticket.summary}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
|
<div className="flex flex-wrap gap-2 text-xs text-neutral-600">
|
||||||
<Badge variant="outline">{ticket.queue ?? "Sem fila"}</Badge>
|
<Badge className={queueBadgeClass}>{ticket.queue ?? "Sem fila"}</Badge>
|
||||||
<TicketStatusBadge status={ticket.status} />
|
<TicketStatusBadge status={ticket.status} />
|
||||||
<span>Solicitante: {ticket.requester.name}</span>
|
<span className="font-medium text-neutral-900">Solicitante: {ticket.requester.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator className="bg-slate-200" />
|
||||||
<div className="flex flex-col gap-3 text-sm text-muted-foreground">
|
<div className="flex flex-col gap-3 text-sm text-neutral-700">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span>Pendentes na fila</span>
|
<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>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span>Em espera</span>
|
<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>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span>SLA violado</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
className="gap-2"
|
className={startButtonClass}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (!userId) return
|
if (!userId) return
|
||||||
const chosen = await playNext({ tenantId: DEFAULT_TENANT_ID, queueId: (selectedQueueId as Id<"queues">) || undefined, agentId: userId as Id<"users"> })
|
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}`)
|
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>
|
||||||
<Button variant="ghost" asChild className="gap-2 text-sm">
|
<Button variant="ghost" asChild className={secondaryButtonClass}>
|
||||||
<Link href="/tickets">
|
<Link href="/tickets">
|
||||||
Ver lista completa
|
Ver lista completa
|
||||||
<IconArrowRight className="size-4" />
|
<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 { Badge } from "@/components/ui/badge"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const priorityConfig = {
|
const priorityStyles: Record<TicketPriority, { label: string; className: string }> = {
|
||||||
LOW: {
|
LOW: { label: "Baixa", className: "border border-slate-200 bg-slate-100 text-slate-700" },
|
||||||
label: "Baixa",
|
MEDIUM: { label: "Média", className: "border border-slate-200 bg-[#dff1fb] text-[#0a4760]" },
|
||||||
className: "bg-slate-100 text-slate-600 border-transparent",
|
HIGH: { label: "Alta", className: "border border-slate-200 bg-[#fde8d1] text-[#7d3b05]" },
|
||||||
},
|
URGENT: { label: "Urgente", className: "border border-slate-200 bg-[#fbd9dd] text-[#8b0f1c]" },
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TicketPriorityPill({ priority }: TicketPriorityPillProps) {
|
const baseClass = "inline-flex items-center rounded-full px-3 py-0.5 text-xs font-semibold"
|
||||||
const config = priorityConfig[priority]
|
|
||||||
|
export function TicketPriorityPill({ priority }: { priority: TicketPriority }) {
|
||||||
|
const styles = priorityStyles[priority]
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge className={cn(baseClass, styles?.className)}>
|
||||||
variant="outline"
|
{styles?.label ?? priority}
|
||||||
className={cn(
|
|
||||||
"rounded-full px-2.5 py-1 text-xs font-medium",
|
|
||||||
config?.className ?? ""
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{config?.label ?? priority}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,72 +11,68 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { ArrowDown, ArrowRight, ArrowUp, ChevronsUp } from "lucide-react"
|
import { ArrowDown, ArrowRight, ArrowUp, ChevronsUp } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const labels: Record<TicketPriority, string> = {
|
const priorityStyles: Record<TicketPriority, { label: string; badgeClass: string }> = {
|
||||||
LOW: "Baixa",
|
LOW: { label: "Baixa", badgeClass: "border border-slate-200 bg-slate-100 text-slate-700" },
|
||||||
MEDIUM: "Média",
|
MEDIUM: { label: "Média", badgeClass: "border border-slate-200 bg-[#dff1fb] text-[#0a4760]" },
|
||||||
HIGH: "Alta",
|
HIGH: { label: "Alta", badgeClass: "border border-slate-200 bg-[#fde8d1] text-[#7d3b05]" },
|
||||||
URGENT: "Urgente",
|
URGENT: { label: "Urgente", badgeClass: "border border-slate-200 bg-[#fbd9dd] text-[#8b0f1c]" },
|
||||||
}
|
}
|
||||||
|
|
||||||
function badgeClass(p: TicketPriority) {
|
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]"
|
||||||
switch (p) {
|
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"
|
||||||
case "URGENT":
|
const iconClass = "size-4 text-neutral-700"
|
||||||
return "bg-red-100 text-red-700"
|
const baseBadgeClass = "inline-flex items-center gap-2 rounded-full px-3 py-0.5 text-xs font-semibold"
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function PriorityIcon({ p }: { p: TicketPriority }) {
|
function PriorityIcon({ value }: { value: TicketPriority }) {
|
||||||
const cls = "size-3.5 text-cyan-600"
|
if (value === "LOW") return <ArrowDown className={iconClass} />
|
||||||
if (p === "LOW") return <ArrowDown className={cls} />
|
if (value === "MEDIUM") return <ArrowRight className={iconClass} />
|
||||||
if (p === "MEDIUM") return <ArrowRight className={cls} />
|
if (value === "HIGH") return <ArrowUp className={iconClass} />
|
||||||
if (p === "HIGH") return <ArrowUp className={cls} />
|
return <ChevronsUp className={iconClass} />
|
||||||
return <ChevronsUp className={cls} />
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PrioritySelect({ ticketId, value }: { ticketId: string; value: TicketPriority }) {
|
export function PrioritySelect({ ticketId, value }: { ticketId: string; value: TicketPriority }) {
|
||||||
const updatePriority = useMutation(api.tickets.updatePriority)
|
const updatePriority = useMutation(api.tickets.updatePriority)
|
||||||
const [priority, setPriority] = useState<TicketPriority>(value)
|
const [priority, setPriority] = useState<TicketPriority>(value)
|
||||||
const { userId } = useAuth()
|
const { userId } = useAuth()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
value={priority}
|
value={priority}
|
||||||
onValueChange={async (val) => {
|
onValueChange={async (selected) => {
|
||||||
const prev = priority
|
const previous = priority
|
||||||
const next = val as TicketPriority
|
const next = selected as TicketPriority
|
||||||
setPriority(next)
|
setPriority(next)
|
||||||
toast.loading("Atualizando prioridade...", { id: "prio" })
|
toast.loading("Atualizando prioridade...", { id: "priority" })
|
||||||
try {
|
try {
|
||||||
if (!userId) throw new Error("No user")
|
if (!userId) throw new Error("missing user")
|
||||||
await updatePriority({ ticketId: ticketId as unknown as Id<"tickets">, priority: next, actorId: userId as Id<"users"> })
|
await updatePriority({ ticketId: ticketId as unknown as Id<"tickets">, priority: next, actorId: userId as Id<"users"> })
|
||||||
toast.success("Prioridade atualizada!", { id: "prio" })
|
toast.success("Prioridade atualizada!", { id: "priority" })
|
||||||
} catch {
|
} catch {
|
||||||
setPriority(prev)
|
setPriority(previous)
|
||||||
toast.error("Não foi possível atualizar a prioridade.", { id: "prio" })
|
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>
|
<SelectValue>
|
||||||
<Badge className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 ${badgeClass(priority)}`}>
|
<Badge className={cn(baseBadgeClass, priorityStyles[priority]?.badgeClass)}>
|
||||||
<PriorityIcon p={priority} /> {labels[priority]}
|
<PriorityIcon value={priority} />
|
||||||
|
{priorityStyles[priority]?.label ?? priority}
|
||||||
</Badge>
|
</Badge>
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
|
||||||
{(["LOW","MEDIUM","HIGH","URGENT"] as const).map((p) => (
|
{(["LOW", "MEDIUM", "HIGH", "URGENT"] as const).map((option) => (
|
||||||
<SelectItem key={p} value={p}>
|
<SelectItem key={option} value={option} className={itemClass}>
|
||||||
<span className="inline-flex items-center gap-2"><PriorityIcon p={p} />{labels[p]}</span>
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<PriorityIcon value={option} />
|
||||||
|
{priorityStyles[option].label}
|
||||||
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,19 +6,17 @@ import { api } from "@/convex/_generated/api";
|
||||||
import { DEFAULT_TENANT_ID } from "@/lib/constants";
|
import { DEFAULT_TENANT_ID } from "@/lib/constants";
|
||||||
import { mapTicketsFromServerList } from "@/lib/mappers/ticket";
|
import { mapTicketsFromServerList } from "@/lib/mappers/ticket";
|
||||||
import { TicketsTable } from "@/components/tickets/tickets-table";
|
import { TicketsTable } from "@/components/tickets/tickets-table";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
|
||||||
import type { Ticket } from "@/lib/schemas/ticket";
|
|
||||||
|
|
||||||
export function RecentTicketsPanel() {
|
export function RecentTicketsPanel() {
|
||||||
const ticketsRaw = useQuery(api.tickets.list, { tenantId: DEFAULT_TENANT_ID, limit: 10 });
|
const ticketsRaw = useQuery(api.tickets.list, { tenantId: DEFAULT_TENANT_ID, limit: 10 });
|
||||||
if (ticketsRaw === undefined) {
|
if (ticketsRaw === undefined) {
|
||||||
return (
|
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">
|
<div className="grid gap-3">
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
<div key={i} className="flex items-center justify-between gap-3">
|
<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-56 animate-pulse rounded bg-slate-100" />
|
||||||
<div className="h-4 w-20 animate-pulse rounded bg-muted" />
|
<div className="h-4 w-20 animate-pulse rounded bg-slate-100" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -27,7 +25,7 @@ export function RecentTicketsPanel() {
|
||||||
}
|
}
|
||||||
const tickets = mapTicketsFromServerList((ticketsRaw ?? []) as unknown[]);
|
const tickets = mapTicketsFromServerList((ticketsRaw ?? []) as unknown[]);
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border bg-card">
|
<div className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||||
<TicketsTable tickets={tickets} />
|
<TicketsTable tickets={tickets} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,26 +2,25 @@
|
||||||
|
|
||||||
import { ticketStatusSchema, type TicketStatus } from "@/lib/schemas/ticket"
|
import { ticketStatusSchema, type TicketStatus } from "@/lib/schemas/ticket"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const statusConfig = {
|
const statusStyles: Record<TicketStatus, { label: string; className: string }> = {
|
||||||
NEW: { label: "Novo", className: "bg-slate-100 text-slate-700 border-transparent" },
|
NEW: { label: "Novo", className: "border border-slate-200 bg-slate-100 text-slate-700" },
|
||||||
OPEN: { label: "Aberto", className: "bg-blue-100 text-blue-700 border-transparent" },
|
OPEN: { label: "Aberto", className: "border border-slate-200 bg-[#dff1fb] text-[#0a4760]" },
|
||||||
PENDING: { label: "Pendente", className: "bg-amber-100 text-amber-700 border-transparent" },
|
PENDING: { label: "Pendente", className: "border border-slate-200 bg-[#fdebd6] text-[#7b4107]" },
|
||||||
ON_HOLD: { label: "Em espera", className: "bg-purple-100 text-purple-700 border-transparent" },
|
ON_HOLD: { label: "Em espera", className: "border border-slate-200 bg-[#ede8ff] text-[#4f2f96]" },
|
||||||
RESOLVED: { label: "Resolvido", className: "bg-emerald-100 text-emerald-700 border-transparent" },
|
RESOLVED: { label: "Resolvido", className: "border border-slate-200 bg-[#dcf4eb] text-[#1f6a45]" },
|
||||||
CLOSED: { label: "Fechado", className: "bg-slate-100 text-slate-700 border-transparent" },
|
CLOSED: { label: "Fechado", className: "border border-slate-200 bg-slate-200 text-slate-700" },
|
||||||
} satisfies Record<TicketStatus, { label: string; className: string }>
|
}
|
||||||
|
|
||||||
type TicketStatusBadgeProps = { status: TicketStatus }
|
type TicketStatusBadgeProps = { status: TicketStatus }
|
||||||
|
|
||||||
export function TicketStatusBadge({ status }: TicketStatusBadgeProps) {
|
export function TicketStatusBadge({ status }: TicketStatusBadgeProps) {
|
||||||
const config = statusConfig[status]
|
const parsed = ticketStatusSchema.parse(status)
|
||||||
|
const styles = statusStyles[parsed]
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge className={cn('inline-flex items-center rounded-full px-3 py-0.5 text-xs font-semibold', styles?.className)}>
|
||||||
variant="outline"
|
{styles?.label ?? parsed}
|
||||||
className={`rounded-full px-2.5 py-1 text-xs font-medium ${config?.className ?? ""}`}
|
|
||||||
>
|
|
||||||
{config?.label ?? status}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,63 +10,56 @@ import { useAuth } from "@/lib/auth-client"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const labels: Record<TicketStatus, string> = {
|
const statusStyles: Record<TicketStatus, { label: string; badgeClass: string }> = {
|
||||||
NEW: "Novo",
|
NEW: { label: "Novo", badgeClass: "border border-slate-200 bg-slate-100 text-slate-700" },
|
||||||
OPEN: "Aberto",
|
OPEN: { label: "Aberto", badgeClass: "border border-slate-200 bg-[#dff1fb] text-[#0a4760]" },
|
||||||
PENDING: "Pendente",
|
PENDING: { label: "Pendente", badgeClass: "border border-slate-200 bg-[#fdebd6] text-[#7b4107]" },
|
||||||
ON_HOLD: "Em espera",
|
ON_HOLD: { label: "Em espera", badgeClass: "border border-slate-200 bg-[#ede8ff] text-[#4f2f96]" },
|
||||||
RESOLVED: "Resolvido",
|
RESOLVED: { label: "Resolvido", badgeClass: "border border-slate-200 bg-[#dcf4eb] text-[#1f6a45]" },
|
||||||
CLOSED: "Fechado",
|
CLOSED: { label: "Fechado", badgeClass: "border border-slate-200 bg-slate-200 text-slate-700" },
|
||||||
}
|
}
|
||||||
|
|
||||||
function badgeClass(s: TicketStatus) {
|
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]"
|
||||||
switch (s) {
|
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"
|
||||||
case "OPEN":
|
const baseBadgeClass = "inline-flex items-center rounded-full px-3 py-0.5 text-xs font-semibold"
|
||||||
return "bg-blue-100 text-blue-700"
|
|
||||||
case "PENDING":
|
|
||||||
return "bg-amber-100 text-amber-700"
|
|
||||||
case "ON_HOLD":
|
|
||||||
return "bg-purple-100 text-purple-700"
|
|
||||||
case "RESOLVED":
|
|
||||||
return "bg-emerald-100 text-emerald-700"
|
|
||||||
case "CLOSED":
|
|
||||||
return "bg-slate-100 text-slate-700"
|
|
||||||
default:
|
|
||||||
return "bg-slate-100 text-slate-700"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StatusSelect({ ticketId, value }: { ticketId: string; value: TicketStatus }) {
|
export function StatusSelect({ ticketId, value }: { ticketId: string; value: TicketStatus }) {
|
||||||
const updateStatus = useMutation(api.tickets.updateStatus)
|
const updateStatus = useMutation(api.tickets.updateStatus)
|
||||||
const [status, setStatus] = useState<TicketStatus>(value)
|
const [status, setStatus] = useState<TicketStatus>(value)
|
||||||
const { userId } = useAuth()
|
const { userId } = useAuth()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
value={status}
|
value={status}
|
||||||
onValueChange={async (val) => {
|
onValueChange={async (selected) => {
|
||||||
const prev = status
|
const previous = status
|
||||||
const next = val as TicketStatus
|
const next = selected as TicketStatus
|
||||||
setStatus(next)
|
setStatus(next)
|
||||||
toast.loading("Atualizando status...", { id: "status" })
|
toast.loading("Atualizando status...", { id: "status" })
|
||||||
try {
|
try {
|
||||||
if (!userId) throw new Error("No user")
|
if (!userId) throw new Error("missing user")
|
||||||
await updateStatus({ ticketId: ticketId as unknown as Id<"tickets">, status: next, actorId: userId as Id<"users"> })
|
await updateStatus({ ticketId: ticketId as unknown as Id<"tickets">, status: next, actorId: userId as Id<"users"> })
|
||||||
toast.success(`Status alterado para ${labels[next] ?? next}.`, { id: "status" })
|
toast.success("Status alterado para " + (statusStyles[next]?.label ?? next) + ".", { id: "status" })
|
||||||
} catch {
|
} catch {
|
||||||
setStatus(prev)
|
setStatus(previous)
|
||||||
toast.error("Não foi possível atualizar o status.", { id: "status" })
|
toast.error("Não foi possível atualizar o status.", { id: "status" })
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-7 w-[160px] border-transparent bg-muted/50 px-2">
|
<SelectTrigger className={triggerClass}>
|
||||||
<SelectValue>
|
<SelectValue>
|
||||||
<Badge className={`rounded-full px-2 py-0.5 ${badgeClass(status)}`}>{labels[status]}</Badge>
|
<Badge className={cn(baseBadgeClass, statusStyles[status]?.badgeClass)}>
|
||||||
|
{statusStyles[status]?.label ?? status}
|
||||||
|
</Badge>
|
||||||
</SelectValue>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<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((s) => (
|
{(["NEW", "OPEN", "PENDING", "ON_HOLD", "RESOLVED", "CLOSED"] as const).map((option) => (
|
||||||
<SelectItem key={s} value={s}>{labels[s as TicketStatus]}</SelectItem>
|
<SelectItem key={option} value={option} className={itemClass}>
|
||||||
|
{statusStyles[option].label}
|
||||||
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,7 @@ import { formatDistanceToNow } from "date-fns"
|
||||||
import { ptBR } from "date-fns/locale"
|
import { ptBR } from "date-fns/locale"
|
||||||
import { IconLock, IconMessage } from "@tabler/icons-react"
|
import { IconLock, IconMessage } from "@tabler/icons-react"
|
||||||
import { Download, FileIcon } from "lucide-react"
|
import { Download, FileIcon } from "lucide-react"
|
||||||
import { useAction, useMutation } from "convex/react"
|
import { useMutation } from "convex/react"
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { api } from "@/convex/_generated/api"
|
import { api } from "@/convex/_generated/api"
|
||||||
import { useAuth } from "@/lib/auth-client"
|
import { useAuth } from "@/lib/auth-client"
|
||||||
|
|
@ -27,10 +26,13 @@ interface TicketCommentsProps {
|
||||||
ticket: TicketWithDetails
|
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) {
|
export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||||
const { userId } = useAuth()
|
const { userId } = useAuth()
|
||||||
const addComment = useMutation(api.tickets.addComment)
|
const addComment = useMutation(api.tickets.addComment)
|
||||||
const generateUploadUrl = useAction(api.files.generateUploadUrl)
|
|
||||||
const [body, setBody] = useState("")
|
const [body, setBody] = useState("")
|
||||||
const [attachmentsToSend, setAttachmentsToSend] = useState<Array<{ storageId: string; name: string; size?: number; type?: string; previewUrl?: string }>>([])
|
const [attachmentsToSend, setAttachmentsToSend] = useState<Array<{ storageId: string; name: string; size?: number; type?: string; previewUrl?: string }>>([])
|
||||||
const [preview, setPreview] = useState<string | null>(null)
|
const [preview, setPreview] = useState<string | null>(null)
|
||||||
|
|
@ -41,45 +43,57 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||||
return [...pending, ...ticket.comments]
|
return [...pending, ...ticket.comments]
|
||||||
}, [pending, ticket.comments])
|
}, [pending, ticket.comments])
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(event: React.FormEvent) {
|
||||||
e.preventDefault()
|
event.preventDefault()
|
||||||
if (!userId) return
|
if (!userId) return
|
||||||
const attachments = attachmentsToSend
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
const attachments = attachmentsToSend
|
||||||
const optimistic = {
|
const optimistic = {
|
||||||
id: `temp-${now.getTime()}`,
|
id: `temp-${now.getTime()}`,
|
||||||
author: ticket.requester,
|
author: ticket.requester,
|
||||||
visibility,
|
visibility,
|
||||||
body: sanitizeEditorHtml(body),
|
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,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
}
|
}
|
||||||
setPending((p) => [optimistic, ...p])
|
|
||||||
|
setPending((current) => [optimistic, ...current])
|
||||||
setBody("")
|
setBody("")
|
||||||
setAttachmentsToSend([])
|
setAttachmentsToSend([])
|
||||||
toast.loading("Enviando comentário...", { id: "comment" })
|
toast.loading("Enviando comentário...", { id: "comment" })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const typedAttachments = attachments.map((a) => ({
|
const payload = attachments.map((attachment) => ({
|
||||||
storageId: a.storageId as unknown as Id<"_storage">,
|
storageId: attachment.storageId as unknown as Id<"_storage">,
|
||||||
name: a.name,
|
name: attachment.name,
|
||||||
size: a.size,
|
size: attachment.size,
|
||||||
type: a.type,
|
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([])
|
setPending([])
|
||||||
toast.success("Comentário enviado!", { id: "comment" })
|
toast.success("Comentário enviado!", { id: "comment" })
|
||||||
} catch (err) {
|
} catch {
|
||||||
setPending([])
|
setPending([])
|
||||||
toast.error("Falha ao enviar comentário.", { id: "comment" })
|
toast.error("Falha ao enviar comentário.", { id: "comment" })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="rounded-xl border bg-card shadow-sm">
|
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||||
<CardHeader className="px-4">
|
<CardHeader className="px-4 pb-3">
|
||||||
<CardTitle className="flex items-center gap-2 text-lg font-semibold">
|
<CardTitle className="flex items-center gap-2 text-lg font-semibold text-neutral-900">
|
||||||
<IconMessage className="size-5" /> Conversa
|
<IconMessage className="size-5 text-neutral-900" /> Conversa
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6 px-4 pb-6">
|
<CardContent className="space-y-6 px-4 pb-6">
|
||||||
|
|
@ -87,10 +101,10 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||||
<Empty>
|
<Empty>
|
||||||
<EmptyHeader>
|
<EmptyHeader>
|
||||||
<EmptyMedia variant="icon">
|
<EmptyMedia variant="icon">
|
||||||
<IconMessage className="size-5" />
|
<IconMessage className="size-5 text-neutral-900" />
|
||||||
</EmptyMedia>
|
</EmptyMedia>
|
||||||
<EmptyTitle>Nenhum comentário ainda</EmptyTitle>
|
<EmptyTitle className="text-neutral-900">Nenhum comentário ainda</EmptyTitle>
|
||||||
<EmptyDescription>Registre o próximo passo abaixo.</EmptyDescription>
|
<EmptyDescription className="text-neutral-600">Registre o próximo passo abaixo.</EmptyDescription>
|
||||||
</EmptyHeader>
|
</EmptyHeader>
|
||||||
</Empty>
|
</Empty>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -100,51 +114,57 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||||
.slice(0, 2)
|
.slice(0, 2)
|
||||||
.map((part) => part[0]?.toUpperCase())
|
.map((part) => part[0]?.toUpperCase())
|
||||||
.join("")
|
.join("")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={comment.id} className="flex gap-3">
|
<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} />
|
<AvatarImage src={comment.author.avatarUrl} alt={comment.author.name} />
|
||||||
<AvatarFallback>{initials}</AvatarFallback>
|
<AvatarFallback>{initials}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
<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" ? (
|
{comment.visibility === "INTERNAL" ? (
|
||||||
<Badge variant="outline" className="gap-1">
|
<Badge className={badgeInternal}>
|
||||||
<IconLock className="size-3" /> Interno
|
<IconLock className="size-3 text-[#00e8ff]" /> Interno
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-neutral-500">
|
||||||
{formatDistanceToNow(comment.createdAt, { addSuffix: true, locale: ptBR })}
|
{formatDistanceToNow(comment.createdAt, { addSuffix: true, locale: ptBR })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="break-words rounded-lg border border-dashed bg-card px-3 py-2 text-sm leading-relaxed text-muted-foreground">
|
<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} />
|
<RichTextContent html={comment.body} />
|
||||||
</div>
|
</div>
|
||||||
{comment.attachments?.length ? (
|
{comment.attachments?.length ? (
|
||||||
<div className="grid max-w-xl grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-3">
|
<div className="grid max-w-xl grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-3">
|
||||||
{comment.attachments.map((att) => {
|
{comment.attachments.map((attachment) => {
|
||||||
const isImg = (att?.url ?? "").match(/\.(png|jpe?g|gif|webp|svg)$/i)
|
const isImage = (attachment?.url ?? "").match(/\.(png|jpe?g|gif|webp|svg)$/i)
|
||||||
if (isImg && att.url) {
|
if (isImage && attachment.url) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={att.id}
|
key={attachment.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPreview(att.url || null)}
|
onClick={() => setPreview(attachment.url || null)}
|
||||||
className="group overflow-hidden rounded-lg border bg-card p-0.5 hover:shadow"
|
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={attachment.url} alt={attachment.name} className="h-24 w-24 rounded-md object-cover" />
|
||||||
<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-neutral-500">
|
||||||
<div className="mt-1 line-clamp-1 w-24 text-ellipsis text-center text-[11px] text-muted-foreground">
|
{attachment.name}
|
||||||
{att.name}
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return (
|
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">
|
<a
|
||||||
<FileIcon className="size-3.5" /> {att.name}
|
key={attachment.id}
|
||||||
{att.url ? <Download className="size-3.5" /> : null}
|
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>
|
</a>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
@ -159,25 +179,26 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||||
<RichTextEditor value={body} onChange={setBody} placeholder="Escreva um comentário..." />
|
<RichTextEditor value={body} onChange={setBody} placeholder="Escreva um comentário..." />
|
||||||
<Dropzone onUploaded={(files) => setAttachmentsToSend((prev) => [...prev, ...files])} />
|
<Dropzone onUploaded={(files) => setAttachmentsToSend((prev) => [...prev, ...files])} />
|
||||||
<div className="flex items-center justify-between gap-2">
|
<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:
|
Visibilidade:
|
||||||
<Select value={visibility} onValueChange={(v) => setVisibility(v as "PUBLIC" | "INTERNAL")}>
|
<Select value={visibility} onValueChange={(value) => setVisibility(value as "PUBLIC" | "INTERNAL")}>
|
||||||
<SelectTrigger className="h-8 w-[140px]"><SelectValue placeholder="Visibilidade" /></SelectTrigger>
|
<SelectTrigger className={selectTriggerClass}>
|
||||||
<SelectContent>
|
<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="PUBLIC">Pública</SelectItem>
|
||||||
<SelectItem value="INTERNAL">Interna</SelectItem>
|
<SelectItem value="INTERNAL">Interna</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" size="sm">Enviar</Button>
|
<Button type="submit" size="sm" className={submitButtonClass}>
|
||||||
|
Enviar
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<Dialog open={!!preview} onOpenChange={(o) => !o && setPreview(null)}>
|
<Dialog open={!!preview} onOpenChange={(open) => !open && setPreview(null)}>
|
||||||
<DialogContent className="max-w-3xl p-0">
|
<DialogContent className="max-w-3xl p-0">
|
||||||
{preview ? (
|
{preview ? <img src={preview} alt="Preview" className="h-auto w-full rounded-xl" /> : null}
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img src={preview} alt="Preview" className="h-auto w-full rounded-xl" />
|
|
||||||
) : null}
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import type { Id } from "@/convex/_generated/dataModel";
|
||||||
import type { TicketWithDetails } from "@/lib/schemas/ticket";
|
import type { TicketWithDetails } from "@/lib/schemas/ticket";
|
||||||
import { getTicketById } from "@/lib/mocks/tickets";
|
import { getTicketById } from "@/lib/mocks/tickets";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { TicketComments } from "@/components/tickets/ticket-comments.rich";
|
import { TicketComments } from "@/components/tickets/ticket-comments.rich";
|
||||||
import { TicketDetailsPanel } from "@/components/tickets/ticket-details-panel";
|
import { TicketDetailsPanel } from "@/components/tickets/ticket-details-panel";
|
||||||
|
|
@ -28,7 +27,7 @@ export function TicketDetailView({ id }: { id: string }) {
|
||||||
}
|
}
|
||||||
if (!ticket) return (
|
if (!ticket) return (
|
||||||
<div className="flex flex-col gap-6 px-4 lg:px-6">
|
<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">
|
<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>
|
<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" />
|
<Skeleton className="h-7 w-2/3" />
|
||||||
|
|
@ -36,7 +35,7 @@ export function TicketDetailView({ id }: { id: string }) {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<div className="grid gap-6 lg:grid-cols-[2fr_1fr]">
|
<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">
|
<CardContent className="space-y-4 p-6">
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
<div key={i} className="space-y-2">
|
<div key={i} className="space-y-2">
|
||||||
|
|
@ -46,7 +45,7 @@ export function TicketDetailView({ id }: { id: string }) {
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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">
|
<CardContent className="space-y-3 p-6">
|
||||||
{Array.from({ length: 5 }).map((_, i) => (<Skeleton key={i} className="h-3 w-full" />))}
|
{Array.from({ length: 5 }).map((_, i) => (<Skeleton key={i} className="h-3 w-full" />))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
|
||||||
|
|
@ -11,75 +11,75 @@ interface TicketDetailsPanelProps {
|
||||||
ticket: TicketWithDetails
|
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) {
|
export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
||||||
return (
|
return (
|
||||||
<Card className="rounded-xl border bg-card shadow-sm">
|
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||||
<CardHeader className="px-4">
|
<CardHeader className="px-4 pb-3">
|
||||||
<CardTitle className="text-lg font-semibold">Detalhes</CardTitle>
|
<CardTitle className="text-lg font-semibold text-neutral-900">Detalhes</CardTitle>
|
||||||
</CardHeader>
|
</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">
|
<div className="space-y-1 break-words">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide">Fila</p>
|
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Fila</p>
|
||||||
<Badge variant="outline" className="max-w-full truncate">{ticket.queue ?? "Sem fila"}</Badge>
|
<Badge className={queueBadgeClass}>{ticket.queue ?? "Sem fila"}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator className="bg-slate-200" />
|
||||||
<div className="space-y-2 break-words">
|
<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 ? (
|
{ticket.slaPolicy ? (
|
||||||
<div className="flex flex-col gap-2 rounded-lg border border-dashed bg-card px-3 py-2">
|
<div className="flex flex-col gap-2 rounded-xl border border-slate-200 bg-white px-3 py-2 shadow-sm">
|
||||||
<span className="text-foreground text-sm font-medium leading-tight">{ticket.slaPolicy.name}</span>
|
<span className="text-sm font-semibold text-neutral-900">{ticket.slaPolicy.name}</span>
|
||||||
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
|
<div className="flex flex-col gap-1 text-xs text-neutral-600">
|
||||||
{ticket.slaPolicy.targetMinutesToFirstResponse ? (
|
{ticket.slaPolicy.targetMinutesToFirstResponse ? (
|
||||||
<span className="leading-normal">
|
<span>Resposta inicial: {ticket.slaPolicy.targetMinutesToFirstResponse} min</span>
|
||||||
Resposta inicial: {ticket.slaPolicy.targetMinutesToFirstResponse} min
|
|
||||||
</span>
|
|
||||||
) : null}
|
) : null}
|
||||||
{ticket.slaPolicy.targetMinutesToResolution ? (
|
{ticket.slaPolicy.targetMinutesToResolution ? (
|
||||||
<span className="leading-normal">
|
<span>Resolução: {ticket.slaPolicy.targetMinutesToResolution} min</span>
|
||||||
Resolução: {ticket.slaPolicy.targetMinutesToResolution} min
|
|
||||||
</span>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span>Sem política atribuída.</span>
|
<span className="text-neutral-600">Sem política atribuída.</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator className="bg-slate-200" />
|
||||||
<div className="space-y-2">
|
<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 ? (
|
{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">
|
<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>
|
||||||
<span className="flex items-center gap-2">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span>Sem dados de SLA ainda.</span>
|
<span className="text-neutral-600">Sem dados de SLA ainda.</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator className="bg-slate-200" />
|
||||||
<div className="space-y-2 break-words">
|
<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">
|
<div className="flex flex-wrap gap-2">
|
||||||
{ticket.tags?.length ? (
|
{ticket.tags?.length ? (
|
||||||
ticket.tags.map((tag) => (
|
ticket.tags.map((tag) => (
|
||||||
<Badge key={tag} variant="outline" className="gap-1">
|
<Badge key={tag} className={tagBadgeClass}>
|
||||||
<IconTags className="size-3" /> {tag}
|
<IconTags className={iconAccentClass} /> {tag}
|
||||||
</Badge>
|
</Badge>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<span>Sem tags.</span>
|
<span className="text-neutral-600">Sem tags.</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator className="bg-slate-200" />
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide">Histórico</p>
|
<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-muted-foreground">
|
<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>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>
|
<span>Atualizado: {format(ticket.updatedAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
|
||||||
{ticket.resolvedAt ? (
|
{ticket.resolvedAt ? (
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useQuery } from "convex/react"
|
import { useQuery } from "convex/react"
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { api } from "@/convex/_generated/api"
|
import { api } from "@/convex/_generated/api"
|
||||||
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
|
|
@ -16,45 +15,47 @@ interface TicketQueueSummaryProps {
|
||||||
export function TicketQueueSummaryCards({ queues }: TicketQueueSummaryProps) {
|
export function TicketQueueSummaryCards({ queues }: TicketQueueSummaryProps) {
|
||||||
const fromServer = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID })
|
const fromServer = useQuery(api.queues.summary, { tenantId: DEFAULT_TENANT_ID })
|
||||||
const data: TicketQueueSummary[] = (queues ?? (fromServer as TicketQueueSummary[] | undefined) ?? [])
|
const data: TicketQueueSummary[] = (queues ?? (fromServer as TicketQueueSummary[] | undefined) ?? [])
|
||||||
|
|
||||||
if (!queues && fromServer === undefined) {
|
if (!queues && fromServer === undefined) {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
{Array.from({ length: 3 }).map((_, index) => (
|
||||||
<div key={i} className="rounded-xl border bg-card p-4">
|
<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-muted" />
|
<div className="h-4 w-24 animate-pulse rounded bg-slate-100" />
|
||||||
<div className="mt-4 h-3 w-full animate-pulse rounded bg-muted" />
|
<div className="mt-4 h-3 w-full animate-pulse rounded bg-slate-100" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
{data.map((queue) => {
|
{data.map((queue) => {
|
||||||
const total = queue.pending + queue.waiting
|
const total = queue.pending + queue.waiting
|
||||||
const breachPercent = total === 0 ? 0 : Math.round((queue.breached / total) * 100)
|
const breachPercent = total === 0 ? 0 : Math.round((queue.breached / total) * 100)
|
||||||
return (
|
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">
|
<CardHeader className="pb-2">
|
||||||
<CardDescription>Fila</CardDescription>
|
<CardDescription className="text-xs uppercase tracking-wide text-neutral-500">Fila</CardDescription>
|
||||||
<CardTitle className="text-lg font-semibold">{queue.name}</CardTitle>
|
<CardTitle className="text-lg font-semibold text-neutral-900">{queue.name}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-3 text-sm">
|
<CardContent className="flex flex-col gap-3 text-sm text-neutral-600">
|
||||||
<div className="flex justify-between text-muted-foreground">
|
<div className="flex justify-between">
|
||||||
<span>Pendentes</span>
|
<span>Pendentes</span>
|
||||||
<span className="font-medium text-foreground">{queue.pending}</span>
|
<span className="font-semibold text-neutral-900">{queue.pending}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-muted-foreground">
|
<div className="flex justify-between">
|
||||||
<span>Aguardando resposta</span>
|
<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>
|
||||||
<div className="flex items-center justify-between text-muted-foreground">
|
<div className="flex items-center justify-between">
|
||||||
<span>Violados</span>
|
<span>Violados</span>
|
||||||
<span className="font-medium text-destructive">{queue.breached}</span>
|
<span className="font-semibold text-red-600">{queue.breached}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-1.5">
|
<div className="pt-1.5">
|
||||||
<Progress value={breachPercent} className="h-1.5" />
|
<Progress value={breachPercent} className="h-1.5 bg-slate-100" indicatorClassName="bg-[#00e8ff]" />
|
||||||
<span className="mt-2 block text-xs text-muted-foreground">
|
<span className="mt-2 block text-xs text-neutral-500">
|
||||||
{breachPercent}% com SLA violado nesta fila
|
{breachPercent}% com SLA violado nesta fila
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,16 @@ interface TicketHeaderProps {
|
||||||
ticket: TicketWithDetails
|
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) {
|
export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
const { userId } = useAuth()
|
const { userId } = useAuth()
|
||||||
const changeAssignee = useMutation(api.tickets.changeAssignee)
|
const changeAssignee = useMutation(api.tickets.changeAssignee)
|
||||||
|
|
@ -40,7 +50,10 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false)
|
||||||
const [subject, setSubject] = useState(ticket.subject)
|
const [subject, setSubject] = useState(ticket.subject)
|
||||||
const [summary, setSummary] = useState(ticket.summary ?? "")
|
const [summary, setSummary] = useState(ticket.summary ?? "")
|
||||||
const dirty = useMemo(() => subject !== ticket.subject || (summary ?? "") !== (ticket.summary ?? ""), [subject, summary, ticket.subject, ticket.summary])
|
const dirty = useMemo(
|
||||||
|
() => subject !== ticket.subject || (summary ?? "") !== (ticket.summary ?? ""),
|
||||||
|
[subject, summary, ticket.subject, ticket.summary]
|
||||||
|
)
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
if (!userId) return
|
if (!userId) return
|
||||||
|
|
@ -69,19 +82,16 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
const isPlaying = lastWork?.type === "WORK_STARTED"
|
const isPlaying = lastWork?.type === "WORK_STARTED"
|
||||||
|
|
||||||
return (
|
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="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<Badge variant="outline" className="rounded-full px-3 py-1 text-xs uppercase tracking-wide">
|
<Badge className={referenceBadgeClass}>#{ticket.reference}</Badge>
|
||||||
#{ticket.reference}
|
|
||||||
</Badge>
|
|
||||||
<PrioritySelect ticketId={ticket.id} value={ticket.priority} />
|
<PrioritySelect ticketId={ticket.id} value={ticket.priority} />
|
||||||
<StatusSelect ticketId={ticket.id} value={status} />
|
<StatusSelect ticketId={ticket.id} value={status} />
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={isPlaying ? "default" : "outline"}
|
className={isPlaying ? pauseButtonClass : startButtonClass}
|
||||||
className={isPlaying ? "bg-black text-white border-black" : "border-black text-black"}
|
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (!userId) return
|
if (!userId) return
|
||||||
const next = await toggleWork({ ticketId: ticket.id as Id<"tickets">, actorId: userId as Id<"users"> })
|
const next = await toggleWork({ ticketId: ticket.id as Id<"tickets">, actorId: userId as Id<"users"> })
|
||||||
|
|
@ -89,49 +99,65 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
else toast.success("Atendimento pausado", { id: "work" })
|
else toast.success("Atendimento pausado", { id: "work" })
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isPlaying ? (<><IconPlayerPause className="mr-1 size-4" /> Pausar</>) : (<><IconPlayerPlay className="mr-1 size-4" /> Iniciar</>)}
|
{isPlaying ? (
|
||||||
|
<>
|
||||||
|
<IconPlayerPause className="size-4 text-white" /> Pausar
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<IconPlayerPlay className="size-4 text-black" /> Iniciar
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Input value={subject} onChange={(e) => setSubject(e.target.value)} className="h-9 text-base font-semibold" />
|
<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
|
<textarea
|
||||||
value={summary}
|
value={summary}
|
||||||
onChange={(e) => setSummary(e.target.value)}
|
onChange={(e) => setSummary(e.target.value)}
|
||||||
rows={3}
|
rows={3}
|
||||||
className="w-full rounded-md border bg-background p-2 text-sm"
|
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"
|
placeholder="Adicione um resumo opcional"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="space-y-1">
|
||||||
<h1 className="break-words text-2xl font-semibold text-foreground">{subject}</h1>
|
<h1 className="break-words text-2xl font-semibold text-neutral-900">{subject}</h1>
|
||||||
{summary ? (
|
{summary ? <p className="max-w-2xl text-sm text-neutral-600">{summary}</p> : null}
|
||||||
<p className="max-w-2xl text-sm text-muted-foreground">{summary}</p>
|
</div>
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
<div className="ms-auto flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<>
|
<>
|
||||||
<Button variant="ghost" size="sm" onClick={handleCancel}>Cancelar</Button>
|
<Button variant="ghost" size="sm" className="text-sm font-semibold text-neutral-700" onClick={handleCancel}>
|
||||||
<Button size="sm" onClick={handleSave} disabled={!dirty}>Salvar</Button>
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" className={startButtonClass} onClick={handleSave} disabled={!dirty}>
|
||||||
|
Salvar
|
||||||
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="outline" size="sm" onClick={() => setEditing(true)}>Editar</Button>
|
<Button size="sm" className={editButtonClass} onClick={() => setEditing(true)}>
|
||||||
|
Editar
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
<DeleteTicketDialog ticketId={ticket.id as Id<"tickets">} />
|
<DeleteTicketDialog ticketId={ticket.id as Id<"tickets">} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator className="bg-slate-200" />
|
||||||
<div className="grid gap-4 text-sm text-muted-foreground sm:grid-cols-2 lg:grid-cols-3">
|
<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">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-xs">Solicitante</span>
|
<span className={sectionLabelClass}>Solicitante</span>
|
||||||
<span className="font-medium text-foreground">{ticket.requester.name}</span>
|
<span className={sectionValueClass}>{ticket.requester.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-xs">Responsável</span>
|
<span className={sectionLabelClass}>Responsável</span>
|
||||||
<Select
|
<Select
|
||||||
value={ticket.assignee?.id ?? ""}
|
value={ticket.assignee?.id ?? ""}
|
||||||
onValueChange={async (value) => {
|
onValueChange={async (value) => {
|
||||||
|
|
@ -145,57 +171,65 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 w-[220px] border-black bg-white"><SelectValue placeholder="Selecionar" /></SelectTrigger>
|
<SelectTrigger className={selectTriggerClass}>
|
||||||
<SelectContent>
|
<SelectValue placeholder="Selecionar" />
|
||||||
{agents.map((a) => (
|
</SelectTrigger>
|
||||||
<SelectItem key={a._id} value={a._id}>{a.name}</SelectItem>
|
<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>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-xs">Fila</span>
|
<span className={sectionLabelClass}>Fila</span>
|
||||||
<Select
|
<Select
|
||||||
value={ticket.queue ?? ""}
|
value={ticket.queue ?? ""}
|
||||||
onValueChange={async (value) => {
|
onValueChange={async (value) => {
|
||||||
if (!userId) return
|
if (!userId) return
|
||||||
const q = queues.find((qq) => qq.name === value)
|
const queue = queues.find((item) => item.name === value)
|
||||||
if (!q) return
|
if (!queue) return
|
||||||
toast.loading("Atualizando fila...", { id: "queue" })
|
toast.loading("Atualizando fila...", { id: "queue" })
|
||||||
try {
|
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" })
|
toast.success("Fila atualizada!", { id: "queue" })
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Não foi possível atualizar a fila.", { id: "queue" })
|
toast.error("Não foi possível atualizar a fila.", { id: "queue" })
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 w-[180px] border-black bg-white"><SelectValue placeholder="Selecionar" /></SelectTrigger>
|
<SelectTrigger className={smallSelectTriggerClass}>
|
||||||
<SelectContent>
|
<SelectValue placeholder="Selecionar" />
|
||||||
{queues.map((q) => (
|
</SelectTrigger>
|
||||||
<SelectItem key={q.id} value={q.name}>{q.name}</SelectItem>
|
<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>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-xs">Atualizado em</span>
|
<span className={sectionLabelClass}>Atualizado em</span>
|
||||||
<span className="font-medium text-foreground">{format(ticket.updatedAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
|
<span className={sectionValueClass}>{format(ticket.updatedAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-xs">Criado em</span>
|
<span className={sectionLabelClass}>Criado em</span>
|
||||||
<span className="font-medium text-foreground">{format(ticket.createdAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
|
<span className={sectionValueClass}>{format(ticket.createdAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
|
||||||
</div>
|
</div>
|
||||||
{ticket.dueAt ? (
|
{ticket.dueAt ? (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-xs">SLA até</span>
|
<span className={sectionLabelClass}>SLA até</span>
|
||||||
<span className="font-medium text-foreground">{format(ticket.dueAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
|
<span className={sectionValueClass}>{format(ticket.dueAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{ticket.slaPolicy ? (
|
{ticket.slaPolicy ? (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-xs">Política</span>
|
<span className={sectionLabelClass}>Política</span>
|
||||||
<span className="font-medium text-foreground">{ticket.slaPolicy.name}</span>
|
<span className={sectionValueClass}>{ticket.slaPolicy.name}</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import {
|
||||||
} from "@tabler/icons-react"
|
} from "@tabler/icons-react"
|
||||||
|
|
||||||
import type { TicketWithDetails } from "@/lib/schemas/ticket"
|
import type { TicketWithDetails } from "@/lib/schemas/ticket"
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
|
@ -23,6 +22,7 @@ const timelineIcons: Record<string, ComponentType<{ className?: string }>> = {
|
||||||
WORK_PAUSED: IconClockHour4,
|
WORK_PAUSED: IconClockHour4,
|
||||||
SUBJECT_CHANGED: IconNote,
|
SUBJECT_CHANGED: IconNote,
|
||||||
SUMMARY_CHANGED: IconNote,
|
SUMMARY_CHANGED: IconNote,
|
||||||
|
QUEUE_CHANGED: IconSquareCheck,
|
||||||
}
|
}
|
||||||
|
|
||||||
const timelineLabels: Record<string, string> = {
|
const timelineLabels: Record<string, string> = {
|
||||||
|
|
@ -43,52 +43,79 @@ interface TicketTimelineProps {
|
||||||
|
|
||||||
export function TicketTimeline({ ticket }: TicketTimelineProps) {
|
export function TicketTimeline({ ticket }: TicketTimelineProps) {
|
||||||
return (
|
return (
|
||||||
<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-6 px-4 pb-6">
|
<CardContent className="space-y-5 px-4 pb-6">
|
||||||
{ticket.timeline.map((entry, index) => {
|
{ticket.timeline.map((entry, index) => {
|
||||||
const Icon = timelineIcons[entry.type] ?? IconClockHour4
|
const Icon = timelineIcons[entry.type] ?? IconClockHour4
|
||||||
const isLast = index === ticket.timeline.length - 1
|
const isLast = index === ticket.timeline.length - 1
|
||||||
return (
|
return (
|
||||||
<div key={entry.id} className="relative pl-10">
|
<div key={entry.id} className="relative pl-11">
|
||||||
{!isLast && (
|
{!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" />
|
<Icon className="size-4" />
|
||||||
</span>
|
</span>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
<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}
|
{timelineLabels[entry.type] ?? entry.type}
|
||||||
</span>
|
</span>
|
||||||
{entry.payload?.actorName ? (
|
{entry.payload?.actorName ? (
|
||||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
<span className="flex items-center gap-1 text-xs text-neutral-500">
|
||||||
<Avatar className="size-5">
|
<Avatar className="size-5 border border-slate-200">
|
||||||
<AvatarImage src={entry.payload?.actorAvatar as string | undefined} alt={String(entry.payload?.actorName ?? "")} />
|
<AvatarImage src={entry.payload?.actorAvatar as string | undefined} alt={String(entry.payload?.actorName ?? "")} />
|
||||||
<AvatarFallback>
|
<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>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
por {String(entry.payload?.actorName ?? '')}
|
por {String(entry.payload?.actorName ?? "")}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : 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 })}
|
{format(entry.createdAt, "dd MMM yyyy HH:mm", { locale: ptBR })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{(() => {
|
{(() => {
|
||||||
const p = (entry.payload || {}) as { toLabel?: string; to?: string; assigneeName?: string; assigneeId?: string; queueName?: string; queueId?: string; requesterName?: string; authorName?: string; authorId?: string; from?: 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
|
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 === "STATUS_CHANGED" && (payload.toLabel || payload.to)) {
|
||||||
if (entry.type === "ASSIGNEE_CHANGED" && (p.assigneeName || p.assigneeId)) message = `Responsável alterado${p.assigneeName ? ` para ${p.assigneeName}` : ""}`
|
message = "Status alterado para " + (payload.toLabel || payload.to)
|
||||||
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 === "ASSIGNEE_CHANGED" && (payload.assigneeName || payload.assigneeId)) {
|
||||||
if (entry.type === "COMMENT_ADDED" && (p.authorName || p.authorId)) message = `Comentário adicionado${p.authorName ? ` por ${p.authorName}` : ""}`
|
message = "Responsável alterado" + (payload.assigneeName ? " para " + payload.assigneeName : "")
|
||||||
if (entry.type === "SUBJECT_CHANGED" && (p.to || p.toLabel)) message = `Assunto alterado${p.to ? ` para “${p.to}”` : ""}`
|
}
|
||||||
if (entry.type === "SUMMARY_CHANGED") message = `Resumo atualizado`
|
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
|
if (!message) return null
|
||||||
|
|
||||||
return (
|
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}
|
{message}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -97,9 +124,8 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
<Separator className="bg-slate-200" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ const priorityOptions = ticketPrioritySchema.options.map((priority) => ({
|
||||||
value: priority,
|
value: priority,
|
||||||
label: {
|
label: {
|
||||||
LOW: "Baixa",
|
LOW: "Baixa",
|
||||||
MEDIUM: "Media",
|
MEDIUM: "Média",
|
||||||
HIGH: "Alta",
|
HIGH: "Alta",
|
||||||
URGENT: "Urgente",
|
URGENT: "Urgente",
|
||||||
}[priority],
|
}[priority],
|
||||||
|
|
@ -90,7 +90,7 @@ export function TicketsFilters({ onChange, queues = [] }: TicketsFiltersProps) {
|
||||||
setFilters((prev) => ({ ...prev, ...partial }))
|
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(() => {
|
useEffect(() => {
|
||||||
onChange?.(filters)
|
onChange?.(filters)
|
||||||
}, [filters, onChange])
|
}, [filters, onChange])
|
||||||
|
|
@ -134,14 +134,18 @@ export function TicketsFilters({ onChange, queues = [] }: TicketsFiltersProps) {
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="outline" size="sm" className="gap-2">
|
<Button
|
||||||
<IconFilter className="size-4" />
|
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
|
Filtros
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-64 space-y-4">
|
<PopoverContent className="w-64 space-y-4">
|
||||||
<div className="space-y-2">
|
<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
|
Status
|
||||||
</p>
|
</p>
|
||||||
<Select
|
<Select
|
||||||
|
|
@ -162,7 +166,7 @@ export function TicketsFilters({ onChange, queues = [] }: TicketsFiltersProps) {
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<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
|
Prioridade
|
||||||
</p>
|
</p>
|
||||||
<Select
|
<Select
|
||||||
|
|
@ -183,7 +187,7 @@ export function TicketsFilters({ onChange, queues = [] }: TicketsFiltersProps) {
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<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
|
Canal
|
||||||
</p>
|
</p>
|
||||||
<Select
|
<Select
|
||||||
|
|
@ -208,7 +212,7 @@ export function TicketsFilters({ onChange, queues = [] }: TicketsFiltersProps) {
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
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)}
|
onClick={() => setPartial(defaultTicketFilters)}
|
||||||
>
|
>
|
||||||
<IconRefresh className="size-4" />
|
<IconRefresh className="size-4" />
|
||||||
|
|
@ -219,7 +223,7 @@ export function TicketsFilters({ onChange, queues = [] }: TicketsFiltersProps) {
|
||||||
{activeFilters.length > 0 && (
|
{activeFilters.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{activeFilters.map((chip) => (
|
{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}
|
{chip}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -30,10 +30,13 @@ const channelLabel: Record<string, string> = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const cellClass = "py-4 align-top"
|
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 }) {
|
function AssigneeCell({ ticket }: { ticket: Ticket }) {
|
||||||
if (!ticket.assignee) {
|
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
|
const initials = ticket.assignee.name
|
||||||
|
|
@ -44,15 +47,15 @@ function AssigneeCell({ ticket }: { ticket: Ticket }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<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} />
|
<AvatarImage src={ticket.assignee.avatarUrl} alt={ticket.assignee.name} />
|
||||||
<AvatarFallback>{initials}</AvatarFallback>
|
<AvatarFallback>{initials}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="flex flex-col">
|
<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}
|
{ticket.assignee.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-neutral-600">
|
||||||
{ticket.assignee.teams?.[0] ?? "Agente"}
|
{ticket.assignee.teams?.[0] ?? "Agente"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -60,17 +63,17 @@ function AssigneeCell({ ticket }: { ticket: Ticket }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
type TicketsTableProps = {
|
export type TicketsTableProps = {
|
||||||
tickets?: Ticket[]
|
tickets?: Ticket[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
||||||
return (
|
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">
|
<CardContent className="px-4 py-4 sm:px-6">
|
||||||
<Table className="min-w-full">
|
<Table className="min-w-full">
|
||||||
<TableHeader>
|
<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 className="w-[110px]">Ticket</TableHead>
|
||||||
<TableHead>Assunto</TableHead>
|
<TableHead>Assunto</TableHead>
|
||||||
<TableHead className="hidden lg:table-cell">Fila</TableHead>
|
<TableHead className="hidden lg:table-cell">Fila</TableHead>
|
||||||
|
|
@ -83,16 +86,16 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{tickets.map((ticket) => (
|
{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}>
|
<TableCell className={cellClass}>
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<Link
|
<Link
|
||||||
href={`/tickets/${ticket.id}`}
|
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}
|
#{ticket.reference}
|
||||||
</Link>
|
</Link>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-neutral-500">
|
||||||
{ticket.queue ?? "Sem fila"}
|
{ticket.queue ?? "Sem fila"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -101,21 +104,17 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<Link
|
<Link
|
||||||
href={`/tickets/${ticket.id}`}
|
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}
|
{ticket.subject}
|
||||||
</Link>
|
</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"}
|
{ticket.summary ?? "Sem resumo"}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
<div className="flex flex-wrap gap-2 text-xs text-neutral-500">
|
||||||
<span>{ticket.requester.name}</span>
|
<span className="font-medium text-neutral-900">{ticket.requester.name}</span>
|
||||||
{ticket.tags?.map((tag) => (
|
{ticket.tags?.map((tag) => (
|
||||||
<Badge
|
<Badge key={tag} className={tagBadgeClass}>
|
||||||
key={tag}
|
|
||||||
variant="outline"
|
|
||||||
className="rounded-full border-transparent bg-slate-100 px-2 py-1 text-xs text-slate-600"
|
|
||||||
>
|
|
||||||
{tag}
|
{tag}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
|
|
@ -123,12 +122,11 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className={`${cellClass} hidden lg:table-cell`}>
|
<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">
|
<Badge className={queueBadgeClass}>{ticket.queue ?? "Sem fila"}</Badge>
|
||||||
{ticket.queue ?? "Sem fila"}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className={`${cellClass} hidden md:table-cell`}>
|
<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}
|
{channelLabel[ticket.channel] ?? ticket.channel}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
@ -139,8 +137,8 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<TicketStatusBadge status={ticket.status} />
|
<TicketStatusBadge status={ticket.status} />
|
||||||
{ticket.metrics?.timeWaitingMinutes ? (
|
{ticket.metrics?.timeWaitingMinutes ? (
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-neutral-500">
|
||||||
Espera {ticket.metrics.timeWaitingMinutes} min
|
Em espera há {ticket.metrics.timeWaitingMinutes} min
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -149,11 +147,8 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
||||||
<AssigneeCell ticket={ticket} />
|
<AssigneeCell ticket={ticket} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className={cellClass}>
|
<TableCell className={cellClass}>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-neutral-500">
|
||||||
{formatDistanceToNow(ticket.updatedAt, {
|
{formatDistanceToNow(ticket.updatedAt, { addSuffix: true, locale: ptBR })}
|
||||||
addSuffix: true,
|
|
||||||
locale: ptBR,
|
|
||||||
})}
|
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
@ -164,10 +159,10 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
||||||
<Empty className="my-6">
|
<Empty className="my-6">
|
||||||
<EmptyHeader>
|
<EmptyHeader>
|
||||||
<EmptyMedia variant="icon">
|
<EmptyMedia variant="icon">
|
||||||
<span className="inline-block size-3 rounded-full bg-muted-foreground/40" />
|
<span className="inline-block size-3 rounded-full border border-slate-300 bg-[#00e8ff]" />
|
||||||
</EmptyMedia>
|
</EmptyMedia>
|
||||||
<EmptyTitle>Nenhum ticket encontrado</EmptyTitle>
|
<EmptyTitle className="text-neutral-900">Nenhum ticket encontrado</EmptyTitle>
|
||||||
<EmptyDescription>
|
<EmptyDescription className="text-neutral-600">
|
||||||
Ajuste os filtros ou crie um novo ticket.
|
Ajuste os filtros ou crie um novo ticket.
|
||||||
</EmptyDescription>
|
</EmptyDescription>
|
||||||
</EmptyHeader>
|
</EmptyHeader>
|
||||||
|
|
@ -180,10 +175,3 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import { mapTicketsFromServerList } from "@/lib/mappers/ticket"
|
||||||
import type { Ticket, TicketQueueSummary } from "@/lib/schemas/ticket"
|
import type { Ticket, TicketQueueSummary } from "@/lib/schemas/ticket"
|
||||||
import { TicketsFilters, TicketFiltersState, defaultTicketFilters } from "@/components/tickets/tickets-filters"
|
import { TicketsFilters, TicketFiltersState, defaultTicketFilters } from "@/components/tickets/tickets-filters"
|
||||||
import { TicketsTable } from "@/components/tickets/tickets-table"
|
import { TicketsTable } from "@/components/tickets/tickets-table"
|
||||||
import { Spinner } from "@/components/ui/spinner"
|
|
||||||
|
|
||||||
export function TicketsView() {
|
export function TicketsView() {
|
||||||
const [filters, setFilters] = useState<TicketFiltersState>(defaultTicketFilters)
|
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">
|
<div className="flex flex-col gap-6 px-4 lg:px-6">
|
||||||
<TicketsFilters onChange={setFilters} queues={(queues ?? []).map((q) => q.name)} />
|
<TicketsFilters onChange={setFilters} queues={(queues ?? []).map((q) => q.name)} />
|
||||||
{ticketsRaw === undefined ? (
|
{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">
|
<div className="grid gap-3">
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
<div key={i} className="flex items-center justify-between gap-3">
|
<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-48 animate-pulse rounded bg-slate-100" />
|
||||||
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
|
<div className="h-4 w-24 animate-pulse rounded bg-slate-100" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,11 @@ import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
type ProgressProps = React.HTMLAttributes<HTMLDivElement> & {
|
type ProgressProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||||
value?: number
|
value?: number
|
||||||
|
indicatorClassName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
|
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))
|
const clamped = Math.min(100, Math.max(0, value))
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -19,7 +20,7 @@ export const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div
|
<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}%)` }}
|
style={{ transform: `translateX(-${100 - clamped}%)` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue