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,
|
"when": 1780904200000,
|
||||||
"tag": "0002_short_custom_field_keys",
|
"tag": "0002_short_custom_field_keys",
|
||||||
"breakpoints": true
|
"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),
|
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),
|
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 { createLifecyclesRouter } from './routes/lifecycles.ts';
|
||||||
import { createUsersRouter } from './routes/users.ts';
|
import { createUsersRouter } from './routes/users.ts';
|
||||||
import { createTemplatesRouter } from './routes/templates.ts';
|
import { createTemplatesRouter } from './routes/templates.ts';
|
||||||
|
import { createViewsRouter } from './routes/views.ts';
|
||||||
|
|
||||||
let db: Db | null = null;
|
let db: Db | null = null;
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ app.route('/custom-fields', createCustomFieldsRouter(getDb()));
|
|||||||
app.route('/lifecycles', createLifecyclesRouter(getDb()));
|
app.route('/lifecycles', createLifecyclesRouter(getDb()));
|
||||||
app.route('/users', createUsersRouter(getDb()));
|
app.route('/users', createUsersRouter(getDb()));
|
||||||
app.route('/templates', createTemplatesRouter(getDb()));
|
app.route('/templates', createTemplatesRouter(getDb()));
|
||||||
|
app.route('/views', createViewsRouter(getDb()));
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
export { 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,
|
LayoutListIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
RefreshCwIcon,
|
RefreshCwIcon,
|
||||||
|
SaveIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
SlidersHorizontalIcon,
|
SlidersHorizontalIcon,
|
||||||
|
XIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { formatDistanceToNow } from "date-fns";
|
import { formatDistanceToNow } from "date-fns";
|
||||||
import { createTicket, getCustomFields, getLifecycles, getQueueCustomFields, getQueues, getTickets, getUsers } from "@/lib/api";
|
import { createTicket, getCustomFields, getLifecycles, getQueueCustomFields, getQueues, getTickets, getUsers, getViews, createView, deleteView } from "@/lib/api";
|
||||||
import type { CustomField, Lifecycle, Queue, QueueCustomField, Ticket, User } from "@/lib/types";
|
import type { CustomField, Lifecycle, Queue, QueueCustomField, SavedView, Ticket, User } from "@/lib/types";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -44,6 +46,20 @@ const VIEW_LABELS: Record<string, string> = {
|
|||||||
type Density = "comfortable" | "compact";
|
type Density = "comfortable" | "compact";
|
||||||
type SortKey = "updated" | "created" | "id";
|
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) {
|
function statusLabel(status: string) {
|
||||||
return STATUS_META[status]?.label ?? status.replaceAll("_", " ");
|
return STATUS_META[status]?.label ?? status.replaceAll("_", " ");
|
||||||
}
|
}
|
||||||
@@ -133,14 +149,16 @@ function TicketWorkbenchContent() {
|
|||||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [statusFilter, setStatusFilter] = useState("all");
|
const [filters, setFilters] = useState<Filter[]>([]);
|
||||||
const [queueFilter, setQueueFilter] = useState("all");
|
|
||||||
const [ownerFilter, setOwnerFilter] = useState("all");
|
|
||||||
const [customFieldFilter, setCustomFieldFilter] = useState("none");
|
|
||||||
const [customFieldValue, setCustomFieldValue] = useState("");
|
|
||||||
const [density, setDensity] = useState<Density>("comfortable");
|
const [density, setDensity] = useState<Density>("comfortable");
|
||||||
const [sortKey, setSortKey] = useState<SortKey>("updated");
|
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 [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [newSubject, setNewSubject] = useState("");
|
const [newSubject, setNewSubject] = useState("");
|
||||||
const [newQueueId, setNewQueueId] = useState("");
|
const [newQueueId, setNewQueueId] = useState("");
|
||||||
@@ -164,19 +182,24 @@ function TicketWorkbenchContent() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
const fetchedAt = Date.now();
|
const fetchedAt = Date.now();
|
||||||
|
|
||||||
const activeQueue = routeQueue || queueFilter;
|
const activeQueue = routeQueue;
|
||||||
const customFieldFilters =
|
const apiStatus = filters.find((f) => f.field === "status")?.value;
|
||||||
customFieldFilter !== "none" && customFieldValue.trim()
|
const apiOwner = filters.find((f) => f.field === "owner")?.value;
|
||||||
? { [customFieldFilter]: customFieldValue.trim() }
|
const apiQueue = filters.find((f) => f.field === "queue")?.value;
|
||||||
: undefined;
|
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([
|
const [ticketsRes, queuesRes, usersRes, fieldsRes, lifecycleRes] = await Promise.all([
|
||||||
getTickets({
|
getTickets({
|
||||||
q: searchQuery.trim() || undefined,
|
q: searchQuery.trim() || undefined,
|
||||||
status: statusFilter !== "all" ? statusFilter : undefined,
|
status: apiStatus || undefined,
|
||||||
queue_id: activeQueue && activeQueue !== "all" ? activeQueue : undefined,
|
queue_id: activeQueue || apiQueue || undefined,
|
||||||
owner_id: ownerFilter !== "all" ? ownerFilter : undefined,
|
owner_id: apiOwner || undefined,
|
||||||
custom_fields: customFieldFilters,
|
custom_fields: Object.keys(customFieldFilters).length > 0 ? customFieldFilters : undefined,
|
||||||
}),
|
}),
|
||||||
getQueues(),
|
getQueues(),
|
||||||
getUsers(),
|
getUsers(),
|
||||||
@@ -223,7 +246,7 @@ function TicketWorkbenchContent() {
|
|||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
setClock(fetchedAt);
|
setClock(fetchedAt);
|
||||||
},
|
},
|
||||||
[customFieldFilter, customFieldValue, newQueueId, ownerFilter, queueFilter, routeQueue, searchQuery, statusFilter]
|
[filters, newQueueId, routeQueue, searchQuery]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -277,8 +300,41 @@ function TicketWorkbenchContent() {
|
|||||||
};
|
};
|
||||||
}, [newQueueId]);
|
}, [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 statusOptions = useMemo(() => {
|
||||||
const selectedFilterQueueId = routeQueue || (queueFilter !== "all" ? queueFilter : "");
|
const queueFilterValue = filters.find((f) => f.field === "queue")?.value;
|
||||||
|
const selectedFilterQueueId = routeQueue || queueFilterValue || "";
|
||||||
const selectedFilterQueue = selectedFilterQueueId
|
const selectedFilterQueue = selectedFilterQueueId
|
||||||
? queues.find((queue) => queue.id === selectedFilterQueueId)
|
? queues.find((queue) => queue.id === selectedFilterQueueId)
|
||||||
: null;
|
: null;
|
||||||
@@ -303,7 +359,7 @@ function TicketWorkbenchContent() {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map((status) => ({ key: status, label: statusLabel(status) })),
|
.map((status) => ({ key: status, label: statusLabel(status) })),
|
||||||
];
|
];
|
||||||
}, [lifecycles, queueFilter, queues, routeQueue, tickets]);
|
}, [filters, lifecycles, queues, routeQueue, tickets]);
|
||||||
|
|
||||||
const inactiveStatuses = useMemo(
|
const inactiveStatuses = useMemo(
|
||||||
() => new Set(
|
() => new Set(
|
||||||
@@ -329,7 +385,9 @@ function TicketWorkbenchContent() {
|
|||||||
const filteredTickets = useMemo(() => {
|
const filteredTickets = useMemo(() => {
|
||||||
const query = searchQuery.trim().toLowerCase();
|
const query = searchQuery.trim().toLowerCase();
|
||||||
const now = clock || 0;
|
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
|
return tickets
|
||||||
.filter((ticket) => {
|
.filter((ticket) => {
|
||||||
@@ -339,8 +397,9 @@ function TicketWorkbenchContent() {
|
|||||||
const week = 7 * 24 * 60 * 60 * 1000;
|
const week = 7 * 24 * 60 * 60 * 1000;
|
||||||
if (!now || now - new Date(ticket.updated_at).getTime() > week) return false;
|
if (!now || now - new Date(ticket.updated_at).getTime() > week) return false;
|
||||||
}
|
}
|
||||||
if (statusFilter !== "all" && ticket.status !== statusFilter) return false;
|
if (statusFilterValue && ticket.status !== statusFilterValue) return false;
|
||||||
if (queue && queue !== "all" && ticket.queue_id !== queue) return false;
|
if (queueFilterValue && ticket.queue_id !== queueFilterValue) return false;
|
||||||
|
if (queue && ticket.queue_id !== queue) return false;
|
||||||
if (!query) return true;
|
if (!query) return true;
|
||||||
return (
|
return (
|
||||||
ticket.subject.toLowerCase().includes(query) ||
|
ticket.subject.toLowerCase().includes(query) ||
|
||||||
@@ -355,17 +414,13 @@ function TicketWorkbenchContent() {
|
|||||||
const bDate = sortKey === "created" ? b.created_at : b.updated_at;
|
const bDate = sortKey === "created" ? b.created_at : b.updated_at;
|
||||||
return new Date(bDate).getTime() - new Date(aDate).getTime();
|
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 =
|
const selectedTicket =
|
||||||
filteredTickets.find((ticket) => ticket.id === selectedId) ?? filteredTickets[0] ?? null;
|
filteredTickets.find((ticket) => ticket.id === selectedId) ?? filteredTickets[0] ?? null;
|
||||||
const visibleTitle = routeQueue
|
const visibleTitle = routeQueue
|
||||||
? queueName(queues, routeQueue)
|
? queueName(queues, routeQueue)
|
||||||
: VIEW_LABELS[view] ?? "All tickets";
|
: 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
|
const newTicketFields = newQueueFields
|
||||||
.map((assignment) => assignment.custom_field)
|
.map((assignment) => assignment.custom_field)
|
||||||
.filter((field): field is CustomField => Boolean(field));
|
.filter((field): field is CustomField => Boolean(field));
|
||||||
@@ -374,20 +429,11 @@ function TicketWorkbenchContent() {
|
|||||||
? lifecycles.find((lifecycle) => lifecycle.id === selectedNewQueue.lifecycle_id)
|
? lifecycles.find((lifecycle) => lifecycle.id === selectedNewQueue.lifecycle_id)
|
||||||
: null;
|
: null;
|
||||||
const newTicketInitialStatus = selectedNewLifecycle?.definition.statuses.initial[0] ?? "new";
|
const newTicketInitialStatus = selectedNewLifecycle?.definition.statuses.initial[0] ?? "new";
|
||||||
const hasQueryFilters =
|
const hasQueryFilters = searchQuery.trim() || filters.length > 0;
|
||||||
searchQuery.trim() ||
|
|
||||||
statusFilter !== "all" ||
|
|
||||||
queueFilter !== "all" ||
|
|
||||||
ownerFilter !== "all" ||
|
|
||||||
(customFieldFilter !== "none" && customFieldValue.trim());
|
|
||||||
|
|
||||||
const clearQueryFilters = () => {
|
const clearQueryFilters = () => {
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
setStatusFilter("all");
|
setFilters([]);
|
||||||
setQueueFilter("all");
|
|
||||||
setOwnerFilter("all");
|
|
||||||
setCustomFieldFilter("none");
|
|
||||||
setCustomFieldValue("");
|
|
||||||
if (routeQueue) router.push("/");
|
if (routeQueue) router.push("/");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -458,6 +504,20 @@ function TicketWorkbenchContent() {
|
|||||||
<RefreshCwIcon className={cn("h-4 w-4", refreshing && "animate-spin")} />
|
<RefreshCwIcon className={cn("h-4 w-4", refreshing && "animate-spin")} />
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</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">
|
<Button size="sm" onClick={() => setDialogOpen(true)} className="h-8 bg-primary shadow-sm">
|
||||||
<PlusIcon className="h-4 w-4" />
|
<PlusIcon className="h-4 w-4" />
|
||||||
New ticket
|
New ticket
|
||||||
@@ -473,8 +533,10 @@ function TicketWorkbenchContent() {
|
|||||||
<StatPill label="needs review" value={metrics.stale} />
|
<StatPill label="needs review" value={metrics.stale} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2 xl:grid-cols-[minmax(260px,1fr)_auto_auto_auto]">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="relative">
|
{/* 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" />
|
<SearchIcon className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<input
|
<input
|
||||||
value={searchQuery}
|
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"
|
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>
|
||||||
|
{hasQueryFilters && (
|
||||||
<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">
|
<Button
|
||||||
{statusOptions.map((filter) => (
|
|
||||||
<button
|
|
||||||
key={filter.key}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setStatusFilter(filter.key)}
|
variant="outline"
|
||||||
className={cn(
|
size="sm"
|
||||||
"h-7 whitespace-nowrap rounded px-2.5 text-xs font-semibold transition-colors",
|
onClick={clearQueryFilters}
|
||||||
statusFilter === filter.key
|
className="h-9 shrink-0 border-border/80 bg-card/70"
|
||||||
? "bg-card text-foreground shadow-sm"
|
>
|
||||||
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSortKey(sortKey === "updated" ? "created" : sortKey === "created" ? "id" : "updated")}
|
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"
|
title="Change sort"
|
||||||
>
|
>
|
||||||
<ArrowDownAZIcon className="h-4 w-4" />
|
<ArrowDownAZIcon className="h-4 w-4" />
|
||||||
@@ -532,7 +568,7 @@ function TicketWorkbenchContent() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setDensity(density === "comfortable" ? "compact" : "comfortable")}
|
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"
|
title="Toggle density"
|
||||||
>
|
>
|
||||||
{density === "comfortable" ? (
|
{density === "comfortable" ? (
|
||||||
@@ -542,76 +578,230 @@ function TicketWorkbenchContent() {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: status quick-filter pills */}
|
||||||
|
<div className="flex min-w-0 items-center gap-1 overflow-x-auto rounded-md border border-border bg-muted/55 p-1 shadow-sm">
|
||||||
|
{statusOptions.map((opt) => {
|
||||||
|
const isActive = opt.key === "all"
|
||||||
|
? !filters.some((f) => f.field === "status")
|
||||||
|
: filters.some((f) => f.field === "status" && f.value === opt.key);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (opt.key === "all") {
|
||||||
|
setFilters((prev) => prev.filter((f) => f.field !== "status"));
|
||||||
|
} else {
|
||||||
|
setFilters((prev) => {
|
||||||
|
const rest = prev.filter((f) => f.field !== "status");
|
||||||
|
if (isActive) return rest;
|
||||||
|
return [...rest, {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
field: "status",
|
||||||
|
operator: "is",
|
||||||
|
value: opt.key,
|
||||||
|
label: buildFilterLabel("status", "is", opt.label),
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"h-7 whitespace-nowrap rounded px-2.5 text-xs font-semibold transition-colors",
|
||||||
|
isActive
|
||||||
|
? "bg-card text-foreground shadow-sm"
|
||||||
|
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2 xl:grid-cols-[minmax(180px,260px)_minmax(180px,240px)_minmax(180px,1fr)_auto]">
|
{/* Row 3: active filter chips + add filter */}
|
||||||
<select
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
value={ownerFilter}
|
{filters
|
||||||
onChange={(event) => setOwnerFilter(event.target.value)}
|
.filter((f) => f.field !== "status")
|
||||||
className="h-9 rounded-md border border-input bg-card/90 px-3 text-sm shadow-sm outline-none focus:border-ring"
|
.map((f) => (
|
||||||
aria-label="Owner filter"
|
<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>
|
{f.label}
|
||||||
<option value="unassigned">Unassigned</option>
|
<button
|
||||||
{users.map((user) => (
|
type="button"
|
||||||
<option key={user.id} value={user.id}>
|
onClick={() => setFilters((prev) => prev.filter((x) => x.id !== f.id))}
|
||||||
{user.username}
|
className="ml-0.5 inline-flex h-4 w-4 items-center justify-center rounded-sm text-muted-foreground hover:text-foreground"
|
||||||
</option>
|
>
|
||||||
|
<XIcon className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
))}
|
))}
|
||||||
</select>
|
<div className="relative">
|
||||||
|
<button
|
||||||
<select
|
type="button"
|
||||||
value={customFieldFilter}
|
onClick={() => {
|
||||||
onChange={(event) => {
|
const el = document.getElementById("add-filter-select");
|
||||||
setCustomFieldFilter(event.target.value);
|
el?.focus();
|
||||||
setCustomFieldValue("");
|
|
||||||
}}
|
}}
|
||||||
className="h-9 rounded-md border border-input bg-card/90 px-3 text-sm shadow-sm outline-none focus:border-ring"
|
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"
|
||||||
aria-label="Custom field filter"
|
|
||||||
>
|
>
|
||||||
<option value="none">Any field</option>
|
<PlusIcon className="h-3 w-3" />
|
||||||
{customFields.map((field) => (
|
Add filter
|
||||||
<option key={field.id} value={field.key}>
|
</button>
|
||||||
{field.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{selectedCustomFieldOptions.length > 0 ? (
|
|
||||||
<select
|
<select
|
||||||
value={customFieldValue}
|
id="add-filter-select"
|
||||||
onChange={(event) => setCustomFieldValue(event.target.value)}
|
value=""
|
||||||
disabled={customFieldFilter === "none"}
|
onChange={(event) => {
|
||||||
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"
|
const value = event.target.value;
|
||||||
aria-label="Custom field 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>
|
<option value="">Add filter...</option>
|
||||||
{selectedCustomFieldOptions.map((option) => (
|
<optgroup label="Queue">
|
||||||
<option key={option} value={option}>
|
{queues.map((q) => (
|
||||||
{option}
|
<option key={`q:${q.id}`} value={`queue:${q.id}`}>{q.name}</option>
|
||||||
</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>
|
</select>
|
||||||
) : (
|
) : (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
<input
|
<input
|
||||||
value={customFieldValue}
|
id={`cf-value-${f.id}`}
|
||||||
onChange={(event) => setCustomFieldValue(event.target.value)}
|
placeholder="value"
|
||||||
disabled={customFieldFilter === "none"}
|
className="h-7 w-32 rounded border border-input bg-card px-2 text-xs outline-none focus:border-ring"
|
||||||
placeholder={customFieldFilter === "none" ? "Select a field first" : "Field value"}
|
onKeyDown={(event) => {
|
||||||
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"
|
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"
|
type="button"
|
||||||
variant="outline"
|
onClick={() => setFilters((prev) => prev.filter((x) => x.id !== f.id))}
|
||||||
size="sm"
|
className="text-xs text-muted-foreground hover:text-foreground"
|
||||||
onClick={clearQueryFilters}
|
|
||||||
disabled={!hasQueryFilters}
|
|
||||||
className="h-9 border-border/80 bg-card/70"
|
|
||||||
>
|
>
|
||||||
Clear
|
cancel
|
||||||
</Button>
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -892,6 +1082,76 @@ function TicketWorkbenchContent() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import {
|
|||||||
PanelLeftIcon,
|
PanelLeftIcon,
|
||||||
CommandIcon,
|
CommandIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { getTickets, getQueues } from "@/lib/api";
|
import { getTickets, getQueues, getViews } from "@/lib/api";
|
||||||
import type { Queue } from "@/lib/types";
|
import type { Queue, SavedView } from "@/lib/types";
|
||||||
import { CommandPalette } from "@/components/command-palette";
|
import { CommandPalette } from "@/components/command-palette";
|
||||||
import { ThemeToggle } from "@/components/theme-toggle";
|
import { ThemeToggle } from "@/components/theme-toggle";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -86,6 +86,7 @@ function SidebarNav() {
|
|||||||
recent: 0,
|
recent: 0,
|
||||||
});
|
});
|
||||||
const [queues, setQueues] = useState<(Queue & { count: number })[]>([]);
|
const [queues, setQueues] = useState<(Queue & { count: number })[]>([]);
|
||||||
|
const [savedViews, setSavedViews] = useState<SavedView[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getTickets().then(({ data }) => {
|
getTickets().then(({ data }) => {
|
||||||
@@ -115,6 +116,10 @@ function SidebarNav() {
|
|||||||
).then(setQueues);
|
).then(setQueues);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
getViews().then(({ data }) => {
|
||||||
|
if (data) setSavedViews(data);
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const collapsed = useSidebarCollapsed();
|
const collapsed = useSidebarCollapsed();
|
||||||
@@ -198,6 +203,29 @@ function SidebarNav() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</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,
|
Queue,
|
||||||
User,
|
User,
|
||||||
Transaction,
|
Transaction,
|
||||||
|
SavedView,
|
||||||
Scrip,
|
Scrip,
|
||||||
Template,
|
Template,
|
||||||
TemplatePreview,
|
TemplatePreview,
|
||||||
@@ -230,3 +231,31 @@ export async function updateCustomField(id: string, data: {
|
|||||||
}): Promise<{ data: CustomField | null; error: string | null }> {
|
}): Promise<{ data: CustomField | null; error: string | null }> {
|
||||||
return request<CustomField>(`/custom-fields/${id}`, { method: "PATCH", body: JSON.stringify(data) });
|
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;
|
success: boolean;
|
||||||
message: string;
|
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