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
|
|
@ -48,6 +48,7 @@ export default defineSchema({
|
||||||
queueId: v.optional(v.id("queues")),
|
queueId: v.optional(v.id("queues")),
|
||||||
requesterId: v.id("users"),
|
requesterId: v.id("users"),
|
||||||
assigneeId: v.optional(v.id("users")),
|
assigneeId: v.optional(v.id("users")),
|
||||||
|
working: v.optional(v.boolean()),
|
||||||
slaPolicyId: v.optional(v.id("slaPolicies")),
|
slaPolicyId: v.optional(v.id("slaPolicies")),
|
||||||
dueAt: v.optional(v.number()), // ms since epoch
|
dueAt: v.optional(v.number()), // ms since epoch
|
||||||
firstResponseAt: v.optional(v.number()),
|
firstResponseAt: v.optional(v.number()),
|
||||||
|
|
|
||||||
|
|
@ -220,6 +220,7 @@ export const create = mutation({
|
||||||
queueId: args.queueId,
|
queueId: args.queueId,
|
||||||
requesterId: args.requesterId,
|
requesterId: args.requesterId,
|
||||||
assigneeId: undefined,
|
assigneeId: undefined,
|
||||||
|
working: false,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
firstResponseAt: undefined,
|
firstResponseAt: undefined,
|
||||||
|
|
@ -348,6 +349,59 @@ export const updatePriority = mutation({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const toggleWork = mutation({
|
||||||
|
args: { ticketId: v.id("tickets"), actorId: v.id("users") },
|
||||||
|
handler: async (ctx, { ticketId, actorId }) => {
|
||||||
|
const t = await ctx.db.get(ticketId)
|
||||||
|
if (!t) return
|
||||||
|
const now = Date.now()
|
||||||
|
const next = !(t.working ?? false)
|
||||||
|
await ctx.db.patch(ticketId, { working: next, updatedAt: now })
|
||||||
|
const actor = (await ctx.db.get(actorId)) as Doc<"users"> | null
|
||||||
|
await ctx.db.insert("ticketEvents", {
|
||||||
|
ticketId,
|
||||||
|
type: next ? "WORK_STARTED" : "WORK_PAUSED",
|
||||||
|
payload: { actorId, actorName: actor?.name, actorAvatar: actor?.avatarUrl },
|
||||||
|
createdAt: now,
|
||||||
|
})
|
||||||
|
return next
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const updateSubject = mutation({
|
||||||
|
args: { ticketId: v.id("tickets"), subject: v.string(), actorId: v.id("users") },
|
||||||
|
handler: async (ctx, { ticketId, subject, actorId }) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const t = await ctx.db.get(ticketId);
|
||||||
|
if (!t) return;
|
||||||
|
await ctx.db.patch(ticketId, { subject, updatedAt: now });
|
||||||
|
const actor = await ctx.db.get(actorId);
|
||||||
|
await ctx.db.insert("ticketEvents", {
|
||||||
|
ticketId,
|
||||||
|
type: "SUBJECT_CHANGED",
|
||||||
|
payload: { from: t.subject, to: subject, actorId, actorName: (actor as Doc<"users"> | null)?.name, actorAvatar: (actor as Doc<"users"> | null)?.avatarUrl },
|
||||||
|
createdAt: now,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateSummary = mutation({
|
||||||
|
args: { ticketId: v.id("tickets"), summary: v.optional(v.string()), actorId: v.id("users") },
|
||||||
|
handler: async (ctx, { ticketId, summary, actorId }) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const t = await ctx.db.get(ticketId);
|
||||||
|
if (!t) return;
|
||||||
|
await ctx.db.patch(ticketId, { summary, updatedAt: now });
|
||||||
|
const actor = await ctx.db.get(actorId);
|
||||||
|
await ctx.db.insert("ticketEvents", {
|
||||||
|
ticketId,
|
||||||
|
type: "SUMMARY_CHANGED",
|
||||||
|
payload: { actorId, actorName: (actor as Doc<"users"> | null)?.name, actorAvatar: (actor as Doc<"users"> | null)?.avatarUrl },
|
||||||
|
createdAt: now,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const playNext = mutation({
|
export const playNext = mutation({
|
||||||
args: {
|
args: {
|
||||||
tenantId: v.string(),
|
tenantId: v.string(),
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,8 @@ const jetBrainsMono = JetBrains_Mono({
|
||||||
})
|
})
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Atlas Support",
|
title: "Sistema de chamados",
|
||||||
description: "Plataforma omnichannel de gestão de chamados",
|
description: "Plataforma de chamados da Rever",
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function RootLayout({
|
export default async function RootLayout({
|
||||||
|
|
@ -43,7 +43,7 @@ export default async function RootLayout({
|
||||||
<ConvexClientProvider>
|
<ConvexClientProvider>
|
||||||
<AuthProvider demoUser={demoUser} tenantId={tenantId}>
|
<AuthProvider demoUser={demoUser} tenantId={tenantId}>
|
||||||
{children}
|
{children}
|
||||||
<Toaster position="top-right" richColors />
|
<Toaster position="bottom-center" richColors />
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ConvexClientProvider>
|
</ConvexClientProvider>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
|
||||||
|
|
||||||
if (!cardContext || !cardContext.nextTicket) {
|
if (!cardContext || !cardContext.nextTicket) {
|
||||||
return (
|
return (
|
||||||
<Card className="border-dashed">
|
<Card className="rounded-xl border bg-card shadow-sm">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Fila sem tickets pendentes</CardTitle>
|
<CardTitle>Fila sem tickets pendentes</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
@ -61,7 +61,7 @@ export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
|
||||||
const ticket = cardContext.nextTicket
|
const ticket = cardContext.nextTicket
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border-dashed">
|
<Card className="rounded-xl border bg-card 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">
|
||||||
Proximo ticket • #{ticket.reference}
|
Proximo ticket • #{ticket.reference}
|
||||||
|
|
|
||||||
|
|
@ -5,19 +5,21 @@ import { useMutation } from "convex/react"
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { api } from "@/convex/_generated/api"
|
import { api } from "@/convex/_generated/api"
|
||||||
import type { Id } from "@/convex/_generated/dataModel"
|
import type { Id } from "@/convex/_generated/dataModel"
|
||||||
|
import type { TicketPriority } from "@/lib/schemas/ticket"
|
||||||
import { useAuth } from "@/lib/auth-client"
|
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 { ArrowDown, ArrowRight, ArrowUp, ChevronsUp } from "lucide-react"
|
||||||
|
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<TicketPriority, string> = {
|
||||||
LOW: "Baixa",
|
LOW: "Baixa",
|
||||||
MEDIUM: "Média",
|
MEDIUM: "Média",
|
||||||
HIGH: "Alta",
|
HIGH: "Alta",
|
||||||
URGENT: "Urgente",
|
URGENT: "Urgente",
|
||||||
}
|
}
|
||||||
|
|
||||||
function badgeClass(p: string) {
|
function badgeClass(p: TicketPriority) {
|
||||||
switch (p) {
|
switch (p) {
|
||||||
case "URGENT":
|
case "URGENT":
|
||||||
return "bg-red-100 text-red-700"
|
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 updatePriority = useMutation(api.tickets.updatePriority)
|
||||||
const [priority, setPriority] = useState(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 (val) => {
|
||||||
const prev = priority
|
const prev = priority
|
||||||
setPriority(val as typeof priority)
|
const next = val as TicketPriority
|
||||||
|
setPriority(next)
|
||||||
toast.loading("Atualizando prioridade...", { id: "prio" })
|
toast.loading("Atualizando prioridade...", { id: "prio" })
|
||||||
try {
|
try {
|
||||||
if (!userId) throw new Error("No user")
|
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" })
|
toast.success("Prioridade atualizada!", { id: "prio" })
|
||||||
} catch {
|
} catch {
|
||||||
setPriority(prev)
|
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">
|
<SelectTrigger className="h-7 w-[140px] border-transparent bg-muted/50 px-2">
|
||||||
<SelectValue>
|
<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>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{(["LOW","MEDIUM","HIGH","URGENT"] as const).map((p) => (
|
{(["LOW","MEDIUM","HIGH","URGENT"] as const).map((p) => (
|
||||||
<SelectItem key={p} value={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>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
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 { RichTextEditor, RichTextContent, sanitizeEditorHtml } from "@/components/ui/rich-text-editor"
|
||||||
import { Dialog, DialogContent } from "@/components/ui/dialog"
|
import { Dialog, DialogContent } from "@/components/ui/dialog"
|
||||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
|
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
|
||||||
|
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
|
||||||
|
|
||||||
interface TicketCommentsProps {
|
interface TicketCommentsProps {
|
||||||
ticket: TicketWithDetails
|
ticket: TicketWithDetails
|
||||||
|
|
@ -57,7 +58,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||||
setPending((p) => [optimistic, ...p])
|
setPending((p) => [optimistic, ...p])
|
||||||
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 typedAttachments = attachments.map((a) => ({
|
||||||
storageId: a.storageId as unknown as Id<"_storage">,
|
storageId: a.storageId as unknown as Id<"_storage">,
|
||||||
|
|
@ -83,9 +84,15 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6 px-4 pb-6">
|
<CardContent className="space-y-6 px-4 pb-6">
|
||||||
{commentsAll.length === 0 ? (
|
{commentsAll.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">
|
<Empty>
|
||||||
Ainda sem comentários. Que tal registrar o próximo passo?
|
<EmptyHeader>
|
||||||
</p>
|
<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) => {
|
commentsAll.map((comment) => {
|
||||||
const initials = comment.author.name
|
const initials = comment.author.name
|
||||||
|
|
@ -111,7 +118,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
||||||
{formatDistanceToNow(comment.createdAt, { addSuffix: true, locale: ptBR })}
|
{formatDistanceToNow(comment.createdAt, { addSuffix: true, locale: ptBR })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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} />
|
<RichTextContent html={comment.body} />
|
||||||
</div>
|
</div>
|
||||||
{comment.attachments?.length ? (
|
{comment.attachments?.length ? (
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { format } from "date-fns"
|
import { format } from "date-fns"
|
||||||
import { ptBR } from "date-fns/locale"
|
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 { useMutation, useQuery } from "convex/react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
|
@ -15,11 +15,12 @@ import type { Doc, Id } from "@/convex/_generated/dataModel"
|
||||||
import type { TicketWithDetails, TicketQueueSummary, TicketStatus } from "@/lib/schemas/ticket"
|
import type { TicketWithDetails, TicketQueueSummary, TicketStatus } from "@/lib/schemas/ticket"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import { TicketPriorityPill } from "@/components/tickets/priority-pill"
|
|
||||||
import { PrioritySelect } from "@/components/tickets/priority-select"
|
import { PrioritySelect } from "@/components/tickets/priority-select"
|
||||||
import { DeleteTicketDialog } from "@/components/tickets/delete-ticket-dialog"
|
import { DeleteTicketDialog } from "@/components/tickets/delete-ticket-dialog"
|
||||||
import { TicketStatusBadge } from "@/components/tickets/status-badge"
|
import { StatusSelect } from "@/components/tickets/status-select"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
|
||||||
interface TicketHeaderProps {
|
interface TicketHeaderProps {
|
||||||
ticket: TicketWithDetails
|
ticket: TicketWithDetails
|
||||||
|
|
@ -27,20 +28,46 @@ interface TicketHeaderProps {
|
||||||
|
|
||||||
export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
const { userId } = useAuth()
|
const { userId } = useAuth()
|
||||||
const updateStatus = useMutation(api.tickets.updateStatus)
|
|
||||||
const changeAssignee = useMutation(api.tickets.changeAssignee)
|
const changeAssignee = useMutation(api.tickets.changeAssignee)
|
||||||
const changeQueue = useMutation(api.tickets.changeQueue)
|
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 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 queues = (useQuery(api.queues.summary, { tenantId: ticket.tenantId }) as TicketQueueSummary[] | undefined) ?? []
|
||||||
const [status, setStatus] = useState<TicketStatus>(ticket.status)
|
const [status] = useState<TicketStatus>(ticket.status)
|
||||||
const statusPt: Record<string, string> = {
|
|
||||||
NEW: "Novo",
|
const [editing, setEditing] = useState(false)
|
||||||
OPEN: "Aberto",
|
const [subject, setSubject] = useState(ticket.subject)
|
||||||
PENDING: "Pendente",
|
const [summary, setSummary] = useState(ticket.summary ?? "")
|
||||||
ON_HOLD: "Em espera",
|
const dirty = useMemo(() => subject !== ticket.subject || (summary ?? "") !== (ticket.summary ?? ""), [subject, summary, ticket.subject, ticket.summary])
|
||||||
RESOLVED: "Resolvido",
|
|
||||||
CLOSED: "Fechado",
|
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 (
|
return (
|
||||||
<div className="space-y-4 rounded-xl border bg-card p-6 shadow-sm">
|
<div className="space-y-4 rounded-xl border bg-card p-6 shadow-sm">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div className="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">
|
<Badge variant="outline" className="rounded-full px-3 py-1 text-xs uppercase tracking-wide">
|
||||||
#{ticket.reference}
|
#{ticket.reference}
|
||||||
</Badge>
|
</Badge>
|
||||||
<PrioritySelect ticketId={ticket.id as any} value={ticket.priority as any} />
|
<PrioritySelect ticketId={ticket.id} value={ticket.priority} />
|
||||||
<TicketStatusBadge status={status} />
|
<StatusSelect ticketId={ticket.id} value={status} />
|
||||||
<Select
|
<Button
|
||||||
value={status}
|
size="sm"
|
||||||
onValueChange={async (value) => {
|
variant={isPlaying ? "default" : "outline"}
|
||||||
const prev = status
|
className={isPlaying ? "bg-black text-white border-black" : "border-black text-black"}
|
||||||
setStatus(value as import("@/lib/schemas/ticket").TicketStatus) // otimista
|
onClick={async () => {
|
||||||
if (!userId) return
|
if (!userId) return
|
||||||
toast.loading("Atualizando status…", { id: "status" })
|
const next = await toggleWork({ ticketId: ticket.id as Id<"tickets">, actorId: userId as Id<"users"> })
|
||||||
try {
|
if (next) toast.success("Atendimento iniciado", { id: "work" })
|
||||||
await updateStatus({ ticketId: ticket.id as Id<"tickets">, status: value, actorId: userId as Id<"users"> })
|
else toast.success("Atendimento pausado", { id: "work" })
|
||||||
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" })
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 w-[150px]">
|
{isPlaying ? (<><IconPlayerPause className="mr-1 size-4" /> Pausar</>) : (<><IconPlayerPlay className="mr-1 size-4" /> Iniciar</>)}
|
||||||
<SelectValue placeholder="Alterar status" />
|
</Button>
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{(["NEW","OPEN","PENDING","ON_HOLD","RESOLVED","CLOSED"]).map((s) => (
|
|
||||||
<SelectItem key={s} value={s}>{statusPt[s]}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-semibold text-foreground break-words">{ticket.subject}</h1>
|
{editing ? (
|
||||||
<p className="max-w-2xl text-sm text-muted-foreground">{ticket.summary}</p>
|
<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">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<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-muted-foreground sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-col gap-1">
|
||||||
<IconUserCircle className="size-4" />
|
<span className="text-xs">Solicitante</span>
|
||||||
Solicitante:
|
|
||||||
<span className="font-medium text-foreground">{ticket.requester.name}</span>
|
<span className="font-medium text-foreground">{ticket.requester.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-col gap-1">
|
||||||
<IconUserCircle className="size-4" />
|
<span className="text-xs">Responsável</span>
|
||||||
Responsável:
|
|
||||||
<span className="font-medium text-foreground">{ticket.assignee?.name ?? "Aguardando atribuição"}</span>
|
|
||||||
<Select
|
<Select
|
||||||
value={ticket.assignee?.id ?? ""}
|
value={ticket.assignee?.id ?? ""}
|
||||||
onValueChange={async (value) => {
|
onValueChange={async (value) => {
|
||||||
if (!userId) return
|
if (!userId) return
|
||||||
toast.loading("Atribuindo responsável…", { id: "assignee" })
|
toast.loading("Atribuindo responsável...", { id: "assignee" })
|
||||||
try {
|
try {
|
||||||
await changeAssignee({ ticketId: ticket.id as Id<"tickets">, assigneeId: value as Id<"users">, actorId: userId as Id<"users"> })
|
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" })
|
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>
|
<SelectContent>
|
||||||
{agents.map((a) => (
|
{agents.map((a) => (
|
||||||
<SelectItem key={a._id} value={a._id}>
|
<SelectItem key={a._id} value={a._id}>{a.name}</SelectItem>
|
||||||
<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>
|
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-col gap-1">
|
||||||
<IconClock className="size-4" />
|
<span className="text-xs">Fila</span>
|
||||||
Fila:
|
|
||||||
<span className="font-medium text-foreground">{ticket.queue ?? "Sem 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 q = queues.find((qq) => qq.name === value)
|
||||||
if (!q) return
|
if (!q) 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: q.id as Id<"queues">, actorId: userId as Id<"users"> })
|
||||||
toast.success("Fila atualizada!", { id: "queue" })
|
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>
|
<SelectContent>
|
||||||
{queues.map((q) => (
|
{queues.map((q) => (
|
||||||
<SelectItem key={q.id} value={q.name}>{q.name}</SelectItem>
|
<SelectItem key={q.id} value={q.name}>{q.name}</SelectItem>
|
||||||
|
|
@ -151,33 +178,23 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-col gap-1">
|
||||||
<IconClock className="size-4" />
|
<span className="text-xs">Atualizado em</span>
|
||||||
Atualizado em:
|
<span className="font-medium text-foreground">{format(ticket.updatedAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
|
||||||
<span className="font-medium text-foreground">
|
|
||||||
{format(ticket.updatedAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-col gap-1">
|
||||||
<IconClock className="size-4" />
|
<span className="text-xs">Criado em</span>
|
||||||
Criado em:
|
<span className="font-medium text-foreground">{format(ticket.createdAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
|
||||||
<span className="font-medium text-foreground">
|
|
||||||
{format(ticket.createdAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{ticket.dueAt ? (
|
{ticket.dueAt ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-col gap-1">
|
||||||
<IconClock className="size-4" />
|
<span className="text-xs">SLA até</span>
|
||||||
SLA ate:
|
<span className="font-medium text-foreground">{format(ticket.dueAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>
|
||||||
<span className="font-medium text-foreground">
|
|
||||||
{format(ticket.dueAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{ticket.slaPolicy ? (
|
{ticket.slaPolicy ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-col gap-1">
|
||||||
<IconClock className="size-4" />
|
<span className="text-xs">Política</span>
|
||||||
Politica:
|
|
||||||
<span className="font-medium text-foreground">{ticket.slaPolicy.name}</span>
|
<span className="font-medium text-foreground">{ticket.slaPolicy.name}</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,10 @@ const timelineIcons: Record<string, ComponentType<{ className?: string }>> = {
|
||||||
STATUS_CHANGED: IconSquareCheck,
|
STATUS_CHANGED: IconSquareCheck,
|
||||||
ASSIGNEE_CHANGED: IconUserCircle,
|
ASSIGNEE_CHANGED: IconUserCircle,
|
||||||
COMMENT_ADDED: IconNote,
|
COMMENT_ADDED: IconNote,
|
||||||
|
WORK_STARTED: IconClockHour4,
|
||||||
|
WORK_PAUSED: IconClockHour4,
|
||||||
|
SUBJECT_CHANGED: IconNote,
|
||||||
|
SUMMARY_CHANGED: IconNote,
|
||||||
}
|
}
|
||||||
|
|
||||||
const timelineLabels: Record<string, string> = {
|
const timelineLabels: Record<string, string> = {
|
||||||
|
|
@ -26,6 +30,10 @@ const timelineLabels: Record<string, string> = {
|
||||||
STATUS_CHANGED: "Status alterado",
|
STATUS_CHANGED: "Status alterado",
|
||||||
ASSIGNEE_CHANGED: "Responsável alterado",
|
ASSIGNEE_CHANGED: "Responsável alterado",
|
||||||
COMMENT_ADDED: "Comentário adicionado",
|
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",
|
QUEUE_CHANGED: "Fila alterada",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,13 +77,15 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
|
||||||
</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 }
|
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
|
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" && (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 === "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 === "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 === "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 === "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
|
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-lg border border-dashed bg-card px-3 py-2 text-sm text-muted-foreground">
|
||||||
|
|
@ -92,3 +102,4 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import type { Ticket } from "@/lib/schemas/ticket"
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
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 {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
|
|
@ -159,14 +161,29 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
{tickets.length === 0 && (
|
{tickets.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center gap-2 py-10 text-center text-sm">
|
<Empty className="my-6">
|
||||||
<p className="text-sm font-medium">Nenhum ticket encontrado</p>
|
<EmptyHeader>
|
||||||
<p className="text-sm text-muted-foreground">
|
<EmptyMedia variant="icon">
|
||||||
Ajuste os filtros ou selecione outra fila.
|
<span className="inline-block size-3 rounded-full bg-muted-foreground/40" />
|
||||||
</p>
|
</EmptyMedia>
|
||||||
</div>
|
<EmptyTitle>Nenhum ticket encontrado</EmptyTitle>
|
||||||
|
<EmptyDescription>
|
||||||
|
Ajuste os filtros ou crie um novo ticket.
|
||||||
|
</EmptyDescription>
|
||||||
|
</EmptyHeader>
|
||||||
|
<EmptyContent>
|
||||||
|
<NewTicketDialog />
|
||||||
|
</EmptyContent>
|
||||||
|
</Empty>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,16 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
<Sonner
|
<Sonner
|
||||||
theme={theme as ToasterProps["theme"]}
|
theme={theme as ToasterProps["theme"]}
|
||||||
className="toaster group"
|
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={
|
style={
|
||||||
{
|
{
|
||||||
"--normal-bg": "var(--popover)",
|
"--normal-bg": "var(--popover)",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue