dashboard widgets
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user