Backend returns PascalCase (Create, StatusChange, SetOwner, Comment, Correspond). Frontend was checking lowercase, causing transaction rendering to fall through to raw type strings.
688 lines
24 KiB
TypeScript
688 lines
24 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, use } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import Link from "next/link";
|
|
import { formatDistanceToNow } from "date-fns";
|
|
import { ArrowLeftIcon, SendIcon, PaperclipIcon } from "lucide-react";
|
|
import {
|
|
getTicket,
|
|
getTicketTransactions,
|
|
getQueues,
|
|
previewTicket,
|
|
updateTicket,
|
|
} from "@/lib/api";
|
|
import type {
|
|
Ticket,
|
|
Transaction,
|
|
Queue,
|
|
PreviewResult,
|
|
UpdateResult,
|
|
} from "@/lib/types";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
const STATUS_COLORS: Record<string, string> = {
|
|
new: "#8a8f98",
|
|
open: "#7170ff",
|
|
in_progress: "#f59e0b",
|
|
resolved: "#22c55e",
|
|
closed: "#6b7280",
|
|
};
|
|
|
|
const STATUS_LABELS: Record<string, string> = {
|
|
new: "New",
|
|
open: "Open",
|
|
in_progress: "In progress",
|
|
resolved: "Resolved",
|
|
closed: "Closed",
|
|
};
|
|
|
|
const ALL_STATUSES = ["new", "open", "in_progress", "resolved", "closed"];
|
|
|
|
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 TransactionBubble({
|
|
tx,
|
|
}: {
|
|
tx: Transaction;
|
|
}) {
|
|
const isSystem =
|
|
tx.transaction_type === "StatusChange" ||
|
|
tx.transaction_type === "SetOwner" ||
|
|
tx.transaction_type === "Create";
|
|
const isAgent = tx.transaction_type === "Correspond" || tx.transaction_type === "Comment";
|
|
|
|
if (isSystem) {
|
|
const timeAgo = formatDistanceToNow(new Date(tx.created_at), {
|
|
addSuffix: true,
|
|
});
|
|
let message = "";
|
|
if (tx.transaction_type === "Create") {
|
|
message = `Ticket created`;
|
|
} else if (tx.transaction_type === "StatusChange") {
|
|
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 === "SetOwner") {
|
|
message = tx.new_value
|
|
? `${getInitial(tx.creator_id)} assigned to ${tx.new_value}`
|
|
: `${getInitial(tx.creator_id)} unassigned`;
|
|
} else {
|
|
message = tx.transaction_type;
|
|
}
|
|
|
|
return (
|
|
<div className="flex justify-center py-2">
|
|
<span className="text-xs text-muted-foreground">
|
|
{message} · {timeAgo}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const isInternal = tx.transaction_type === "Comment";
|
|
const timeAgo = formatDistanceToNow(new Date(tx.created_at), {
|
|
addSuffix: true,
|
|
});
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"flex gap-3 py-3 px-4",
|
|
isAgent ? "flex-row-reverse" : ""
|
|
)}
|
|
>
|
|
<div
|
|
className="w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0"
|
|
style={{
|
|
backgroundColor: isAgent
|
|
? getInitialColor(tx.creator_id)
|
|
: "var(--muted)",
|
|
}}
|
|
>
|
|
<span
|
|
className="text-[11px] font-semibold"
|
|
style={{ color: isAgent ? "#f7f8f8" : "var(--muted-foreground)" }}
|
|
>
|
|
{getInitial(tx.creator_id)}
|
|
</span>
|
|
</div>
|
|
<div
|
|
className={cn(
|
|
"max-w-[75%] rounded-lg px-3 py-2 transition-all duration-150",
|
|
isAgent
|
|
? "bg-primary/15 text-foreground"
|
|
: isInternal
|
|
? "bg-muted border border-border text-foreground"
|
|
: "bg-muted text-foreground"
|
|
)}
|
|
>
|
|
{isInternal && (
|
|
<div className="text-[10px] font-semibold text-chart-3 mb-0.5 uppercase tracking-wider">
|
|
Internal note
|
|
</div>
|
|
)}
|
|
<p className="text-sm whitespace-pre-wrap">
|
|
{typeof tx.data === "object" && tx.data !== null && "body" in (tx.data as Record<string, unknown>)
|
|
? String((tx.data as Record<string, unknown>).body)
|
|
: tx.transaction_type}
|
|
</p>
|
|
<p className="text-[10px] text-muted-foreground mt-1">{timeAgo}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function TicketDetailPage({
|
|
params,
|
|
}: {
|
|
params: Promise<{ id: string }>;
|
|
}) {
|
|
const { id } = use(params);
|
|
const router = useRouter();
|
|
|
|
const [ticket, setTicket] = useState<Ticket | null>(null);
|
|
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
|
const [queue, setQueue] = useState<Queue | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// Reply
|
|
const [replyText, setReplyText] = useState("");
|
|
const [replyMode, setReplyMode] = useState<"public" | "internal">("public");
|
|
|
|
// Status change
|
|
const [statusSelectOpen, setStatusSelectOpen] = useState(false);
|
|
const [pendingStatus, setPendingStatus] = useState<string | null>(null);
|
|
const [preview, setPreview] = useState<PreviewResult | null>(null);
|
|
const [previewLoading, setPreviewLoading] = useState(false);
|
|
const [previewError, setPreviewError] = useState<string | null>(null);
|
|
const [applyLoading, setApplyLoading] = useState(false);
|
|
const [scripResults, setScripResults] = useState<UpdateResult["scrip_results"] | null>(null);
|
|
|
|
const fetchData = async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
const [ticketRes, txRes, queuesRes] = await Promise.all([
|
|
getTicket(id),
|
|
getTicketTransactions(id),
|
|
getQueues(),
|
|
]);
|
|
|
|
if (ticketRes.error) {
|
|
setError(ticketRes.error);
|
|
} else {
|
|
setTicket(ticketRes.data);
|
|
}
|
|
|
|
if (txRes.error) {
|
|
setError((prev) => prev || txRes.error);
|
|
} else {
|
|
setTransactions(txRes.data ?? []);
|
|
}
|
|
|
|
if (queuesRes.data && ticketRes.data) {
|
|
const q = queuesRes.data.find((q) => q.id === ticketRes.data!.queue_id);
|
|
if (q) setQueue(q);
|
|
}
|
|
|
|
setLoading(false);
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
}, [id]);
|
|
|
|
const handleStatusSelect = async (newStatus: string) => {
|
|
setPendingStatus(newStatus);
|
|
setStatusSelectOpen(false);
|
|
setPreview(null);
|
|
setPreviewError(null);
|
|
setScripResults(null);
|
|
|
|
if (newStatus === ticket?.status) return;
|
|
|
|
setPreviewLoading(true);
|
|
const { data, error } = await previewTicket(id, { status: newStatus });
|
|
setPreviewLoading(false);
|
|
|
|
if (error) {
|
|
setPreviewError(error);
|
|
setPendingStatus(null);
|
|
} else {
|
|
setPreview(data);
|
|
}
|
|
};
|
|
|
|
const handleApplyStatus = async () => {
|
|
if (!pendingStatus) return;
|
|
setApplyLoading(true);
|
|
setPreviewError(null);
|
|
|
|
const { data, error } = await updateTicket(id, { status: pendingStatus });
|
|
|
|
setApplyLoading(false);
|
|
|
|
if (error) {
|
|
setPreviewError(error);
|
|
} else if (data) {
|
|
setTicket(data.ticket);
|
|
setScripResults(data.scrip_results);
|
|
setPreview(null);
|
|
setPendingStatus(null);
|
|
// Refresh transactions
|
|
const txRes = await getTicketTransactions(id);
|
|
if (txRes.data) setTransactions(txRes.data);
|
|
}
|
|
};
|
|
|
|
const handleCancelStatus = () => {
|
|
setPendingStatus(null);
|
|
setPreview(null);
|
|
setPreviewError(null);
|
|
setScripResults(null);
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex h-full">
|
|
<div className="flex-1 p-4 space-y-4">
|
|
{Array.from({ length: 6 }).map((_, i) => (
|
|
<div key={i} className="flex gap-3">
|
|
<div className="w-6 h-6 rounded-full bg-muted animate-pulse" />
|
|
<div className="flex-1 space-y-2">
|
|
<div className="h-3 bg-muted rounded animate-pulse w-3/4" />
|
|
<div className="h-3 bg-muted rounded animate-pulse w-1/2" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="w-80 bg-sidebar border-l border-border p-4 space-y-3">
|
|
{Array.from({ length: 5 }).map((_, i) => (
|
|
<div key={i} className="h-6 bg-muted rounded animate-pulse" />
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error && !ticket) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center h-full">
|
|
<p className="text-destructive text-sm">{error}</p>
|
|
<button
|
|
onClick={fetchData}
|
|
className="mt-2 text-sm text-primary hover:text-primary/80 transition-all duration-150"
|
|
>
|
|
Retry
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!ticket) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center h-full">
|
|
<p className="text-muted-foreground text-sm">Ticket not found</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const currentStatusColor =
|
|
STATUS_COLORS[ticket.status] || STATUS_COLORS.new;
|
|
const currentStatusLabel =
|
|
STATUS_LABELS[ticket.status] || ticket.status;
|
|
|
|
return (
|
|
<div className="flex h-full">
|
|
{/* Left panel — conversation */}
|
|
<div className="flex-1 flex flex-col min-w-0">
|
|
{/* Header */}
|
|
<div className="flex items-center gap-3 px-4 py-2.5 border-b border-border">
|
|
<Link
|
|
href="/"
|
|
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-all duration-150"
|
|
>
|
|
<ArrowLeftIcon className="w-3.5 h-3.5" />
|
|
All tickets
|
|
</Link>
|
|
</div>
|
|
|
|
{/* Title */}
|
|
<div className="px-4 py-3 border-b border-border">
|
|
<h1 className="text-sm font-semibold text-foreground truncate">
|
|
{ticket.subject}
|
|
</h1>
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
<span className="font-mono">{ticket.id.slice(0, 8)}</span> · {queue?.name || ticket.queue_id}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Conversation */}
|
|
<div className="flex-1 overflow-y-auto">
|
|
{transactions.length === 0 && (
|
|
<div className="flex flex-col items-center justify-center py-20">
|
|
<p className="text-sm text-muted-foreground">
|
|
No activity yet
|
|
</p>
|
|
</div>
|
|
)}
|
|
{transactions.map((tx) => (
|
|
<TransactionBubble key={tx.id} tx={tx} />
|
|
))}
|
|
</div>
|
|
|
|
{/* Reply box */}
|
|
<div className="border-t border-border bg-sidebar p-3">
|
|
{/* Toggle tabs */}
|
|
<div className="flex gap-0.5 mb-2 p-0.5 rounded-lg bg-background w-fit">
|
|
<button
|
|
onClick={() => setReplyMode("public")}
|
|
className={cn(
|
|
"px-2.5 py-1 rounded-md text-xs font-medium transition-all duration-150",
|
|
replyMode === "public"
|
|
? "bg-primary text-primary-foreground"
|
|
: "text-muted-foreground hover:text-foreground"
|
|
)}
|
|
>
|
|
Reply
|
|
</button>
|
|
<button
|
|
onClick={() => setReplyMode("internal")}
|
|
className={cn(
|
|
"px-2.5 py-1 rounded-md text-xs font-medium transition-all duration-150",
|
|
replyMode === "internal"
|
|
? "bg-primary text-primary-foreground"
|
|
: "text-muted-foreground hover:text-foreground"
|
|
)}
|
|
>
|
|
Internal note
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex items-end gap-2">
|
|
<textarea
|
|
value={replyText}
|
|
onChange={(e) => setReplyText(e.target.value)}
|
|
placeholder="Reply to this ticket..."
|
|
rows={2}
|
|
className="flex-1 px-3 py-2 rounded-lg bg-background border border-border text-sm text-foreground placeholder:text-muted-foreground outline-none focus:border-primary focus:ring-1 focus:ring-primary resize-none transition-all duration-150"
|
|
/>
|
|
<div className="flex items-center gap-1">
|
|
<button className="w-8 h-8 flex items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-all duration-150" title="Attach file (coming soon)">
|
|
<PaperclipIcon className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
disabled={!replyText.trim()}
|
|
className={cn(
|
|
"w-8 h-8 flex items-center justify-center rounded-lg transition-all duration-150",
|
|
replyText.trim()
|
|
? "bg-primary text-primary-foreground hover:bg-primary/80"
|
|
: "bg-muted text-muted-foreground cursor-not-allowed"
|
|
)}
|
|
>
|
|
<SendIcon className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right panel — properties */}
|
|
<div className="w-80 flex-shrink-0 bg-sidebar border-l border-border flex flex-col overflow-y-auto">
|
|
<div className="p-4 space-y-5">
|
|
{/* Section: Status */}
|
|
<div>
|
|
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
|
Status
|
|
</h3>
|
|
<div className="relative">
|
|
<button
|
|
onClick={() => setStatusSelectOpen(!statusSelectOpen)}
|
|
className="w-full flex items-center gap-3 px-4 py-2.5 rounded-lg border border-border bg-background text-sm hover:border-foreground/20 transition-all duration-150"
|
|
>
|
|
<span
|
|
className="w-3 h-3 rounded-full flex-shrink-0"
|
|
style={{ backgroundColor: currentStatusColor }}
|
|
/>
|
|
<span className="text-foreground font-medium flex-1 text-left">
|
|
{currentStatusLabel}
|
|
</span>
|
|
<svg
|
|
className="w-4 h-4 text-muted-foreground"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M19 9l-7 7-7-7"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
|
|
{statusSelectOpen && (
|
|
<div className="absolute top-full left-0 right-0 mt-1 z-10 bg-popover border border-border rounded-lg shadow-xl overflow-hidden">
|
|
{ALL_STATUSES.map((status) => {
|
|
const color = STATUS_COLORS[status];
|
|
const label = STATUS_LABELS[status];
|
|
const isCurrent = status === ticket.status;
|
|
return (
|
|
<button
|
|
key={status}
|
|
onClick={() => handleStatusSelect(status)}
|
|
disabled={isCurrent}
|
|
className={cn(
|
|
"w-full flex items-center gap-3 px-4 py-2.5 text-sm text-left transition-all duration-150",
|
|
isCurrent
|
|
? "bg-accent text-muted-foreground cursor-default"
|
|
: "text-foreground hover:bg-accent"
|
|
)}
|
|
>
|
|
<span
|
|
className="w-3 h-3 rounded-full flex-shrink-0"
|
|
style={{ backgroundColor: color }}
|
|
/>
|
|
{label}
|
|
{isCurrent && (
|
|
<span className="text-xs text-muted-foreground ml-auto">
|
|
current
|
|
</span>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Status change preview */}
|
|
{preview && (
|
|
<div className="p-3 rounded-lg bg-background border border-border">
|
|
<p className="text-xs text-muted-foreground mb-2">
|
|
Preview: changing to{" "}
|
|
<span className="text-foreground font-medium">
|
|
{STATUS_LABELS[pendingStatus || ""]}
|
|
</span>
|
|
</p>
|
|
{preview.prepared_scrips.length > 0 ? (
|
|
<div className="space-y-1 mb-3">
|
|
{preview.prepared_scrips.map((scrip) => (
|
|
<div
|
|
key={scrip.scripId}
|
|
className="text-xs text-foreground flex items-center gap-1.5"
|
|
>
|
|
<span className="w-2 h-2 rounded-full bg-chart-3 flex-shrink-0" />
|
|
{scrip.scripName}
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-xs text-muted-foreground mb-3">
|
|
No scrips will fire
|
|
</p>
|
|
)}
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={handleApplyStatus}
|
|
disabled={applyLoading}
|
|
className="px-2.5 py-1 rounded-md text-xs font-medium bg-primary hover:bg-primary/80 text-primary-foreground disabled:opacity-50 transition-all duration-150"
|
|
>
|
|
{applyLoading
|
|
? "Applying..."
|
|
: `Apply — ${preview.prepared_scrips.length} scrip${preview.prepared_scrips.length !== 1 ? "s" : ""} will fire`}
|
|
</button>
|
|
<button
|
|
onClick={handleCancelStatus}
|
|
disabled={applyLoading}
|
|
className="px-2.5 py-1 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground transition-all duration-150"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{previewError && (
|
|
<div className="p-2 rounded-lg bg-destructive/5 border border-destructive/10">
|
|
<p className="text-xs text-destructive">{previewError}</p>
|
|
</div>
|
|
)}
|
|
|
|
{scripResults && (
|
|
<div className="p-3 rounded-lg bg-background border border-border">
|
|
<p className="text-xs text-muted-foreground mb-2">Scrip results:</p>
|
|
<div className="space-y-1">
|
|
{scripResults.map((result) => (
|
|
<div
|
|
key={result.scripId}
|
|
className={cn(
|
|
"text-xs flex items-center gap-1.5",
|
|
result.success ? "text-[#22c55e]" : "text-destructive"
|
|
)}
|
|
>
|
|
<span
|
|
className={cn(
|
|
"w-2 h-2 rounded-full flex-shrink-0",
|
|
result.success ? "bg-[#22c55e]" : "bg-destructive"
|
|
)}
|
|
/>
|
|
{result.message}
|
|
</div>
|
|
))}
|
|
</div>
|
|
<button
|
|
onClick={() => setScripResults(null)}
|
|
className="mt-2 text-xs text-muted-foreground hover:text-foreground transition-all duration-150"
|
|
>
|
|
Dismiss
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<Separator />
|
|
|
|
{/* Section: Assignment */}
|
|
<div>
|
|
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
|
Assignment
|
|
</h3>
|
|
|
|
{/* Assignee */}
|
|
<div className="mb-3">
|
|
<label className="block text-[11px] font-medium text-muted-foreground mb-1">
|
|
Assignee
|
|
</label>
|
|
{ticket.owner_id ? (
|
|
<div className="flex items-center gap-2 px-3 py-2 rounded-lg border border-border bg-background transition-all duration-150 hover:border-foreground/20">
|
|
<div
|
|
className="w-5 h-5 rounded-full flex items-center justify-center flex-shrink-0"
|
|
style={{
|
|
backgroundColor: getInitialColor(ticket.owner_id),
|
|
}}
|
|
>
|
|
<span className="text-[10px] font-semibold text-primary-foreground">
|
|
{getInitial(ticket.owner_id)}
|
|
</span>
|
|
</div>
|
|
<span className="text-sm text-foreground">
|
|
{ticket.owner_id}
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<div className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-muted-foreground">
|
|
Unassigned
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Priority (placeholder) */}
|
|
<div>
|
|
<label className="block text-[11px] font-medium text-muted-foreground mb-1">
|
|
Priority
|
|
</label>
|
|
<div className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-muted-foreground">
|
|
Not set
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* Section: Details */}
|
|
<div>
|
|
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
|
Details
|
|
</h3>
|
|
|
|
{/* Queue */}
|
|
<div className="mb-3">
|
|
<label className="block text-[11px] font-medium text-muted-foreground mb-1">
|
|
Queue
|
|
</label>
|
|
<div className="px-3 py-2 rounded-lg border border-border bg-background">
|
|
<span className="text-sm text-foreground">
|
|
{queue?.name || ticket.queue_id}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Custom fields */}
|
|
{ticket.custom_fields && ticket.custom_fields.length > 0 && (
|
|
<div className="mb-3">
|
|
<label className="block text-[11px] font-medium text-muted-foreground mb-1">
|
|
Custom fields
|
|
</label>
|
|
<div className="space-y-1.5">
|
|
{ticket.custom_fields.map((cf) => (
|
|
<div
|
|
key={cf.id}
|
|
className="flex justify-between items-center px-3 py-1.5 rounded-lg border border-border bg-background"
|
|
>
|
|
<span className="text-xs text-muted-foreground">
|
|
{cf.custom_field?.name || cf.custom_field_id}
|
|
</span>
|
|
<span className="text-xs text-foreground font-medium">
|
|
{cf.value}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Dates */}
|
|
<div>
|
|
<div className="space-y-1.5 text-xs">
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">Created</span>
|
|
<span className="text-foreground tabular-nums">
|
|
{formatDistanceToNow(new Date(ticket.created_at), {
|
|
addSuffix: true,
|
|
})}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">Updated</span>
|
|
<span className="text-foreground tabular-nums">
|
|
{formatDistanceToNow(new Date(ticket.updated_at), {
|
|
addSuffix: true,
|
|
})}
|
|
</span>
|
|
</div>
|
|
{ticket.resolved_at && (
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">Resolved</span>
|
|
<span className="text-foreground tabular-nums">
|
|
{formatDistanceToNow(new Date(ticket.resolved_at), {
|
|
addSuffix: true,
|
|
})}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|