From 97ca2b3b548db103b41d3ebdf97d335a8f524197 Mon Sep 17 00:00:00 2001 From: esdrasrenan Date: Sat, 4 Oct 2025 14:56:16 -0300 Subject: [PATCH] feat(ui): priority dropdown badge, delete ticket modal, Empty state component; Convex mutations (updatePriority/remove); layout polish - PrioritySelect with translucent badge + Select options - DeleteTicketDialog with confirmation and icons - Empty UI primitives and basic usage (kept simple to ensure build) - Convex: tickets.updatePriority & tickets.remove - Header: integrate priority select, delete action, avatar-rich assignee select --- web/convex/tickets.ts | 42 ++++++++++++ .../tickets/delete-ticket-dialog.tsx | 61 +++++++++++++++++ .../components/tickets/priority-select.tsx | 67 +++++++++++++++++++ .../tickets/ticket-summary-header.tsx | 19 +++++- web/src/components/ui/empty.tsx | 36 ++++++++++ 5 files changed, 222 insertions(+), 3 deletions(-) create mode 100644 web/src/components/tickets/delete-ticket-dialog.tsx create mode 100644 web/src/components/tickets/priority-select.tsx create mode 100644 web/src/components/ui/empty.tsx diff --git a/web/convex/tickets.ts b/web/convex/tickets.ts index 5bd8e32..7f2edcc 100644 --- a/web/convex/tickets.ts +++ b/web/convex/tickets.ts @@ -333,6 +333,21 @@ export const changeQueue = mutation({ }, }); +export const updatePriority = mutation({ + args: { ticketId: v.id("tickets"), priority: v.string(), actorId: v.id("users") }, + handler: async (ctx, { ticketId, priority, actorId }) => { + const now = Date.now(); + await ctx.db.patch(ticketId, { priority, updatedAt: now }); + const pt: Record = { LOW: "Baixa", MEDIUM: "Média", HIGH: "Alta", URGENT: "Urgente" }; + await ctx.db.insert("ticketEvents", { + ticketId, + type: "PRIORITY_CHANGED", + payload: { to: priority, toLabel: pt[priority] ?? priority, actorId }, + createdAt: now, + }); + }, +}); + export const playNext = mutation({ args: { tenantId: v.string(), @@ -422,3 +437,30 @@ export const playNext = mutation({ } }, }); + +export const remove = mutation({ + args: { ticketId: v.id("tickets"), actorId: v.id("users") }, + handler: async (ctx, { ticketId, actorId }) => { + // delete comments (and attachments) + const comments = await ctx.db + .query("ticketComments") + .withIndex("by_ticket", (q) => q.eq("ticketId", ticketId)) + .collect(); + for (const c of comments) { + for (const att of c.attachments ?? []) { + try { await ctx.storage.delete(att.storageId); } catch {} + } + await ctx.db.delete(c._id); + } + // delete events + const events = await ctx.db + .query("ticketEvents") + .withIndex("by_ticket", (q) => q.eq("ticketId", ticketId)) + .collect(); + for (const ev of events) await ctx.db.delete(ev._id); + // delete ticket + await ctx.db.delete(ticketId); + // (optional) event is moot after deletion + return true; + }, +}); diff --git a/web/src/components/tickets/delete-ticket-dialog.tsx b/web/src/components/tickets/delete-ticket-dialog.tsx new file mode 100644 index 0000000..75d1b31 --- /dev/null +++ b/web/src/components/tickets/delete-ticket-dialog.tsx @@ -0,0 +1,61 @@ +"use client" + +import { useRouter } from "next/navigation" +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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogTrigger } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { AlertTriangle, Trash2 } from "lucide-react" +import { toast } from "sonner" + +export function DeleteTicketDialog({ ticketId }: { ticketId: Id<"tickets"> }) { + const router = useRouter() + const remove = useMutation(api.tickets.remove) + const [open, setOpen] = useState(false) + const [loading, setLoading] = useState(false) + + async function confirm() { + setLoading(true) + toast.loading("Excluindo ticket...", { id: "del" }) + try { + await remove({ ticketId, actorId: undefined as unknown as Id<"users"> }) + toast.success("Ticket excluído.", { id: "del" }) + setOpen(false) + router.push("/tickets") + } catch { + toast.error("Não foi possível excluir o ticket.", { id: "del" }) + } finally { + setLoading(false) + } + } + + return ( + + + + + + + + Excluir ticket + + + Esta ação é permanente e removerá o ticket, comentários e eventos associados. Deseja continuar? + + +
+ + +
+
+
+ ) +} + diff --git a/web/src/components/tickets/priority-select.tsx b/web/src/components/tickets/priority-select.tsx new file mode 100644 index 0000000..d35d8f2 --- /dev/null +++ b/web/src/components/tickets/priority-select.tsx @@ -0,0 +1,67 @@ +"use client" + +import { useState } from "react" +import { useMutation } from "convex/react" +// @ts-ignore +import { api } from "@/convex/_generated/api" +import type { Id } from "@/convex/_generated/dataModel" +import type { TicketStatus } from "@/lib/schemas/ticket" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Badge } from "@/components/ui/badge" +import { toast } from "sonner" + +const labels: Record = { + LOW: "Baixa", + MEDIUM: "Média", + HIGH: "Alta", + URGENT: "Urgente", +} + +function badgeClass(p: string) { + switch (p) { + case "URGENT": + return "bg-red-100 text-red-700" + case "HIGH": + return "bg-amber-100 text-amber-700" + case "MEDIUM": + return "bg-blue-100 text-blue-700" + default: + return "bg-slate-100 text-slate-700" + } +} + +export function PrioritySelect({ ticketId, value }: { ticketId: Id<"tickets">; value: "LOW" | "MEDIUM" | "HIGH" | "URGENT" }) { + const updatePriority = useMutation(api.tickets.updatePriority) + const [priority, setPriority] = useState(value) + return ( + + ) +} + diff --git a/web/src/components/tickets/ticket-summary-header.tsx b/web/src/components/tickets/ticket-summary-header.tsx index 2ddb943..89edf2d 100644 --- a/web/src/components/tickets/ticket-summary-header.tsx +++ b/web/src/components/tickets/ticket-summary-header.tsx @@ -16,6 +16,8 @@ import type { TicketWithDetails, TicketQueueSummary, TicketStatus } from "@/lib/ 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" @@ -47,7 +49,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { #{ticket.reference} - + diff --git a/web/src/components/ui/empty.tsx b/web/src/components/ui/empty.tsx new file mode 100644 index 0000000..98b356a --- /dev/null +++ b/web/src/components/ui/empty.tsx @@ -0,0 +1,36 @@ +"use client" + +import * as React from "react" +import { cn } from "@/lib/utils" + +export function Empty({ className, ...props }: React.ComponentProps<"div">) { + return
+} + +export function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) { + return
+} + +export function EmptyMedia({ variant = "default", className, children }: { variant?: "default" | "icon"; className?: string; children?: React.ReactNode }) { + if (variant === "icon") { + return ( +
+ {children} +
+ ) + } + return
{children}
+} + +export function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) { + return
+} + +export function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) { + return

+} + +export function EmptyContent({ className, ...props }: React.ComponentProps<"div">) { + return

+} +