feat: add dashboards — tables, CRUD API, widget data endpoint
- New dashboards table (name, description, layout, is_default) - New dashboard_widgets table (view_id, title, widget_type, position, config) - GET/POST/PATCH/DELETE /dashboards - GET/POST/PATCH/DELETE /dashboards/:id/widgets - GET /dashboards/:id/widgets/:id/data — runs saved view filters, returns pre-aggregated data for count/ticket_list/status_chart/grouped_counts - is_default uniqueness enforced on PATCH Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
22
drizzle/migrations/0004_sturdy_natasha_romanoff.sql
Normal file
22
drizzle/migrations/0004_sturdy_natasha_romanoff.sql
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
CREATE TABLE "dashboard_widgets" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"dashboard_id" uuid NOT NULL,
|
||||||
|
"view_id" uuid NOT NULL,
|
||||||
|
"title" text NOT NULL,
|
||||||
|
"widget_type" text NOT NULL,
|
||||||
|
"position" jsonb DEFAULT '{"x":0,"y":0,"w":4,"h":2}',
|
||||||
|
"config" jsonb DEFAULT '{}',
|
||||||
|
"created_at" timestamp with time zone DEFAULT now()
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "dashboards" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"layout" jsonb DEFAULT '[]',
|
||||||
|
"is_default" boolean DEFAULT false,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now()
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "dashboard_widgets" ADD CONSTRAINT "dashboard_widgets_dashboard_id_dashboards_id_fk" FOREIGN KEY ("dashboard_id") REFERENCES "public"."dashboards"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "dashboard_widgets" ADD CONSTRAINT "dashboard_widgets_view_id_views_id_fk" FOREIGN KEY ("view_id") REFERENCES "public"."views"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
1156
drizzle/migrations/meta/0004_snapshot.json
Normal file
1156
drizzle/migrations/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,13 @@
|
|||||||
"when": 1780995910694,
|
"when": 1780995910694,
|
||||||
"tag": "0003_dry_caretaker",
|
"tag": "0003_dry_caretaker",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780996807814,
|
||||||
|
"tag": "0004_sturdy_natasha_romanoff",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -123,3 +123,23 @@ export const views = pgTable('views', {
|
|||||||
creator_id: uuid('creator_id').references(() => users.id),
|
creator_id: uuid('creator_id').references(() => users.id),
|
||||||
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const dashboards = pgTable('dashboards', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
description: text('description'),
|
||||||
|
layout: jsonb('layout').default('[]'),
|
||||||
|
is_default: boolean('is_default').default(false),
|
||||||
|
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const dashboardWidgets = pgTable('dashboard_widgets', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
dashboard_id: uuid('dashboard_id').notNull().references(() => dashboards.id, { onDelete: 'cascade' }),
|
||||||
|
view_id: uuid('view_id').notNull().references(() => views.id, { onDelete: 'cascade' }),
|
||||||
|
title: text('title').notNull(),
|
||||||
|
widget_type: text('widget_type').notNull(),
|
||||||
|
position: jsonb('position').default('{"x":0,"y":0,"w":4,"h":2}'),
|
||||||
|
config: jsonb('config').default('{}'),
|
||||||
|
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||||
|
});
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ 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';
|
import { createViewsRouter } from './routes/views.ts';
|
||||||
|
import { createDashboardsRouter } from './routes/dashboards.ts';
|
||||||
|
|
||||||
let db: Db | null = null;
|
let db: Db | null = null;
|
||||||
|
|
||||||
@@ -37,6 +38,7 @@ 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()));
|
app.route('/views', createViewsRouter(getDb()));
|
||||||
|
app.route('/dashboards', createDashboardsRouter(getDb()));
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
export { app };
|
export { app };
|
||||||
|
|||||||
385
src/routes/dashboards.ts
Normal file
385
src/routes/dashboards.ts
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { HTTPException } from 'hono/http-exception';
|
||||||
|
import { asc, eq } from 'drizzle-orm';
|
||||||
|
import type { Db } from '../db/index.ts';
|
||||||
|
import {
|
||||||
|
dashboards,
|
||||||
|
dashboardWidgets,
|
||||||
|
tickets,
|
||||||
|
customFieldValues,
|
||||||
|
customFields,
|
||||||
|
lifecycles,
|
||||||
|
queues,
|
||||||
|
views,
|
||||||
|
} from '../db/schema.ts';
|
||||||
|
|
||||||
|
function statusClass(def: { statuses: { initial: string[]; active: string[]; inactive: string[] } }, status: string): string {
|
||||||
|
if (def.statuses.initial.includes(status)) return 'initial';
|
||||||
|
if (def.statuses.active.includes(status)) return 'active';
|
||||||
|
if (def.statuses.inactive.includes(status)) return 'inactive';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDashboardsRouter(db: Db): Hono {
|
||||||
|
const router = new Hono();
|
||||||
|
|
||||||
|
// ── Dashboards CRUD ──
|
||||||
|
|
||||||
|
router.get('/', async (c) => {
|
||||||
|
const result = await db.query.dashboards.findMany({
|
||||||
|
orderBy: asc(dashboards.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 [dashboard] = await db.insert(dashboards).values({
|
||||||
|
name,
|
||||||
|
description: body.description ?? null,
|
||||||
|
layout: body.layout ?? [],
|
||||||
|
is_default: body.is_default ?? false,
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
if (!dashboard) {
|
||||||
|
throw new HTTPException(500, { message: 'Failed to create dashboard' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(dashboard, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:id', async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
const dashboard = await db.query.dashboards.findFirst({
|
||||||
|
where: eq(dashboards.id, id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!dashboard) {
|
||||||
|
throw new HTTPException(404, { message: 'Dashboard not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const widgets = await db.query.dashboardWidgets.findMany({
|
||||||
|
where: eq(dashboardWidgets.dashboard_id, id),
|
||||||
|
orderBy: asc(dashboardWidgets.created_at),
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({ ...dashboard, widgets });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch('/:id', async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
const body = await c.req.json();
|
||||||
|
|
||||||
|
const existing = await db.query.dashboards.findFirst({
|
||||||
|
where: eq(dashboards.id, id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new HTTPException(404, { message: 'Dashboard not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: Partial<typeof dashboards.$inferInsert> = {};
|
||||||
|
if (body.name !== undefined) updateData.name = String(body.name).trim();
|
||||||
|
if (body.description !== undefined) updateData.description = body.description ?? null;
|
||||||
|
if (body.layout !== undefined) updateData.layout = body.layout;
|
||||||
|
if (body.is_default !== undefined) {
|
||||||
|
updateData.is_default = body.is_default;
|
||||||
|
// If setting this as default, unset others
|
||||||
|
if (body.is_default) {
|
||||||
|
await db.update(dashboards)
|
||||||
|
.set({ is_default: false })
|
||||||
|
.where(eq(dashboards.is_default, true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await db.update(dashboards)
|
||||||
|
.set(updateData)
|
||||||
|
.where(eq(dashboards.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return c.json(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id', async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
const existing = await db.query.dashboards.findFirst({
|
||||||
|
where: eq(dashboards.id, id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new HTTPException(404, { message: 'Dashboard not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(dashboards).where(eq(dashboards.id, id));
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Widgets CRUD ──
|
||||||
|
|
||||||
|
router.get('/:id/widgets', async (c) => {
|
||||||
|
const dashboardId = c.req.param('id');
|
||||||
|
const result = await db.query.dashboardWidgets.findMany({
|
||||||
|
where: eq(dashboardWidgets.dashboard_id, dashboardId),
|
||||||
|
orderBy: asc(dashboardWidgets.created_at),
|
||||||
|
});
|
||||||
|
return c.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/widgets', async (c) => {
|
||||||
|
const dashboardId = c.req.param('id');
|
||||||
|
const dashboard = await db.query.dashboards.findFirst({
|
||||||
|
where: eq(dashboards.id, dashboardId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!dashboard) {
|
||||||
|
throw new HTTPException(404, { message: 'Dashboard not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await c.req.json();
|
||||||
|
const title = String(body.title ?? 'Widget').trim();
|
||||||
|
const widgetType = String(body.widget_type ?? 'count').trim();
|
||||||
|
const viewId = String(body.view_id ?? '').trim();
|
||||||
|
|
||||||
|
if (!viewId) {
|
||||||
|
throw new HTTPException(400, { message: 'view_id is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [widget] = await db.insert(dashboardWidgets).values({
|
||||||
|
dashboard_id: dashboardId,
|
||||||
|
view_id: viewId,
|
||||||
|
title,
|
||||||
|
widget_type: widgetType,
|
||||||
|
position: body.position ?? { x: 0, y: 0, w: 4, h: 2 },
|
||||||
|
config: body.config ?? {},
|
||||||
|
}).returning();
|
||||||
|
|
||||||
|
if (!widget) {
|
||||||
|
throw new HTTPException(500, { message: 'Failed to create widget' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(widget, 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch('/:id/widgets/:widgetId', async (c) => {
|
||||||
|
const widgetId = c.req.param('widgetId');
|
||||||
|
const body = await c.req.json();
|
||||||
|
|
||||||
|
const existing = await db.query.dashboardWidgets.findFirst({
|
||||||
|
where: eq(dashboardWidgets.id, widgetId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new HTTPException(404, { message: 'Widget not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: Partial<typeof dashboardWidgets.$inferInsert> = {};
|
||||||
|
if (body.title !== undefined) updateData.title = String(body.title).trim();
|
||||||
|
if (body.widget_type !== undefined) updateData.widget_type = String(body.widget_type);
|
||||||
|
if (body.position !== undefined) updateData.position = body.position;
|
||||||
|
if (body.config !== undefined) updateData.config = body.config;
|
||||||
|
|
||||||
|
const [updated] = await db.update(dashboardWidgets)
|
||||||
|
.set(updateData)
|
||||||
|
.where(eq(dashboardWidgets.id, widgetId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return c.json(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id/widgets/:widgetId', async (c) => {
|
||||||
|
const widgetId = c.req.param('widgetId');
|
||||||
|
const existing = await db.query.dashboardWidgets.findFirst({
|
||||||
|
where: eq(dashboardWidgets.id, widgetId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new HTTPException(404, { message: 'Widget not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(dashboardWidgets).where(eq(dashboardWidgets.id, widgetId));
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Widget data endpoint ──
|
||||||
|
|
||||||
|
router.get('/:id/widgets/:widgetId/data', async (c) => {
|
||||||
|
const widgetId = c.req.param('widgetId');
|
||||||
|
|
||||||
|
const widget = await db.query.dashboardWidgets.findFirst({
|
||||||
|
where: eq(dashboardWidgets.id, widgetId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!widget) {
|
||||||
|
throw new HTTPException(404, { message: 'Widget not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const view = await db.query.views.findFirst({
|
||||||
|
where: eq(views.id, widget.view_id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!view) {
|
||||||
|
return c.json({ error: 'View not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply saved view filters
|
||||||
|
const savedFilters = (view.filters ?? []) as { field: string; operator: string; value: string }[];
|
||||||
|
let result = await db.query.tickets.findMany({
|
||||||
|
orderBy: asc(tickets.created_at),
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const f of savedFilters) {
|
||||||
|
if (f.field === 'status') {
|
||||||
|
result = result.filter((t) => t.status === f.value);
|
||||||
|
} else if (f.field === 'queue') {
|
||||||
|
result = result.filter((t) => t.queue_id === f.value);
|
||||||
|
} else if (f.field === 'owner') {
|
||||||
|
result = f.value === 'unassigned'
|
||||||
|
? result.filter((t) => !t.owner_id)
|
||||||
|
: result.filter((t) => t.owner_id === f.value);
|
||||||
|
} else if (f.field.startsWith('cf.')) {
|
||||||
|
const cfKey = f.field.slice(3);
|
||||||
|
const ticketIds = result.map((t) => t.id);
|
||||||
|
if (ticketIds.length > 0) {
|
||||||
|
const cfValues = await db.query.customFieldValues.findMany({
|
||||||
|
where: (table, { and, inArray, eq }) =>
|
||||||
|
and(
|
||||||
|
inArray(table.ticket_id, ticketIds),
|
||||||
|
eq(table.value, f.value),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const matchingIds = new Set(cfValues.map((v) => v.ticket_id));
|
||||||
|
// Also find the field ID for the key
|
||||||
|
const cfField = await db.query.customFields.findFirst({
|
||||||
|
where: eq(customFields.key, cfKey),
|
||||||
|
});
|
||||||
|
if (cfField) {
|
||||||
|
const cfValuesForField = await db.query.customFieldValues.findMany({
|
||||||
|
where: (table, { and, inArray, eq }) =>
|
||||||
|
and(
|
||||||
|
inArray(table.ticket_id, ticketIds),
|
||||||
|
eq(table.custom_field_id, cfField.id),
|
||||||
|
eq(table.value, f.value),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const matchSet = new Set(cfValuesForField.map((v) => v.ticket_id));
|
||||||
|
result = result.filter((t) => matchSet.has(t.id));
|
||||||
|
} else {
|
||||||
|
result = result.filter((t) => matchingIds.has(t.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = (widget.config as Record<string, unknown>)?.limit as number ?? 5;
|
||||||
|
|
||||||
|
// Find lifecycle for status classification
|
||||||
|
const queueIds = [...new Set(result.map((r) => r.queue_id))];
|
||||||
|
const queueRecords = queueIds.length > 0
|
||||||
|
? await db.query.queues.findMany({
|
||||||
|
where: (table, { inArray }) => inArray(table.id, queueIds),
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const lifecycleIds = [...new Set(queueRecords.map((q) => q.lifecycle_id).filter(Boolean))] as string[];
|
||||||
|
const lifecycleRecords = lifecycleIds.length > 0
|
||||||
|
? await db.query.lifecycles.findMany({
|
||||||
|
where: (table, { inArray }) => inArray(table.id, lifecycleIds),
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const lifecycleByQueue = new Map<string, { statuses: { initial: string[]; active: string[]; inactive: string[] } }>();
|
||||||
|
for (const qr of queueRecords) {
|
||||||
|
if (qr.lifecycle_id) {
|
||||||
|
const lc = lifecycleRecords.find((l) => l.id === qr.lifecycle_id);
|
||||||
|
if (lc) lifecycleByQueue.set(qr.id, lc.definition as any);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get owner usernames
|
||||||
|
const ownerIds = [...new Set(result.map((t) => t.owner_id).filter(Boolean))] as string[];
|
||||||
|
const ownerUsers = ownerIds.length > 0
|
||||||
|
? await db.query.users.findMany({
|
||||||
|
where: (table, { inArray }) => inArray(table.id, ownerIds),
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const ownerName = new Map(ownerUsers.map((u) => [u.id, u.username]));
|
||||||
|
|
||||||
|
// Get queue names
|
||||||
|
const queueName = new Map(queueRecords.map((q) => [q.id, q.name]));
|
||||||
|
|
||||||
|
switch (widget.widget_type) {
|
||||||
|
case 'count': {
|
||||||
|
return c.json({ type: 'count', total: result.length, title: widget.title, view_id: view.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ticket_list': {
|
||||||
|
const slice = result.slice(0, limit).map((ticket) => ({
|
||||||
|
id: ticket.id,
|
||||||
|
subject: ticket.subject,
|
||||||
|
status: ticket.status,
|
||||||
|
owner_id: ticket.owner_id,
|
||||||
|
owner_name: ticket.owner_id ? ownerName.get(ticket.owner_id) ?? null : null,
|
||||||
|
queue_name: queueName.get(ticket.queue_id) ?? ticket.queue_id.slice(0, 8),
|
||||||
|
updated_at: ticket.updated_at?.toISOString(),
|
||||||
|
}));
|
||||||
|
return c.json({ type: 'ticket_list', tickets: slice, total: result.length, title: widget.title, view_id: view.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'status_chart': {
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
for (const ticket of result) {
|
||||||
|
counts[ticket.status] = (counts[ticket.status] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
return c.json({ type: 'status_chart', counts, total: result.length, title: widget.title, view_id: view.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'grouped_counts': {
|
||||||
|
const groupBy = (widget.config as Record<string, unknown>)?.group_by as string ?? 'owner';
|
||||||
|
const groups: Record<string, number> = {};
|
||||||
|
|
||||||
|
if (groupBy === 'owner') {
|
||||||
|
for (const ticket of result) {
|
||||||
|
const label = ticket.owner_id
|
||||||
|
? (ownerName.get(ticket.owner_id) ?? ticket.owner_id.slice(0, 8))
|
||||||
|
: 'Unassigned';
|
||||||
|
groups[label] = (groups[label] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
} else if (groupBy === 'queue') {
|
||||||
|
for (const ticket of result) {
|
||||||
|
const label = queueName.get(ticket.queue_id) ?? ticket.queue_id.slice(0, 8);
|
||||||
|
groups[label] = (groups[label] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
} else if (groupBy.startsWith('cf.')) {
|
||||||
|
const cfKey = groupBy.slice(3);
|
||||||
|
const cfField = await db.query.customFields.findFirst({
|
||||||
|
where: eq(customFields.key, cfKey),
|
||||||
|
});
|
||||||
|
if (cfField) {
|
||||||
|
const ticketIds = result.map((t) => t.id);
|
||||||
|
const cfValues = ticketIds.length > 0
|
||||||
|
? await db.query.customFieldValues.findMany({
|
||||||
|
where: (table, { and, inArray, eq }) =>
|
||||||
|
and(
|
||||||
|
inArray(table.ticket_id, ticketIds),
|
||||||
|
eq(table.custom_field_id, cfField.id),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
for (const v of cfValues) {
|
||||||
|
groups[v.value] = (groups[v.value] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c.json({ type: 'grouped_counts', groups, total: result.length, group_by: groupBy, title: widget.title, view_id: view.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return c.json({ type: 'count', total: result.length, title: widget.title, view_id: view.id });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
319
web/src/app/dashboards/[id]/page.tsx
Normal file
319
web/src/app/dashboards/[id]/page.tsx
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, use, useCallback } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
PlusIcon,
|
||||||
|
Trash2Icon,
|
||||||
|
RefreshCwIcon,
|
||||||
|
GaugeIcon,
|
||||||
|
LayoutGridIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
getDashboard,
|
||||||
|
createWidget,
|
||||||
|
deleteWidget,
|
||||||
|
getWidgetData,
|
||||||
|
getViews,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import type {
|
||||||
|
Dashboard,
|
||||||
|
DashboardWidget,
|
||||||
|
SavedView,
|
||||||
|
WidgetData,
|
||||||
|
} from "@/lib/types";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { CountWidget } from "@/components/widgets/count-widget";
|
||||||
|
import { TicketListWidget } from "@/components/widgets/ticket-list-widget";
|
||||||
|
import { StatusChartWidget } from "@/components/widgets/status-chart-widget";
|
||||||
|
import { GroupedCountsWidget } from "@/components/widgets/grouped-counts-widget";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function widgetGridStyle(position: { x: number; y: number; w: number; h: number }) {
|
||||||
|
return {
|
||||||
|
gridColumn: `${position.x + 1} / span ${position.w}`,
|
||||||
|
gridRow: `${position.y + 1} / span ${position.h}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = use(params);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [dashboard, setDashboard] = useState<Dashboard | null>(null);
|
||||||
|
const [widgets, setWidgets] = useState<(DashboardWidget & { data?: WidgetData })[]>([]);
|
||||||
|
const [views, setViews] = useState<SavedView[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Add widget dialog
|
||||||
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
|
const [addViewId, setAddViewId] = useState("");
|
||||||
|
const [addTitle, setAddTitle] = useState("");
|
||||||
|
const [addType, setAddType] = useState("count");
|
||||||
|
const [addGroupBy, setAddGroupBy] = useState("owner");
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
|
||||||
|
const fetchDashboard = useCallback(async () => {
|
||||||
|
const { data, error } = await getDashboard(id);
|
||||||
|
if (error || !data) {
|
||||||
|
setError(error ?? "Dashboard not found");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDashboard(data);
|
||||||
|
const widgetList = data.widgets ?? [];
|
||||||
|
setWidgets(widgetList);
|
||||||
|
|
||||||
|
// Fetch data for each widget
|
||||||
|
for (const widget of widgetList) {
|
||||||
|
const { data: wData } = await getWidgetData(id, widget.id);
|
||||||
|
if (wData) {
|
||||||
|
setWidgets((prev) =>
|
||||||
|
prev.map((w) => (w.id === widget.id ? { ...w, data: wData } : w))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchDashboard();
|
||||||
|
getViews().then(({ data }) => {
|
||||||
|
if (data) setViews(data);
|
||||||
|
});
|
||||||
|
}, [fetchDashboard]);
|
||||||
|
|
||||||
|
const handleAddWidget = async () => {
|
||||||
|
if (!addViewId || !addTitle.trim()) return;
|
||||||
|
setAdding(true);
|
||||||
|
const pos = { x: 0, y: widgets.length, w: 4, h: 2 };
|
||||||
|
const config = addType === "grouped_counts" ? { group_by: addGroupBy } : {};
|
||||||
|
const { data, error } = await createWidget(id, {
|
||||||
|
view_id: addViewId,
|
||||||
|
title: addTitle.trim(),
|
||||||
|
widget_type: addType,
|
||||||
|
position: pos,
|
||||||
|
config,
|
||||||
|
});
|
||||||
|
if (!error && data) {
|
||||||
|
setWidgets((prev) => [...prev, data]);
|
||||||
|
const { data: wData } = await getWidgetData(id, data.id);
|
||||||
|
if (wData) {
|
||||||
|
setWidgets((prev) => prev.map((w) => (w.id === data.id ? { ...w, data: wData } : w)));
|
||||||
|
}
|
||||||
|
setAddOpen(false);
|
||||||
|
setAddViewId("");
|
||||||
|
setAddTitle("");
|
||||||
|
setAddType("count");
|
||||||
|
}
|
||||||
|
setAdding(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteWidget = async (widgetId: string) => {
|
||||||
|
await deleteWidget(id, widgetId);
|
||||||
|
setWidgets((prev) => prev.filter((w) => w.id !== widgetId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderWidget = (widget: DashboardWidget & { data?: WidgetData }) => {
|
||||||
|
if (!widget.data) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center rounded-lg border border-border bg-card p-4">
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (widget.data.type) {
|
||||||
|
case "count":
|
||||||
|
return <CountWidget data={widget.data} />;
|
||||||
|
case "ticket_list":
|
||||||
|
return <TicketListWidget data={widget.data} />;
|
||||||
|
case "status_chart":
|
||||||
|
return <StatusChartWidget data={widget.data} />;
|
||||||
|
case "grouped_counts":
|
||||||
|
return <GroupedCountsWidget data={widget.data} />;
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center rounded-lg border border-border bg-card p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">Unknown type: {widget.data.type}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !dashboard) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-4">
|
||||||
|
<p className="text-sm text-muted-foreground">{error ?? "Dashboard not found"}</p>
|
||||||
|
<Link href="/" className="text-sm text-primary hover:underline">
|
||||||
|
Go to ticket list
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col bg-background/80">
|
||||||
|
<header className="shrink-0 border-b border-border bg-card/82 backdrop-blur">
|
||||||
|
<div className="flex items-center justify-between px-5 py-3 lg:px-6">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase text-muted-foreground">
|
||||||
|
<LayoutGridIcon className="h-3.5 w-3.5" />
|
||||||
|
Dashboard
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-1 text-xl font-semibold text-foreground">{dashboard.name}</h1>
|
||||||
|
{dashboard.description && (
|
||||||
|
<p className="mt-0.5 text-sm text-muted-foreground">{dashboard.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={fetchDashboard}
|
||||||
|
className="h-8 border-border/80 bg-card/70"
|
||||||
|
>
|
||||||
|
<RefreshCwIcon className="h-4 w-4" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={() => setAddOpen(true)} className="h-8 bg-primary shadow-sm">
|
||||||
|
<PlusIcon className="h-4 w-4" />
|
||||||
|
Add widget
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto p-5 lg:p-6">
|
||||||
|
{widgets.length === 0 ? (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-3">
|
||||||
|
<LayoutGridIcon className="h-10 w-10 text-muted-foreground/40" />
|
||||||
|
<p className="text-sm text-muted-foreground">No widgets yet</p>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setAddOpen(true)}>
|
||||||
|
<PlusIcon className="h-4 w-4" />
|
||||||
|
Add your first widget
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid auto-rows-[minmax(120px,auto)] grid-cols-12 gap-4">
|
||||||
|
{widgets.map((widget) => (
|
||||||
|
<div
|
||||||
|
key={widget.id}
|
||||||
|
className="group relative"
|
||||||
|
style={widgetGridStyle(widget.position)}
|
||||||
|
>
|
||||||
|
{renderWidget(widget)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDeleteWidget(widget.id)}
|
||||||
|
className="absolute right-2 top-2 hidden h-6 w-6 items-center justify-center rounded bg-destructive/90 text-destructive-foreground transition-opacity hover:bg-destructive group-hover:flex"
|
||||||
|
title="Remove widget"
|
||||||
|
>
|
||||||
|
<Trash2Icon className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={addOpen} onOpenChange={setAddOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add widget</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Choose a saved view and widget type to add to this dashboard.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">Widget title</label>
|
||||||
|
<input
|
||||||
|
value={addTitle}
|
||||||
|
onChange={(e) => setAddTitle(e.target.value)}
|
||||||
|
placeholder="e.g. Open tickets"
|
||||||
|
className="mt-1 h-9 w-full rounded-md border border-input bg-card px-3 text-sm outline-none focus:border-ring"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">Saved view</label>
|
||||||
|
<select
|
||||||
|
value={addViewId}
|
||||||
|
onChange={(e) => {
|
||||||
|
setAddViewId(e.target.value);
|
||||||
|
const view = views.find((v) => v.id === e.target.value);
|
||||||
|
if (view && !addTitle) setAddTitle(view.name);
|
||||||
|
}}
|
||||||
|
className="mt-1 h-9 w-full rounded-md border border-input bg-card px-3 text-sm outline-none focus:border-ring"
|
||||||
|
>
|
||||||
|
<option value="">Select a view...</option>
|
||||||
|
{views.map((v) => (
|
||||||
|
<option key={v.id} value={v.id}>
|
||||||
|
{v.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">Widget type</label>
|
||||||
|
<select
|
||||||
|
value={addType}
|
||||||
|
onChange={(e) => setAddType(e.target.value)}
|
||||||
|
className="mt-1 h-9 w-full rounded-md border border-input bg-card px-3 text-sm outline-none focus:border-ring"
|
||||||
|
>
|
||||||
|
<option value="count">Count (big number)</option>
|
||||||
|
<option value="ticket_list">Ticket list (mini table)</option>
|
||||||
|
<option value="status_chart">Status chart (donut)</option>
|
||||||
|
<option value="grouped_counts">Grouped counts (bar chart)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{addType === "grouped_counts" && (
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">Group by</label>
|
||||||
|
<select
|
||||||
|
value={addGroupBy}
|
||||||
|
onChange={(e) => setAddGroupBy(e.target.value)}
|
||||||
|
className="mt-1 h-9 w-full rounded-md border border-input bg-card px-3 text-sm outline-none focus:border-ring"
|
||||||
|
>
|
||||||
|
<option value="owner">Owner</option>
|
||||||
|
<option value="queue">Queue</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setAddOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={!addViewId || !addTitle.trim() || adding}
|
||||||
|
onClick={handleAddWidget}
|
||||||
|
>
|
||||||
|
{adding ? "Adding..." : "Add widget"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
XIcon,
|
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, getViews, createView, deleteView } from "@/lib/api";
|
import { createTicket, getCustomFields, getLifecycles, getQueueCustomFields, getQueues, getTickets, getUsers, getViews, createView, deleteView, getDashboards } from "@/lib/api";
|
||||||
import type { CustomField, Lifecycle, Queue, QueueCustomField, SavedView, 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 {
|
||||||
@@ -158,6 +158,7 @@ function TicketWorkbenchContent() {
|
|||||||
const [viewIdFromUrl, setViewIdFromUrl] = useState<string | null>(null);
|
const [viewIdFromUrl, setViewIdFromUrl] = useState<string | null>(null);
|
||||||
const [saveViewOpen, setSaveViewOpen] = useState(false);
|
const [saveViewOpen, setSaveViewOpen] = useState(false);
|
||||||
const [saveViewName, setSaveViewName] = useState("");
|
const [saveViewName, setSaveViewName] = useState("");
|
||||||
|
const [addFilterOpen, setAddFilterOpen] = useState(false);
|
||||||
|
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [newSubject, setNewSubject] = useState("");
|
const [newSubject, setNewSubject] = useState("");
|
||||||
@@ -253,6 +254,15 @@ function TicketWorkbenchContent() {
|
|||||||
void Promise.resolve().then(() => fetchData());
|
void Promise.resolve().then(() => fetchData());
|
||||||
}, [fetchData]);
|
}, [fetchData]);
|
||||||
|
|
||||||
|
// Redirect to default dashboard if one exists and no params set
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchParams.toString()) return;
|
||||||
|
getDashboards().then(({ data }) => {
|
||||||
|
const def = data?.find((d) => d.is_default);
|
||||||
|
if (def) router.replace(`/dashboards/${def.id}`);
|
||||||
|
});
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (searchParams.get("new") === "true") {
|
if (searchParams.get("new") === "true") {
|
||||||
queueMicrotask(() => setDialogOpen(true));
|
queueMicrotask(() => setDialogOpen(true));
|
||||||
@@ -315,6 +325,7 @@ function TicketWorkbenchContent() {
|
|||||||
getViews().then(({ data }) => {
|
getViews().then(({ data }) => {
|
||||||
const view = data?.find((v) => v.id === paramViewId);
|
const view = data?.find((v) => v.id === paramViewId);
|
||||||
if (view?.filters && Array.isArray(view.filters)) {
|
if (view?.filters && Array.isArray(view.filters)) {
|
||||||
|
setSearchQuery("");
|
||||||
setFilters(
|
setFilters(
|
||||||
(view.filters as { field: string; operator: string; value: string }[])
|
(view.filters as { field: string; operator: string; value: string }[])
|
||||||
.filter((f) => f.field && f.value)
|
.filter((f) => f.field && f.value)
|
||||||
@@ -329,6 +340,10 @@ function TicketWorkbenchContent() {
|
|||||||
if (view.sort_key) setSortKey(view.sort_key as SortKey);
|
if (view.sort_key) setSortKey(view.sort_key as SortKey);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
} else if (!paramViewId && viewIdFromUrl) {
|
||||||
|
// User navigated away from a view — clear filters
|
||||||
|
setFilters([]);
|
||||||
|
setSearchQuery("");
|
||||||
}
|
}
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
@@ -641,98 +656,112 @@ function TicketWorkbenchContent() {
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => setAddFilterOpen((prev) => !prev)}
|
||||||
const el = document.getElementById("add-filter-select");
|
|
||||||
el?.focus();
|
|
||||||
}}
|
|
||||||
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"
|
className="inline-flex h-7 items-center gap-1 rounded border border-dashed border-border px-2 text-xs font-medium text-muted-foreground hover:border-ring hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
<PlusIcon className="h-3 w-3" />
|
<PlusIcon className="h-3 w-3" />
|
||||||
Add filter
|
Add filter
|
||||||
</button>
|
</button>
|
||||||
<select
|
{addFilterOpen && (
|
||||||
id="add-filter-select"
|
<>
|
||||||
value=""
|
<div className="fixed inset-0 z-10" onClick={() => setAddFilterOpen(false)} />
|
||||||
onChange={(event) => {
|
<div className="absolute left-0 top-full z-20 mt-1 w-52 rounded-md border border-border bg-card p-1 shadow-lg">
|
||||||
const value = event.target.value;
|
<div className="px-2 py-1.5 text-[10px] font-semibold uppercase text-muted-foreground">Queue</div>
|
||||||
if (!value) return;
|
{queues.map((q) => (
|
||||||
const [fieldType, fieldKey] = value.split(":");
|
<button
|
||||||
const existing = filters.find((f) => f.field === (fieldType === "cf" ? `cf.${fieldKey}` : fieldType));
|
key={`q:${q.id}`}
|
||||||
if (existing) return;
|
type="button"
|
||||||
|
className="w-full rounded px-2 py-1.5 text-left text-xs text-foreground hover:bg-accent"
|
||||||
if (fieldType === "queue") {
|
onClick={() => {
|
||||||
const q = queues.find((x) => x.id === fieldKey);
|
if (!filters.find((f) => f.field === "queue")) {
|
||||||
if (q) {
|
setFilters((prev) => [...prev, {
|
||||||
setFilters((prev) => [...prev, {
|
id: crypto.randomUUID(),
|
||||||
id: crypto.randomUUID(),
|
field: "queue",
|
||||||
field: "queue",
|
operator: "is",
|
||||||
operator: "is",
|
value: q.id,
|
||||||
value: fieldKey,
|
label: buildFilterLabel("queue", "is", q.name),
|
||||||
label: buildFilterLabel("queue", "is", q.name),
|
}]);
|
||||||
}]);
|
}
|
||||||
}
|
setAddFilterOpen(false);
|
||||||
} else if (fieldType === "owner") {
|
}}
|
||||||
if (fieldKey === "unassigned") {
|
>
|
||||||
setFilters((prev) => [...prev, {
|
{q.name}
|
||||||
id: crypto.randomUUID(),
|
</button>
|
||||||
field: "owner",
|
))}
|
||||||
operator: "is",
|
<div className="mt-0.5 border-t border-border pt-0.5" />
|
||||||
value: "unassigned",
|
<div className="px-2 py-1.5 text-[10px] font-semibold uppercase text-muted-foreground">Owner</div>
|
||||||
label: buildFilterLabel("owner", "is", "Unassigned"),
|
<button
|
||||||
}]);
|
type="button"
|
||||||
} else {
|
className="w-full rounded px-2 py-1.5 text-left text-xs text-foreground hover:bg-accent"
|
||||||
const u = users.find((x) => x.id === fieldKey);
|
onClick={() => {
|
||||||
if (u) {
|
if (!filters.find((f) => f.field === "owner")) {
|
||||||
setFilters((prev) => [...prev, {
|
setFilters((prev) => [...prev, {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
field: "owner",
|
field: "owner",
|
||||||
operator: "is",
|
operator: "is",
|
||||||
value: fieldKey,
|
value: "unassigned",
|
||||||
label: buildFilterLabel("owner", "is", u.username),
|
label: buildFilterLabel("owner", "is", "Unassigned"),
|
||||||
}]);
|
}]);
|
||||||
}
|
}
|
||||||
}
|
setAddFilterOpen(false);
|
||||||
} else if (fieldType === "cf") {
|
}}
|
||||||
const cf = customFields.find((x) => x.key === fieldKey);
|
>
|
||||||
if (cf) {
|
Unassigned
|
||||||
const cfFilter: Filter = {
|
</button>
|
||||||
id: crypto.randomUUID(),
|
{users.map((u) => (
|
||||||
field: `cf.${fieldKey}`,
|
<button
|
||||||
operator: "is",
|
key={`o:${u.id}`}
|
||||||
value: "",
|
type="button"
|
||||||
label: `${cf.name} is ...`,
|
className="w-full rounded px-2 py-1.5 text-left text-xs text-foreground hover:bg-accent"
|
||||||
};
|
onClick={() => {
|
||||||
setFilters((prev) => [...prev, cfFilter]);
|
if (!filters.find((f) => f.field === "owner")) {
|
||||||
// Focus value input after adding
|
setFilters((prev) => [...prev, {
|
||||||
setTimeout(() => {
|
id: crypto.randomUUID(),
|
||||||
const input = document.getElementById(`cf-value-${cfFilter.id}`) as HTMLInputElement;
|
field: "owner",
|
||||||
input?.focus();
|
operator: "is",
|
||||||
}, 50);
|
value: u.id,
|
||||||
}
|
label: buildFilterLabel("owner", "is", u.username),
|
||||||
}
|
}]);
|
||||||
event.target.value = "";
|
}
|
||||||
}}
|
setAddFilterOpen(false);
|
||||||
className="sr-only"
|
}}
|
||||||
aria-label="Add filter"
|
>
|
||||||
>
|
{u.username}
|
||||||
<option value="">Add filter...</option>
|
</button>
|
||||||
<optgroup label="Queue">
|
))}
|
||||||
{queues.map((q) => (
|
{customFields.length > 0 && (
|
||||||
<option key={`q:${q.id}`} value={`queue:${q.id}`}>{q.name}</option>
|
<>
|
||||||
))}
|
<div className="mt-0.5 border-t border-border pt-0.5" />
|
||||||
</optgroup>
|
<div className="px-2 py-1.5 text-[10px] font-semibold uppercase text-muted-foreground">Custom field</div>
|
||||||
<optgroup label="Owner">
|
{customFields.map((cf) => (
|
||||||
<option value="owner:unassigned">Unassigned</option>
|
<button
|
||||||
{users.map((u) => (
|
key={`cf:${cf.id}`}
|
||||||
<option key={`o:${u.id}`} value={`owner:${u.id}`}>{u.username}</option>
|
type="button"
|
||||||
))}
|
className="w-full rounded px-2 py-1.5 text-left text-xs text-foreground hover:bg-accent"
|
||||||
</optgroup>
|
onClick={() => {
|
||||||
<optgroup label="Custom field">
|
const cfFilter: Filter = {
|
||||||
{customFields.map((cf) => (
|
id: crypto.randomUUID(),
|
||||||
<option key={`cf:${cf.id}`} value={`cf:${cf.key}`}>{cf.name}</option>
|
field: `cf.${cf.key}`,
|
||||||
))}
|
operator: "is",
|
||||||
</optgroup>
|
value: "",
|
||||||
</select>
|
label: `${cf.name} is ...`,
|
||||||
|
};
|
||||||
|
setFilters((prev) => [...prev, cfFilter]);
|
||||||
|
setAddFilterOpen(false);
|
||||||
|
setTimeout(() => {
|
||||||
|
const input = document.getElementById(`cf-value-${cfFilter.id}`) as HTMLInputElement;
|
||||||
|
input?.focus();
|
||||||
|
}, 50);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cf.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import {
|
|||||||
PanelLeftIcon,
|
PanelLeftIcon,
|
||||||
CommandIcon,
|
CommandIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { getTickets, getQueues, getViews } from "@/lib/api";
|
import { getTickets, getQueues, getViews, getDashboards } from "@/lib/api";
|
||||||
import type { Queue, SavedView } from "@/lib/types";
|
import type { Dashboard, 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";
|
||||||
@@ -87,6 +87,7 @@ function SidebarNav() {
|
|||||||
});
|
});
|
||||||
const [queues, setQueues] = useState<(Queue & { count: number })[]>([]);
|
const [queues, setQueues] = useState<(Queue & { count: number })[]>([]);
|
||||||
const [savedViews, setSavedViews] = useState<SavedView[]>([]);
|
const [savedViews, setSavedViews] = useState<SavedView[]>([]);
|
||||||
|
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getTickets().then(({ data }) => {
|
getTickets().then(({ data }) => {
|
||||||
@@ -120,6 +121,10 @@ function SidebarNav() {
|
|||||||
getViews().then(({ data }) => {
|
getViews().then(({ data }) => {
|
||||||
if (data) setSavedViews(data);
|
if (data) setSavedViews(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
getDashboards().then(({ data }) => {
|
||||||
|
if (data) setDashboards(data);
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const collapsed = useSidebarCollapsed();
|
const collapsed = useSidebarCollapsed();
|
||||||
@@ -204,6 +209,29 @@ function SidebarNav() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{dashboards.length > 0 && (
|
||||||
|
<div className="mt-4">
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="px-2 py-1.5 text-[11px] font-semibold text-sidebar-foreground/45 uppercase">
|
||||||
|
Dashboards
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{dashboards.map((dash) => {
|
||||||
|
const active =
|
||||||
|
pathname.startsWith("/dashboards/") && pathname.endsWith(dash.id);
|
||||||
|
return (
|
||||||
|
<SidebarNavItem
|
||||||
|
key={dash.id}
|
||||||
|
href={`/dashboards/${dash.id}`}
|
||||||
|
icon={LayoutGridIcon}
|
||||||
|
label={dash.name}
|
||||||
|
active={active}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{savedViews.length > 0 && (
|
{savedViews.length > 0 && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
|
|||||||
21
web/src/components/widgets/count-widget.tsx
Normal file
21
web/src/components/widgets/count-widget.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { WidgetData } from "@/lib/types";
|
||||||
|
|
||||||
|
export function CountWidget({ data }: { data: WidgetData }) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (data.view_id) params.set("view_id", data.view_id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/?${params.toString()}`}
|
||||||
|
className="flex h-full flex-col items-center justify-center rounded-lg border border-border bg-card p-4 transition-colors hover:border-ring/50 hover:bg-accent/30"
|
||||||
|
>
|
||||||
|
<span className="text-3xl font-bold tabular-nums text-foreground">
|
||||||
|
{data.total}
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 text-sm text-muted-foreground">{data.title}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
web/src/components/widgets/grouped-counts-widget.tsx
Normal file
39
web/src/components/widgets/grouped-counts-widget.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { WidgetData } from "@/lib/types";
|
||||||
|
|
||||||
|
export function GroupedCountsWidget({ data }: { data: WidgetData }) {
|
||||||
|
const groups = data.groups ?? {};
|
||||||
|
const entries = Object.entries(groups).sort(([, a], [, b]) => b - a);
|
||||||
|
const max = entries.length > 0 ? Math.max(...entries.map(([, c]) => c)) : 1;
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center rounded-lg border border-border bg-card p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">No data</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col overflow-hidden rounded-lg border border-border bg-card">
|
||||||
|
<div className="border-b border-border px-3 py-2">
|
||||||
|
<span className="text-xs font-semibold text-foreground">{data.title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-1.5 overflow-auto p-3">
|
||||||
|
{entries.map(([label, count]) => (
|
||||||
|
<div key={label} className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="w-20 shrink-0 truncate text-foreground">{label}</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div
|
||||||
|
className="h-2.5 rounded-sm bg-primary/60 transition-all"
|
||||||
|
style={{ width: `${Math.round((count / max) * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="w-8 shrink-0 text-right tabular-nums text-muted-foreground">{count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
web/src/components/widgets/status-chart-widget.tsx
Normal file
76
web/src/components/widgets/status-chart-widget.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { WidgetData } from "@/lib/types";
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
new: "#64748b",
|
||||||
|
open: "#2563eb",
|
||||||
|
in_progress: "#d97706",
|
||||||
|
resolved: "#16a34a",
|
||||||
|
closed: "#71717a",
|
||||||
|
};
|
||||||
|
|
||||||
|
function statusLabel(status: string) {
|
||||||
|
return status.replaceAll("_", " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusChartWidget({ data }: { data: WidgetData }) {
|
||||||
|
const counts = data.counts ?? {};
|
||||||
|
const entries = Object.entries(counts).sort(([, a], [, b]) => b - a);
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center rounded-lg border border-border bg-card p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">No data</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col overflow-hidden rounded-lg border border-border bg-card">
|
||||||
|
<div className="border-b border-border px-3 py-2">
|
||||||
|
<span className="text-xs font-semibold text-foreground">{data.title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 items-center gap-4 p-4">
|
||||||
|
{/* Donut */}
|
||||||
|
<svg viewBox="0 0 40 40" className="h-16 w-16 shrink-0">
|
||||||
|
{entries.map(([, count], index) => {
|
||||||
|
const total = entries.reduce((sum, [, c]) => sum + c, 0);
|
||||||
|
const offset = entries
|
||||||
|
.slice(0, index)
|
||||||
|
.reduce((sum, [, c]) => sum + (c / total) * 100, 0);
|
||||||
|
const pct = (count / total) * 100;
|
||||||
|
const circumference = 2 * Math.PI * 15;
|
||||||
|
const dash = (pct / 100) * circumference;
|
||||||
|
const color = STATUS_COLORS[entries[index][0]] ?? "#71717a";
|
||||||
|
return (
|
||||||
|
<circle
|
||||||
|
key={entries[index][0]}
|
||||||
|
cx="20" cy="20" r="15"
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth="6"
|
||||||
|
strokeDasharray={`${dash} ${circumference - dash}`}
|
||||||
|
strokeDashoffset={-(offset / 100) * circumference}
|
||||||
|
transform="rotate(-90 20 20)"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="min-w-0 flex-1 space-y-1.5">
|
||||||
|
{entries.map(([status, count]) => (
|
||||||
|
<div key={status} className="flex items-center gap-2 text-xs">
|
||||||
|
<span
|
||||||
|
className="h-2 w-2 shrink-0 rounded-full"
|
||||||
|
style={{ backgroundColor: STATUS_COLORS[status] ?? "#71717a" }}
|
||||||
|
/>
|
||||||
|
<span className="flex-1 capitalize text-foreground">{statusLabel(status)}</span>
|
||||||
|
<span className="tabular-nums text-muted-foreground">{count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
web/src/components/widgets/ticket-list-widget.tsx
Normal file
59
web/src/components/widgets/ticket-list-widget.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { formatDistanceToNow } from "date-fns";
|
||||||
|
import { CircleIcon } from "lucide-react";
|
||||||
|
import type { WidgetData } from "@/lib/types";
|
||||||
|
import { cn, formatTicketId } from "@/lib/utils";
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
new: "#64748b",
|
||||||
|
open: "#2563eb",
|
||||||
|
in_progress: "#d97706",
|
||||||
|
resolved: "#16a34a",
|
||||||
|
closed: "#71717a",
|
||||||
|
};
|
||||||
|
|
||||||
|
function statusLabel(status: string) {
|
||||||
|
return status.replaceAll("_", " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TicketListWidget({ data }: { data: WidgetData }) {
|
||||||
|
const tickets = data.tickets ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col overflow-hidden rounded-lg border border-border bg-card">
|
||||||
|
<div className="flex items-center justify-between border-b border-border px-3 py-2">
|
||||||
|
<span className="text-xs font-semibold text-foreground">{data.title}</span>
|
||||||
|
<span className="text-[11px] tabular-nums text-muted-foreground">{data.total}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{tickets.length === 0 ? (
|
||||||
|
<p className="px-3 py-4 text-center text-xs text-muted-foreground">No tickets</p>
|
||||||
|
) : (
|
||||||
|
tickets.map((ticket) => (
|
||||||
|
<Link
|
||||||
|
key={ticket.id}
|
||||||
|
href={`/tickets/${ticket.id}`}
|
||||||
|
className="flex items-center gap-2 border-b border-border/50 px-3 py-2 text-xs transition-colors hover:bg-accent/40 last:border-b-0"
|
||||||
|
>
|
||||||
|
<CircleIcon
|
||||||
|
className="h-2 w-2 shrink-0"
|
||||||
|
style={{ color: STATUS_COLORS[ticket.status] ?? "#71717a", fill: STATUS_COLORS[ticket.status] ?? "#71717a" }}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1 truncate font-medium text-foreground">
|
||||||
|
{ticket.subject}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-[11px] text-muted-foreground">
|
||||||
|
{ticket.owner_name ?? "unassigned"}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-[11px] text-muted-foreground/60">
|
||||||
|
{formatDistanceToNow(new Date(ticket.updated_at), { addSuffix: true })}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import type {
|
import type {
|
||||||
Ticket,
|
Ticket,
|
||||||
Queue,
|
Queue,
|
||||||
|
Dashboard,
|
||||||
|
DashboardWidget,
|
||||||
|
WidgetData,
|
||||||
User,
|
User,
|
||||||
Transaction,
|
Transaction,
|
||||||
SavedView,
|
SavedView,
|
||||||
@@ -259,3 +262,54 @@ export async function updateView(id: string, data: {
|
|||||||
export async function deleteView(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
|
export async function deleteView(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
|
||||||
return request<{ ok: boolean }>(`/views/${id}`, { method: "DELETE" });
|
return request<{ ok: boolean }>(`/views/${id}`, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getDashboards(): Promise<{ data: Dashboard[] | null; error: string | null }> {
|
||||||
|
return request<Dashboard[]>("/dashboards");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDashboard(id: string): Promise<{ data: Dashboard | null; error: string | null }> {
|
||||||
|
return request<Dashboard>(`/dashboards/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDashboard(data: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
is_default?: boolean;
|
||||||
|
}): Promise<{ data: Dashboard | null; error: string | null }> {
|
||||||
|
return request<Dashboard>("/dashboards", { method: "POST", body: JSON.stringify(data) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateDashboard(id: string, data: {
|
||||||
|
name?: string;
|
||||||
|
description?: string | null;
|
||||||
|
is_default?: boolean;
|
||||||
|
layout?: unknown[];
|
||||||
|
}): Promise<{ data: Dashboard | null; error: string | null }> {
|
||||||
|
return request<Dashboard>(`/dashboards/${id}`, { method: "PATCH", body: JSON.stringify(data) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteDashboard(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
|
||||||
|
return request<{ ok: boolean }>(`/dashboards/${id}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDashboardWidgets(dashboardId: string): Promise<{ data: DashboardWidget[] | null; error: string | null }> {
|
||||||
|
return request<DashboardWidget[]>(`/dashboards/${dashboardId}/widgets`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createWidget(dashboardId: string, data: {
|
||||||
|
view_id: string;
|
||||||
|
title: string;
|
||||||
|
widget_type: string;
|
||||||
|
position?: { x: number; y: number; w: number; h: number };
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
}): Promise<{ data: DashboardWidget | null; error: string | null }> {
|
||||||
|
return request<DashboardWidget>(`/dashboards/${dashboardId}/widgets`, { method: "POST", body: JSON.stringify(data) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteWidget(dashboardId: string, widgetId: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
|
||||||
|
return request<{ ok: boolean }>(`/dashboards/${dashboardId}/widgets/${widgetId}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWidgetData(dashboardId: string, widgetId: string): Promise<{ data: WidgetData | null; error: string | null }> {
|
||||||
|
return request<WidgetData>(`/dashboards/${dashboardId}/widgets/${widgetId}/data`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -145,3 +145,45 @@ export interface SavedView {
|
|||||||
creator_id: string | null;
|
creator_id: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Dashboard {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
layout: unknown[];
|
||||||
|
is_default: boolean;
|
||||||
|
created_at: string;
|
||||||
|
widgets?: DashboardWidget[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardWidget {
|
||||||
|
id: string;
|
||||||
|
dashboard_id: string;
|
||||||
|
view_id: string;
|
||||||
|
title: string;
|
||||||
|
widget_type: string;
|
||||||
|
position: { x: number; y: number; w: number; h: number };
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WidgetTicket {
|
||||||
|
id: number;
|
||||||
|
subject: string;
|
||||||
|
status: string;
|
||||||
|
owner_id: string | null;
|
||||||
|
owner_name: string | null;
|
||||||
|
queue_name: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WidgetData {
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
total: number;
|
||||||
|
view_id: string;
|
||||||
|
tickets?: WidgetTicket[];
|
||||||
|
counts?: Record<string, number>;
|
||||||
|
groups?: Record<string, number>;
|
||||||
|
group_by?: string;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user