diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index 453baf1..e199038 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -2,10 +2,10 @@ import { useState, useEffect, useCallback } from "react"; import { useRouter, useSearchParams } from "next/navigation"; -import { PlusIcon, SearchIcon } from "lucide-react"; +import { PlusIcon, SearchIcon, ArrowLeftIcon, SendIcon, PaperclipIcon } from "lucide-react"; import { formatDistanceToNow } from "date-fns"; -import { getTickets, getQueues, createTicket } from "@/lib/api"; -import type { Ticket, Queue } from "@/lib/types"; +import { getTickets, getTicket, getTicketTransactions, getQueues, createTicket } from "@/lib/api"; +import type { Ticket, Queue, Transaction } from "@/lib/types"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { @@ -17,12 +17,11 @@ import { DialogFooter, } from "@/components/ui/dialog"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; + Sheet, + SheetContent, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; import { Textarea } from "@/components/ui/textarea"; import { cn } from "@/lib/utils"; @@ -53,6 +52,20 @@ const FILTERS = [ type FilterKey = (typeof FILTERS)[number]["key"]; +function getInitial(name: string): string { + if (!name) return "?"; + return name.charAt(0).toUpperCase(); +} + +function getInitialColor(name: string): string { + const colors = ["#5e6ad2", "#7170ff", "#828fff", "#f59e0b", "#22c55e"]; + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = name.charCodeAt(i) + ((hash << 5) - hash); + } + return colors[Math.abs(hash) % colors.length]; +} + function TicketRow({ ticket, onClick }: { ticket: Ticket; onClick: () => void }) { const statusColor = STATUS_COLORS[ticket.status] || STATUS_COLORS.new; const shortId = ticket.id.slice(0, 8); @@ -61,33 +74,33 @@ function TicketRow({ ticket, onClick }: { ticket: Ticket; onClick: () => void }) return ( @@ -96,17 +109,250 @@ function TicketRow({ ticket, onClick }: { ticket: Ticket; onClick: () => void }) function SkeletonRow() { return ( -
-
-
-
-
-
-
+
+
+
+
+
+
+
); } +function TicketDetailSheet({ + ticketId, + open, + onOpenChange, +}: { + ticketId: string | null; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const [ticket, setTicket] = useState(null); + const [transactions, setTransactions] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [replyText, setReplyText] = useState(""); + + useEffect(() => { + if (open && ticketId) { + setLoading(true); + setError(null); + Promise.all([ + getTicket(ticketId), + getTicketTransactions(ticketId), + ]).then(([ticketRes, txRes]) => { + if (ticketRes.error) { + setError(ticketRes.error); + } else { + setTicket(ticketRes.data); + } + if (txRes.data) { + setTransactions(txRes.data); + } + setLoading(false); + }); + } + }, [open, ticketId]); + + return ( + + + {loading && ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+
+
+
+
+
+ ))} +
+ )} + + {error && ( +
+

{error}

+
+ )} + + {!loading && !error && ticket && ( +
+ {/* Header */} + + + {ticket.subject} + +

+ {ticket.id.slice(0, 8)} +

+
+ + {/* Conversation */} +
+ {transactions.length === 0 && ( +
+

+ No activity yet +

+
+ )} + {transactions.map((tx) => { + const isSystem = + tx.transaction_type === "status_change" || + tx.transaction_type === "assignment" || + tx.transaction_type === "create"; + const isAgent = + tx.transaction_type === "agent_reply" || + tx.transaction_type === "internal_note"; + + if (isSystem) { + const txTimeAgo = formatDistanceToNow( + new Date(tx.created_at), + { addSuffix: true } + ); + let message = ""; + if (tx.transaction_type === "create") { + message = "Ticket created"; + } else if (tx.transaction_type === "status_change") { + const oldLabel = tx.old_value + ? STATUS_LABELS[tx.old_value] || tx.old_value + : "?"; + const newLabel = tx.new_value + ? STATUS_LABELS[tx.new_value] || tx.new_value + : "?"; + message = `${getInitial(tx.creator_id)} changed status from ${oldLabel} to ${newLabel}`; + } else if (tx.transaction_type === "assignment") { + message = tx.new_value + ? `${getInitial(tx.creator_id)} assigned to ${tx.new_value}` + : `${getInitial(tx.creator_id)} unassigned`; + } else { + message = tx.transaction_type; + } + return ( +
+ + {message} ยท {txTimeAgo} + +
+ ); + } + + const isInternal = + tx.transaction_type === "internal_note"; + const txTimeAgo = formatDistanceToNow( + new Date(tx.created_at), + { addSuffix: true } + ); + + return ( +
+
+ + {getInitial(tx.creator_id)} + +
+
+ {isInternal && ( +
+ Internal note +
+ )} +

+ {typeof tx.data === "object" && + tx.data !== null && + "body" in (tx.data as Record) + ? String( + (tx.data as Record).body + ) + : tx.transaction_type} +

+

+ {txTimeAgo} +

+
+
+ ); + })} +
+ + {/* Reply box */} +
+
+