feat(ui,tickets): aplicar visual Rever (badges revertidas), header com play/pause, edição inline com cancelar, empty states e toasts centralizados; novas mutations Convex (updateSubject/updateSummary/toggleWork)
This commit is contained in:
parent
881bb7bfdd
commit
6c57c691f3
14 changed files with 512 additions and 307 deletions
|
|
@ -18,10 +18,10 @@ const jetBrainsMono = JetBrains_Mono({
|
|||
display: "swap",
|
||||
})
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Atlas Support",
|
||||
description: "Plataforma omnichannel de gestão de chamados",
|
||||
}
|
||||
export const metadata: Metadata = {
|
||||
title: "Sistema de chamados",
|
||||
description: "Plataforma de chamados da Rever",
|
||||
}
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
|
|
@ -43,7 +43,7 @@ export default async function RootLayout({
|
|||
<ConvexClientProvider>
|
||||
<AuthProvider demoUser={demoUser} tenantId={tenantId}>
|
||||
{children}
|
||||
<Toaster position="top-right" richColors />
|
||||
<Toaster position="bottom-center" richColors />
|
||||
</AuthProvider>
|
||||
</ConvexClientProvider>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
|
|||
|
||||
if (!cardContext || !cardContext.nextTicket) {
|
||||
return (
|
||||
<Card className="border-dashed">
|
||||
<Card className="rounded-xl border bg-card shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Fila sem tickets pendentes</CardTitle>
|
||||
</CardHeader>
|
||||
|
|
@ -60,8 +60,8 @@ export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
|
|||
|
||||
const ticket = cardContext.nextTicket
|
||||
|
||||
return (
|
||||
<Card className="border-dashed">
|
||||
return (
|
||||
<Card className="rounded-xl border bg-card shadow-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2">
|
||||
<CardTitle className="text-lg font-semibold">
|
||||
Proximo ticket • #{ticket.reference}
|
||||
|
|
|
|||
|
|
@ -1,40 +1,40 @@
|
|||
import { cn } from "@/lib/utils"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
|
||||
const priorityConfig = {
|
||||
LOW: {
|
||||
label: "Baixa",
|
||||
className: "bg-slate-100 text-slate-600 border-transparent",
|
||||
},
|
||||
MEDIUM: {
|
||||
label: "Media",
|
||||
className: "bg-blue-100 text-blue-600 border-transparent",
|
||||
},
|
||||
HIGH: {
|
||||
label: "Alta",
|
||||
className: "bg-amber-100 text-amber-700 border-transparent",
|
||||
},
|
||||
URGENT: {
|
||||
label: "Urgente",
|
||||
className: "bg-red-100 text-red-700 border-transparent",
|
||||
},
|
||||
} satisfies Record<string, { label: string; className: string }>
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
|
||||
const priorityConfig = {
|
||||
LOW: {
|
||||
label: "Baixa",
|
||||
className: "bg-slate-100 text-slate-600 border-transparent",
|
||||
},
|
||||
MEDIUM: {
|
||||
label: "Media",
|
||||
className: "bg-blue-100 text-blue-600 border-transparent",
|
||||
},
|
||||
HIGH: {
|
||||
label: "Alta",
|
||||
className: "bg-amber-100 text-amber-700 border-transparent",
|
||||
},
|
||||
URGENT: {
|
||||
label: "Urgente",
|
||||
className: "bg-red-100 text-red-700 border-transparent",
|
||||
},
|
||||
} satisfies Record<string, { label: string; className: string }>
|
||||
|
||||
type TicketPriorityPillProps = {
|
||||
priority: keyof typeof priorityConfig
|
||||
}
|
||||
|
||||
export function TicketPriorityPill({ priority }: TicketPriorityPillProps) {
|
||||
const config = priorityConfig[priority]
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"rounded-full px-2.5 py-1 text-xs font-medium",
|
||||
config?.className ?? ""
|
||||
)}
|
||||
>
|
||||
{config?.label ?? priority}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
export function TicketPriorityPill({ priority }: TicketPriorityPillProps) {
|
||||
const config = priorityConfig[priority]
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"rounded-full px-2.5 py-1 text-xs font-medium",
|
||||
config?.className ?? ""
|
||||
)}
|
||||
>
|
||||
{config?.label ?? priority}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,19 +5,21 @@ import { useMutation } from "convex/react"
|
|||
// @ts-ignore
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import type { TicketPriority } from "@/lib/schemas/ticket"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { toast } from "sonner"
|
||||
import { ArrowDown, ArrowRight, ArrowUp, ChevronsUp } from "lucide-react"
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
const labels: Record<TicketPriority, string> = {
|
||||
LOW: "Baixa",
|
||||
MEDIUM: "Média",
|
||||
HIGH: "Alta",
|
||||
URGENT: "Urgente",
|
||||
}
|
||||
|
||||
function badgeClass(p: string) {
|
||||
function badgeClass(p: TicketPriority) {
|
||||
switch (p) {
|
||||
case "URGENT":
|
||||
return "bg-red-100 text-red-700"
|
||||
|
|
@ -30,20 +32,29 @@ function badgeClass(p: string) {
|
|||
}
|
||||
}
|
||||
|
||||
export function PrioritySelect({ ticketId, value }: { ticketId: Id<"tickets">; value: "LOW" | "MEDIUM" | "HIGH" | "URGENT" }) {
|
||||
function PriorityIcon({ p }: { p: TicketPriority }) {
|
||||
const cls = "size-3.5 text-cyan-600"
|
||||
if (p === "LOW") return <ArrowDown className={cls} />
|
||||
if (p === "MEDIUM") return <ArrowRight className={cls} />
|
||||
if (p === "HIGH") return <ArrowUp className={cls} />
|
||||
return <ChevronsUp className={cls} />
|
||||
}
|
||||
|
||||
export function PrioritySelect({ ticketId, value }: { ticketId: string; value: TicketPriority }) {
|
||||
const updatePriority = useMutation(api.tickets.updatePriority)
|
||||
const [priority, setPriority] = useState(value)
|
||||
const [priority, setPriority] = useState<TicketPriority>(value)
|
||||
const { userId } = useAuth()
|
||||
return (
|
||||
<Select
|
||||
value={priority}
|
||||
onValueChange={async (val) => {
|
||||
const prev = priority
|
||||
setPriority(val as typeof priority)
|
||||
const next = val as TicketPriority
|
||||
setPriority(next)
|
||||
toast.loading("Atualizando prioridade...", { id: "prio" })
|
||||
try {
|
||||
if (!userId) throw new Error("No user")
|
||||
await updatePriority({ ticketId, priority: val as any, 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" })
|
||||
} catch {
|
||||
setPriority(prev)
|
||||
|
|
@ -53,16 +64,19 @@ export function PrioritySelect({ ticketId, value }: { ticketId: Id<"tickets">; v
|
|||
>
|
||||
<SelectTrigger className="h-7 w-[140px] border-transparent bg-muted/50 px-2">
|
||||
<SelectValue>
|
||||
<Badge className={`rounded-full px-2 py-0.5 ${badgeClass(priority)}`}>{labels[priority]}</Badge>
|
||||
<Badge className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 ${badgeClass(priority)}`}>
|
||||
<PriorityIcon p={priority} /> {labels[priority]}
|
||||
</Badge>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(["LOW","MEDIUM","HIGH","URGENT"] as const).map((p) => (
|
||||
<SelectItem key={p} value={p}>
|
||||
<span className="inline-flex items-center gap-2"><span className={`h-2 w-2 rounded-full ${p==="URGENT"?"bg-red-500":p==="HIGH"?"bg-amber-500":p==="MEDIUM"?"bg-blue-500":"bg-slate-400"}`}></span>{labels[p]}</span>
|
||||
<span className="inline-flex items-center gap-2"><PriorityIcon p={p} />{labels[p]}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,13 +3,13 @@
|
|||
import { ticketStatusSchema, type TicketStatus } from "@/lib/schemas/ticket"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
|
||||
const statusConfig = {
|
||||
NEW: { label: "Novo", className: "bg-slate-100 text-slate-700 border-transparent" },
|
||||
OPEN: { label: "Aberto", className: "bg-blue-100 text-blue-700 border-transparent" },
|
||||
PENDING: { label: "Pendente", className: "bg-amber-100 text-amber-700 border-transparent" },
|
||||
ON_HOLD: { label: "Em espera", className: "bg-purple-100 text-purple-700 border-transparent" },
|
||||
RESOLVED: { label: "Resolvido", className: "bg-emerald-100 text-emerald-700 border-transparent" },
|
||||
CLOSED: { label: "Fechado", className: "bg-slate-100 text-slate-700 border-transparent" },
|
||||
const statusConfig = {
|
||||
NEW: { label: "Novo", className: "bg-slate-100 text-slate-700 border-transparent" },
|
||||
OPEN: { label: "Aberto", className: "bg-blue-100 text-blue-700 border-transparent" },
|
||||
PENDING: { label: "Pendente", className: "bg-amber-100 text-amber-700 border-transparent" },
|
||||
ON_HOLD: { label: "Em espera", className: "bg-purple-100 text-purple-700 border-transparent" },
|
||||
RESOLVED: { label: "Resolvido", className: "bg-emerald-100 text-emerald-700 border-transparent" },
|
||||
CLOSED: { label: "Fechado", className: "bg-slate-100 text-slate-700 border-transparent" },
|
||||
} satisfies Record<TicketStatus, { label: string; className: string }>
|
||||
|
||||
type TicketStatusBadgeProps = { status: TicketStatus }
|
||||
|
|
@ -17,11 +17,11 @@ type TicketStatusBadgeProps = { status: TicketStatus }
|
|||
export function TicketStatusBadge({ status }: TicketStatusBadgeProps) {
|
||||
const config = statusConfig[status]
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`rounded-full px-2.5 py-1 text-xs font-medium ${config?.className ?? ""}`}
|
||||
>
|
||||
{config?.label ?? status}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`rounded-full px-2.5 py-1 text-xs font-medium ${config?.className ?? ""}`}
|
||||
>
|
||||
{config?.label ?? status}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
74
web/src/components/tickets/status-select.tsx
Normal file
74
web/src/components/tickets/status-select.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useMutation } from "convex/react"
|
||||
// @ts-ignore
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import type { Id } from "@/convex/_generated/dataModel"
|
||||
import type { TicketStatus } from "@/lib/schemas/ticket"
|
||||
import { useAuth } from "@/lib/auth-client"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { toast } from "sonner"
|
||||
|
||||
const labels: Record<TicketStatus, string> = {
|
||||
NEW: "Novo",
|
||||
OPEN: "Aberto",
|
||||
PENDING: "Pendente",
|
||||
ON_HOLD: "Em espera",
|
||||
RESOLVED: "Resolvido",
|
||||
CLOSED: "Fechado",
|
||||
}
|
||||
|
||||
function badgeClass(s: TicketStatus) {
|
||||
switch (s) {
|
||||
case "OPEN":
|
||||
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 }) {
|
||||
const updateStatus = useMutation(api.tickets.updateStatus)
|
||||
const [status, setStatus] = useState<TicketStatus>(value)
|
||||
const { userId } = useAuth()
|
||||
return (
|
||||
<Select
|
||||
value={status}
|
||||
onValueChange={async (val) => {
|
||||
const prev = status
|
||||
const next = val as TicketStatus
|
||||
setStatus(next)
|
||||
toast.loading("Atualizando status...", { id: "status" })
|
||||
try {
|
||||
if (!userId) throw new Error("No user")
|
||||
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" })
|
||||
} catch {
|
||||
setStatus(prev)
|
||||
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">
|
||||
<SelectValue>
|
||||
<Badge className={`rounded-full px-2 py-0.5 ${badgeClass(status)}`}>{labels[status]}</Badge>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(["NEW","OPEN","PENDING","ON_HOLD","RESOLVED","CLOSED"] as const).map((s) => (
|
||||
<SelectItem key={s} value={s}>{labels[s as TicketStatus]}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ import { Dropzone } from "@/components/ui/dropzone"
|
|||
import { RichTextEditor, RichTextContent, sanitizeEditorHtml } from "@/components/ui/rich-text-editor"
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog"
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
|
||||
|
||||
interface TicketCommentsProps {
|
||||
ticket: TicketWithDetails
|
||||
|
|
@ -57,7 +58,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
setPending((p) => [optimistic, ...p])
|
||||
setBody("")
|
||||
setAttachmentsToSend([])
|
||||
toast.loading("Enviando comentário.", { id: "comment" })
|
||||
toast.loading("Enviando comentário...", { id: "comment" })
|
||||
try {
|
||||
const typedAttachments = attachments.map((a) => ({
|
||||
storageId: a.storageId as unknown as Id<"_storage">,
|
||||
|
|
@ -83,9 +84,15 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
</CardHeader>
|
||||
<CardContent className="space-y-6 px-4 pb-6">
|
||||
{commentsAll.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ainda sem comentários. Que tal registrar o próximo passo?
|
||||
</p>
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<IconMessage className="size-5" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Nenhum comentário ainda</EmptyTitle>
|
||||
<EmptyDescription>Registre o próximo passo abaixo.</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
) : (
|
||||
commentsAll.map((comment) => {
|
||||
const initials = comment.author.name
|
||||
|
|
@ -111,38 +118,38 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
{formatDistanceToNow(comment.createdAt, { addSuffix: true, locale: ptBR })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded-lg border border-dashed bg-card px-3 py-2 text-sm leading-relaxed text-muted-foreground break-words">
|
||||
<div className="break-words rounded-lg border border-dashed bg-card px-3 py-2 text-sm leading-relaxed text-muted-foreground">
|
||||
<RichTextContent html={comment.body} />
|
||||
</div>
|
||||
{comment.attachments?.length ? (
|
||||
<div className="grid max-w-xl grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-3">
|
||||
{comment.attachments.map((att) => {
|
||||
const isImg = (att?.url ?? "").match(/\.(png|jpe?g|gif|webp|svg)$/i)
|
||||
if (isImg && att.url) {
|
||||
return (
|
||||
<button
|
||||
key={att.id}
|
||||
type="button"
|
||||
onClick={() => setPreview(att.url || null)}
|
||||
className="group overflow-hidden rounded-lg border bg-card p-0.5 hover:shadow"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={att.url} alt={att.name} className="h-24 w-24 rounded-md object-cover" />
|
||||
<div className="mt-1 line-clamp-1 w-24 text-ellipsis text-center text-[11px] text-muted-foreground">
|
||||
{att.name}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<a key={att.id} href={att.url} download={att.name} target="_blank" className="flex items-center gap-2 rounded-md border px-2 py-1 text-xs hover:bg-muted">
|
||||
<FileIcon className="size-3.5" /> {att.name}
|
||||
{att.url ? <Download className="size-3.5" /> : null}
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{comment.attachments?.length ? (
|
||||
<div className="grid max-w-xl grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-3">
|
||||
{comment.attachments.map((att) => {
|
||||
const isImg = (att?.url ?? "").match(/\.(png|jpe?g|gif|webp|svg)$/i)
|
||||
if (isImg && att.url) {
|
||||
return (
|
||||
<button
|
||||
key={att.id}
|
||||
type="button"
|
||||
onClick={() => setPreview(att.url || null)}
|
||||
className="group overflow-hidden rounded-lg border bg-card p-0.5 hover:shadow"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={att.url} alt={att.name} className="h-24 w-24 rounded-md object-cover" />
|
||||
<div className="mt-1 line-clamp-1 w-24 text-ellipsis text-center text-[11px] text-muted-foreground">
|
||||
{att.name}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<a key={att.id} href={att.url} download={att.name} target="_blank" className="flex items-center gap-2 rounded-md border px-2 py-1 text-xs hover:bg-muted">
|
||||
<FileIcon className="size-3.5" /> {att.name}
|
||||
{att.url ? <Download className="size-3.5" /> : null}
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -154,7 +161,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
Visibilidade:
|
||||
<Select value={visibility} onValueChange={(v) => setVisibility(v as "PUBLIC" | "INTERNAL")}>
|
||||
<Select value={visibility} onValueChange={(v) => setVisibility(v as "PUBLIC" | "INTERNAL")}>
|
||||
<SelectTrigger className="h-8 w-[140px]"><SelectValue placeholder="Visibilidade" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="PUBLIC">Pública</SelectItem>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useMemo, useState } from "react"
|
||||
import { format } from "date-fns"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
import { IconClock, IconUserCircle } from "@tabler/icons-react"
|
||||
import { IconPlayerPause, IconPlayerPlay } from "@tabler/icons-react"
|
||||
import { useMutation, useQuery } from "convex/react"
|
||||
import { toast } from "sonner"
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
|
|
@ -15,32 +15,59 @@ import type { Doc, Id } from "@/convex/_generated/dataModel"
|
|||
import type { TicketWithDetails, TicketQueueSummary, TicketStatus } from "@/lib/schemas/ticket"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { TicketPriorityPill } from "@/components/tickets/priority-pill"
|
||||
import { PrioritySelect } from "@/components/tickets/priority-select"
|
||||
import { DeleteTicketDialog } from "@/components/tickets/delete-ticket-dialog"
|
||||
import { TicketStatusBadge } from "@/components/tickets/status-badge"
|
||||
import { StatusSelect } from "@/components/tickets/status-select"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
|
||||
interface TicketHeaderProps {
|
||||
ticket: TicketWithDetails
|
||||
}
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
interface TicketHeaderProps {
|
||||
ticket: TicketWithDetails
|
||||
}
|
||||
|
||||
export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||
const { userId } = useAuth()
|
||||
const updateStatus = useMutation(api.tickets.updateStatus)
|
||||
const changeAssignee = useMutation(api.tickets.changeAssignee)
|
||||
const changeQueue = useMutation(api.tickets.changeQueue)
|
||||
const updateSubject = useMutation(api.tickets.updateSubject)
|
||||
const updateSummary = useMutation(api.tickets.updateSummary)
|
||||
const toggleWork = useMutation(api.tickets.toggleWork)
|
||||
const agents = (useQuery(api.users.listAgents, { tenantId: ticket.tenantId }) as Doc<"users">[] | undefined) ?? []
|
||||
const queues = (useQuery(api.queues.summary, { tenantId: ticket.tenantId }) as TicketQueueSummary[] | undefined) ?? []
|
||||
const [status, setStatus] = useState<TicketStatus>(ticket.status)
|
||||
const statusPt: Record<string, string> = {
|
||||
NEW: "Novo",
|
||||
OPEN: "Aberto",
|
||||
PENDING: "Pendente",
|
||||
ON_HOLD: "Em espera",
|
||||
RESOLVED: "Resolvido",
|
||||
CLOSED: "Fechado",
|
||||
const [status] = useState<TicketStatus>(ticket.status)
|
||||
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [subject, setSubject] = useState(ticket.subject)
|
||||
const [summary, setSummary] = useState(ticket.summary ?? "")
|
||||
const dirty = useMemo(() => subject !== ticket.subject || (summary ?? "") !== (ticket.summary ?? ""), [subject, summary, ticket.subject, ticket.summary])
|
||||
|
||||
async function handleSave() {
|
||||
if (!userId) return
|
||||
toast.loading("Salvando alterações...", { id: "save-header" })
|
||||
try {
|
||||
if (subject !== ticket.subject) {
|
||||
await updateSubject({ ticketId: ticket.id as Id<"tickets">, subject: subject.trim(), actorId: userId as Id<"users"> })
|
||||
}
|
||||
if ((summary ?? "") !== (ticket.summary ?? "")) {
|
||||
await updateSummary({ ticketId: ticket.id as Id<"tickets">, summary: (summary ?? "").trim(), actorId: userId as Id<"users"> })
|
||||
}
|
||||
toast.success("Cabeçalho atualizado!", { id: "save-header" })
|
||||
setEditing(false)
|
||||
} catch {
|
||||
toast.error("Não foi possível salvar.", { id: "save-header" })
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
setSubject(ticket.subject)
|
||||
setSummary(ticket.summary ?? "")
|
||||
setEditing(false)
|
||||
}
|
||||
|
||||
const lastWork = [...ticket.timeline].reverse().find((e) => e.type === "WORK_STARTED" || e.type === "WORK_PAUSED")
|
||||
const isPlaying = lastWork?.type === "WORK_STARTED"
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-xl border bg-card p-6 shadow-sm">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
|
|
@ -49,57 +76,67 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
<Badge variant="outline" className="rounded-full px-3 py-1 text-xs uppercase tracking-wide">
|
||||
#{ticket.reference}
|
||||
</Badge>
|
||||
<PrioritySelect ticketId={ticket.id as any} value={ticket.priority as any} />
|
||||
<TicketStatusBadge status={status} />
|
||||
<Select
|
||||
value={status}
|
||||
onValueChange={async (value) => {
|
||||
const prev = status
|
||||
setStatus(value as import("@/lib/schemas/ticket").TicketStatus) // otimista
|
||||
<PrioritySelect ticketId={ticket.id} value={ticket.priority} />
|
||||
<StatusSelect ticketId={ticket.id} value={status} />
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isPlaying ? "default" : "outline"}
|
||||
className={isPlaying ? "bg-black text-white border-black" : "border-black text-black"}
|
||||
onClick={async () => {
|
||||
if (!userId) return
|
||||
toast.loading("Atualizando status…", { id: "status" })
|
||||
try {
|
||||
await updateStatus({ ticketId: ticket.id as Id<"tickets">, status: value, actorId: userId as Id<"users"> })
|
||||
toast.success(`Status alterado para ${statusPt[value]}.`, { id: "status" })
|
||||
} catch (e) {
|
||||
setStatus(prev)
|
||||
toast.error("Não foi possível alterar o status.", { id: "status" })
|
||||
}
|
||||
const next = await toggleWork({ ticketId: ticket.id as Id<"tickets">, actorId: userId as Id<"users"> })
|
||||
if (next) toast.success("Atendimento iniciado", { id: "work" })
|
||||
else toast.success("Atendimento pausado", { id: "work" })
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[150px]">
|
||||
<SelectValue placeholder="Alterar status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(["NEW","OPEN","PENDING","ON_HOLD","RESOLVED","CLOSED"]).map((s) => (
|
||||
<SelectItem key={s} value={s}>{statusPt[s]}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isPlaying ? (<><IconPlayerPause className="mr-1 size-4" /> Pausar</>) : (<><IconPlayerPlay className="mr-1 size-4" /> Iniciar</>)}
|
||||
</Button>
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-foreground break-words">{ticket.subject}</h1>
|
||||
<p className="max-w-2xl text-sm text-muted-foreground">{ticket.summary}</p>
|
||||
{editing ? (
|
||||
<div className="space-y-2">
|
||||
<Input value={subject} onChange={(e) => setSubject(e.target.value)} className="h-9 text-base font-semibold" />
|
||||
<textarea
|
||||
value={summary}
|
||||
onChange={(e) => setSummary(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full rounded-md border bg-background p-2 text-sm"
|
||||
placeholder="Adicione um resumo opcional"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h1 className="break-words text-2xl font-semibold text-foreground">{subject}</h1>
|
||||
{summary ? (
|
||||
<p className="max-w-2xl text-sm text-muted-foreground">{summary}</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
<div className="ms-auto flex items-center gap-2">
|
||||
<DeleteTicketDialog ticketId={ticket.id as any} />
|
||||
{editing ? (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={handleCancel}>Cancelar</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={!dirty}>Salvar</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={() => setEditing(true)}>Editar</Button>
|
||||
)}
|
||||
<DeleteTicketDialog ticketId={ticket.id as Id<"tickets">} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="grid gap-4 text-sm text-muted-foreground sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<IconUserCircle className="size-4" />
|
||||
Solicitante:
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs">Solicitante</span>
|
||||
<span className="font-medium text-foreground">{ticket.requester.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<IconUserCircle className="size-4" />
|
||||
Responsável:
|
||||
<span className="font-medium text-foreground">{ticket.assignee?.name ?? "Aguardando atribuição"}</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs">Responsável</span>
|
||||
<Select
|
||||
value={ticket.assignee?.id ?? ""}
|
||||
onValueChange={async (value) => {
|
||||
if (!userId) return
|
||||
toast.loading("Atribuindo responsável…", { id: "assignee" })
|
||||
toast.loading("Atribuindo responsável...", { id: "assignee" })
|
||||
try {
|
||||
await changeAssignee({ ticketId: ticket.id as Id<"tickets">, assigneeId: value as Id<"users">, actorId: userId as Id<"users"> })
|
||||
toast.success("Responsável atualizado!", { id: "assignee" })
|
||||
|
|
@ -108,33 +145,23 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[220px]"><SelectValue placeholder="Selecionar" /></SelectTrigger>
|
||||
<SelectTrigger className="h-8 w-[220px] border-black bg-white"><SelectValue placeholder="Selecionar" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{agents.map((a) => (
|
||||
<SelectItem key={a._id} value={a._id}>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="inline-flex size-6 items-center justify-center overflow-hidden rounded-full bg-muted">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
{a.avatarUrl ? <img src={a.avatarUrl} alt={a.name} className="h-6 w-6 rounded-full object-cover" /> : <span className="text-[10px] font-medium">{a.name.split(' ').slice(0,2).map(p=>p[0]).join('').toUpperCase()}</span>}
|
||||
</span>
|
||||
{a.name}
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem key={a._id} value={a._id}>{a.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<IconClock className="size-4" />
|
||||
Fila:
|
||||
<span className="font-medium text-foreground">{ticket.queue ?? "Sem fila"}</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs">Fila</span>
|
||||
<Select
|
||||
value={ticket.queue ?? ""}
|
||||
onValueChange={async (value) => {
|
||||
if (!userId) return
|
||||
const q = queues.find((qq) => qq.name === value)
|
||||
if (!q) return
|
||||
toast.loading("Atualizando fila…", { id: "queue" })
|
||||
toast.loading("Atualizando fila...", { id: "queue" })
|
||||
try {
|
||||
await changeQueue({ ticketId: ticket.id as Id<"tickets">, queueId: q.id as Id<"queues">, actorId: userId as Id<"users"> })
|
||||
toast.success("Fila atualizada!", { id: "queue" })
|
||||
|
|
@ -143,7 +170,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[180px]"><SelectValue placeholder="Selecionar" /></SelectTrigger>
|
||||
<SelectTrigger className="h-8 w-[180px] border-black bg-white"><SelectValue placeholder="Selecionar" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{queues.map((q) => (
|
||||
<SelectItem key={q.id} value={q.name}>{q.name}</SelectItem>
|
||||
|
|
@ -151,37 +178,27 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<IconClock className="size-4" />
|
||||
Atualizado em:
|
||||
<span className="font-medium text-foreground">
|
||||
{format(ticket.updatedAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}
|
||||
</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs">Atualizado em</span>
|
||||
<span className="font-medium text-foreground">{format(ticket.updatedAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<IconClock className="size-4" />
|
||||
Criado em:
|
||||
<span className="font-medium text-foreground">
|
||||
{format(ticket.createdAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}
|
||||
</span>
|
||||
</div>
|
||||
{ticket.dueAt ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<IconClock className="size-4" />
|
||||
SLA ate:
|
||||
<span className="font-medium text-foreground">
|
||||
{format(ticket.dueAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{ticket.slaPolicy ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<IconClock className="size-4" />
|
||||
Politica:
|
||||
<span className="font-medium text-foreground">{ticket.slaPolicy.name}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs">Criado em</span>
|
||||
<span className="font-medium text-foreground">{format(ticket.createdAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
|
||||
</div>
|
||||
{ticket.dueAt ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs">SLA até</span>
|
||||
<span className="font-medium text-foreground">{format(ticket.dueAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{ticket.slaPolicy ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs">Política</span>
|
||||
<span className="font-medium text-foreground">{ticket.slaPolicy.name}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,10 @@ const timelineIcons: Record<string, ComponentType<{ className?: string }>> = {
|
|||
STATUS_CHANGED: IconSquareCheck,
|
||||
ASSIGNEE_CHANGED: IconUserCircle,
|
||||
COMMENT_ADDED: IconNote,
|
||||
WORK_STARTED: IconClockHour4,
|
||||
WORK_PAUSED: IconClockHour4,
|
||||
SUBJECT_CHANGED: IconNote,
|
||||
SUMMARY_CHANGED: IconNote,
|
||||
}
|
||||
|
||||
const timelineLabels: Record<string, string> = {
|
||||
|
|
@ -26,6 +30,10 @@ const timelineLabels: Record<string, string> = {
|
|||
STATUS_CHANGED: "Status alterado",
|
||||
ASSIGNEE_CHANGED: "Responsável alterado",
|
||||
COMMENT_ADDED: "Comentário adicionado",
|
||||
WORK_STARTED: "Atendimento iniciado",
|
||||
WORK_PAUSED: "Atendimento pausado",
|
||||
SUBJECT_CHANGED: "Assunto atualizado",
|
||||
SUMMARY_CHANGED: "Resumo atualizado",
|
||||
QUEUE_CHANGED: "Fila alterada",
|
||||
}
|
||||
|
||||
|
|
@ -69,13 +77,15 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
|
|||
</span>
|
||||
</div>
|
||||
{(() => {
|
||||
const p = (entry.payload || {}) as { toLabel?: string; to?: string; assigneeName?: string; assigneeId?: string; queueName?: string; queueId?: string; requesterName?: string; authorName?: string; authorId?: string }
|
||||
const p = (entry.payload || {}) as { toLabel?: string; to?: string; assigneeName?: string; assigneeId?: string; queueName?: string; queueId?: string; requesterName?: string; authorName?: string; authorId?: string; from?: string }
|
||||
let message: string | null = null
|
||||
if (entry.type === "STATUS_CHANGED" && (p.toLabel || p.to)) message = `Status alterado para ${p.toLabel || p.to}`
|
||||
if (entry.type === "ASSIGNEE_CHANGED" && (p.assigneeName || p.assigneeId)) message = `Responsável alterado${p.assigneeName ? ` para ${p.assigneeName}` : ""}`
|
||||
if (entry.type === "QUEUE_CHANGED" && (p.queueName || p.queueId)) message = `Fila alterada${p.queueName ? ` para ${p.queueName}` : ""}`
|
||||
if (entry.type === "CREATED" && (p.requesterName)) message = `Criado por ${p.requesterName}`
|
||||
if (entry.type === "COMMENT_ADDED" && (p.authorName || p.authorId)) message = `Comentário adicionado${p.authorName ? ` por ${p.authorName}` : ""}`
|
||||
if (entry.type === "SUBJECT_CHANGED" && (p.to || p.toLabel)) message = `Assunto alterado${p.to ? ` para “${p.to}”` : ""}`
|
||||
if (entry.type === "SUMMARY_CHANGED") message = `Resumo atualizado`
|
||||
if (!message) return null
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed bg-card px-3 py-2 text-sm text-muted-foreground">
|
||||
|
|
@ -92,3 +102,4 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
|
|||
)
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,68 +1,70 @@
|
|||
import Link from "next/link"
|
||||
import { formatDistanceToNow } from "date-fns"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
|
||||
import { tickets as ticketsMock } from "@/lib/mocks/tickets"
|
||||
import type { Ticket } from "@/lib/schemas/ticket"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { TicketPriorityPill } from "@/components/tickets/priority-pill"
|
||||
import { TicketStatusBadge } from "@/components/tickets/status-badge"
|
||||
|
||||
const channelLabel: Record<string, string> = {
|
||||
EMAIL: "E-mail",
|
||||
WHATSAPP: "WhatsApp",
|
||||
CHAT: "Chat",
|
||||
PHONE: "Telefone",
|
||||
API: "API",
|
||||
MANUAL: "Manual",
|
||||
}
|
||||
|
||||
const cellClass = "py-4 align-top"
|
||||
|
||||
function AssigneeCell({ ticket }: { ticket: Ticket }) {
|
||||
if (!ticket.assignee) {
|
||||
return <span className="text-sm text-muted-foreground">Sem responsável</span>
|
||||
}
|
||||
|
||||
const initials = ticket.assignee.name
|
||||
.split(" ")
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase())
|
||||
.join("")
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="size-8">
|
||||
<AvatarImage src={ticket.assignee.avatarUrl} alt={ticket.assignee.name} />
|
||||
<AvatarFallback>{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium leading-none text-foreground">
|
||||
{ticket.assignee.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{ticket.assignee.teams?.[0] ?? "Agente"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type TicketsTableProps = {
|
||||
tickets?: Ticket[]
|
||||
}
|
||||
|
||||
export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
||||
import Link from "next/link"
|
||||
import { formatDistanceToNow } from "date-fns"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
|
||||
import { tickets as ticketsMock } from "@/lib/mocks/tickets"
|
||||
import type { Ticket } from "@/lib/schemas/ticket"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
|
||||
import { NewTicketDialog } from "@/components/tickets/new-ticket-dialog"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { TicketPriorityPill } from "@/components/tickets/priority-pill"
|
||||
import { TicketStatusBadge } from "@/components/tickets/status-badge"
|
||||
|
||||
const channelLabel: Record<string, string> = {
|
||||
EMAIL: "E-mail",
|
||||
WHATSAPP: "WhatsApp",
|
||||
CHAT: "Chat",
|
||||
PHONE: "Telefone",
|
||||
API: "API",
|
||||
MANUAL: "Manual",
|
||||
}
|
||||
|
||||
const cellClass = "py-4 align-top"
|
||||
|
||||
function AssigneeCell({ ticket }: { ticket: Ticket }) {
|
||||
if (!ticket.assignee) {
|
||||
return <span className="text-sm text-muted-foreground">Sem responsável</span>
|
||||
}
|
||||
|
||||
const initials = ticket.assignee.name
|
||||
.split(" ")
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase())
|
||||
.join("")
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="size-8">
|
||||
<AvatarImage src={ticket.assignee.avatarUrl} alt={ticket.assignee.name} />
|
||||
<AvatarFallback>{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium leading-none text-foreground">
|
||||
{ticket.assignee.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{ticket.assignee.teams?.[0] ?? "Agente"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type TicketsTableProps = {
|
||||
tickets?: Ticket[]
|
||||
}
|
||||
|
||||
export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
||||
return (
|
||||
<Card className="border bg-card/90 shadow-sm">
|
||||
<CardContent className="px-4 py-4 sm:px-6">
|
||||
|
|
@ -159,14 +161,29 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
|||
</TableBody>
|
||||
</Table>
|
||||
{tickets.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-10 text-center text-sm">
|
||||
<p className="text-sm font-medium">Nenhum ticket encontrado</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ajuste os filtros ou selecione outra fila.
|
||||
</p>
|
||||
</div>
|
||||
<Empty className="my-6">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<span className="inline-block size-3 rounded-full bg-muted-foreground/40" />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>Nenhum ticket encontrado</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Ajuste os filtros ou crie um novo ticket.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<NewTicketDialog />
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -4,26 +4,26 @@ import { cva, type VariantProps } from "class-variance-authority"
|
|||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
|
|
|
|||
|
|
@ -6,20 +6,30 @@ import { Toaster as Sonner, ToasterProps } from "sonner"
|
|||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast: "border border-black bg-black text-white shadow-md",
|
||||
title: "font-medium",
|
||||
description: "text-white/80",
|
||||
icon: "text-cyan-400",
|
||||
actionButton: "bg-white text-black border border-black",
|
||||
cancelButton: "bg-transparent text-white border border-white/40",
|
||||
},
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue