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