feat: auth system, scrip scheduler, UI widgets, and new API routes
- Add session-based authentication (login page, middleware, auth context) - Add cron-like scrip scheduler for time-based conditions - Add layout builder, scrip wizard, searchable select components - Add trend chart widget for dashboards - Add notifications, attachments, queue-permissions API routes - Add seed-users script - Update schema with 10 new migrations (0008-0017) - Apply redesign: Linear-inspired dark theme, conversation-centric UI - Gitignore runtime data directory Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
||||
ArrowDownAZIcon,
|
||||
CheckCircle2Icon,
|
||||
ChevronRightIcon,
|
||||
DownloadIcon,
|
||||
GaugeIcon,
|
||||
LayoutGridIcon,
|
||||
LayoutListIcon,
|
||||
@@ -18,8 +19,8 @@ import {
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { createTicket, getCustomFields, getLifecycles, getQueueCustomFields, getQueues, getTickets, getUsers, getViews, createView, deleteView, getDashboards, updateTicket } from "@/lib/api";
|
||||
import type { CustomField, Lifecycle, Queue, QueueCustomField, SavedView, Ticket, User } from "@/lib/types";
|
||||
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,
|
||||
@@ -30,6 +31,8 @@ import {
|
||||
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" },
|
||||
@@ -51,35 +54,22 @@ type SortKey = "updated" | "created" | "id";
|
||||
interface ColumnConfig {
|
||||
key: string;
|
||||
label: string;
|
||||
width: number; // px
|
||||
visible: boolean;
|
||||
width: number;
|
||||
}
|
||||
|
||||
const ALL_COLUMNS: ColumnConfig[] = [
|
||||
{ key: "id", label: "ID", width: 100, visible: true },
|
||||
{ key: "subject", label: "Subject", width: 320, visible: true },
|
||||
{ key: "status", label: "Status", width: 120, visible: true },
|
||||
{ key: "queue", label: "Queue", width: 140, visible: true },
|
||||
{ key: "owner", label: "Owner", width: 130, visible: true },
|
||||
{ key: "created", label: "Created", width: 130, visible: false },
|
||||
{ key: "updated", label: "Updated", width: 130, visible: false },
|
||||
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 },
|
||||
];
|
||||
|
||||
function baseColumns(): ColumnConfig[] {
|
||||
return [
|
||||
{ key: "id", label: "ID", width: 100, visible: true },
|
||||
{ key: "subject", label: "Subject", width: 400, visible: true },
|
||||
{ key: "status", label: "Status", width: 120, visible: true },
|
||||
{ key: "queue", label: "Queue", width: 140, visible: true },
|
||||
{ key: "owner", label: "Owner", width: 130, visible: true },
|
||||
{ key: "created", label: "Created", width: 130, visible: false },
|
||||
{ key: "updated", label: "Updated", width: 130, visible: false },
|
||||
];
|
||||
}
|
||||
|
||||
function defaultColumns(): ColumnConfig[] {
|
||||
return baseColumns().map((c) => ({ ...c }));
|
||||
}
|
||||
const DEFAULT_ROW1 = ["id", "subject", "status"];
|
||||
const DEFAULT_ROW2 = ["queue", "owner"];
|
||||
|
||||
const LS_KEY = "tessera_columns";
|
||||
|
||||
@@ -105,6 +95,22 @@ 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 });
|
||||
}
|
||||
@@ -178,8 +184,10 @@ function TicketWorkbenchContent() {
|
||||
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);
|
||||
@@ -187,47 +195,82 @@ function TicketWorkbenchContent() {
|
||||
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 [columns, setColumns] = useState<ColumnConfig[]>(() => {
|
||||
if (typeof window === "undefined") return defaultColumns();
|
||||
const [row1Keys, setRow1Keys] = useState<string[]>(() => {
|
||||
if (typeof window === "undefined") return DEFAULT_ROW1;
|
||||
try {
|
||||
const stored = localStorage.getItem(LS_KEY);
|
||||
if (stored) return JSON.parse(stored) as ColumnConfig[];
|
||||
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 defaultColumns();
|
||||
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 columns to localStorage
|
||||
// Persist layout to localStorage
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem(LS_KEY, JSON.stringify(columns)); } catch { /* ignore */ }
|
||||
}, [columns]);
|
||||
try { localStorage.setItem(LS_KEY, JSON.stringify({ row1: row1Keys, row2Entries })); } catch { /* ignore */ }
|
||||
}, [row1Keys, row2Entries]);
|
||||
|
||||
// Build available columns: base + custom fields
|
||||
const availableColumns = useMemo(() => {
|
||||
const base = baseColumns();
|
||||
const cfCols: ColumnConfig[] = customFields
|
||||
// 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,
|
||||
visible: columns.find((c) => c.key === `cf.${cf.key}`)?.visible ?? false,
|
||||
}));
|
||||
// Merge with current visibility state
|
||||
const merged = base.map((bc) => {
|
||||
const current = columns.find((c) => c.key === bc.key);
|
||||
return current ?? bc;
|
||||
});
|
||||
for (const cf of cfCols) {
|
||||
const current = columns.find((c) => c.key === cf.key);
|
||||
merged.push(current ?? cf);
|
||||
}
|
||||
return merged;
|
||||
}, [customFields, columns]);
|
||||
.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[]>([]);
|
||||
@@ -268,6 +311,9 @@ function TicketWorkbenchContent() {
|
||||
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.")) {
|
||||
@@ -276,19 +322,23 @@ function TicketWorkbenchContent() {
|
||||
}
|
||||
|
||||
const routeTeamId = searchParams.get("team_id") ?? "";
|
||||
const [ticketsRes, queuesRes, usersRes, fieldsRes, lifecycleRes] = await Promise.all([
|
||||
const [ticketsRes, queuesRes, usersRes, fieldsRes, lifecycleRes, teamsRes] = await Promise.all([
|
||||
getTickets({
|
||||
q: searchQuery.trim() || undefined,
|
||||
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) {
|
||||
@@ -314,6 +364,12 @@ function TicketWorkbenchContent() {
|
||||
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 {
|
||||
@@ -327,10 +383,11 @@ function TicketWorkbenchContent() {
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
setInitialLoad(false);
|
||||
setRefreshing(false);
|
||||
setClock(fetchedAt);
|
||||
},
|
||||
[filters, newQueueId, routeQueue, searchQuery]
|
||||
[filters, newQueueId, routeQueue, debouncedQuery]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -422,7 +479,12 @@ function TicketWorkbenchContent() {
|
||||
);
|
||||
if (view.sort_key) setSortKey(view.sort_key as SortKey);
|
||||
if (view.columns && Array.isArray(view.columns) && view.columns.length > 0) {
|
||||
setColumns(view.columns as ColumnConfig[]);
|
||||
// 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 })));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -430,7 +492,8 @@ function TicketWorkbenchContent() {
|
||||
// User navigated away from a view — clear filters and reset columns
|
||||
setFilters([]);
|
||||
setSearchQuery("");
|
||||
setColumns(defaultColumns());
|
||||
setRow1Keys(DEFAULT_ROW1);
|
||||
setRow2Entries(DEFAULT_ROW2.map((k) => ({ key: k, under: k })));
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
@@ -485,7 +548,6 @@ function TicketWorkbenchContent() {
|
||||
}, [clock, inactiveStatuses, tickets]);
|
||||
|
||||
const filteredTickets = useMemo(() => {
|
||||
const query = searchQuery.trim().toLowerCase();
|
||||
const now = clock || 0;
|
||||
const queue = routeQueue;
|
||||
const statusFilterValue = filters.find((f) => f.field === "status")?.value;
|
||||
@@ -506,13 +568,7 @@ function TicketWorkbenchContent() {
|
||||
if (statusFilterValue && ticket.status !== statusFilterValue) return false;
|
||||
if (queueFilterValue && ticket.queue_id !== queueFilterValue) return false;
|
||||
if (queue && ticket.queue_id !== queue) return false;
|
||||
if (!query) return true;
|
||||
return (
|
||||
ticket.subject.toLowerCase().includes(query) ||
|
||||
formatTicketId(ticket.id).toLowerCase().includes(query) ||
|
||||
statusLabel(ticket.status).toLowerCase().includes(query) ||
|
||||
queueName(queues, ticket.queue_id).toLowerCase().includes(query)
|
||||
);
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (sortKey === "id") return b.id - a.id;
|
||||
@@ -520,7 +576,7 @@ function TicketWorkbenchContent() {
|
||||
const bDate = sortKey === "created" ? b.created_at : b.updated_at;
|
||||
return new Date(bDate).getTime() - new Date(aDate).getTime();
|
||||
});
|
||||
}, [clock, filters, queues, routeQueue, searchQuery, sortKey, tickets, view]);
|
||||
}, [clock, filters, queues, routeQueue, sortKey, tickets, view]);
|
||||
|
||||
|
||||
|
||||
@@ -538,24 +594,19 @@ function TicketWorkbenchContent() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchStatus = async (newStatus: string) => {
|
||||
const handleBatchAction = async (update: { status?: string; owner_id?: string | null; team_id?: string | null }) => {
|
||||
setBatchSaving(true);
|
||||
for (const id of batchIds) {
|
||||
await updateTicket(id, { status: newStatus });
|
||||
}
|
||||
const ids = Array.from(batchIds);
|
||||
const { data, error } = await batchUpdateTickets({ ticket_ids: ids, ...update });
|
||||
setBatchSaving(false);
|
||||
setBatchIds(new Set());
|
||||
await fetchData();
|
||||
};
|
||||
|
||||
const handleBatchAssign = async () => {
|
||||
const me = users[0]?.id;
|
||||
if (!me) return;
|
||||
setBatchSaving(true);
|
||||
for (const id of batchIds) {
|
||||
await updateTicket(id, { owner_id: me });
|
||||
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);
|
||||
}
|
||||
setBatchSaving(false);
|
||||
setBatchIds(new Set());
|
||||
await fetchData();
|
||||
};
|
||||
@@ -572,30 +623,27 @@ function TicketWorkbenchContent() {
|
||||
e.stopPropagation();
|
||||
setResizingCol(leftKey);
|
||||
const startX = e.clientX;
|
||||
const leftCol = columns.find((c) => c.key === leftKey);
|
||||
const rightCol = rightKey ? columns.find((c) => c.key === rightKey) : null;
|
||||
const leftStart = leftCol?.width ?? 140;
|
||||
const rightStart = rightCol?.width ?? 140;
|
||||
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 = rightCol ? Math.max(50, Math.min(800, rightStart - delta)) : undefined;
|
||||
setColumns((prev) =>
|
||||
prev.map((c) => {
|
||||
if (c.key === leftKey) return { ...c, width: newLeft };
|
||||
if (rightCol && c.key === rightCol.key) return { ...c, width: newRight! };
|
||||
return c;
|
||||
})
|
||||
);
|
||||
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.body.classList.remove("select-none");
|
||||
setResizingCol(null);
|
||||
};
|
||||
document.body.classList.add("select-none");
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
};
|
||||
@@ -659,7 +707,7 @@ function TicketWorkbenchContent() {
|
||||
if (data) router.push(`/tickets/${data.ticket.id}`);
|
||||
};
|
||||
|
||||
if (loading) return <SkeletonWorkbench />;
|
||||
if (initialLoad && loading) return <SkeletonWorkbench />;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-background/80">
|
||||
@@ -676,6 +724,36 @@ function TicketWorkbenchContent() {
|
||||
</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"
|
||||
@@ -723,7 +801,7 @@ function TicketWorkbenchContent() {
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
placeholder="Search subject, ticket ID, queue, or status"
|
||||
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>
|
||||
@@ -830,7 +908,7 @@ function TicketWorkbenchContent() {
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
ref={addFilterBtnRef}
|
||||
type="button"
|
||||
@@ -846,6 +924,15 @@ function TicketWorkbenchContent() {
|
||||
<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>
|
||||
|
||||
@@ -881,6 +968,35 @@ function TicketWorkbenchContent() {
|
||||
</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 */}
|
||||
@@ -889,11 +1005,11 @@ function TicketWorkbenchContent() {
|
||||
density === "compact" ? "min-h-7" : "min-h-8"
|
||||
)} style={{ display: "table-row" }}>
|
||||
<div style={{ display: "table-cell", width: 48 }} />
|
||||
{availableColumns.filter((c) => c.visible).map((col, idx, arr) => (
|
||||
{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: col.width }}
|
||||
style={{ display: "table-cell", width: colWidth(col.key, col.width) }}
|
||||
>
|
||||
{/* Resize handle: drags the boundary, resizes column to the LEFT */}
|
||||
{idx > 0 && (
|
||||
@@ -910,6 +1026,39 @@ function TicketWorkbenchContent() {
|
||||
<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
|
||||
@@ -946,10 +1095,10 @@ function TicketWorkbenchContent() {
|
||||
title={statusLabel(ticket.status)}
|
||||
/>
|
||||
</div>
|
||||
{availableColumns.filter((c) => c.visible).map((col) => {
|
||||
{row1Fields.map((col) => {
|
||||
const cellStyle = {
|
||||
display: "table-cell" as const,
|
||||
width: col.width,
|
||||
width: colWidth(col.key, col.width),
|
||||
verticalAlign: "middle" as const,
|
||||
padding: density === "compact" ? "4px 12px" : "8px 12px",
|
||||
};
|
||||
@@ -957,64 +1106,121 @@ function TicketWorkbenchContent() {
|
||||
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":
|
||||
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":
|
||||
}
|
||||
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-[200px]" style={cellStyle}>
|
||||
<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" && (
|
||||
<span className="mt-0.5 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{ownerName ?? "Unassigned"}
|
||||
<span className="h-1 w-1 rounded-full bg-border" />
|
||||
Created {relativeTime(ticket.created_at)}
|
||||
</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":
|
||||
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":
|
||||
}
|
||||
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":
|
||||
}
|
||||
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":
|
||||
}
|
||||
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} />;
|
||||
}
|
||||
@@ -1188,7 +1394,7 @@ function TicketWorkbenchContent() {
|
||||
name: saveViewName.trim(),
|
||||
filters: storedFilters,
|
||||
sort_key: sortKey,
|
||||
columns,
|
||||
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]);
|
||||
@@ -1220,7 +1426,7 @@ function TicketWorkbenchContent() {
|
||||
name: saveViewName.trim(),
|
||||
filters: storedFilters,
|
||||
sort_key: sortKey,
|
||||
columns,
|
||||
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]);
|
||||
@@ -1250,32 +1456,26 @@ function TicketWorkbenchContent() {
|
||||
>
|
||||
{!addFilterField ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded px-2 py-1.5 text-left text-xs text-foreground hover:bg-accent"
|
||||
onClick={() => {
|
||||
if (!filters.find((f) => f.field === "queue")) {
|
||||
setAddFilterField("queue");
|
||||
setAddFilterOperator("is");
|
||||
{[
|
||||
{ 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("");
|
||||
} else {
|
||||
setAddFilterOpen(false);
|
||||
}
|
||||
}}
|
||||
>Queue</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded px-2 py-1.5 text-left text-xs text-foreground hover:bg-accent"
|
||||
onClick={() => {
|
||||
if (!filters.find((f) => f.field === "owner")) {
|
||||
setAddFilterField("owner");
|
||||
setAddFilterOperator("is");
|
||||
setAddFilterValue("");
|
||||
} else {
|
||||
setAddFilterOpen(false);
|
||||
}
|
||||
}}
|
||||
>Owner</button>
|
||||
}}
|
||||
>{label}</button>
|
||||
))}
|
||||
<div className="my-1 border-t border-border/30" />
|
||||
{customFields.map((cf) => (
|
||||
<button
|
||||
key={`cf-portal-${cf.id}`}
|
||||
@@ -1283,7 +1483,7 @@ function TicketWorkbenchContent() {
|
||||
className="w-full rounded px-2 py-1.5 text-left text-xs text-foreground hover:bg-accent"
|
||||
onClick={() => {
|
||||
setAddFilterField(`cf.${cf.key}`);
|
||||
setAddFilterOperator("is");
|
||||
setAddFilterOperator(cf.field_type === "date" || cf.field_type === "datetime" ? "before" : "contains");
|
||||
setAddFilterValue("");
|
||||
}}
|
||||
>{cf.name}</button>
|
||||
@@ -1295,26 +1495,55 @@ function TicketWorkbenchContent() {
|
||||
<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>
|
||||
<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 === "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" ? (
|
||||
<select 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">
|
||||
<option value="">Select queue...</option>
|
||||
{queues.map((q) => (<option key={q.id} value={q.id}>{q.name}</option>))}
|
||||
</select>
|
||||
<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" ? (
|
||||
<select 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">
|
||||
<option value="">Select owner...</option>
|
||||
<option value="unassigned">Unassigned</option>
|
||||
{users.map((u) => (<option key={u.id} value={u.id}>{u.username}</option>))}
|
||||
</select>
|
||||
<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()) {
|
||||
setFilters((prev) => [...prev, { id: crypto.randomUUID(), field: addFilterField, operator: addFilterOperator, value: addFilterValue, label: buildFilterLabel(addFilterField, addFilterOperator, addFilterValue) }]);
|
||||
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);
|
||||
}
|
||||
@@ -1326,11 +1555,12 @@ function TicketWorkbenchContent() {
|
||||
<button type="button" disabled={!addFilterValue.trim()}
|
||||
onClick={() => {
|
||||
if (!addFilterValue.trim()) return;
|
||||
const field = addFilterField;
|
||||
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);
|
||||
@@ -1346,33 +1576,16 @@ function TicketWorkbenchContent() {
|
||||
)}
|
||||
|
||||
{typeof document !== "undefined" && colPickerOpen && createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[9998]" onClick={() => setColPickerOpen(false)} />
|
||||
<div className="fixed z-[9999] w-48 rounded-md border border-border bg-card p-1 shadow-lg"
|
||||
style={{ left: "calc(100% - 220px)", top: "72px" }}
|
||||
>
|
||||
{availableColumns.map((col) => {
|
||||
const isVisible = columns.find((c) => c.key === col.key)?.visible ?? col.visible;
|
||||
return (
|
||||
<button
|
||||
key={col.key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setColumns((prev) =>
|
||||
prev.map((c) => c.key === col.key ? { ...c, visible: !c.visible } : c)
|
||||
);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs text-foreground hover:bg-accent"
|
||||
>
|
||||
<span className={cn("text-xs", isVisible ? "text-primary" : "text-muted-foreground/30")}>
|
||||
{isVisible ? "✓" : "—"}
|
||||
</span>
|
||||
{col.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>,
|
||||
<LayoutBuilder
|
||||
fields={allFields}
|
||||
row1={row1Fields}
|
||||
row2={row2EntriesResolved}
|
||||
onChange={(r1, r2) => {
|
||||
setRow1Keys(r1.map((f) => f.key));
|
||||
setRow2Entries(r2);
|
||||
}}
|
||||
onClose={() => setColPickerOpen(false)}
|
||||
/>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user