Add ticket detail page with transaction timeline and status change

This commit is contained in:
Gjermund Høsøien Wiggen
2026-06-07 22:02:08 +02:00
parent a49e888011
commit 1029176873

View File

@@ -0,0 +1,375 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
import { formatDistanceToNow } from "date-fns";
import {
ArrowLeft,
Plus,
ArrowRightLeft,
MessageSquare,
Pencil,
RefreshCw,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardHeader,
CardTitle,
CardContent,
} from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import {
getTicket,
getTicketTransactions,
getQueues,
previewTicket,
updateTicket,
} from "@/lib/api";
import type { Ticket, Transaction, Queue, PreviewResult, UpdateResult } from "@/lib/types";
const STATUS_COLORS: Record<string, string> = {
new: "bg-blue-500/10 text-blue-400 border-blue-500/30",
open: "bg-sky-500/10 text-sky-400 border-sky-500/30",
in_progress: "bg-yellow-500/10 text-yellow-400 border-yellow-500/30",
resolved: "bg-green-500/10 text-green-400 border-green-500/30",
closed: "bg-neutral-500/10 text-neutral-400 border-neutral-500/30",
};
const TX_ICONS: Record<string, React.ElementType> = {
Create: Plus,
StatusChange: ArrowRightLeft,
Comment: MessageSquare,
CustomField: Pencil,
};
const TX_COLORS: Record<string, string> = {
Create: "bg-green-500/10 text-green-400 border-green-500/30",
StatusChange: "bg-blue-500/10 text-blue-400 border-blue-500/30",
Comment: "bg-neutral-500/10 text-neutral-400 border-neutral-500/30",
CustomField: "bg-purple-500/10 text-purple-400 border-purple-500/30",
};
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
export default function TicketDetailPage() {
const { id } = useParams<{ id: string }>();
const [ticket, setTicket] = useState<Ticket | null>(null);
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [queues, setQueues] = useState<Queue[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedStatus, setSelectedStatus] = useState<string>("");
const [previewing, setPreviewing] = useState(false);
const [applying, setApplying] = useState(false);
const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
const [previewResult, setPreviewResult] = useState<PreviewResult | null>(null);
const [previewError, setPreviewError] = useState<string | null>(null);
const [applyResult, setApplyResult] = useState<UpdateResult | null>(null);
const [applyError, setApplyError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
const [tRes, txRes, qRes] = await Promise.all([
getTicket(id),
getTicketTransactions(id),
getQueues(),
]);
if (tRes.error) setError(tRes.error);
else {
setTicket(tRes.data);
setSelectedStatus(tRes.data?.status ?? "");
}
if (txRes.error) setError(txRes.error);
else setTransactions(txRes.data ?? []);
if (qRes.error && !error) setError(qRes.error);
else setQueues(qRes.data ?? []);
setLoading(false);
}, [id]);
useEffect(() => {
fetchData();
}, [fetchData]);
const handlePreview = async () => {
if (!selectedStatus) return;
setPreviewing(true);
setPreviewError(null);
const { data, error } = await previewTicket(id, { status: selectedStatus });
setPreviewing(false);
if (error) {
setPreviewError(error);
} else {
setPreviewResult(data);
}
setPreviewDialogOpen(true);
};
const handleApply = async () => {
if (!selectedStatus) return;
setApplying(true);
setApplyError(null);
setApplyResult(null);
const { data, error } = await updateTicket(id, { status: selectedStatus });
setApplying(false);
if (error) {
setApplyError(error);
} else {
setApplyResult(data);
await fetchData();
}
};
const queueName = queues.find((q) => q.id === ticket?.queue_id)?.name ?? ticket?.queue_id ?? "Unknown";
if (loading) {
return <div className="text-sm text-neutral-400 py-12 text-center">Loading ticket...</div>;
}
if (error && !ticket) {
return (
<div className="flex flex-col items-center gap-4 py-12">
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-400">
{error}
</div>
<Link href="/" className="text-sm text-blue-400 hover:underline">
Back to tickets
</Link>
</div>
);
}
if (!ticket) {
return (
<div className="text-sm text-neutral-400 py-12 text-center">
Ticket not found.{" "}
<Link href="/" className="text-blue-400 hover:underline">
Back to tickets
</Link>
</div>
);
}
return (
<div className="flex flex-col gap-6">
<Link
href="/"
className="inline-flex items-center gap-1.5 text-sm text-neutral-400 hover:text-neutral-200 transition-colors w-fit"
>
<ArrowLeft className="size-4" />
All tickets
</Link>
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div className="flex flex-col gap-1.5">
<CardTitle className="text-xl font-semibold">{ticket.subject}</CardTitle>
<div className="flex items-center gap-3 text-sm text-neutral-400">
<span>{queueName}</span>
<span>Created {formatDate(ticket.created_at)}</span>
<span>Updated {formatDate(ticket.updated_at)}</span>
</div>
</div>
<Badge className={`border ${STATUS_COLORS[ticket.status] ?? "bg-neutral-500/10 text-neutral-400 border-neutral-500/30"}`}>
{ticket.status.replace("_", " ")}
</Badge>
</div>
<div className="text-sm text-neutral-400 mt-1">
Owner: {ticket.owner_id ?? "Unassigned"}
</div>
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Change Status</CardTitle>
</CardHeader>
<CardContent className="flex items-center gap-3 flex-wrap">
<Select value={selectedStatus} onValueChange={(v) => setSelectedStatus(!v || v === "_none" ? "" : v)}>
<SelectTrigger className="w-44">
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
{["new", "open", "in_progress", "resolved", "closed"].map((s) => (
<SelectItem key={s} value={s}>
{s.replace("_", " ")}
</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="outline" onClick={handlePreview} disabled={!selectedStatus || selectedStatus === ticket.status || previewing}>
{previewing ? (
<>
<RefreshCw className="size-4 animate-spin" />
Previewing...
</>
) : (
"Preview"
)}
</Button>
<Button onClick={handleApply} disabled={!selectedStatus || selectedStatus === ticket.status || applying}>
{applying ? (
<>
<RefreshCw className="size-4 animate-spin" />
Applying...
</>
) : (
"Apply"
)}
</Button>
{applyError && <span className="text-sm text-red-400">{applyError}</span>}
{applyResult && (
<div className="flex flex-col gap-1 w-full mt-2">
<span className="text-sm text-green-400">Status updated successfully.</span>
{applyResult.scrip_results.map((r) => (
<div
key={r.scripId}
className={`text-xs rounded-md px-2 py-1 ${
r.success
? "bg-green-500/10 text-green-400"
: "bg-red-500/10 text-red-400"
}`}
>
{r.scripId}: {r.message}
</div>
))}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Transaction Timeline</CardTitle>
</CardHeader>
<CardContent>
{transactions.length === 0 ? (
<div className="text-sm text-neutral-400 py-4">No transactions yet.</div>
) : (
<div className="flex flex-col gap-3">
{transactions.map((tx) => {
const Icon = TX_ICONS[tx.transaction_type] ?? MessageSquare;
return (
<div key={tx.id} className="flex gap-3">
<div className="mt-0.5 flex-shrink-0">
<Icon className="size-4 text-neutral-500" />
</div>
<div className="flex flex-col gap-0.5 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<Badge
className={`border text-[10px] px-1.5 ${TX_COLORS[tx.transaction_type] ?? "bg-neutral-500/10 text-neutral-400 border-neutral-500/30"}`}
>
{tx.transaction_type}
</Badge>
{tx.field && (
<span className="text-xs text-neutral-300">
{tx.field}
{tx.old_value && tx.new_value ? (
<>
{" "}
<span className="text-neutral-500">{tx.old_value}</span> {" "}
<span className="text-neutral-200">{tx.new_value}</span>
</>
) : tx.new_value ? (
<>
{" "}
set to <span className="text-neutral-200">{tx.new_value}</span>
</>
) : null}
</span>
)}
</div>
<span className="text-xs text-neutral-500">
{formatDistanceToNow(new Date(tx.created_at), { addSuffix: true })}
</span>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
{ticket.custom_fields && ticket.custom_fields.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Custom Fields</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-2">
{ticket.custom_fields.map((cf) => (
<div key={cf.id} className="flex gap-2 text-sm">
<span className="text-neutral-400">
{cf.custom_field?.name ?? cf.custom_field_id}:
</span>
<span className="text-neutral-200">{cf.value}</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
<Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}>
<DialogContent showCloseButton={true}>
<DialogHeader>
<DialogTitle>Preview Status Change</DialogTitle>
<DialogDescription>
The following scrips would execute when changing status to{" "}
<span className="text-neutral-200 font-medium">
{selectedStatus.replace("_", " ")}
</span>
.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2">
{previewError && (
<div className="text-sm text-red-400">{previewError}</div>
)}
{previewResult && previewResult.prepared_scrips.length === 0 && (
<div className="text-sm text-neutral-400">No scrips would be triggered.</div>
)}
{previewResult?.prepared_scrips.map((ps) => (
<div
key={ps.scripId}
className="rounded-lg border border-neutral-800 bg-neutral-900 p-3 text-sm"
>
<div className="font-medium text-neutral-200">{ps.scripName}</div>
<div className="text-neutral-400 text-xs mt-0.5">
Action: {ps.actionType}
{ps.dryRun ? " (dry run)" : " (would execute)"}
</div>
</div>
))}
</div>
</DialogContent>
</Dialog>
</div>
);
}