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>
1034 lines
40 KiB
TypeScript
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>
|
|
);
|
|
}
|