Files
tessera/web/src/app/tickets/[id]/page.tsx
Gjermund Høsøien Wiggen 06cc7c79a3 feat: enhance frontend UI — command palette, admin redesign, API coverage
Types + API:
- Add User, TemplatePreview, QueueCustomField types
- Add getUsers, getTemplates, createTemplate, updateTemplate,
  previewTemplate, updateQueue, updateLifecycle, updateCustomField API functions

UI:
- Command palette: keyboard-first navigation with fuzzy ticket search
- Admin: comprehensive redesign with tab-based layout (Queues, Lifecycles,
  Scrips, Custom Fields, Templates, Users)
- Ticket list: improved inbox-style rows with quick actions
- Ticket detail: enhanced conversation thread and properties sidebar
- App shell: sidebar visual refinement with active indicator bar
- Theme toggle: smoother transitions

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:43:28 +02:00

1034 lines
40 KiB
TypeScript

"use client";
import { useState, useEffect, use, useCallback } from "react";
import type { ReactNode } from "react";
import Link from "next/link";
import { formatDistanceToNow } from "date-fns";
import {
ArrowLeftIcon,
BotIcon,
CheckCircle2Icon,
ChevronDownIcon,
CircleIcon,
Clock3Icon,
FileTextIcon,
MessageSquareIcon,
PaperclipIcon,
PencilIcon,
SaveIcon,
SendIcon,
UserRoundIcon,
XIcon,
} from "lucide-react";
import {
getTicket,
getTicketTransactions,
getQueues,
getLifecycles,
getUsers,
getQueueCustomFields,
previewTicket,
updateTicket,
updateTicketCustomField,
sendComment,
} from "@/lib/api";
import type {
Ticket,
Transaction,
Queue,
Lifecycle,
User,
QueueCustomField,
PreviewResult,
UpdateResult,
} from "@/lib/types";
import { Separator } from "@/components/ui/separator";
import { cn, formatTicketId } from "@/lib/utils";
const STATUS_COLORS: Record<string, string> = {
new: "#64748b",
open: "#2563eb",
in_progress: "#d97706",
resolved: "#16a34a",
closed: "#71717a",
};
const STATUS_LABELS: Record<string, string> = {
new: "New",
open: "Open",
in_progress: "In progress",
resolved: "Resolved",
closed: "Closed",
};
const DEFAULT_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 = ["#2563eb", "#0f766e", "#9333ea", "#d97706", "#16a34a"];
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] ?? colors[0];
}
function statusLabel(status: string) {
return STATUS_LABELS[status] ?? status.replaceAll("_", " ");
}
function StatusBadge({ status }: { status: string }) {
return (
<span className="inline-flex h-7 items-center gap-2 rounded border border-border bg-card px-2.5 text-xs font-semibold text-foreground shadow-sm">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: STATUS_COLORS[status] ?? STATUS_COLORS.new }}
/>
{statusLabel(status)}
</span>
);
}
function userLabel(users: User[], userId: string | null) {
if (!userId) return "Unassigned";
const user = users.find((item) => item.id === userId);
return user?.username ?? userId;
}
function TransactionCard({
tx,
users,
customFieldLabels,
}: {
tx: Transaction;
users: User[];
customFieldLabels: Record<string, string>;
}) {
const isSystem =
tx.transaction_type === "StatusChange" ||
tx.transaction_type === "SetOwner" ||
tx.transaction_type === "CustomFieldChange" ||
tx.transaction_type === "Create";
const isInternal = tx.transaction_type === "Comment";
const isMessage = tx.transaction_type === "Correspond" || isInternal;
const timeAgo = formatDistanceToNow(new Date(tx.created_at), { addSuffix: true });
const body =
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;
if (isSystem) {
let message = tx.transaction_type;
if (tx.transaction_type === "Create") {
message = "Ticket created";
} else if (tx.transaction_type === "StatusChange") {
const oldLabel = tx.old_value ? statusLabel(tx.old_value) : "?";
const newLabel = tx.new_value ? statusLabel(tx.new_value) : "?";
message = `Status changed from ${oldLabel} to ${newLabel}`;
} else if (tx.transaction_type === "SetOwner") {
message = tx.new_value ? `Assigned to ${userLabel(users, tx.new_value)}` : "Unassigned";
} else if (tx.transaction_type === "CustomFieldChange") {
const fieldName = tx.field ? customFieldLabels[tx.field] ?? "Custom field" : "Custom field";
message = tx.new_value
? `${fieldName} set to ${tx.new_value}`
: `${fieldName} cleared`;
}
return (
<div className="grid grid-cols-[28px_minmax(0,1fr)] gap-3 px-6 py-3">
<div className="flex justify-center">
<span className="mt-1 flex h-5 w-5 items-center justify-center rounded bg-muted text-muted-foreground">
<BotIcon className="h-3.5 w-3.5" />
</span>
</div>
<div className="rounded-md border border-border bg-muted/55 px-3 py-2 text-sm">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="font-medium text-foreground">{message}</span>
<span className="text-xs text-muted-foreground">{timeAgo}</span>
</div>
</div>
</div>
);
}
return (
<article className="grid grid-cols-[28px_minmax(0,1fr)] gap-3 px-6 py-4">
<div
className="flex h-7 w-7 items-center justify-center rounded-md text-[11px] font-semibold text-white"
style={{ backgroundColor: getInitialColor(userLabel(users, tx.creator_id)) }}
>
{getInitial(userLabel(users, tx.creator_id))}
</div>
<div className="overflow-hidden rounded-md border border-border bg-card shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border bg-muted/35 px-3 py-2">
<div className="flex items-center gap-2">
{isMessage ? (
<MessageSquareIcon className="h-3.5 w-3.5 text-muted-foreground" />
) : (
<FileTextIcon className="h-3.5 w-3.5 text-muted-foreground" />
)}
<span className="text-sm font-semibold text-foreground">
{userLabel(users, tx.creator_id)}
</span>
{isInternal && (
<span className="rounded bg-amber-500/12 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-amber-700 dark:text-amber-300">
Internal
</span>
)}
</div>
<span className="text-xs text-muted-foreground">{timeAgo}</span>
</div>
<p className="whitespace-pre-wrap px-3 py-3 text-sm leading-6 text-foreground">
{body}
</p>
</div>
</article>
);
}
function PropertyRow({ label, value }: { label: string; value: ReactNode }) {
return (
<div className="grid grid-cols-[92px_minmax(0,1fr)] gap-3 border-b border-border px-3 py-2.5 last:border-b-0">
<dt className="text-[11px] font-semibold uppercase text-muted-foreground">{label}</dt>
<dd className="min-w-0 truncate text-sm text-foreground">{value}</dd>
</div>
);
}
export default function TicketDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id: idParam } = use(params);
const id = Number(idParam);
const [ticket, setTicket] = useState<Ticket | null>(null);
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [queue, setQueue] = useState<Queue | null>(null);
const [lifecycles, setLifecycles] = useState<Lifecycle[]>([]);
const [users, setUsers] = useState<User[]>([]);
const [queueFields, setQueueFields] = useState<QueueCustomField[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [fieldError, setFieldError] = useState<string | null>(null);
const [replyText, setReplyText] = useState("");
const [replyMode, setReplyMode] = useState<"public" | "internal">("public");
const [sending, setSending] = useState(false);
const [sendError, setSendError] = useState<string | null>(null);
const [statusSelectOpen, setStatusSelectOpen] = useState(false);
const [pendingStatus, setPendingStatus] = useState<string | null>(null);
const [preview, setPreview] = useState<PreviewResult | null>(null);
const [previewError, setPreviewError] = useState<string | null>(null);
const [applyLoading, setApplyLoading] = useState(false);
const [scripResults, setScripResults] = useState<UpdateResult["scrip_results"] | null>(null);
const [editingSubject, setEditingSubject] = useState(false);
const [subjectDraft, setSubjectDraft] = useState("");
const [fieldSaving, setFieldSaving] = useState<"subject" | "owner" | null>(null);
const [customFieldDrafts, setCustomFieldDrafts] = useState<Record<string, string>>({});
const [customFieldSaving, setCustomFieldSaving] = useState<string | null>(null);
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
const [ticketRes, txRes, queuesRes, lifecycleRes, usersRes] = await Promise.all([
getTicket(id),
getTicketTransactions(id),
getQueues(),
getLifecycles(),
getUsers(),
]);
if (ticketRes.error) {
setError(ticketRes.error);
} else {
setTicket(ticketRes.data);
setSubjectDraft(ticketRes.data?.subject ?? "");
}
if (txRes.error) {
setError((prev) => prev || txRes.error);
} else {
setTransactions(txRes.data ?? []);
}
if (queuesRes.data && ticketRes.data) {
const matchedQueue = queuesRes.data.find((item) => item.id === ticketRes.data!.queue_id);
if (matchedQueue) setQueue(matchedQueue);
}
if (lifecycleRes.error) {
setError((prev) => prev || lifecycleRes.error);
} else {
setLifecycles(lifecycleRes.data ?? []);
}
if (ticketRes.data) {
const { data, error } = await getQueueCustomFields(ticketRes.data.queue_id);
if (error) {
setError((prev) => prev || error);
} else {
setQueueFields(data ?? []);
}
setCustomFieldDrafts(
Object.fromEntries(
(ticketRes.data.custom_fields ?? []).map((value) => [value.custom_field_id, value.value])
)
);
}
if (usersRes.error) {
setError((prev) => prev || usersRes.error);
} else {
setUsers(usersRes.data ?? []);
}
setLoading(false);
}, [id]);
useEffect(() => {
void Promise.resolve().then(() => fetchData());
}, [fetchData]);
const handleStatusSelect = async (newStatus: string) => {
setPendingStatus(newStatus);
setStatusSelectOpen(false);
setPreview(null);
setPreviewError(null);
setScripResults(null);
if (newStatus === ticket?.status) return;
const { data, error } = await previewTicket(id, { status: newStatus });
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);
const txRes = await getTicketTransactions(id);
if (txRes.data) setTransactions(txRes.data);
}
};
const handleCancelStatus = () => {
setPendingStatus(null);
setPreview(null);
setPreviewError(null);
setScripResults(null);
};
const refreshTransactions = async () => {
const txRes = await getTicketTransactions(id);
if (txRes.data) setTransactions(txRes.data);
};
const handleSaveSubject = async () => {
const nextSubject = subjectDraft.trim();
if (!ticket || !nextSubject || nextSubject === ticket.subject || fieldSaving) {
setEditingSubject(false);
setSubjectDraft(ticket?.subject ?? "");
return;
}
setFieldSaving("subject");
setFieldError(null);
const { data, error } = await updateTicket(id, { subject: nextSubject });
setFieldSaving(null);
if (error) {
setFieldError(error);
return;
}
if (data) {
setTicket(data.ticket);
setSubjectDraft(data.ticket.subject);
setEditingSubject(false);
await refreshTransactions();
}
};
const handleOwnerChange = async (ownerId: string) => {
if (!ticket || fieldSaving) return;
const nextOwnerId = ownerId || null;
if (nextOwnerId === ticket.owner_id) return;
setFieldSaving("owner");
setFieldError(null);
const { data, error } = await updateTicket(id, { owner_id: nextOwnerId });
setFieldSaving(null);
if (error) {
setFieldError(error);
return;
}
if (data) {
setTicket(data.ticket);
await refreshTransactions();
}
};
const handleCustomFieldSave = async (fieldId: string) => {
if (!ticket || customFieldSaving) return;
const value = customFieldDrafts[fieldId]?.trim() ?? "";
const currentValue = ticket.custom_fields?.find((item) => item.custom_field_id === fieldId)?.value ?? "";
if (value === currentValue) return;
const field = queueFields.find((assignment) => assignment.custom_field_id === fieldId)?.custom_field;
if (value && field?.pattern) {
const regex = new RegExp(field.pattern);
if (!regex.test(value)) {
setFieldError(`${field.name}: value does not match the required pattern.`);
return;
}
}
setCustomFieldSaving(fieldId);
setFieldError(null);
const { error } = await updateTicketCustomField(id, fieldId, value);
setCustomFieldSaving(null);
if (error) {
setFieldError(error);
return;
}
const [ticketRes, txRes] = await Promise.all([
getTicket(id),
getTicketTransactions(id),
]);
if (ticketRes.data) {
setTicket(ticketRes.data);
setCustomFieldDrafts(
Object.fromEntries(
(ticketRes.data.custom_fields ?? []).map((item) => [item.custom_field_id, item.value])
)
);
}
if (txRes.data) setTransactions(txRes.data);
};
const customFieldValue = (fieldId: string) =>
ticket?.custom_fields?.find((item) => item.custom_field_id === fieldId)?.value ?? "";
const handleSendComment = async () => {
if (!replyText.trim() || sending) return;
setSending(true);
setSendError(null);
const { error } = await sendComment(id, {
body: replyText.trim(),
internal: replyMode === "internal",
});
setSending(false);
if (error) {
setSendError(error);
} else {
setReplyText("");
setSendError(null);
const txRes = await getTicketTransactions(id);
if (txRes.data) setTransactions(txRes.data);
}
};
if (loading) {
return (
<div className="grid h-full grid-cols-1 xl:grid-cols-[minmax(0,1fr)_348px]">
<div className="flex flex-col">
<div className="border-b border-border bg-card/80 px-6 py-5">
<div className="h-5 w-32 animate-pulse rounded bg-muted" />
<div className="mt-3 h-8 w-2/3 animate-pulse rounded bg-muted" />
</div>
<div className="flex-1 space-y-3 p-6">
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="grid grid-cols-[28px_minmax(0,1fr)] gap-3">
<div className="h-7 w-7 animate-pulse rounded bg-muted" />
<div className="h-20 animate-pulse rounded-md bg-muted" />
</div>
))}
</div>
</div>
<div className="hidden border-l border-border bg-card/70 p-5 xl:block">
<div className="h-72 animate-pulse rounded-md bg-muted" />
</div>
</div>
);
}
if (error && !ticket) {
return (
<div className="flex h-full flex-col items-center justify-center">
<p className="text-sm text-destructive">{error}</p>
<button
onClick={fetchData}
className="mt-2 text-sm font-medium text-primary hover:text-primary/80"
>
Retry
</button>
</div>
);
}
if (!ticket) {
return (
<div className="flex h-full flex-col items-center justify-center">
<p className="text-sm text-muted-foreground">Ticket not found</p>
</div>
);
}
const lifecycle = queue?.lifecycle_id
? lifecycles.find((item) => item.id === queue.lifecycle_id)
: null;
const statusOptions = lifecycle
? Array.from(new Set([
ticket.status,
...(lifecycle.definition.transitions[ticket.status] ?? []),
...(lifecycle.definition.transitions["*"] ?? []),
]))
: Array.from(new Set([ticket.status, ...DEFAULT_STATUSES]));
const currentStatusColor = STATUS_COLORS[ticket.status] || STATUS_COLORS.new;
const currentStatusLabel = statusLabel(ticket.status);
const customFieldLabels = Object.fromEntries(
queueFields.map((assignment) => [
assignment.custom_field_id,
assignment.custom_field?.name ?? assignment.custom_field_id,
])
);
return (
<div className="grid h-full grid-cols-1 bg-background/80 xl:grid-cols-[minmax(0,1fr)_348px]">
<main className="flex min-w-0 flex-col">
<header className="shrink-0 border-b border-border bg-card/86 backdrop-blur">
<div className="flex items-center justify-between gap-3 border-b border-border px-5 py-2.5">
<Link
href="/"
className="inline-flex items-center gap-1.5 text-xs font-semibold text-muted-foreground transition-colors hover:text-foreground"
>
<ArrowLeftIcon className="h-3.5 w-3.5" />
Work queue
</Link>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Clock3Icon className="h-3.5 w-3.5" />
Updated {formatDistanceToNow(new Date(ticket.updated_at), { addSuffix: true })}
</div>
</div>
<div className="px-5 py-4 lg:px-6">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs font-semibold text-muted-foreground">
{formatTicketId(ticket.id)}
</span>
<StatusBadge status={ticket.status} />
<span className="truncate text-xs font-medium text-muted-foreground">
{queue?.name || ticket.queue_id}
</span>
</div>
<div className="mt-2 flex max-w-4xl items-start gap-2">
{editingSubject ? (
<>
<textarea
value={subjectDraft}
onChange={(event) => setSubjectDraft(event.target.value)}
onKeyDown={(event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
void handleSaveSubject();
}
if (event.key === "Escape") {
setEditingSubject(false);
setSubjectDraft(ticket.subject);
}
}}
rows={2}
className="min-h-20 flex-1 resize-none rounded-md border border-input bg-background px-3 py-2 text-xl font-semibold leading-tight text-foreground shadow-sm outline-none focus:border-ring"
autoFocus
/>
<div className="flex shrink-0 items-center gap-1">
<button
onClick={() => void handleSaveSubject()}
disabled={!subjectDraft.trim() || fieldSaving === "subject"}
className="flex h-8 w-8 items-center justify-center rounded-md bg-primary text-primary-foreground transition-colors hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground"
title="Save subject"
type="button"
>
<SaveIcon className="h-4 w-4" />
</button>
<button
onClick={() => {
setEditingSubject(false);
setSubjectDraft(ticket.subject);
}}
className="flex h-8 w-8 items-center justify-center rounded-md border border-border bg-card text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
title="Cancel"
type="button"
>
<XIcon className="h-4 w-4" />
</button>
</div>
</>
) : (
<>
<h1 className="min-w-0 flex-1 text-2xl font-semibold leading-tight text-foreground">
{ticket.subject}
</h1>
<button
onClick={() => {
setSubjectDraft(ticket.subject);
setEditingSubject(true);
}}
className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-border bg-card text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
title="Edit subject"
type="button"
>
<PencilIcon className="h-4 w-4" />
</button>
</>
)}
</div>
{fieldError && <p className="mt-2 text-xs text-destructive">{fieldError}</p>}
</div>
</div>
</div>
</header>
<section className="min-h-0 flex-1 overflow-y-auto bg-card/38">
{transactions.length === 0 ? (
<div className="flex min-h-96 flex-col items-center justify-center px-6 text-center">
<div className="mb-3 flex h-11 w-11 items-center justify-center rounded-md border border-border bg-card">
<MessageSquareIcon className="h-5 w-5 text-muted-foreground" />
</div>
<p className="text-sm font-medium text-foreground">No activity yet</p>
<p className="mt-1 text-sm text-muted-foreground">
Reply or change status to start the ticket ledger.
</p>
</div>
) : (
<div className="mx-auto max-w-4xl py-4">
{transactions.map((tx) => (
<TransactionCard
key={tx.id}
tx={tx}
users={users}
customFieldLabels={customFieldLabels}
/>
))}
</div>
)}
</section>
<footer className="shrink-0 border-t border-border bg-card/90 p-4 backdrop-blur">
<div className="mx-auto max-w-4xl rounded-md border border-border bg-background/72 p-3 shadow-sm">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex rounded-md border border-border bg-muted/55 p-1">
<button
onClick={() => setReplyMode("public")}
className={cn(
"h-7 rounded px-2.5 text-xs font-semibold transition-colors",
replyMode === "public"
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
Reply
</button>
<button
onClick={() => setReplyMode("internal")}
className={cn(
"h-7 rounded px-2.5 text-xs font-semibold transition-colors",
replyMode === "internal"
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
Internal note
</button>
</div>
<span className="text-xs text-muted-foreground">
{replyMode === "internal" ? "Visible to staff only" : "Public correspondence"}
</span>
</div>
<div className="flex items-end gap-2">
<textarea
value={replyText}
onChange={(event) => {
setReplyText(event.target.value);
setSendError(null);
}}
placeholder={replyMode === "internal" ? "Add internal context..." : "Write a reply..."}
rows={3}
className="min-h-24 flex-1 resize-none rounded-md border border-input bg-card/90 px-3 py-2 text-sm leading-6 text-foreground shadow-sm outline-none transition-colors placeholder:text-muted-foreground focus:border-ring"
/>
<div className="flex items-center gap-1">
<button
className="flex h-9 w-9 items-center justify-center rounded-md border border-border bg-card text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
title="Attach file (coming soon)"
type="button"
>
<PaperclipIcon className="h-4 w-4" />
</button>
<button
onClick={handleSendComment}
disabled={!replyText.trim() || sending}
className={cn(
"flex h-9 w-9 items-center justify-center rounded-md transition-colors",
replyText.trim() && !sending
? "bg-primary text-primary-foreground hover:bg-primary/90"
: "bg-muted text-muted-foreground"
)}
type="button"
>
{sending ? (
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
) : (
<SendIcon className="h-4 w-4" />
)}
</button>
</div>
</div>
{sendError && <p className="mt-2 text-xs text-destructive">{sendError}</p>}
</div>
</footer>
</main>
<aside className="hidden min-h-0 overflow-y-auto border-l border-border bg-card/78 backdrop-blur xl:block">
<div className="space-y-5 p-5">
<section>
<div className="mb-2 flex items-center justify-between">
<h2 className="text-xs font-semibold uppercase text-muted-foreground">Status</h2>
<CircleIcon
className="h-3.5 w-3.5"
style={{ color: currentStatusColor }}
/>
</div>
<div className="relative">
<button
onClick={() => setStatusSelectOpen(!statusSelectOpen)}
className="flex w-full items-center gap-3 rounded-md border border-border bg-background/70 px-3 py-2.5 text-sm shadow-sm transition-colors hover:bg-accent"
type="button"
>
<span
className="h-3 w-3 rounded-full"
style={{ backgroundColor: currentStatusColor }}
/>
<span className="flex-1 text-left font-semibold text-foreground">
{currentStatusLabel}
</span>
<ChevronDownIcon className="h-4 w-4 text-muted-foreground" />
</button>
{statusSelectOpen && (
<div className="absolute left-0 right-0 top-full z-20 mt-1 overflow-hidden rounded-md border border-border bg-popover shadow-xl">
{statusOptions.map((status) => {
const isCurrent = status === ticket.status;
return (
<button
key={status}
onClick={() => handleStatusSelect(status)}
disabled={isCurrent}
className={cn(
"flex w-full items-center gap-3 px-3 py-2.5 text-left text-sm transition-colors",
isCurrent
? "bg-accent text-muted-foreground"
: "text-foreground hover:bg-accent"
)}
type="button"
>
<span
className="h-3 w-3 rounded-full"
style={{ backgroundColor: STATUS_COLORS[status] ?? STATUS_COLORS.new }}
/>
{statusLabel(status)}
{isCurrent && <span className="ml-auto text-xs">current</span>}
</button>
);
})}
</div>
)}
</div>
</section>
{preview && (
<section className="rounded-md border border-border bg-background/70 p-3 shadow-sm">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-foreground">
<BotIcon className="h-4 w-4 text-primary" />
Automation preview
</div>
<p className="text-xs text-muted-foreground">
Changing to{" "}
<span className="font-semibold text-foreground">
{pendingStatus ? statusLabel(pendingStatus) : ""}
</span>
</p>
<div className="my-3 space-y-1.5">
{preview.prepared_scrips.length > 0 ? (
preview.prepared_scrips.map((scrip) => (
<div
key={scrip.scripId}
className="flex items-center gap-2 rounded border border-border bg-card px-2 py-1.5 text-xs text-foreground"
>
<span className="h-1.5 w-1.5 rounded-full bg-primary" />
{scrip.scripName}
</div>
))
) : (
<p className="rounded border border-border bg-muted/40 px-2 py-1.5 text-xs text-muted-foreground">
No scrips will fire
</p>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={handleApplyStatus}
disabled={applyLoading}
className="rounded-md bg-primary px-2.5 py-1.5 text-xs font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
type="button"
>
{applyLoading ? "Applying..." : "Apply change"}
</button>
<button
onClick={handleCancelStatus}
disabled={applyLoading}
className="rounded-md px-2.5 py-1.5 text-xs font-semibold text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
type="button"
>
Cancel
</button>
</div>
</section>
)}
{previewError && (
<div className="rounded-md border border-destructive/20 bg-destructive/10 p-3">
<p className="text-xs text-destructive">{previewError}</p>
</div>
)}
{scripResults && (
<section className="rounded-md border border-border bg-background/70 p-3 shadow-sm">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-foreground">
<CheckCircle2Icon className="h-4 w-4 text-emerald-600" />
Scrip results
</div>
<div className="space-y-1.5">
{scripResults.map((result) => (
<div
key={result.scripId}
className={cn(
"flex items-center gap-2 text-xs",
result.success ? "text-emerald-700 dark:text-emerald-300" : "text-destructive"
)}
>
<span
className={cn(
"h-1.5 w-1.5 rounded-full",
result.success ? "bg-emerald-600" : "bg-destructive"
)}
/>
{result.message}
</div>
))}
</div>
<button
onClick={() => setScripResults(null)}
className="mt-2 text-xs font-medium text-muted-foreground hover:text-foreground"
type="button"
>
Dismiss
</button>
</section>
)}
<Separator />
<section>
<h2 className="mb-2 text-xs font-semibold uppercase text-muted-foreground">
Assignment
</h2>
<dl className="overflow-hidden rounded-md border border-border bg-background/60">
<div className="grid grid-cols-[92px_minmax(0,1fr)] gap-3 border-b border-border px-3 py-2.5">
<dt className="text-[11px] font-semibold uppercase text-muted-foreground">Owner</dt>
<dd className="min-w-0">
<select
value={ticket.owner_id ?? ""}
onChange={(event) => void handleOwnerChange(event.target.value)}
disabled={fieldSaving === "owner"}
className="h-8 w-full rounded-md border border-input bg-card px-2 text-sm text-foreground outline-none transition-colors focus:border-ring disabled:opacity-60"
aria-label="Owner"
>
<option value="">Unassigned</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.username}
</option>
))}
</select>
</dd>
</div>
<PropertyRow label="Priority" value="Not set" />
</dl>
</section>
<section>
<h2 className="mb-2 text-xs font-semibold uppercase text-muted-foreground">
Details
</h2>
<dl className="overflow-hidden rounded-md border border-border bg-background/60">
<PropertyRow label="Queue" value={queue?.name || ticket.queue_id} />
<PropertyRow
label="Created"
value={formatDistanceToNow(new Date(ticket.created_at), { addSuffix: true })}
/>
<PropertyRow
label="Updated"
value={formatDistanceToNow(new Date(ticket.updated_at), { addSuffix: true })}
/>
{ticket.resolved_at && (
<PropertyRow
label="Resolved"
value={formatDistanceToNow(new Date(ticket.resolved_at), { addSuffix: true })}
/>
)}
</dl>
</section>
<section>
<h2 className="mb-2 text-xs font-semibold uppercase text-muted-foreground">
Custom fields
</h2>
{queueFields.length === 0 ? (
<div className="rounded-md border border-border bg-background/60 px-3 py-3 text-sm text-muted-foreground">
No fields are assigned to this queue.
</div>
) : (
<div className="overflow-hidden rounded-md border border-border bg-background/60">
{queueFields.map((assignment) => {
const field = assignment.custom_field;
const fieldId = assignment.custom_field_id;
const options = Array.isArray(field?.values)
? field.values.map((value) => String(value))
: [];
const fieldType = field?.field_type.toLowerCase() ?? "";
const currentDraft = customFieldDrafts[fieldId] ?? customFieldValue(fieldId);
const dirty = currentDraft !== customFieldValue(fieldId);
const isSaving = customFieldSaving === fieldId;
return (
<div
key={assignment.id}
className="grid gap-2 border-b border-border px-3 py-3 last:border-b-0"
>
<label className="text-[11px] font-semibold uppercase text-muted-foreground">
{field?.name ?? fieldId}
</label>
<div className="flex items-center gap-2">
{(fieldType.includes("select") || options.length > 0) && options.length > 0 ? (
<select
value={currentDraft}
onChange={(event) => {
const nextValue = event.target.value;
setCustomFieldDrafts((current) => ({ ...current, [fieldId]: nextValue }));
}}
className="h-8 min-w-0 flex-1 rounded-md border border-input bg-card px-2 text-sm text-foreground outline-none transition-colors focus:border-ring"
>
<option value="">Not set</option>
{options.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
) : (
<input
value={currentDraft}
onChange={(event) =>
setCustomFieldDrafts((current) => ({ ...current, [fieldId]: event.target.value }))
}
onKeyDown={(event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
void handleCustomFieldSave(fieldId);
}
}}
placeholder={field?.pattern ? field.pattern : "Not set"}
className="h-8 min-w-0 flex-1 rounded-md border border-input bg-card px-2 text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-ring"
/>
)}
<button
onClick={() => void handleCustomFieldSave(fieldId)}
disabled={!dirty || isSaving}
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-md transition-colors",
dirty && !isSaving
? "bg-primary text-primary-foreground hover:bg-primary/90"
: "border border-border bg-card text-muted-foreground"
)}
title="Save custom field"
type="button"
>
<SaveIcon className="h-4 w-4" />
</button>
</div>
</div>
);
})}
</div>
)}
</section>
<section className="rounded-md border border-border bg-accent/38 p-3">
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
<UserRoundIcon className="h-4 w-4 text-primary" />
Work mode
</div>
<p className="mt-2 text-sm text-muted-foreground">
Reply from the dock, preview status automation, then commit changes when the side effects look right.
</p>
</section>
</div>
</aside>
</div>
);
}