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:
12
drizzle/migrations/0003_dry_caretaker.sql
Normal file
12
drizzle/migrations/0003_dry_caretaker.sql
Normal 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;
|
||||
1011
drizzle/migrations/meta/0003_snapshot.json
Normal file
1011
drizzle/migrations/meta/0003_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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
84
src/routes/views.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user