feat: add saved views — database table, CRUD API, migration

- New views table (id, name, filters jsonb, sort_key, is_public, creator_id)
- GET/POST/PATCH/DELETE /views endpoints
- Register views router in server

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Gjermund Høsøien Wiggen
2026-06-09 11:10:25 +02:00
parent 000e97e1bd
commit aa90b88991
10 changed files with 1615 additions and 154 deletions

View File

@@ -0,0 +1,12 @@
CREATE TABLE "views" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"filters" jsonb DEFAULT '[]' NOT NULL,
"sort_key" text DEFAULT 'updated',
"columns" jsonb DEFAULT '[]',
"is_public" boolean DEFAULT false,
"creator_id" uuid,
"created_at" timestamp with time zone DEFAULT now()
);
--> statement-breakpoint
ALTER TABLE "views" ADD CONSTRAINT "views_creator_id_users_id_fk" FOREIGN KEY ("creator_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,13 @@
"when": 1780904200000,
"tag": "0002_short_custom_field_keys",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1780995910694,
"tag": "0003_dry_caretaker",
"breakpoints": true
}
]
}

View File

@@ -112,3 +112,14 @@ export const customFieldValues = pgTable('custom_field_values', {
ticketIdIdx: index('custom_field_values_ticket_id_idx').on(table.ticket_id),
cfIdIdx: index('custom_field_values_custom_field_id_idx').on(table.custom_field_id),
}));
export const views = pgTable('views', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
filters: jsonb('filters').notNull().default('[]'),
sort_key: text('sort_key').default('updated'),
columns: jsonb('columns').default('[]'),
is_public: boolean('is_public').default(false),
creator_id: uuid('creator_id').references(() => users.id),
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
});

View File

@@ -12,6 +12,7 @@ import { createCustomFieldsRouter } from './routes/custom-fields.ts';
import { createLifecyclesRouter } from './routes/lifecycles.ts';
import { createUsersRouter } from './routes/users.ts';
import { createTemplatesRouter } from './routes/templates.ts';
import { createViewsRouter } from './routes/views.ts';
let db: Db | null = null;
@@ -35,6 +36,7 @@ app.route('/custom-fields', createCustomFieldsRouter(getDb()));
app.route('/lifecycles', createLifecyclesRouter(getDb()));
app.route('/users', createUsersRouter(getDb()));
app.route('/templates', createTemplatesRouter(getDb()));
app.route('/views', createViewsRouter(getDb()));
export default app;
export { app };

84
src/routes/views.ts Normal file
View File

@@ -0,0 +1,84 @@
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { asc, eq } from 'drizzle-orm';
import type { Db } from '../db/index.ts';
import { views } from '../db/schema.ts';
export function createViewsRouter(db: Db): Hono {
const router = new Hono();
router.get('/', async (c) => {
const result = await db.query.views.findMany({
orderBy: asc(views.name),
});
return c.json(result);
});
router.post('/', async (c) => {
const body = await c.req.json();
const name = String(body.name ?? '').trim();
if (!name) {
throw new HTTPException(400, { message: 'name is required' });
}
const [view] = await db.insert(views).values({
name,
filters: body.filters ?? [],
sort_key: body.sort_key ?? 'updated',
columns: body.columns ?? [],
is_public: body.is_public ?? false,
creator_id: body.creator_id || null,
}).returning();
if (!view) {
throw new HTTPException(500, { message: 'Failed to create view' });
}
return c.json(view, 201);
});
router.patch('/:id', async (c) => {
const id = c.req.param('id');
const body = await c.req.json();
const existing = await db.query.views.findFirst({
where: eq(views.id, id),
});
if (!existing) {
throw new HTTPException(404, { message: 'View not found' });
}
const updateData: Partial<typeof views.$inferInsert> = {};
if (body.name !== undefined) updateData.name = String(body.name).trim();
if (body.filters !== undefined) updateData.filters = body.filters;
if (body.sort_key !== undefined) updateData.sort_key = body.sort_key;
if (body.columns !== undefined) updateData.columns = body.columns;
if (body.is_public !== undefined) updateData.is_public = body.is_public;
const [updated] = await db.update(views)
.set(updateData)
.where(eq(views.id, id))
.returning();
return c.json(updated);
});
router.delete('/:id', async (c) => {
const id = c.req.param('id');
const existing = await db.query.views.findFirst({
where: eq(views.id, id),
});
if (!existing) {
throw new HTTPException(404, { message: 'View not found' });
}
await db.delete(views).where(eq(views.id, id));
return c.json({ ok: true });
});
return router;
}

View File

@@ -10,12 +10,14 @@ import {
LayoutListIcon,
PlusIcon,
RefreshCwIcon,
SaveIcon,
SearchIcon,
SlidersHorizontalIcon,
XIcon,
} from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import { createTicket, getCustomFields, getLifecycles, getQueueCustomFields, getQueues, getTickets, getUsers } from "@/lib/api";
import type { CustomField, Lifecycle, Queue, QueueCustomField, Ticket, User } from "@/lib/types";
import { createTicket, getCustomFields, getLifecycles, getQueueCustomFields, getQueues, getTickets, getUsers, getViews, createView, deleteView } from "@/lib/api";
import type { CustomField, Lifecycle, Queue, QueueCustomField, SavedView, Ticket, User } from "@/lib/types";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -44,6 +46,20 @@ const VIEW_LABELS: Record<string, string> = {
type Density = "comfortable" | "compact";
type SortKey = "updated" | "created" | "id";
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("_", " ");
}
@@ -133,14 +149,16 @@ function TicketWorkbenchContent() {
const [selectedId, setSelectedId] = useState<number | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [queueFilter, setQueueFilter] = useState("all");
const [ownerFilter, setOwnerFilter] = useState("all");
const [customFieldFilter, setCustomFieldFilter] = useState("none");
const [customFieldValue, setCustomFieldValue] = useState("");
const [filters, setFilters] = useState<Filter[]>([]);
const [density, setDensity] = useState<Density>("comfortable");
const [sortKey, setSortKey] = useState<SortKey>("updated");
// Saved views
const [savedViewsList, setSavedViewsList] = useState<SavedView[]>([]);
const [viewIdFromUrl, setViewIdFromUrl] = useState<string | null>(null);
const [saveViewOpen, setSaveViewOpen] = useState(false);
const [saveViewName, setSaveViewName] = useState("");
const [dialogOpen, setDialogOpen] = useState(false);
const [newSubject, setNewSubject] = useState("");
const [newQueueId, setNewQueueId] = useState("");
@@ -164,19 +182,24 @@ function TicketWorkbenchContent() {
setError(null);
const fetchedAt = Date.now();
const activeQueue = routeQueue || queueFilter;
const customFieldFilters =
customFieldFilter !== "none" && customFieldValue.trim()
? { [customFieldFilter]: customFieldValue.trim() }
: undefined;
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 customFieldFilters: Record<string, string> = {};
for (const f of filters) {
if (f.field.startsWith("cf.")) {
customFieldFilters[f.field.slice(3)] = f.value;
}
}
const [ticketsRes, queuesRes, usersRes, fieldsRes, lifecycleRes] = await Promise.all([
getTickets({
q: searchQuery.trim() || undefined,
status: statusFilter !== "all" ? statusFilter : undefined,
queue_id: activeQueue && activeQueue !== "all" ? activeQueue : undefined,
owner_id: ownerFilter !== "all" ? ownerFilter : undefined,
custom_fields: customFieldFilters,
status: apiStatus || undefined,
queue_id: activeQueue || apiQueue || undefined,
owner_id: apiOwner || undefined,
custom_fields: Object.keys(customFieldFilters).length > 0 ? customFieldFilters : undefined,
}),
getQueues(),
getUsers(),
@@ -223,7 +246,7 @@ function TicketWorkbenchContent() {
setRefreshing(false);
setClock(fetchedAt);
},
[customFieldFilter, customFieldValue, newQueueId, ownerFilter, queueFilter, routeQueue, searchQuery, statusFilter]
[filters, newQueueId, routeQueue, searchQuery]
);
useEffect(() => {
@@ -277,8 +300,41 @@ function TicketWorkbenchContent() {
};
}, [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)) {
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);
}
});
}
}, [searchParams]);
const statusOptions = useMemo(() => {
const selectedFilterQueueId = routeQueue || (queueFilter !== "all" ? queueFilter : "");
const queueFilterValue = filters.find((f) => f.field === "queue")?.value;
const selectedFilterQueueId = routeQueue || queueFilterValue || "";
const selectedFilterQueue = selectedFilterQueueId
? queues.find((queue) => queue.id === selectedFilterQueueId)
: null;
@@ -303,7 +359,7 @@ function TicketWorkbenchContent() {
.filter(Boolean)
.map((status) => ({ key: status, label: statusLabel(status) })),
];
}, [lifecycles, queueFilter, queues, routeQueue, tickets]);
}, [filters, lifecycles, queues, routeQueue, tickets]);
const inactiveStatuses = useMemo(
() => new Set(
@@ -329,7 +385,9 @@ function TicketWorkbenchContent() {
const filteredTickets = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
const now = clock || 0;
const queue = routeQueue || queueFilter;
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) => {
@@ -339,8 +397,9 @@ function TicketWorkbenchContent() {
const week = 7 * 24 * 60 * 60 * 1000;
if (!now || now - new Date(ticket.updated_at).getTime() > week) return false;
}
if (statusFilter !== "all" && ticket.status !== statusFilter) return false;
if (queue && queue !== "all" && ticket.queue_id !== queue) 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;
if (!query) return true;
return (
ticket.subject.toLowerCase().includes(query) ||
@@ -355,17 +414,13 @@ function TicketWorkbenchContent() {
const bDate = sortKey === "created" ? b.created_at : b.updated_at;
return new Date(bDate).getTime() - new Date(aDate).getTime();
});
}, [clock, queueFilter, queues, routeQueue, searchQuery, sortKey, statusFilter, tickets, view]);
}, [clock, filters, queues, routeQueue, searchQuery, sortKey, tickets, view]);
const selectedTicket =
filteredTickets.find((ticket) => ticket.id === selectedId) ?? filteredTickets[0] ?? null;
const visibleTitle = routeQueue
? queueName(queues, routeQueue)
: VIEW_LABELS[view] ?? "All tickets";
const selectedCustomField = customFields.find((field) => field.key === customFieldFilter);
const selectedCustomFieldOptions = Array.isArray(selectedCustomField?.values)
? selectedCustomField.values.map((value) => String(value))
: [];
const newTicketFields = newQueueFields
.map((assignment) => assignment.custom_field)
.filter((field): field is CustomField => Boolean(field));
@@ -374,20 +429,11 @@ function TicketWorkbenchContent() {
? lifecycles.find((lifecycle) => lifecycle.id === selectedNewQueue.lifecycle_id)
: null;
const newTicketInitialStatus = selectedNewLifecycle?.definition.statuses.initial[0] ?? "new";
const hasQueryFilters =
searchQuery.trim() ||
statusFilter !== "all" ||
queueFilter !== "all" ||
ownerFilter !== "all" ||
(customFieldFilter !== "none" && customFieldValue.trim());
const hasQueryFilters = searchQuery.trim() || filters.length > 0;
const clearQueryFilters = () => {
setSearchQuery("");
setStatusFilter("all");
setQueueFilter("all");
setOwnerFilter("all");
setCustomFieldFilter("none");
setCustomFieldValue("");
setFilters([]);
if (routeQueue) router.push("/");
};
@@ -458,6 +504,20 @@ function TicketWorkbenchContent() {
<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
@@ -473,8 +533,10 @@ function TicketWorkbenchContent() {
<StatPill label="needs review" value={metrics.stale} />
</div>
<div className="grid gap-2 xl:grid-cols-[minmax(260px,1fr)_auto_auto_auto]">
<div className="relative">
<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}
@@ -483,47 +545,21 @@ function TicketWorkbenchContent() {
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>
<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((filter) => (
<button
key={filter.key}
{hasQueryFilters && (
<Button
type="button"
onClick={() => setStatusFilter(filter.key)}
className={cn(
"h-7 whitespace-nowrap rounded px-2.5 text-xs font-semibold transition-colors",
statusFilter === filter.key
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
variant="outline"
size="sm"
onClick={clearQueryFilters}
className="h-9 shrink-0 border-border/80 bg-card/70"
>
Clear
</Button>
)}
>
{filter.label}
</button>
))}
</div>
<select
value={routeQueue || queueFilter}
onChange={(event) => {
setQueueFilter(event.target.value);
if (routeQueue) router.push("/");
}}
className="h-9 rounded-md border border-input bg-card/90 px-3 text-sm shadow-sm outline-none focus:border-ring"
aria-label="Queue filter"
>
<option value="all">All queues</option>
{queues.map((queue) => (
<option key={queue.id} value={queue.id}>
{queue.name}
</option>
))}
</select>
<div className="flex items-center gap-1">
<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"
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" />
@@ -532,7 +568,7 @@ function TicketWorkbenchContent() {
<button
type="button"
onClick={() => setDensity(density === "comfortable" ? "compact" : "comfortable")}
className="inline-flex h-9 w-9 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"
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" ? (
@@ -542,76 +578,230 @@ function TicketWorkbenchContent() {
)}
</button>
</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>
<div className="grid gap-2 xl:grid-cols-[minmax(180px,260px)_minmax(180px,240px)_minmax(180px,1fr)_auto]">
<select
value={ownerFilter}
onChange={(event) => setOwnerFilter(event.target.value)}
className="h-9 rounded-md border border-input bg-card/90 px-3 text-sm shadow-sm outline-none focus:border-ring"
aria-label="Owner filter"
{/* 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"
>
<option value="all">Any owner</option>
<option value="unassigned">Unassigned</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.username}
</option>
{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>
))}
</select>
<select
value={customFieldFilter}
onChange={(event) => {
setCustomFieldFilter(event.target.value);
setCustomFieldValue("");
<div className="relative">
<button
type="button"
onClick={() => {
const el = document.getElementById("add-filter-select");
el?.focus();
}}
className="h-9 rounded-md border border-input bg-card/90 px-3 text-sm shadow-sm outline-none focus:border-ring"
aria-label="Custom field filter"
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"
>
<option value="none">Any field</option>
{customFields.map((field) => (
<option key={field.id} value={field.key}>
{field.name}
</option>
))}
</select>
{selectedCustomFieldOptions.length > 0 ? (
<PlusIcon className="h-3 w-3" />
Add filter
</button>
<select
value={customFieldValue}
onChange={(event) => setCustomFieldValue(event.target.value)}
disabled={customFieldFilter === "none"}
className="h-9 rounded-md border border-input bg-card/90 px-3 text-sm shadow-sm outline-none focus:border-ring disabled:opacity-55"
aria-label="Custom field value"
id="add-filter-select"
value=""
onChange={(event) => {
const value = event.target.value;
if (!value) return;
const [fieldType, fieldKey] = value.split(":");
const existing = filters.find((f) => f.field === (fieldType === "cf" ? `cf.${fieldKey}` : fieldType));
if (existing) return;
if (fieldType === "queue") {
const q = queues.find((x) => x.id === fieldKey);
if (q) {
setFilters((prev) => [...prev, {
id: crypto.randomUUID(),
field: "queue",
operator: "is",
value: fieldKey,
label: buildFilterLabel("queue", "is", q.name),
}]);
}
} else if (fieldType === "owner") {
if (fieldKey === "unassigned") {
setFilters((prev) => [...prev, {
id: crypto.randomUUID(),
field: "owner",
operator: "is",
value: "unassigned",
label: buildFilterLabel("owner", "is", "Unassigned"),
}]);
} else {
const u = users.find((x) => x.id === fieldKey);
if (u) {
setFilters((prev) => [...prev, {
id: crypto.randomUUID(),
field: "owner",
operator: "is",
value: fieldKey,
label: buildFilterLabel("owner", "is", u.username),
}]);
}
}
} else if (fieldType === "cf") {
const cf = customFields.find((x) => x.key === fieldKey);
if (cf) {
const cfFilter: Filter = {
id: crypto.randomUUID(),
field: `cf.${fieldKey}`,
operator: "is",
value: "",
label: `${cf.name} is ...`,
};
setFilters((prev) => [...prev, cfFilter]);
// Focus value input after adding
setTimeout(() => {
const input = document.getElementById(`cf-value-${cfFilter.id}`) as HTMLInputElement;
input?.focus();
}, 50);
}
}
event.target.value = "";
}}
className="sr-only"
aria-label="Add filter"
>
<option value="">Any value</option>
{selectedCustomFieldOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
<option value="">Add filter...</option>
<optgroup label="Queue">
{queues.map((q) => (
<option key={`q:${q.id}`} value={`queue:${q.id}`}>{q.name}</option>
))}
</optgroup>
<optgroup label="Owner">
<option value="owner:unassigned">Unassigned</option>
{users.map((u) => (
<option key={`o:${u.id}`} value={`owner:${u.id}`}>{u.username}</option>
))}
</optgroup>
<optgroup label="Custom field">
{customFields.map((cf) => (
<option key={`cf:${cf.id}`} value={`cf:${cf.key}`}>{cf.name}</option>
))}
</optgroup>
</select>
</div>
</div>
{/* Inline value inputs for custom field filters */}
{filters
.filter((f) => f.field.startsWith("cf.") && f.value === "")
.map((f) => {
const cf = customFields.find((x) => x.key === f.field.slice(3));
const options = Array.isArray(cf?.values)
? cf.values.map((v) => String(v))
: [];
return (
<div key={`cf-input-${f.id}`} className="flex items-center gap-1.5">
<span className="text-xs font-medium text-muted-foreground">{cf?.name ?? f.field.slice(3)}:</span>
{options.length > 0 ? (
<select
id={`cf-value-${f.id}`}
value=""
onChange={(event) => {
const val = event.target.value;
if (!val) return;
setFilters((prev) =>
prev.map((x) =>
x.id === f.id
? { ...x, value: val, label: buildFilterLabel(x.field, "is", val) }
: x
)
);
}}
className="h-7 rounded border border-input bg-card px-2 text-xs outline-none"
>
<option value="">Select value...</option>
{options.map((opt) => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
) : (
<div className="flex items-center gap-1">
<input
value={customFieldValue}
onChange={(event) => setCustomFieldValue(event.target.value)}
disabled={customFieldFilter === "none"}
placeholder={customFieldFilter === "none" ? "Select a field first" : "Field value"}
className="h-9 rounded-md border border-input bg-card/90 px-3 text-sm shadow-sm outline-none transition-colors placeholder:text-muted-foreground focus:border-ring disabled:opacity-55"
id={`cf-value-${f.id}`}
placeholder="value"
className="h-7 w-32 rounded border border-input bg-card px-2 text-xs outline-none focus:border-ring"
onKeyDown={(event) => {
if (event.key === "Enter") {
const val = event.currentTarget.value.trim();
if (val) {
setFilters((prev) =>
prev.map((x) =>
x.id === f.id
? { ...x, value: val, label: buildFilterLabel(x.field, "is", val) }
: x
)
);
}
}
}}
/>
)}
<Button
<button
type="button"
variant="outline"
size="sm"
onClick={clearQueryFilters}
disabled={!hasQueryFilters}
className="h-9 border-border/80 bg-card/70"
onClick={() => setFilters((prev) => prev.filter((x) => x.id !== f.id))}
className="text-xs text-muted-foreground hover:text-foreground"
>
Clear
</Button>
cancel
</button>
</div>
)}
</div>
);
})}
</div>
</div>
</header>
@@ -892,6 +1082,76 @@ function TicketWorkbenchContent() {
</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,
});
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,
});
if (!error && data) {
setSavedViewsList((prev) => [...prev, data]);
setSaveViewOpen(false);
setSaveViewName("");
}
}}
>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -13,8 +13,8 @@ import {
PanelLeftIcon,
CommandIcon,
} from "lucide-react";
import { getTickets, getQueues } from "@/lib/api";
import type { Queue } from "@/lib/types";
import { getTickets, getQueues, getViews } from "@/lib/api";
import type { Queue, SavedView } from "@/lib/types";
import { CommandPalette } from "@/components/command-palette";
import { ThemeToggle } from "@/components/theme-toggle";
import { cn } from "@/lib/utils";
@@ -86,6 +86,7 @@ function SidebarNav() {
recent: 0,
});
const [queues, setQueues] = useState<(Queue & { count: number })[]>([]);
const [savedViews, setSavedViews] = useState<SavedView[]>([]);
useEffect(() => {
getTickets().then(({ data }) => {
@@ -115,6 +116,10 @@ function SidebarNav() {
).then(setQueues);
}
});
getViews().then(({ data }) => {
if (data) setSavedViews(data);
});
}, []);
const collapsed = useSidebarCollapsed();
@@ -198,6 +203,29 @@ function SidebarNav() {
})}
</div>
)}
{savedViews.length > 0 && (
<div className="mt-4">
{!collapsed && (
<div className="px-2 py-1.5 text-[11px] font-semibold text-sidebar-foreground/45 uppercase">
Saved views
</div>
)}
{savedViews.map((view) => {
const active =
pathname === "/" && searchParams.get("view_id") === view.id;
return (
<SidebarNavItem
key={view.id}
href={`/?view_id=${view.id}`}
icon={ClockIcon}
label={view.name}
active={active}
/>
);
})}
</div>
)}
</>
);
}

View File

@@ -3,6 +3,7 @@ import type {
Queue,
User,
Transaction,
SavedView,
Scrip,
Template,
TemplatePreview,
@@ -230,3 +231,31 @@ export async function updateCustomField(id: string, data: {
}): Promise<{ data: CustomField | null; error: string | null }> {
return request<CustomField>(`/custom-fields/${id}`, { method: "PATCH", body: JSON.stringify(data) });
}
export async function getViews(): Promise<{ data: SavedView[] | null; error: string | null }> {
return request<SavedView[]>("/views");
}
export async function createView(data: {
name: string;
filters: { field: string; operator: string; value: string }[];
sort_key?: string;
columns?: unknown[];
is_public?: boolean;
}): Promise<{ data: SavedView | null; error: string | null }> {
return request<SavedView>("/views", { method: "POST", body: JSON.stringify(data) });
}
export async function updateView(id: string, data: {
name?: string;
filters?: { field: string; operator: string; value: string }[];
sort_key?: string;
columns?: unknown[];
is_public?: boolean;
}): Promise<{ data: SavedView | null; error: string | null }> {
return request<SavedView>(`/views/${id}`, { method: "PATCH", body: JSON.stringify(data) });
}
export async function deleteView(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/views/${id}`, { method: "DELETE" });
}

View File

@@ -128,3 +128,20 @@ export interface ScripResult {
success: boolean;
message: string;
}
export interface SavedFilter {
field: string;
operator: string;
value: string;
}
export interface SavedView {
id: string;
name: string;
filters: SavedFilter[];
sort_key: string;
columns: unknown[];
is_public: boolean;
creator_id: string | null;
created_at: string;
}