Add ticket detail page with transaction timeline and status change
This commit is contained in:
375
web/src/app/tickets/[id]/page.tsx
Normal file
375
web/src/app/tickets/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user