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 && (
+
+ )}
+
+ {!loading && !error && ticket && (
+
+ {/* Header */}
+
+
+ {ticket.subject}
+
+
+ {ticket.id.slice(0, 8)}
+
+
+
+ {/* Conversation */}
+
+ {transactions.length === 0 && (
+
+ )}
+ {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 */}
+
+
+ )}
+
+
+ );
+}
+
export default function TicketListPage() {
const router = useRouter();
const searchParams = useSearchParams();
@@ -127,6 +373,10 @@ export default function TicketListPage() {
const [submitting, setSubmitting] = useState(false);
const [createError, setCreateError] = useState(null);
+ // Sheet
+ const [sheetTicketId, setSheetTicketId] = useState(null);
+ const [sheetOpen, setSheetOpen] = useState(false);
+
const initialQueueId = searchParams.get("queue") || "";
const fetchData = useCallback(async () => {
@@ -206,26 +456,28 @@ export default function TicketListPage() {
setNewSubject("");
setNewQueueId("");
setNewDescription("");
- router.push(`/tickets/${data.id}`);
+ // Open in sheet
+ setSheetTicketId(data.id);
+ setSheetOpen(true);
}
};
return (
{/* Top bar */}
-
+
{/* Filter chips */}
-
+
{FILTERS.map((filter) => (