- Watcher system: ticket_watchers table, watch/unwatch endpoints, notifications to watchers on comments and updates, watcher/cc recipient sources in SendEmail scrip action, watch toggle and watcher avatars in ticket detail UI - SLA engine: sla_policies table, SLA deadline columns on tickets, CRUD routes, OnSlaBreach scrip condition, scheduler SLA calculation, deadlines set on create/reply, cleared on resolve, SLA indicators on ticket list and detail, SLA Policies tab in admin - Rich text: marked-based markdown rendering with XSS safety, Write/Preview toggle in comment composer, styled prose output
1619 lines
72 KiB
TypeScript
1619 lines
72 KiB
TypeScript
"use client";
|
|
|
|
import React, { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { useRouter, useSearchParams } from "next/navigation";
|
|
import {
|
|
ArrowDownAZIcon,
|
|
CheckCircle2Icon,
|
|
ChevronRightIcon,
|
|
DownloadIcon,
|
|
GaugeIcon,
|
|
LayoutGridIcon,
|
|
LayoutListIcon,
|
|
PlusIcon,
|
|
RefreshCwIcon,
|
|
SaveIcon,
|
|
SearchIcon,
|
|
SlidersHorizontalIcon,
|
|
XIcon,
|
|
} from "lucide-react";
|
|
import { formatDistanceToNow } from "date-fns";
|
|
import { createTicket, getCustomFields, getLifecycles, getQueueCustomFields, getQueues, getTickets, getUsers, getViews, createView, deleteView, getDashboards, updateTicket, batchUpdateTickets, getTeams } from "@/lib/api";
|
|
import type { CustomField, Lifecycle, Queue, QueueCustomField, SavedView, Team, Ticket, User } from "@/lib/types";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { cn, formatTicketId } from "@/lib/utils";
|
|
import { SearchableSelect } from "@/components/searchable-select";
|
|
import { LayoutBuilder, type SubtitleEntry } from "@/components/layout-builder";
|
|
|
|
const STATUS_META: Record<string, { label: string; color: string; tone: string }> = {
|
|
new: { label: "New", color: "#64748b", tone: "bg-slate-500/10 text-slate-700 dark:text-slate-300" },
|
|
open: { label: "Open", color: "#2563eb", tone: "bg-blue-500/10 text-blue-700 dark:text-blue-300" },
|
|
in_progress: { label: "In progress", color: "#d97706", tone: "bg-amber-500/10 text-amber-700 dark:text-amber-300" },
|
|
resolved: { label: "Resolved", color: "#16a34a", tone: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300" },
|
|
closed: { label: "Closed", color: "#71717a", tone: "bg-zinc-500/10 text-zinc-700 dark:text-zinc-300" },
|
|
};
|
|
|
|
const VIEW_LABELS: Record<string, string> = {
|
|
my: "My tickets",
|
|
unassigned: "Unassigned",
|
|
recent: "Recently updated",
|
|
};
|
|
|
|
type Density = "comfortable" | "compact";
|
|
type SortKey = "updated" | "created" | "id";
|
|
|
|
interface ColumnConfig {
|
|
key: string;
|
|
label: string;
|
|
width: number;
|
|
}
|
|
|
|
const ALL_FIELDS: ColumnConfig[] = [
|
|
{ key: "id", label: "ID", width: 100 },
|
|
{ key: "subject", label: "Subject", width: 400 },
|
|
{ key: "status", label: "Status", width: 120 },
|
|
{ key: "queue", label: "Queue", width: 140 },
|
|
{ key: "owner", label: "Owner", width: 130 },
|
|
{ key: "created", label: "Created", width: 130 },
|
|
{ key: "updated", label: "Updated", width: 130 },
|
|
{ key: "team", label: "Team", width: 130 },
|
|
];
|
|
|
|
const DEFAULT_ROW1 = ["id", "subject", "status"];
|
|
const DEFAULT_ROW2 = ["queue", "owner"];
|
|
|
|
const LS_KEY = "tessera_columns";
|
|
|
|
interface Filter {
|
|
id: string;
|
|
field: string; // "status" | "queue" | "owner" | custom field key ("cf.<key>")
|
|
operator: string; // "is" | "is_not"
|
|
value: string;
|
|
label: string; // human-readable label for the chip
|
|
}
|
|
|
|
function buildFilterLabel(field: string, operator: string, valueLabel: string): string {
|
|
const fieldLabel = field.startsWith("cf.") ? field.slice(3) : field;
|
|
const op = operator === "is_not" ? "is not" : "is";
|
|
return `${fieldLabel} ${op} ${valueLabel}`;
|
|
}
|
|
|
|
function statusLabel(status: string) {
|
|
return STATUS_META[status]?.label ?? status.replaceAll("_", " ");
|
|
}
|
|
|
|
function queueName(queues: Queue[], queueId: string) {
|
|
return queues.find((queue) => queue.id === queueId)?.name ?? queueId.slice(0, 8);
|
|
}
|
|
|
|
function getSubtitleValue(key: string, ticket: Ticket, context: { users: User[]; queues: Queue[]; teamsList: Team[] }): string | null {
|
|
if (key === "subject") return null;
|
|
if (key === "id") return formatTicketId(ticket.id);
|
|
if (key === "status") return statusLabel(ticket.status);
|
|
if (key === "queue") return context.queues.find((q) => q.id === ticket.queue_id)?.name ?? ticket.queue_id.slice(0, 8);
|
|
if (key === "owner") return ticket.owner_id ? (context.users.find((u) => u.id === ticket.owner_id)?.username ?? "assigned") : "Unassigned";
|
|
if (key === "team") return context.teamsList.find((t) => t.id === ticket.team_id)?.name ?? null;
|
|
if (key === "created") return relativeTime(ticket.created_at);
|
|
if (key === "updated") return relativeTime(ticket.updated_at);
|
|
if (key.startsWith("cf.")) {
|
|
const cfKey = key.slice(3);
|
|
return ticket.custom_fields?.find((v) => v.custom_field?.key === cfKey || v.custom_field?.name === cfKey)?.value ?? null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function relativeTime(value: string) {
|
|
return formatDistanceToNow(new Date(value), { addSuffix: true });
|
|
}
|
|
|
|
function TicketStatusBadge({ status }: { status: string }) {
|
|
const meta = STATUS_META[status];
|
|
return (
|
|
<span
|
|
className={cn(
|
|
"inline-flex h-6 items-center gap-1.5 rounded px-2 text-xs font-semibold capitalize",
|
|
meta?.tone ?? "bg-muted text-muted-foreground"
|
|
)}
|
|
>
|
|
<span
|
|
className="h-1.5 w-1.5 rounded-full"
|
|
style={{ backgroundColor: meta?.color ?? "#71717a" }}
|
|
/>
|
|
{statusLabel(status)}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function StatPill({
|
|
label,
|
|
value,
|
|
}: {
|
|
label: string;
|
|
value: number;
|
|
}) {
|
|
return (
|
|
<span className="inline-flex h-6 items-center gap-1.5 rounded border border-border/80 bg-card/80 px-2 text-[11px] text-muted-foreground shadow-sm">
|
|
<span className="font-semibold tabular-nums text-foreground">{value}</span>
|
|
{label}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function SkeletonWorkbench() {
|
|
return (
|
|
<div className="flex h-full flex-col">
|
|
<div className="border-b border-border px-5 py-4">
|
|
<div className="h-7 w-48 animate-pulse rounded bg-muted" />
|
|
<div className="mt-3 h-9 w-full max-w-xl animate-pulse rounded bg-muted" />
|
|
</div>
|
|
<div className="grid flex-1 grid-cols-1 overflow-hidden xl:grid-cols-[minmax(0,1fr)_360px]">
|
|
<div className="overflow-hidden">
|
|
{Array.from({ length: 12 }).map((_, index) => (
|
|
<div key={index} className="flex items-center gap-4 border-b border-border px-5 py-4">
|
|
<div className="h-4 w-4 animate-pulse rounded bg-muted" />
|
|
<div className="min-w-0 flex-1 space-y-2">
|
|
<div className="h-4 w-2/5 animate-pulse rounded bg-muted" />
|
|
<div className="h-3 w-3/5 animate-pulse rounded bg-muted" />
|
|
</div>
|
|
<div className="h-6 w-20 animate-pulse rounded bg-muted" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="hidden border-l border-border p-5 xl:block">
|
|
<div className="h-40 animate-pulse rounded-md bg-muted" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TicketWorkbenchContent() {
|
|
const router = useRouter();
|
|
const searchParams = useSearchParams();
|
|
|
|
const [tickets, setTickets] = useState<Ticket[]>([]);
|
|
const [queues, setQueues] = useState<Queue[]>([]);
|
|
const [lifecycles, setLifecycles] = useState<Lifecycle[]>([]);
|
|
const [users, setUsers] = useState<User[]>([]);
|
|
const [teamsList, setTeamsList] = useState<Team[]>([]);
|
|
const [customFields, setCustomFields] = useState<CustomField[]>([]);
|
|
const [clock, setClock] = useState(0);
|
|
const [initialLoad, setInitialLoad] = useState(true);
|
|
const [loading, setLoading] = useState(true);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [batchIds, setBatchIds] = useState<Set<number>>(new Set());
|
|
const [batchSaving, setBatchSaving] = useState(false);
|
|
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [debouncedQuery, setDebouncedQuery] = useState("");
|
|
const searchRef = useRef(searchQuery);
|
|
searchRef.current = searchQuery;
|
|
// Debounce search: update debouncedQuery 300ms after user stops typing
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => setDebouncedQuery(searchQuery), 300);
|
|
return () => clearTimeout(timer);
|
|
}, [searchQuery]);
|
|
const [filters, setFilters] = useState<Filter[]>([]);
|
|
const [row1Keys, setRow1Keys] = useState<string[]>(() => {
|
|
if (typeof window === "undefined") return DEFAULT_ROW1;
|
|
try {
|
|
const stored = localStorage.getItem(LS_KEY);
|
|
if (stored) {
|
|
const parsed = JSON.parse(stored);
|
|
if (parsed.row1) return parsed.row1 as string[];
|
|
// Migrate old format
|
|
if (Array.isArray(parsed)) {
|
|
return parsed.filter((c: any) => c.visible !== false && c.display !== "subtitle" && c.display !== "hidden").map((c: any) => c.key);
|
|
}
|
|
}
|
|
} catch { /* ignore */ }
|
|
return DEFAULT_ROW1;
|
|
});
|
|
const [row2Entries, setRow2Entries] = useState<SubtitleEntry[]>(() => {
|
|
if (typeof window === "undefined") return DEFAULT_ROW2.map((k) => ({ key: k, under: k }));
|
|
try {
|
|
const stored = localStorage.getItem(LS_KEY);
|
|
if (stored) {
|
|
const parsed = JSON.parse(stored);
|
|
if (parsed.row2Entries) return parsed.row2Entries as SubtitleEntry[];
|
|
if (parsed.row2 && Array.isArray(parsed.row2)) {
|
|
// Migrate: old flat keys → entries with self as under
|
|
if (typeof parsed.row2[0] === "string") return parsed.row2.map((k: string) => ({ key: k, under: k }));
|
|
return parsed.row2 as SubtitleEntry[];
|
|
}
|
|
}
|
|
} catch { /* ignore */ }
|
|
return DEFAULT_ROW2.map((k) => ({ key: k, under: k }));
|
|
});
|
|
const [density, setDensity] = useState<Density>("comfortable");
|
|
const [sortKey, setSortKey] = useState<SortKey>("updated");
|
|
const [resizingCol, setResizingCol] = useState<string | null>(null);
|
|
const [colWidths, setColWidths] = useState<Record<string, number>>({});
|
|
const [colPickerOpen, setColPickerOpen] = useState(false);
|
|
|
|
// Persist layout to localStorage
|
|
useEffect(() => {
|
|
try { localStorage.setItem(LS_KEY, JSON.stringify({ row1: row1Keys, row2Entries })); } catch { /* ignore */ }
|
|
}, [row1Keys, row2Entries]);
|
|
|
|
// Build available fields: base + custom fields
|
|
const allFields = useMemo(() => {
|
|
const cfFields: ColumnConfig[] = customFields
|
|
.filter((cf) => cf.key)
|
|
.map((cf) => ({ key: `cf.${cf.key}`, label: cf.name, width: 140 }));
|
|
return [...ALL_FIELDS, ...cfFields];
|
|
}, [customFields]);
|
|
|
|
const fieldByKey = useMemo(() => {
|
|
const map = new Map<string, ColumnConfig>();
|
|
for (const f of allFields) map.set(f.key, f);
|
|
return map;
|
|
}, [allFields]);
|
|
|
|
const row1Fields = row1Keys.map((k) => fieldByKey.get(k)).filter(Boolean) as ColumnConfig[];
|
|
const row2EntriesResolved = row2Entries.filter((e) => fieldByKey.has(e.key));
|
|
// Group subtitle entries by which column they sit under
|
|
const subsByColumn = new Map<string, SubtitleEntry[]>();
|
|
for (const e of row2EntriesResolved) {
|
|
const list = subsByColumn.get(e.under) ?? [];
|
|
list.push(e);
|
|
subsByColumn.set(e.under, list);
|
|
}
|
|
|
|
const colWidth = (key: string, fallback: number) => colWidths[key] ?? fallback;
|
|
|
|
// Saved views
|
|
const [savedViewsList, setSavedViewsList] = useState<SavedView[]>([]);
|
|
const [viewIdFromUrl, setViewIdFromUrl] = useState<string | null>(null);
|
|
const [saveViewOpen, setSaveViewOpen] = useState(false);
|
|
const [saveViewName, setSaveViewName] = useState("");
|
|
const [addFilterOpen, setAddFilterOpen] = useState(false);
|
|
const [addFilterField, setAddFilterField] = useState<string | null>(null);
|
|
const [addFilterOperator, setAddFilterOperator] = useState("is");
|
|
const [addFilterValue, setAddFilterValue] = useState("");
|
|
const [filterPopoverPos, setFilterPopoverPos] = useState({ left: 0, top: 0 });
|
|
const addFilterBtnRef = useRef<HTMLButtonElement>(null);
|
|
|
|
const [dialogOpen, setDialogOpen] = useState(false);
|
|
const [newSubject, setNewSubject] = useState("");
|
|
const [newQueueId, setNewQueueId] = useState("");
|
|
const [newDescription, setNewDescription] = useState("");
|
|
const [newQueueFields, setNewQueueFields] = useState<QueueCustomField[]>([]);
|
|
const [newCustomFieldValues, setNewCustomFieldValues] = useState<Record<string, string>>({});
|
|
const [newFieldsLoading, setNewFieldsLoading] = useState(false);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [createError, setCreateError] = useState<string | null>(null);
|
|
|
|
const view = searchParams.get("view") ?? "all";
|
|
const routeQueue = searchParams.get("queue") ?? "";
|
|
|
|
const fetchData = useCallback(
|
|
async (background = false) => {
|
|
if (background) {
|
|
setRefreshing(true);
|
|
} else {
|
|
setLoading(true);
|
|
}
|
|
setError(null);
|
|
const fetchedAt = Date.now();
|
|
|
|
const activeQueue = routeQueue;
|
|
const apiStatus = filters.find((f) => f.field === "status")?.value;
|
|
const apiOwner = filters.find((f) => f.field === "owner")?.value;
|
|
const apiQueue = filters.find((f) => f.field === "queue")?.value;
|
|
const apiSubject = filters.find((f) => f.field === "subject");
|
|
const apiCreated = filters.find((f) => f.field === "created");
|
|
const apiUpdated = filters.find((f) => f.field === "updated");
|
|
const customFieldFilters: Record<string, string> = {};
|
|
for (const f of filters) {
|
|
if (f.field.startsWith("cf.")) {
|
|
customFieldFilters[f.field.slice(3)] = f.value;
|
|
}
|
|
}
|
|
|
|
const routeTeamId = searchParams.get("team_id") ?? "";
|
|
const [ticketsRes, queuesRes, usersRes, fieldsRes, lifecycleRes, teamsRes] = await Promise.all([
|
|
getTickets({
|
|
q: debouncedQuery.trim() || undefined,
|
|
status: apiStatus || undefined,
|
|
queue_id: activeQueue || apiQueue || undefined,
|
|
owner_id: apiOwner || undefined,
|
|
team_id: routeTeamId || undefined,
|
|
custom_fields: Object.keys(customFieldFilters).length > 0 ? customFieldFilters : undefined,
|
|
subject: apiSubject ? `${apiSubject.operator}:${apiSubject.value}` : undefined,
|
|
created: apiCreated ? `${apiCreated.operator}:${apiCreated.value}` : undefined,
|
|
updated: apiUpdated ? `${apiUpdated.operator}:${apiUpdated.value}` : undefined,
|
|
}),
|
|
getQueues(),
|
|
getUsers(),
|
|
getCustomFields(),
|
|
getLifecycles(),
|
|
getTeams(),
|
|
]);
|
|
|
|
if (ticketsRes.error) {
|
|
setError(ticketsRes.error);
|
|
setTickets([]);
|
|
} else {
|
|
setTickets(ticketsRes.data ?? []);
|
|
}
|
|
|
|
if (queuesRes.error) {
|
|
setError((current) => current ?? queuesRes.error);
|
|
} else {
|
|
const queueData = queuesRes.data ?? [];
|
|
setQueues(queueData);
|
|
if (!newQueueId && queueData.length > 0) {
|
|
setNewQueueId(queueData[0].id);
|
|
}
|
|
}
|
|
|
|
if (usersRes.error) {
|
|
setError((current) => current ?? usersRes.error);
|
|
} else {
|
|
setUsers(usersRes.data ?? []);
|
|
}
|
|
|
|
if (teamsRes?.error) {
|
|
setError((current) => current ?? teamsRes.error);
|
|
} else if (teamsRes?.data) {
|
|
setTeamsList(teamsRes.data);
|
|
}
|
|
|
|
if (fieldsRes.error) {
|
|
setError((current) => current ?? fieldsRes.error);
|
|
} else {
|
|
setCustomFields(fieldsRes.data ?? []);
|
|
}
|
|
|
|
if (lifecycleRes.error) {
|
|
setError((current) => current ?? lifecycleRes.error);
|
|
} else {
|
|
setLifecycles(lifecycleRes.data ?? []);
|
|
}
|
|
|
|
setLoading(false);
|
|
setInitialLoad(false);
|
|
setRefreshing(false);
|
|
setClock(fetchedAt);
|
|
},
|
|
[filters, newQueueId, routeQueue, debouncedQuery]
|
|
);
|
|
|
|
useEffect(() => {
|
|
void Promise.resolve().then(() => fetchData());
|
|
}, [fetchData]);
|
|
|
|
// Redirect to default dashboard if one exists and no params set
|
|
useEffect(() => {
|
|
if (searchParams.toString()) return;
|
|
getDashboards().then(({ data }) => {
|
|
const def = data?.find((d) => d.is_default);
|
|
if (def) router.replace(`/dashboards/${def.id}`);
|
|
});
|
|
}, [searchParams]);
|
|
|
|
useEffect(() => {
|
|
if (searchParams.get("new") === "true") {
|
|
queueMicrotask(() => setDialogOpen(true));
|
|
const url = new URL(window.location.href);
|
|
url.searchParams.delete("new");
|
|
window.history.replaceState({}, "", url.toString());
|
|
}
|
|
}, [searchParams]);
|
|
|
|
useEffect(() => {
|
|
if (!newQueueId) {
|
|
queueMicrotask(() => {
|
|
setNewQueueFields([]);
|
|
setNewCustomFieldValues({});
|
|
});
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
queueMicrotask(() => {
|
|
if (!cancelled) setNewFieldsLoading(true);
|
|
});
|
|
void getQueueCustomFields(newQueueId).then(({ data, error }) => {
|
|
if (cancelled) return;
|
|
setNewFieldsLoading(false);
|
|
if (error) {
|
|
setCreateError(error);
|
|
setNewQueueFields([]);
|
|
setNewCustomFieldValues({});
|
|
return;
|
|
}
|
|
const assignments = data ?? [];
|
|
setNewQueueFields(assignments);
|
|
setNewCustomFieldValues((current) => {
|
|
const next: Record<string, string> = {};
|
|
for (const assignment of assignments) {
|
|
next[assignment.custom_field_id] = current[assignment.custom_field_id] ?? "";
|
|
}
|
|
return next;
|
|
});
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [newQueueId]);
|
|
|
|
// Load saved views list
|
|
useEffect(() => {
|
|
getViews().then(({ data }) => {
|
|
if (data) setSavedViewsList(data);
|
|
});
|
|
}, [clock]);
|
|
|
|
// Load view from URL param
|
|
useEffect(() => {
|
|
const paramViewId = searchParams.get("view_id");
|
|
setViewIdFromUrl(paramViewId);
|
|
if (paramViewId) {
|
|
getViews().then(({ data }) => {
|
|
const view = data?.find((v) => v.id === paramViewId);
|
|
if (view?.filters && Array.isArray(view.filters)) {
|
|
setSearchQuery("");
|
|
setFilters(
|
|
(view.filters as { field: string; operator: string; value: string }[])
|
|
.filter((f) => f.field && f.value)
|
|
.map((f) => ({
|
|
id: crypto.randomUUID(),
|
|
field: f.field,
|
|
operator: f.operator || "is",
|
|
value: f.value,
|
|
label: buildFilterLabel(f.field, f.operator || "is", f.value),
|
|
}))
|
|
);
|
|
if (view.sort_key) setSortKey(view.sort_key as SortKey);
|
|
if (view.columns && Array.isArray(view.columns) && view.columns.length > 0) {
|
|
// Load row1/row2 from saved view columns if available, else fall back to default
|
|
const cols = view.columns as any[];
|
|
const r1 = cols.filter((c: any) => c.display !== "subtitle" && c.visible !== false).map((c: any) => c.key);
|
|
const r2 = cols.filter((c: any) => c.display === "subtitle").map((c: any) => c.key);
|
|
if (r1.length > 0) setRow1Keys(r1);
|
|
if (r2.length > 0) setRow2Entries(r2.map((k: string) => ({ key: k, under: k })));
|
|
}
|
|
}
|
|
});
|
|
} else if (!paramViewId && viewIdFromUrl) {
|
|
// User navigated away from a view — clear filters and reset columns
|
|
setFilters([]);
|
|
setSearchQuery("");
|
|
setRow1Keys(DEFAULT_ROW1);
|
|
setRow2Entries(DEFAULT_ROW2.map((k) => ({ key: k, under: k })));
|
|
}
|
|
}, [searchParams]);
|
|
|
|
const statusOptions = useMemo(() => {
|
|
const queueFilterValue = filters.find((f) => f.field === "queue")?.value;
|
|
const selectedFilterQueueId = routeQueue || queueFilterValue || "";
|
|
const selectedFilterQueue = selectedFilterQueueId
|
|
? queues.find((queue) => queue.id === selectedFilterQueueId)
|
|
: null;
|
|
const selectedFilterLifecycle = selectedFilterQueue?.lifecycle_id
|
|
? lifecycles.find((lifecycle) => lifecycle.id === selectedFilterQueue.lifecycle_id)
|
|
: null;
|
|
const lifecycleStatuses = selectedFilterLifecycle
|
|
? [
|
|
...selectedFilterLifecycle.definition.statuses.initial,
|
|
...selectedFilterLifecycle.definition.statuses.active,
|
|
...selectedFilterLifecycle.definition.statuses.inactive,
|
|
]
|
|
: lifecycles.flatMap((lifecycle) => [
|
|
...lifecycle.definition.statuses.initial,
|
|
...lifecycle.definition.statuses.active,
|
|
...lifecycle.definition.statuses.inactive,
|
|
]);
|
|
|
|
return [
|
|
{ key: "all", label: "All" },
|
|
...Array.from(new Set([...lifecycleStatuses, ...tickets.map((ticket) => ticket.status)]))
|
|
.filter(Boolean)
|
|
.map((status) => ({ key: status, label: statusLabel(status) })),
|
|
];
|
|
}, [filters, lifecycles, queues, routeQueue, tickets]);
|
|
|
|
const inactiveStatuses = useMemo(
|
|
() => new Set(
|
|
lifecycles.length > 0
|
|
? lifecycles.flatMap((lifecycle) => lifecycle.definition.statuses.inactive)
|
|
: ["resolved", "closed"]
|
|
),
|
|
[lifecycles]
|
|
);
|
|
|
|
const metrics = useMemo(() => {
|
|
const now = clock || 0;
|
|
const day = 24 * 60 * 60 * 1000;
|
|
return {
|
|
total: tickets.length,
|
|
active: tickets.filter((ticket) => !inactiveStatuses.has(ticket.status)).length,
|
|
unassigned: tickets.filter((ticket) => !ticket.owner_id).length,
|
|
stale: tickets.filter((ticket) => now && now - new Date(ticket.updated_at).getTime() > 3 * day).length,
|
|
recent: tickets.filter((ticket) => now && now - new Date(ticket.updated_at).getTime() < day).length,
|
|
};
|
|
}, [clock, inactiveStatuses, tickets]);
|
|
|
|
const filteredTickets = useMemo(() => {
|
|
const now = clock || 0;
|
|
const queue = routeQueue;
|
|
const statusFilterValue = filters.find((f) => f.field === "status")?.value;
|
|
const queueFilterValue = filters.find((f) => f.field === "queue")?.value;
|
|
|
|
return tickets
|
|
.filter((ticket) => {
|
|
if (view === "my") {
|
|
const myOwner = searchParams.get("owner");
|
|
if (myOwner && ticket.owner_id !== myOwner) return false;
|
|
if (!myOwner && !ticket.owner_id) return false;
|
|
}
|
|
if (view === "unassigned" && ticket.owner_id) return false;
|
|
if (view === "recent") {
|
|
const week = 7 * 24 * 60 * 60 * 1000;
|
|
if (!now || now - new Date(ticket.updated_at).getTime() > week) return false;
|
|
}
|
|
if (statusFilterValue && ticket.status !== statusFilterValue) return false;
|
|
if (queueFilterValue && ticket.queue_id !== queueFilterValue) return false;
|
|
if (queue && ticket.queue_id !== queue) return false;
|
|
return true;
|
|
})
|
|
.sort((a, b) => {
|
|
if (sortKey === "id") return b.id - a.id;
|
|
const aDate = sortKey === "created" ? a.created_at : a.updated_at;
|
|
const bDate = sortKey === "created" ? b.created_at : b.updated_at;
|
|
return new Date(bDate).getTime() - new Date(aDate).getTime();
|
|
});
|
|
}, [clock, filters, queues, routeQueue, sortKey, tickets, view]);
|
|
|
|
|
|
|
|
const handleQuickStatus = async (ticketId: number, newStatus: string) => {
|
|
const { data } = await updateTicket(ticketId, { status: newStatus });
|
|
if (data) {
|
|
setTickets((prev) => prev.map((t) => (t.id === ticketId ? data.ticket : t)));
|
|
}
|
|
};
|
|
|
|
const handleQuickAssign = async (ticketId: number) => {
|
|
const { data } = await updateTicket(ticketId, { owner_id: users[0]?.id ?? null });
|
|
if (data) {
|
|
setTickets((prev) => prev.map((t) => (t.id === ticketId ? data.ticket : t)));
|
|
}
|
|
};
|
|
|
|
const handleBatchAction = async (update: { status?: string; owner_id?: string | null; team_id?: string | null }) => {
|
|
setBatchSaving(true);
|
|
const ids = Array.from(batchIds);
|
|
const { data, error } = await batchUpdateTickets({ ticket_ids: ids, ...update });
|
|
setBatchSaving(false);
|
|
if (!error && data) {
|
|
const failed = data.results.filter((r) => !r.ok);
|
|
if (failed.length > 0) {
|
|
setError(`${failed.length} of ${ids.length} tickets failed to update`);
|
|
}
|
|
} else if (error) {
|
|
setError(error);
|
|
}
|
|
setBatchIds(new Set());
|
|
await fetchData();
|
|
};
|
|
|
|
const toggleBatchId = (id: number) => {
|
|
setBatchIds((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) next.delete(id); else next.add(id);
|
|
return next;
|
|
});
|
|
};
|
|
const handleColumnResize = (leftKey: string, rightKey: string | null, e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setResizingCol(leftKey);
|
|
const startX = e.clientX;
|
|
const leftField = fieldByKey.get(leftKey);
|
|
const rightField = rightKey ? fieldByKey.get(rightKey) : null;
|
|
const leftStart = colWidths[leftKey] ?? leftField?.width ?? 140;
|
|
const rightStart = rightField ? (colWidths[rightField.key] ?? rightField.width) : 140;
|
|
|
|
const onMove = (ev: MouseEvent) => {
|
|
const delta = ev.clientX - startX;
|
|
const newLeft = Math.max(50, Math.min(800, leftStart + delta));
|
|
const newRight = rightField ? Math.max(50, Math.min(800, rightStart - delta)) : undefined;
|
|
setColWidths((prev) => {
|
|
const next = { ...prev, [leftKey]: newLeft };
|
|
if (rightField && newRight !== undefined) next[rightField.key] = newRight;
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const onUp = () => {
|
|
setResizingCol(null);
|
|
document.removeEventListener("mousemove", onMove);
|
|
document.removeEventListener("mouseup", onUp);
|
|
};
|
|
document.addEventListener("mousemove", onMove);
|
|
document.addEventListener("mouseup", onUp);
|
|
};
|
|
|
|
const visibleTitle = routeQueue
|
|
? queueName(queues, routeQueue)
|
|
: VIEW_LABELS[view] ?? "All tickets";
|
|
const newTicketFields = newQueueFields
|
|
.map((assignment) => assignment.custom_field)
|
|
.filter((field): field is CustomField => Boolean(field));
|
|
const selectedNewQueue = queues.find((queue) => queue.id === newQueueId);
|
|
const selectedNewLifecycle = selectedNewQueue?.lifecycle_id
|
|
? lifecycles.find((lifecycle) => lifecycle.id === selectedNewQueue.lifecycle_id)
|
|
: null;
|
|
const newTicketInitialStatus = selectedNewLifecycle?.definition.statuses.initial[0] ?? "new";
|
|
const hasQueryFilters = searchQuery.trim() || filters.length > 0;
|
|
|
|
const clearQueryFilters = () => {
|
|
setSearchQuery("");
|
|
setFilters([]);
|
|
if (routeQueue) router.push("/");
|
|
};
|
|
|
|
const handleCreate = async () => {
|
|
if (!newSubject.trim() || !newQueueId) return;
|
|
for (const field of newTicketFields) {
|
|
const value = (newCustomFieldValues[field.id] ?? "").trim();
|
|
if (value && field.pattern) {
|
|
const regex = new RegExp(field.pattern);
|
|
if (!regex.test(value)) {
|
|
setCreateError(`${field.name}: value does not match the required pattern.`);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
setSubmitting(true);
|
|
setCreateError(null);
|
|
|
|
const { data, error } = await createTicket({
|
|
subject: newSubject.trim(),
|
|
queue_id: newQueueId,
|
|
description: newDescription.trim() || undefined,
|
|
custom_fields: Object.fromEntries(
|
|
Object.entries(newCustomFieldValues)
|
|
.map(([fieldId, value]) => [fieldId, value.trim()])
|
|
.filter(([, value]) => value)
|
|
),
|
|
});
|
|
|
|
setSubmitting(false);
|
|
|
|
if (error) {
|
|
setCreateError(error);
|
|
return;
|
|
}
|
|
|
|
setDialogOpen(false);
|
|
setNewSubject("");
|
|
setNewDescription("");
|
|
setNewCustomFieldValues({});
|
|
if (data) router.push(`/tickets/${data.ticket.id}`);
|
|
};
|
|
|
|
if (initialLoad && loading) return <SkeletonWorkbench />;
|
|
|
|
return (
|
|
<div className="flex h-full flex-col bg-background/80">
|
|
<header className="shrink-0 border-b border-border bg-card/82 backdrop-blur">
|
|
<div className="flex flex-col gap-3 px-5 py-4 lg:px-6">
|
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase text-muted-foreground">
|
|
<GaugeIcon className="h-3.5 w-3.5" />
|
|
Work queue
|
|
</div>
|
|
<h1 className="mt-1 truncate text-2xl font-semibold text-foreground">
|
|
{visibleTitle}
|
|
</h1>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
// Build CSV from visible columns
|
|
const allCols = [...row1Fields, ...(density === "comfortable" ? row2EntriesResolved.map((e) => fieldByKey.get(e.key)).filter(Boolean) as ColumnConfig[] : [])];
|
|
const headers = allCols.map((c) => c.label);
|
|
const rows = filteredTickets.map((ticket) => {
|
|
const ctx = { users, queues, teamsList };
|
|
return allCols.map((col) => {
|
|
if (col.key.startsWith("cf.")) {
|
|
const cfKey = col.key.slice(3);
|
|
return ticket.custom_fields?.find((v) => v.custom_field?.key === cfKey || v.custom_field?.name === cfKey)?.value ?? "";
|
|
}
|
|
const v = getSubtitleValue(col.key, ticket, ctx);
|
|
return v ?? "";
|
|
});
|
|
});
|
|
const csv = [headers.join(','), ...rows.map((r) => r.map((v) => `"${String(v).replace(/"/g, '""')}"`).join(','))].join('\n');
|
|
const blob = new Blob([csv], { type: 'text/csv' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url; a.download = `tickets-${new Date().toISOString().slice(0,10)}.csv`;
|
|
a.click(); URL.revokeObjectURL(url);
|
|
}}
|
|
className="h-8 border-border/80 bg-card/70"
|
|
>
|
|
<DownloadIcon className="h-4 w-4" />
|
|
Export
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => fetchData(true)}
|
|
disabled={refreshing}
|
|
className="h-8 border-border/80 bg-card/70"
|
|
>
|
|
<RefreshCwIcon className={cn("h-4 w-4", refreshing && "animate-spin")} />
|
|
Refresh
|
|
</Button>
|
|
{filters.length > 0 && (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => {
|
|
setSaveViewName("");
|
|
setSaveViewOpen(true);
|
|
}}
|
|
className="h-8 border-border/80 bg-card/70"
|
|
>
|
|
<SaveIcon className="h-4 w-4" />
|
|
Save view
|
|
</Button>
|
|
)}
|
|
<Button size="sm" onClick={() => setDialogOpen(true)} className="h-8 bg-primary shadow-sm">
|
|
<PlusIcon className="h-4 w-4" />
|
|
New ticket
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<StatPill label="total" value={metrics.total} />
|
|
<StatPill label="active" value={metrics.active} />
|
|
<StatPill label="unassigned" value={metrics.unassigned} />
|
|
<StatPill label="updated today" value={metrics.recent} />
|
|
<StatPill label="needs review" value={metrics.stale} />
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
{/* Row 1: search + sort/density */}
|
|
<div className="flex items-center gap-2">
|
|
<div className="relative flex-1">
|
|
<SearchIcon className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
<input
|
|
value={searchQuery}
|
|
onChange={(event) => setSearchQuery(event.target.value)}
|
|
placeholder="Search tickets, comments, custom fields..."
|
|
className="h-9 w-full rounded-md border border-input bg-card/90 pl-9 pr-3 text-sm shadow-sm outline-none transition-colors placeholder:text-muted-foreground focus:border-ring"
|
|
/>
|
|
</div>
|
|
{hasQueryFilters && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={clearQueryFilters}
|
|
className="h-9 shrink-0 border-border/80 bg-card/70"
|
|
>
|
|
Clear
|
|
</Button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={() => setSortKey(sortKey === "updated" ? "created" : sortKey === "created" ? "id" : "updated")}
|
|
className="inline-flex h-9 items-center gap-2 rounded-md border border-border bg-card/90 px-3 text-sm text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-foreground shrink-0"
|
|
title="Change sort"
|
|
>
|
|
<ArrowDownAZIcon className="h-4 w-4" />
|
|
{sortKey === "updated" ? "Updated" : sortKey === "created" ? "Created" : "ID"}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setDensity(density === "comfortable" ? "compact" : "comfortable")}
|
|
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md border border-border bg-card/90 text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-foreground"
|
|
title="Toggle density"
|
|
>
|
|
{density === "comfortable" ? (
|
|
<LayoutListIcon className="h-4 w-4" />
|
|
) : (
|
|
<SlidersHorizontalIcon className="h-4 w-4" />
|
|
)}
|
|
</button>
|
|
<div className="relative">
|
|
<button
|
|
type="button"
|
|
onClick={() => setColPickerOpen((v) => !v)}
|
|
className="inline-flex h-9 items-center gap-1.5 rounded-md border border-border bg-card/90 px-3 text-sm text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-foreground"
|
|
title="Choose columns"
|
|
>
|
|
<LayoutGridIcon className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Row 2: status quick-filter pills */}
|
|
<div className="flex min-w-0 items-center gap-1 overflow-x-auto rounded-md border border-border bg-muted/55 p-1 shadow-sm">
|
|
{statusOptions.map((opt) => {
|
|
const isActive = opt.key === "all"
|
|
? !filters.some((f) => f.field === "status")
|
|
: filters.some((f) => f.field === "status" && f.value === opt.key);
|
|
return (
|
|
<button
|
|
key={opt.key}
|
|
type="button"
|
|
onClick={() => {
|
|
if (opt.key === "all") {
|
|
setFilters((prev) => prev.filter((f) => f.field !== "status"));
|
|
} else {
|
|
setFilters((prev) => {
|
|
const rest = prev.filter((f) => f.field !== "status");
|
|
if (isActive) return rest;
|
|
return [...rest, {
|
|
id: crypto.randomUUID(),
|
|
field: "status",
|
|
operator: "is",
|
|
value: opt.key,
|
|
label: buildFilterLabel("status", "is", opt.label),
|
|
}];
|
|
});
|
|
}
|
|
}}
|
|
className={cn(
|
|
"h-7 whitespace-nowrap rounded px-2.5 text-xs font-semibold transition-colors",
|
|
isActive
|
|
? "bg-card text-foreground shadow-sm"
|
|
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
)}
|
|
>
|
|
{opt.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Row 3: active filter chips + add filter */}
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
{filters
|
|
.filter((f) => f.field !== "status")
|
|
.map((f) => (
|
|
<span
|
|
key={f.id}
|
|
className="inline-flex h-7 items-center gap-1.5 rounded border border-border bg-accent/60 px-2 text-xs font-medium text-foreground"
|
|
>
|
|
{f.label}
|
|
<button
|
|
type="button"
|
|
onClick={() => setFilters((prev) => prev.filter((x) => x.id !== f.id))}
|
|
className="ml-0.5 inline-flex h-4 w-4 items-center justify-center rounded-sm text-muted-foreground hover:text-foreground"
|
|
>
|
|
<XIcon className="h-3 w-3" />
|
|
</button>
|
|
</span>
|
|
))}
|
|
<div className="flex items-center gap-1.5">
|
|
<button
|
|
ref={addFilterBtnRef}
|
|
type="button"
|
|
onClick={() => {
|
|
if (addFilterBtnRef.current) {
|
|
const rect = addFilterBtnRef.current.getBoundingClientRect();
|
|
setFilterPopoverPos({ left: rect.left, top: rect.bottom + 4 });
|
|
}
|
|
setAddFilterOpen((prev) => !prev);
|
|
}}
|
|
className="inline-flex h-7 items-center gap-1 rounded border border-dashed border-border px-2 text-xs font-medium text-muted-foreground hover:border-ring hover:text-foreground transition-colors"
|
|
>
|
|
<PlusIcon className="h-3 w-3" />
|
|
Add filter
|
|
</button>
|
|
{filters.length > 0 && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setFilters([])}
|
|
className="inline-flex h-7 items-center rounded px-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
Clear all
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{error && (
|
|
<div className="flex items-center justify-between shrink-0 border-b border-destructive/20 bg-destructive/10 px-5 py-2 text-sm text-destructive">
|
|
<span>{error}</span>
|
|
<button type="button" onClick={() => fetchData()} className="ml-3 text-xs font-medium underline hover:no-underline">
|
|
Retry
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="min-h-0 flex-1">
|
|
<section className="min-w-0 overflow-auto bg-card/48 h-full">
|
|
<div className="min-w-[760px]">
|
|
{filteredTickets.length === 0 ? (
|
|
<div className="flex min-h-80 flex-col items-center justify-center px-5 text-center">
|
|
<div className="mb-3 flex h-11 w-11 items-center justify-center rounded-md border border-border bg-background">
|
|
<SearchIcon className="h-5 w-5 text-muted-foreground" />
|
|
</div>
|
|
<p className="text-sm font-medium text-foreground">No tickets in this view</p>
|
|
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
|
|
Adjust the filters or create a new ticket for this queue.
|
|
</p>
|
|
<Button onClick={() => setDialogOpen(true)} className="mt-4 h-8" size="sm">
|
|
<PlusIcon className="h-4 w-4" />
|
|
New ticket
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{batchIds.size > 0 && (
|
|
<div className="flex items-center gap-3 border-b border-border bg-primary/5 px-4 py-2">
|
|
<span className="text-xs font-semibold text-foreground">{batchIds.size} selected</span>
|
|
<select
|
|
onChange={(e) => {
|
|
const val = e.target.value;
|
|
if (val) { handleBatchAction({ status: val }); e.target.value = ""; }
|
|
}}
|
|
className="h-7 rounded border border-border/50 bg-card px-2 text-[11px] outline-none"
|
|
>
|
|
<option value="">Set status...</option>
|
|
{Array.from(new Set(filteredTickets.map((t) => t.status))).map((s) => (
|
|
<option key={s} value={s}>{s}</option>
|
|
))}
|
|
</select>
|
|
<button
|
|
onClick={() => handleBatchAction({ owner_id: null })}
|
|
className="h-7 rounded border border-border/50 bg-card px-2 text-[11px] hover:bg-accent"
|
|
>
|
|
Unassign
|
|
</button>
|
|
<button
|
|
onClick={() => setBatchIds(new Set())}
|
|
className="ml-auto text-[11px] text-muted-foreground hover:text-foreground"
|
|
>
|
|
Clear
|
|
</button>
|
|
</div>
|
|
)}
|
|
{/* Table layout for consistent column alignment */}
|
|
<div style={{ display: "table", tableLayout: "fixed", minWidth: "100%" }}>
|
|
{/* Column header */}
|
|
<div className={cn(
|
|
"sticky top-0 z-10 border-b border-border bg-muted/50",
|
|
density === "compact" ? "min-h-7" : "min-h-8"
|
|
)} style={{ display: "table-row" }}>
|
|
<div style={{ display: "table-cell", width: 48 }} />
|
|
{row1Fields.map((col, idx, arr) => (
|
|
<div
|
|
key={col.key}
|
|
className="relative border-r border-border/60 px-3 align-middle last:border-r-0"
|
|
style={{ display: "table-cell", width: colWidth(col.key, col.width) }}
|
|
>
|
|
{/* Resize handle: drags the boundary, resizes column to the LEFT */}
|
|
{idx > 0 && (
|
|
<div
|
|
className="absolute left-0 top-0 h-full w-3 -ml-1.5 cursor-col-resize hover:bg-primary/30 z-10"
|
|
onMouseDown={(e) => handleColumnResize(arr[idx - 1].key, col.key, e)}
|
|
/>
|
|
)}
|
|
<span className="text-[10px] font-semibold uppercase text-muted-foreground/60 truncate block">
|
|
{col.label}
|
|
</span>
|
|
</div>
|
|
))}
|
|
<div style={{ display: "table-cell", width: 48 }} />
|
|
</div>
|
|
|
|
{/* Subtitle header — labels for each row2 field under its matching column */}
|
|
{row2EntriesResolved.length > 0 && (
|
|
<div className="border-b border-border/30 bg-muted/30" style={{ display: "table-row" }}>
|
|
<div style={{ display: "table-cell", width: 48 }} />
|
|
{row1Fields.map((col) => {
|
|
const subsHere = subsByColumn.get(col.key) ?? [];
|
|
const orphans = col.key === "subject" ? row2EntriesResolved.filter((e) => !row1Fields.some((rf) => rf.key === e.under)) : [];
|
|
if (subsHere.length > 0 || orphans.length > 0) {
|
|
return (
|
|
<div
|
|
key={col.key}
|
|
className="px-3 py-0.5 align-middle"
|
|
style={{ display: "table-cell", width: colWidth(col.key, col.width) }}
|
|
>
|
|
<div className="flex items-center gap-2 text-[9px] font-medium uppercase text-muted-foreground/50">
|
|
{subsHere.map((e) => {
|
|
const f = fieldByKey.get(e.key);
|
|
return <span key={e.key}>{f?.label ?? e.key}</span>;
|
|
})}
|
|
{orphans.map((e) => {
|
|
const f = fieldByKey.get(e.key);
|
|
return <span key={e.key}>{f?.label ?? e.key}</span>;
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
return <div key={col.key} style={{ display: "table-cell", width: colWidth(col.key, col.width) }} />;
|
|
})}
|
|
<div style={{ display: "table-cell", width: 48 }} />
|
|
</div>
|
|
)}
|
|
|
|
{filteredTickets.map((ticket) => {
|
|
const selected = false;
|
|
const ownerName = ticket.owner_id
|
|
? users.find((u) => u.id === ticket.owner_id)?.username ?? "assigned"
|
|
: null;
|
|
|
|
return (
|
|
<div
|
|
key={ticket.id}
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={() => router.push(`/tickets/${ticket.id}`)}
|
|
onDoubleClick={() => router.push(`/tickets/${ticket.id}`)}
|
|
onKeyDown={(e) => { if (e.key === "Enter") router.push(`/tickets/${ticket.id}`); }}
|
|
className={cn(
|
|
"group cursor-pointer border-b border-border/80",
|
|
density === "compact" ? "" : "",
|
|
selected
|
|
? "bg-accent/80 shadow-[inset_3px_0_0_var(--primary)]"
|
|
: "hover:bg-accent/45"
|
|
)}
|
|
style={{ display: "table-row" }}
|
|
>
|
|
<div className="flex items-center justify-center gap-1.5" style={{ display: "table-cell", width: 48, verticalAlign: "middle" }} onClick={(e) => e.stopPropagation()}>
|
|
<input
|
|
type="checkbox"
|
|
checked={batchIds.has(ticket.id)}
|
|
onChange={() => toggleBatchId(ticket.id)}
|
|
className="h-3 w-3 rounded border-border accent-primary opacity-0 group-hover:opacity-100 transition-opacity"
|
|
/>
|
|
<span
|
|
className="h-2 w-2 rounded-full shrink-0"
|
|
style={{ backgroundColor: STATUS_META[ticket.status]?.color ?? "#71717a" }}
|
|
title={statusLabel(ticket.status)}
|
|
/>
|
|
{(ticket as any).sla_breached && (
|
|
<span
|
|
className="h-2 w-2 rounded-full shrink-0 bg-red-500"
|
|
title={`SLA breached: ${(ticket as any).sla_breached}`}
|
|
/>
|
|
)}
|
|
{!(ticket as any).sla_breached && (ticket as any).sla_resolution_deadline && (
|
|
<span
|
|
className={cn(
|
|
"h-2 w-2 rounded-full shrink-0",
|
|
new Date((ticket as any).sla_resolution_deadline) < new Date(Date.now() + 60 * 60 * 1000)
|
|
? "bg-amber-500"
|
|
: "bg-emerald-500"
|
|
)}
|
|
title={`SLA due ${formatDistanceToNow(new Date((ticket as any).sla_resolution_deadline), { addSuffix: true })}`}
|
|
/>
|
|
)}
|
|
</div>
|
|
{row1Fields.map((col) => {
|
|
const cellStyle = {
|
|
display: "table-cell" as const,
|
|
width: colWidth(col.key, col.width),
|
|
verticalAlign: "middle" as const,
|
|
padding: density === "compact" ? "4px 12px" : "8px 12px",
|
|
};
|
|
|
|
if (col.key.startsWith("cf.")) {
|
|
const cfKey = col.key.slice(3);
|
|
const cfValue = ticket.custom_fields?.find((v) => v.custom_field?.key === cfKey || v.custom_field?.name === cfKey)?.value;
|
|
const cfSubs = subsByColumn.get(col.key) ?? [];
|
|
return (
|
|
<div key={col.key} className="truncate text-sm text-foreground" style={cellStyle}>
|
|
{cfValue ?? "—"}
|
|
{density === "comfortable" && cfSubs.map((e) => <div key={e.key} className="text-xs text-muted-foreground/70">{getSubtitleValue(e.key, ticket, {users, queues, teamsList})}</div>)}
|
|
</div>
|
|
);
|
|
}
|
|
switch (col.key) {
|
|
case "id": {
|
|
const idSubs = subsByColumn.get("id") ?? [];
|
|
return (
|
|
<div key={col.key} className="font-mono text-xs font-semibold text-muted-foreground" style={cellStyle}>
|
|
{formatTicketId(ticket.id)}
|
|
{density === "comfortable" && idSubs.map((e) => <div key={e.key} className="text-xs text-muted-foreground/70">{getSubtitleValue(e.key, ticket, {users, queues, teamsList})}</div>)}
|
|
</div>
|
|
);
|
|
}
|
|
case "subject": {
|
|
// Subtitle fields that don't have a matching row1 column
|
|
// Subtitle under subject + orphans (under column not in row1)
|
|
const subsHere = row2EntriesResolved.filter((e) =>
|
|
e.under === "subject" || !row1Fields.some((rf) => rf.key === e.under)
|
|
);
|
|
const subParts: string[] = [];
|
|
const ctx = { users, queues, teamsList };
|
|
for (const e of subsHere) {
|
|
const v = getSubtitleValue(e.key, ticket, ctx);
|
|
if (v) subParts.push(v);
|
|
}
|
|
return (
|
|
<div key={col.key} className="min-w-[240px]" style={cellStyle}>
|
|
<span className="block truncate text-sm font-semibold text-foreground">
|
|
{ticket.subject}
|
|
</span>
|
|
{density === "comfortable" && subParts.length > 0 && (
|
|
<div className="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
{subParts.map((part, i) => (
|
|
<span key={i} className="flex items-center gap-1.5">
|
|
{i > 0 && <span className="h-1 w-1 rounded-full bg-border shrink-0" />}
|
|
{part}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
case "status":
|
|
const statusSubs = subsByColumn.get("status") ?? [];
|
|
return (
|
|
<div key={col.key} style={cellStyle}>
|
|
<TicketStatusBadge status={ticket.status} />
|
|
{density === "comfortable" && statusSubs.map((e) => (
|
|
<div key={e.key} className="mt-0.5 text-xs text-muted-foreground">
|
|
{getSubtitleValue(e.key, ticket, { users, queues, teamsList })}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
case "queue": {
|
|
const subs = subsByColumn.get("queue") ?? [];
|
|
return (
|
|
<div key={col.key} className="truncate text-sm font-medium text-muted-foreground" style={cellStyle}>
|
|
{queueName(queues, ticket.queue_id)}
|
|
{density === "comfortable" && subs.map((e) => (
|
|
<div key={e.key} className="text-xs text-muted-foreground/70">{getSubtitleValue(e.key, ticket, {users, queues, teamsList})}</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
case "owner": {
|
|
const subs = subsByColumn.get("owner") ?? [];
|
|
return (
|
|
<div key={col.key} className="truncate text-sm text-foreground" style={cellStyle}>
|
|
{ownerName ?? "—"}
|
|
{density === "comfortable" && subs.map((e) => (
|
|
<div key={e.key} className="text-xs text-muted-foreground/70">{getSubtitleValue(e.key, ticket, {users, queues, teamsList})}</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
case "created": {
|
|
const subs = subsByColumn.get("created") ?? [];
|
|
return (
|
|
<div key={col.key} className="text-xs text-muted-foreground" style={cellStyle}>
|
|
{relativeTime(ticket.created_at)}
|
|
{density === "comfortable" && subs.map((e) => (
|
|
<div key={e.key} className="text-xs text-muted-foreground/70">{getSubtitleValue(e.key, ticket, {users, queues, teamsList})}</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
case "updated": {
|
|
const subs = subsByColumn.get("updated") ?? [];
|
|
return (
|
|
<div key={col.key} className="text-xs text-muted-foreground" style={cellStyle}>
|
|
{relativeTime(ticket.updated_at)}
|
|
{density === "comfortable" && subs.map((e) => (
|
|
<div key={e.key} className="text-xs text-muted-foreground/70">{getSubtitleValue(e.key, ticket, {users, queues, teamsList})}</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
case "team": {
|
|
const subs = subsByColumn.get("team") ?? [];
|
|
return (
|
|
<div key={col.key} className="text-xs text-muted-foreground" style={cellStyle}>
|
|
{teamsList.find((t) => t.id === ticket.team_id)?.name ?? "—"}
|
|
{density === "comfortable" && subs.map((e) => (
|
|
<div key={e.key} className="text-xs text-muted-foreground/70">{getSubtitleValue(e.key, ticket, {users, queues, teamsList})}</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
default:
|
|
return <div key={col.key} style={cellStyle} />;
|
|
}
|
|
})}
|
|
<div className="flex items-center justify-end px-2 text-muted-foreground/30" style={{ display: "table-cell", width: 48, verticalAlign: "middle" }}>
|
|
<ChevronRightIcon className="h-4 w-4" />
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
|
<DialogContent className="sm:max-w-2xl" showCloseButton>
|
|
<DialogHeader>
|
|
<DialogTitle>New ticket</DialogTitle>
|
|
<DialogDescription>
|
|
Create a request, incident, task, or follow-up for the right queue.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="grid gap-3">
|
|
<label className="grid gap-1.5 text-sm font-medium">
|
|
Subject
|
|
<input
|
|
value={newSubject}
|
|
onChange={(event) => setNewSubject(event.target.value)}
|
|
placeholder="Short summary"
|
|
className="h-9 rounded-md border border-input bg-background px-3 text-sm font-normal outline-none focus:border-ring"
|
|
autoFocus
|
|
/>
|
|
</label>
|
|
|
|
<label className="grid gap-1.5 text-sm font-medium">
|
|
Queue
|
|
<select
|
|
value={newQueueId}
|
|
onChange={(event) => {
|
|
setNewQueueId(event.target.value);
|
|
setCreateError(null);
|
|
}}
|
|
className="h-9 rounded-md border border-input bg-background px-3 text-sm font-normal outline-none focus:border-ring"
|
|
>
|
|
<option value="" disabled>
|
|
Select queue
|
|
</option>
|
|
{queues.map((queue) => (
|
|
<option key={queue.id} value={queue.id}>
|
|
{queue.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<span className="text-xs font-normal text-muted-foreground">
|
|
Starts as <span className="font-mono text-foreground">{newTicketInitialStatus}</span>
|
|
{selectedNewLifecycle ? ` from ${selectedNewLifecycle.name}` : ""}
|
|
</span>
|
|
</label>
|
|
|
|
<label className="grid gap-1.5 text-sm font-medium">
|
|
Description
|
|
<textarea
|
|
value={newDescription}
|
|
onChange={(event) => setNewDescription(event.target.value)}
|
|
placeholder="Optional context"
|
|
rows={4}
|
|
className="resize-none rounded-md border border-input bg-background px-3 py-2 text-sm font-normal outline-none focus:border-ring"
|
|
/>
|
|
</label>
|
|
|
|
{(newFieldsLoading || newTicketFields.length > 0) && (
|
|
<div className="rounded-md border border-border bg-muted/25">
|
|
<div className="border-b border-border px-3 py-2">
|
|
<div className="text-sm font-semibold text-foreground">Queue fields</div>
|
|
<div className="mt-0.5 text-xs text-muted-foreground">
|
|
Metadata attached to this ticket from the selected queue.
|
|
</div>
|
|
</div>
|
|
<div className="grid gap-3 p-3">
|
|
{newFieldsLoading ? (
|
|
<div className="h-9 animate-pulse rounded-md bg-muted" />
|
|
) : (
|
|
newTicketFields.map((field) => {
|
|
const options = Array.isArray(field.values)
|
|
? field.values.map((value) => String(value))
|
|
: [];
|
|
return (
|
|
<label key={field.id} className="grid gap-1.5 text-sm font-medium">
|
|
<span className="flex min-w-0 items-center justify-between gap-3">
|
|
<span className="truncate">{field.name}</span>
|
|
<span className="shrink-0 font-mono text-[11px] font-normal text-muted-foreground">{field.key}</span>
|
|
</span>
|
|
{options.length > 0 ? (
|
|
<select
|
|
value={newCustomFieldValues[field.id] ?? ""}
|
|
onChange={(event) => setNewCustomFieldValues((current) => ({
|
|
...current,
|
|
[field.id]: event.target.value,
|
|
}))}
|
|
className="h-9 rounded-md border border-input bg-background px-3 text-sm font-normal outline-none focus:border-ring"
|
|
>
|
|
<option value="">No value</option>
|
|
{options.map((option) => (
|
|
<option key={option} value={option}>{option}</option>
|
|
))}
|
|
</select>
|
|
) : (
|
|
<input
|
|
value={newCustomFieldValues[field.id] ?? ""}
|
|
onChange={(event) => setNewCustomFieldValues((current) => ({
|
|
...current,
|
|
[field.id]: event.target.value,
|
|
}))}
|
|
placeholder={field.pattern ? `Pattern: ${field.pattern}` : "Optional value"}
|
|
className="h-9 rounded-md border border-input bg-background px-3 text-sm font-normal outline-none focus:border-ring"
|
|
/>
|
|
)}
|
|
{field.pattern && (
|
|
<span className="text-[11px] text-muted-foreground">
|
|
Must match: <code className="rounded bg-muted px-1 font-mono">{field.pattern}</code>
|
|
</span>
|
|
)}
|
|
</label>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{createError && <p className="text-sm text-destructive">{createError}</p>}
|
|
|
|
<DialogFooter showCloseButton={false}>
|
|
<Button
|
|
onClick={handleCreate}
|
|
disabled={!newSubject.trim() || !newQueueId || submitting}
|
|
>
|
|
{submitting ? "Creating..." : "Create ticket"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog open={saveViewOpen} onOpenChange={setSaveViewOpen}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Save view</DialogTitle>
|
|
<DialogDescription>
|
|
Save the current filters as a named view. It will appear in your sidebar.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<input
|
|
value={saveViewName}
|
|
onChange={(event) => setSaveViewName(event.target.value)}
|
|
placeholder="View name"
|
|
className="h-9 w-full rounded-md border border-input bg-card px-3 text-sm outline-none focus:border-ring"
|
|
autoFocus
|
|
onKeyDown={async (event) => {
|
|
if (event.key === "Enter" && saveViewName.trim()) {
|
|
const storedFilters = filters.map((f) => ({
|
|
field: f.field,
|
|
operator: f.operator,
|
|
value: f.value,
|
|
}));
|
|
const { data, error } = await createView({
|
|
name: saveViewName.trim(),
|
|
filters: storedFilters,
|
|
sort_key: sortKey,
|
|
columns: [...row1Fields.map((f) => ({...f, display: "column"})), ...row2EntriesResolved.map((e) => ({key: e.key, under: e.under, display: "subtitle"}))] as any,
|
|
});
|
|
if (!error && data) {
|
|
setSavedViewsList((prev) => [...prev, data]);
|
|
setSaveViewOpen(false);
|
|
setSaveViewName("");
|
|
}
|
|
}
|
|
}}
|
|
/>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setSaveViewOpen(false)}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
disabled={!saveViewName.trim()}
|
|
onClick={async () => {
|
|
if (!saveViewName.trim()) return;
|
|
const storedFilters = filters.map((f) => ({
|
|
field: f.field,
|
|
operator: f.operator,
|
|
value: f.value,
|
|
}));
|
|
const { data, error } = await createView({
|
|
name: saveViewName.trim(),
|
|
filters: storedFilters,
|
|
sort_key: sortKey,
|
|
columns: [...row1Fields.map((f) => ({...f, display: "column"})), ...row2EntriesResolved.map((e) => ({key: e.key, under: e.under, display: "subtitle"}))] as any,
|
|
});
|
|
if (!error && data) {
|
|
setSavedViewsList((prev) => [...prev, data]);
|
|
setSaveViewOpen(false);
|
|
setSaveViewName("");
|
|
}
|
|
}}
|
|
>
|
|
Save
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{typeof document !== "undefined" && addFilterOpen && createPortal(
|
|
<>
|
|
<div
|
|
className="fixed inset-0 z-[9998]"
|
|
onClick={() => {
|
|
setAddFilterOpen(false);
|
|
setAddFilterField(null);
|
|
}}
|
|
/>
|
|
<div
|
|
className="fixed z-[9999] w-52 rounded-md border border-border bg-popover p-1 shadow-lg"
|
|
style={{ left: filterPopoverPos.left, top: filterPopoverPos.top }}
|
|
>
|
|
{!addFilterField ? (
|
|
<>
|
|
{[
|
|
{ field: "queue", label: "Queue" },
|
|
{ field: "owner", label: "Owner" },
|
|
{ field: "subject", label: "Subject" },
|
|
{ field: "created", label: "Created date" },
|
|
{ field: "updated", label: "Updated date" },
|
|
].map(({ field, label }) => (
|
|
<button
|
|
key={field}
|
|
type="button"
|
|
className="w-full rounded px-2 py-1.5 text-left text-xs text-foreground hover:bg-accent"
|
|
onClick={() => {
|
|
if (field === "queue" && filters.find((f) => f.field === "queue")) { setAddFilterOpen(false); return; }
|
|
setAddFilterField(field);
|
|
setAddFilterOperator(field === "created" || field === "updated" ? "before" : "contains");
|
|
setAddFilterValue("");
|
|
}}
|
|
>{label}</button>
|
|
))}
|
|
<div className="my-1 border-t border-border/30" />
|
|
{customFields.map((cf) => (
|
|
<button
|
|
key={`cf-portal-${cf.id}`}
|
|
type="button"
|
|
className="w-full rounded px-2 py-1.5 text-left text-xs text-foreground hover:bg-accent"
|
|
onClick={() => {
|
|
setAddFilterField(`cf.${cf.key}`);
|
|
setAddFilterOperator(cf.field_type === "date" || cf.field_type === "datetime" ? "before" : "contains");
|
|
setAddFilterValue("");
|
|
}}
|
|
>{cf.name}</button>
|
|
))}
|
|
</>
|
|
) : (
|
|
<div className="space-y-2 p-1">
|
|
<div className="flex items-center gap-1 text-xs">
|
|
<button type="button" onClick={() => setAddFilterField(null)} className="text-muted-foreground hover:text-foreground">←</button>
|
|
<span className="font-medium text-foreground">{addFilterField.startsWith("cf.") ? addFilterField.slice(3) : addFilterField}</span>
|
|
</div>
|
|
{addFilterField === "queue" || addFilterField === "owner" ? (
|
|
<select value={addFilterOperator} onChange={(e) => setAddFilterOperator(e.target.value)} className="h-7 w-full rounded border border-input bg-card px-2 text-xs outline-none">
|
|
<option value="is">is</option>
|
|
<option value="is_not">is not</option>
|
|
</select>
|
|
) : addFilterField === "created" || addFilterField === "updated" ? (
|
|
<select value={addFilterOperator} onChange={(e) => setAddFilterOperator(e.target.value)} className="h-7 w-full rounded border border-input bg-card px-2 text-xs outline-none">
|
|
<option value="before">before</option>
|
|
<option value="after">after</option>
|
|
</select>
|
|
) : (
|
|
<select value={addFilterOperator} onChange={(e) => setAddFilterOperator(e.target.value)} className="h-7 w-full rounded border border-input bg-card px-2 text-xs outline-none">
|
|
<option value="contains">contains</option>
|
|
<option value="is">is</option>
|
|
<option value="is_not">is not</option>
|
|
<option value="starts_with">starts with</option>
|
|
</select>
|
|
)}
|
|
{addFilterField === "queue" ? (
|
|
<SearchableSelect
|
|
value={addFilterValue}
|
|
onChange={setAddFilterValue}
|
|
options={queues.map((q) => ({ value: q.id, label: q.name }))}
|
|
placeholder="Select queue..."
|
|
searchPlaceholder="Search queues..."
|
|
className="w-48"
|
|
/>
|
|
) : addFilterField === "owner" ? (
|
|
<SearchableSelect
|
|
value={addFilterValue}
|
|
onChange={setAddFilterValue}
|
|
options={[
|
|
{ value: "unassigned", label: "Unassigned" },
|
|
...users.map((u) => ({ value: u.id, label: u.username })),
|
|
]}
|
|
placeholder="Select owner..."
|
|
searchPlaceholder="Search users..."
|
|
className="w-48"
|
|
/>
|
|
) : addFilterField === "created" || addFilterField === "updated" ? (
|
|
<input type="date" value={addFilterValue} onChange={(e) => setAddFilterValue(e.target.value)} className="h-7 w-full rounded border border-input bg-card px-2 text-xs outline-none" />
|
|
) : (
|
|
<input value={addFilterValue} onChange={(e) => setAddFilterValue(e.target.value)} placeholder="Value" className="h-7 w-full rounded border border-input bg-card px-2 text-xs outline-none"
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && addFilterValue.trim()) {
|
|
const field = addFilterField!;
|
|
const value = addFilterValue;
|
|
let valueLabel = field === "created" || field === "updated" ? new Date(value).toLocaleDateString() : value;
|
|
setFilters((prev) => [...prev, { id: crypto.randomUUID(), field, operator: addFilterOperator, value, label: buildFilterLabel(field, addFilterOperator, valueLabel) }]);
|
|
setAddFilterField(null);
|
|
setAddFilterOpen(false);
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
<div className="flex items-center justify-end gap-1 pt-1">
|
|
<button type="button" onClick={() => { setAddFilterField(null); setAddFilterOpen(false); }} className="rounded px-2 py-1 text-xs text-muted-foreground hover:text-foreground">Cancel</button>
|
|
<button type="button" disabled={!addFilterValue.trim()}
|
|
onClick={() => {
|
|
if (!addFilterValue.trim()) return;
|
|
const field = addFilterField!;
|
|
const value = addFilterValue;
|
|
let valueLabel = value;
|
|
if (field === "queue") valueLabel = queues.find((q) => q.id === value)?.name ?? value;
|
|
else if (field === "owner") valueLabel = value === "unassigned" ? "Unassigned" : users.find((u) => u.id === value)?.username ?? value;
|
|
else if (field === "created" || field === "updated") valueLabel = new Date(value).toLocaleDateString();
|
|
setFilters((prev) => [...prev, { id: crypto.randomUUID(), field, operator: addFilterOperator, value, label: buildFilterLabel(field, addFilterOperator, valueLabel) }]);
|
|
setAddFilterField(null);
|
|
setAddFilterOpen(false);
|
|
}}
|
|
className="rounded bg-primary px-2 py-1 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
|
>Apply</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>,
|
|
document.body
|
|
)}
|
|
|
|
{typeof document !== "undefined" && colPickerOpen && createPortal(
|
|
<LayoutBuilder
|
|
fields={allFields}
|
|
row1={row1Fields}
|
|
row2={row2EntriesResolved}
|
|
onChange={(r1, r2) => {
|
|
setRow1Keys(r1.map((f) => f.key));
|
|
setRow2Entries(r2);
|
|
}}
|
|
onClose={() => setColPickerOpen(false)}
|
|
/>,
|
|
document.body
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function TicketWorkbenchPage() {
|
|
return (
|
|
<Suspense fallback={<SkeletonWorkbench />}>
|
|
<TicketWorkbenchContent />
|
|
</Suspense>
|
|
);
|
|
}
|