dashboard widgets

This commit is contained in:
JiriUhlir
2026-08-03 13:10:41 +02:00
parent 2a3d75c85e
commit bbc2236c0d
14 changed files with 1196 additions and 141 deletions
+87
View File
@@ -0,0 +1,87 @@
/**
* Rozlozeni dashboardu. Uklada se zvlast pro **kazdou dvojici uzivatel a firma** -
* clovek ve dvou firmach chce v kazde videt neco jineho a smichat mu to
* dohromady by bylo horsi nez zadne nastaveni.
*
* POZOR: data jsou v pameti procesu, restart je vrati na vychozi rozlozeni.
*/
import { findWidget, type WidgetSize } from './widgets.js';
export interface LayoutItem {
/** Instance widgetu. Tentyz widget muze byt na dashboardu vickrat. */
id: string;
widgetId: string;
size: WidgetSize;
}
/** Kolik widgetu jeste dava smysl. Nad tim je to seznam, ne prehled. */
export const MAX_WIDGETS = 12;
/** Co uvidi nekdo, kdo si dashboard jeste neupravil. */
const defaultLayout: LayoutItem[] = [
{ id: 'w1', widgetId: 'stat.activeAutomations', size: 'third' },
{ id: 'w2', widgetId: 'stat.openTickets', size: 'third' },
{ id: 'w3', widgetId: 'stat.activeIncidents', size: 'third' },
{ id: 'w4', widgetId: 'chart.runs', size: 'full' },
{ id: 'w5', widgetId: 'list.tickets', size: 'half' },
{ id: 'w6', widgetId: 'list.incidents', size: 'half' },
];
const layouts = new Map<string, LayoutItem[]>();
function key(userId: string, tenantId: string): string {
return `${userId}:${tenantId}`;
}
export function getLayout(userId: string, tenantId: string): LayoutItem[] {
return layouts.get(key(userId, tenantId)) ?? defaultLayout.map((item) => ({ ...item }));
}
/** true = uzivatel si rozlozeni uz upravil, nekouka na vychozi. */
export function hasCustomLayout(userId: string, tenantId: string): boolean {
return layouts.has(key(userId, tenantId));
}
export function saveLayout(userId: string, tenantId: string, items: LayoutItem[]): LayoutItem[] {
layouts.set(key(userId, tenantId), items);
console.info(`[layout] ${userId}@${tenantId}: ulozeno ${items.length} widgetu`);
return items;
}
/** Vrati dashboard do vychoziho stavu. */
export function resetLayout(userId: string, tenantId: string): LayoutItem[] {
layouts.delete(key(userId, tenantId));
console.info(`[layout] ${userId}@${tenantId}: vraceno na vychozi`);
return getLayout(userId, tenantId);
}
/**
* Overi rozlozeni proti katalogu. Vraci seznam problemu, prazdny znamena v poradku.
* Neznamy widget ani nepovolena sirka se neulozi - klient by pak dostal zpatky
* neco, co neumi vykreslit.
*/
export function validateLayout(items: LayoutItem[]): string[] {
const problems: string[] = [];
if (items.length > MAX_WIDGETS) {
problems.push(`Na dashboard se vejde nejvýš ${MAX_WIDGETS} widgetů.`);
}
const seen = new Set<string>();
for (const item of items) {
if (seen.has(item.id)) problems.push(`Widget s ID „${item.id}" je uvedený dvakrát.`);
seen.add(item.id);
const definition = findWidget(item.widgetId);
if (!definition) {
problems.push(`Widget „${item.widgetId}" v katalogu neexistuje.`);
continue;
}
if (!definition.sizes.includes(item.size)) {
problems.push(`Widget „${definition.name}" nelze zobrazit v této šířce.`);
}
}
return problems;
}
+125
View File
@@ -0,0 +1,125 @@
/**
* Katalog widgetu prehledu. Stejny princip jako katalog konektoru:
* server je zdroj pravdy o tom, co jde na dashboard polozit.
*
* `kind` rika klientovi, kterou komponentu vykreslit. Novy druh znamena
* i novou komponentu na webu, proto jich je zamerne malo a jsou obecne.
*/
export type WidgetSize = 'third' | 'half' | 'full';
export type WidgetKind =
| 'stat'
| 'chart'
| 'ticketList'
| 'incidentList'
| 'workload';
export interface WidgetDefinition {
id: string;
name: string;
description: string;
kind: WidgetKind;
/** Ktere sirky ma smysl nabizet. Graf v tretine sloupce necteme. */
sizes: WidgetSize[];
defaultSize: WidgetSize;
/**
* Jen u `stat`: ktere cislo ze souhrnu ukazat.
* Klic odpovida poli v odpovedi /api/dashboard/summary.
*/
metric?: 'openTickets' | 'activeIncidents' | 'activeAutomations' | 'runsToday' | 'savedHoursMonth' | 'uptime';
}
export const widgets: WidgetDefinition[] = [
{
id: 'stat.openTickets',
name: 'Otevřené tickety',
description: 'Počet nevyřešených požadavků.',
kind: 'stat',
sizes: ['third', 'half'],
defaultSize: 'third',
metric: 'openTickets',
},
{
id: 'stat.activeIncidents',
name: 'Běžící incidenty',
description: 'Počet incidentů, které právě řešíte.',
kind: 'stat',
sizes: ['third', 'half'],
defaultSize: 'third',
metric: 'activeIncidents',
},
{
id: 'stat.activeAutomations',
name: 'Aktivní automatizace',
description: 'Kolik automatizací je zapnutých.',
kind: 'stat',
sizes: ['third', 'half'],
defaultSize: 'third',
metric: 'activeAutomations',
},
{
id: 'stat.runsToday',
name: 'Běhy dnes',
description: 'Počet spuštění automatizací za dnešek.',
kind: 'stat',
sizes: ['third', 'half'],
defaultSize: 'third',
metric: 'runsToday',
},
{
id: 'stat.savedHours',
name: 'Ušetřené hodiny',
description: 'Odhad úspory za tento měsíc.',
kind: 'stat',
sizes: ['third', 'half'],
defaultSize: 'third',
metric: 'savedHoursMonth',
},
{
id: 'stat.uptime',
name: 'Dostupnost',
description: 'Dostupnost služeb v procentech.',
kind: 'stat',
sizes: ['third', 'half'],
defaultSize: 'third',
metric: 'uptime',
},
{
id: 'chart.runs',
name: 'Graf běhů',
description: 'Spuštění a chyby za posledních 14 dní.',
kind: 'chart',
// V tretine sloupce by byl graf necitelny, proto ji nenabizime.
sizes: ['half', 'full'],
defaultSize: 'full',
},
{
id: 'list.tickets',
name: 'Poslední tickety',
description: 'Nejnovější požadavky s odkazem na detail.',
kind: 'ticketList',
sizes: ['half', 'full'],
defaultSize: 'half',
},
{
id: 'list.incidents',
name: 'Incidenty',
description: 'Přehled posledních incidentů.',
kind: 'incidentList',
sizes: ['half', 'full'],
defaultSize: 'half',
},
{
id: 'panel.workload',
name: 'Kdo co má u sebe',
description: 'Vytížení týmu a fronta bez řešitele.',
kind: 'workload',
sizes: ['half', 'full'],
defaultSize: 'half',
},
];
export function findWidget(id: string): WidgetDefinition | undefined {
return widgets.find((widget) => widget.id === id);
}
+75
View File
@@ -460,6 +460,81 @@ export function buildOpenApiDocument() {
responses: { '200': { description: 'Souhrnne metriky a casova rada' } },
},
},
'/api/dashboard/widgets': {
get: {
tags: ['Dashboard'],
summary: 'Katalog widgetu prehledu',
description: 'Co jde polozit na dashboard vcetne povolenych sirek.',
security: [{ bearerAuth: [] }],
responses: { '200': { description: 'Widgety' } },
},
},
'/api/dashboard/layout': {
get: {
tags: ['Dashboard'],
summary: 'Rozlozeni dashboardu',
description:
'Uklada se pro dvojici uzivatel a firma. `custom: false` znamena, ' +
'ze uzivatel kouka na vychozi rozlozeni.',
security: [{ bearerAuth: [] }],
parameters: [
{
name: 'tenantId',
in: 'query',
schema: { type: 'string' },
description: 'Firma. Bez ni se pouzije prvni, do ktere uzivatel patri.',
},
],
responses: {
'200': { description: 'Rozlozeni' },
'403': { description: 'Ucet nepatri do zadne firmy' },
'404': { description: 'Firma neexistuje, nebo do ni uzivatel nepatri' },
},
},
put: {
tags: ['Dashboard'],
summary: 'Ulozit rozlozeni',
description: 'Overuje se proti katalogu. Neznamy widget nebo sirka vraci 400.',
security: [{ bearerAuth: [] }],
parameters: [{ name: 'tenantId', in: 'query', schema: { type: 'string' } }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['items'],
properties: {
items: {
type: 'array',
items: {
type: 'object',
required: ['id', 'widgetId', 'size'],
properties: {
id: { type: 'string', example: 'w1' },
widgetId: { type: 'string', example: 'stat.openTickets' },
size: { type: 'string', enum: ['third', 'half', 'full'] },
},
},
},
},
},
},
},
},
responses: {
'200': { description: 'Ulozeno' },
'400': { description: 'Neplatne rozlozeni' },
},
},
delete: {
tags: ['Dashboard'],
summary: 'Vratit na vychozi rozlozeni',
security: [{ bearerAuth: [] }],
parameters: [{ name: 'tenantId', in: 'query', schema: { type: 'string' } }],
responses: { '200': { description: 'Vychozi rozlozeni' } },
},
},
'/api/dashboard/access': {
get: {
tags: ['Dashboard'],
+90
View File
@@ -23,7 +23,15 @@ import {
findOperation,
providedFieldsFor,
} from '../data/connectors.js';
import {
getLayout,
hasCustomLayout,
resetLayout,
saveLayout,
validateLayout,
} from '../data/dashboardLayouts.js';
import { collectScopes } from '../data/flowScope.js';
import { widgets } from '../data/widgets.js';
import { listIncidents } from '../data/incidentStore.js';
import { getSummary } from '../data/mock.js';
import { findPersonByEmail, listPeople } from '../data/people.js';
@@ -78,6 +86,88 @@ dashboardRouter.get('/incidents', (_req, res) => {
res.json({ items: listIncidents() });
});
// ------------------------------------------------------- rozlozeni dashboardu
dashboardRouter.get('/widgets', (_req, res) => {
res.json({ items: widgets });
});
/**
* Rozlozeni je vzdy za konkretni firmu, i kdyz uzivatel kouka na pohled "vse".
* Jinak by clovek ve dvou firmach nemel kam ulozit dve ruzna nastaveni.
*/
function layoutTenant(req: Request, res: Response): string | null {
const access = accessFor(req.user!);
const requested = typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined;
const tenantId = requested ?? access.defaultTenantId;
if (!tenantId) {
res.status(403).json({ error: 'no_tenant', message: 'Účet nepatří do žádné firmy.' });
return null;
}
if (!access.tenants.some((tenant) => tenant.id === tenantId)) {
console.warn(`[layout] ${req.user!.email}: pokus o firmu ${tenantId} bez clenstvi`);
res.status(404).json({ error: 'not_found', message: 'Firma neexistuje, nebo do ní nepatříte.' });
return null;
}
return tenantId;
}
dashboardRouter.get('/layout', (req, res) => {
const tenantId = layoutTenant(req, res);
if (!tenantId) return;
return res.json({
tenantId,
items: getLayout(req.user!.id, tenantId),
custom: hasCustomLayout(req.user!.id, tenantId),
});
});
const layoutSchema = z.object({
items: z.array(
z.object({
id: z.string().min(1),
widgetId: z.string().min(1),
size: z.enum(['third', 'half', 'full']),
}),
),
});
dashboardRouter.put('/layout', (req, res) => {
const tenantId = layoutTenant(req, res);
if (!tenantId) return;
const parsed = layoutSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: 'validation_error',
message: parsed.error.issues[0]?.message ?? 'Neplatné rozložení.',
});
}
const problems = validateLayout(parsed.data.items);
if (problems.length > 0) {
console.warn(`[layout] ${req.user!.email}: neplatne rozlozeni - ${problems.join(' ')}`);
return res.status(400).json({ error: 'validation_error', message: problems[0] });
}
const items = saveLayout(req.user!.id, tenantId, parsed.data.items);
return res.json({ tenantId, items, custom: true });
});
/** Vraceni na vychozi. Zamerne DELETE - je to smazani ulozeneho nastaveni. */
dashboardRouter.delete('/layout', (req, res) => {
const tenantId = layoutTenant(req, res);
if (!tenantId) return;
return res.json({
tenantId,
items: resetLayout(req.user!.id, tenantId),
custom: false,
});
});
// ------------------------------------------------------------------- tickety
/** Resitele vybrane firmy. Klient je potrebuje do nabidky prirazeni i do prehledu. */