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
This commit is contained in:
parent
ea60c3b841
commit
97ca2b3b54
5 changed files with 222 additions and 3 deletions
|
|
@ -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<string, string> = { 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({
|
export const playNext = mutation({
|
||||||
args: {
|
args: {
|
||||||
tenantId: v.string(),
|
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;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
|
||||||
61
web/src/components/tickets/delete-ticket-dialog.tsx
Normal file
61
web/src/components/tickets/delete-ticket-dialog.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="gap-2 text-destructive hover:bg-destructive/10">
|
||||||
|
<Trash2 className="size-4" /> Excluir
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-destructive">
|
||||||
|
<AlertTriangle className="size-5" /> Excluir ticket
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Esta ação é permanente e removerá o ticket, comentários e eventos associados. Deseja continuar?
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>Cancelar</Button>
|
||||||
|
<Button variant="destructive" onClick={confirm} disabled={loading} className="gap-2">
|
||||||
|
{loading ? "Excluindo..." : (<><Trash2 className="size-4" /> Excluir</>)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
67
web/src/components/tickets/priority-select.tsx
Normal file
67
web/src/components/tickets/priority-select.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useMutation } from "convex/react"
|
||||||
|
// @ts-ignore
|
||||||
|
import { api } from "@/convex/_generated/api"
|
||||||
|
import type { Id } from "@/convex/_generated/dataModel"
|
||||||
|
import type { TicketStatus } from "@/lib/schemas/ticket"
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
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 (
|
||||||
|
<Select
|
||||||
|
value={priority}
|
||||||
|
onValueChange={async (val) => {
|
||||||
|
const prev = priority
|
||||||
|
setPriority(val as typeof priority)
|
||||||
|
toast.loading("Atualizando prioridade...", { id: "prio" })
|
||||||
|
try {
|
||||||
|
await updatePriority({ ticketId, priority: val as any, actorId: undefined as unknown as Id<"users"> })
|
||||||
|
toast.success("Prioridade atualizada!", { id: "prio" })
|
||||||
|
} catch {
|
||||||
|
setPriority(prev)
|
||||||
|
toast.error("Não foi possível atualizar a prioridade.", { id: "prio" })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -16,6 +16,8 @@ import type { TicketWithDetails, TicketQueueSummary, TicketStatus } from "@/lib/
|
||||||
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 { 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 { TicketStatusBadge } from "@/components/tickets/status-badge"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
|
|
||||||
|
|
@ -47,7 +49,7 @@ 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>
|
||||||
<TicketPriorityPill priority={ticket.priority} />
|
<PrioritySelect ticketId={ticket.id as any} value={ticket.priority as any} />
|
||||||
<TicketStatusBadge status={status} />
|
<TicketStatusBadge status={status} />
|
||||||
<Select
|
<Select
|
||||||
value={status}
|
value={status}
|
||||||
|
|
@ -77,6 +79,9 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-semibold text-foreground break-words">{ticket.subject}</h1>
|
<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>
|
<p className="max-w-2xl text-sm text-muted-foreground">{ticket.summary}</p>
|
||||||
|
<div className="ms-auto flex items-center gap-2">
|
||||||
|
<DeleteTicketDialog ticketId={ticket.id as any} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
@ -103,10 +108,18 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 w-[180px]"><SelectValue placeholder="Selecionar" /></SelectTrigger>
|
<SelectTrigger className="h-8 w-[220px]"><SelectValue placeholder="Selecionar" /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{agents.map((a) => (
|
{agents.map((a) => (
|
||||||
<SelectItem key={a._id} value={a._id}>{a.name}</SelectItem>
|
<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>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
|
||||||
36
web/src/components/ui/empty.tsx
Normal file
36
web/src/components/ui/empty.tsx
Normal file
|
|
@ -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 <div className={cn("flex flex-col items-center gap-4 rounded-xl border bg-card p-8 text-center", className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return <div className={cn("flex flex-col items-center gap-2", className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyMedia({ variant = "default", className, children }: { variant?: "default" | "icon"; className?: string; children?: React.ReactNode }) {
|
||||||
|
if (variant === "icon") {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex size-12 items-center justify-center rounded-full bg-muted text-muted-foreground", className)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <div className={className}>{children}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return <div className={cn("text-lg font-semibold", className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
return <p className={cn("max-w-sm text-sm text-muted-foreground", className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return <div className={cn("mt-2 flex items-center gap-2", className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue