diff --git a/documentation/01-prehled-a-stav.md b/documentation/01-prehled-a-stav.md index 48132bc..8d8c8e5 100644 --- a/documentation/01-prehled-a-stav.md +++ b/documentation/01-prehled-a-stav.md @@ -29,6 +29,7 @@ React aplikaci ze slozky `dist/public`. | Vystupy kroku a predvalidace | hotovo | podminka se umi zeptat, co vratil predchozi krok | | Kanaly WhatsApp, FB, Instagram | hotovo | vcetne vzorovych automatizaci na prijem | | Firmy a prava | hotovo | tri pohledy, uzivatel muze byt ve vic firmach | +| Nastavitelny dashboard | hotovo | widgety, sirky a poradi, ulozene za uzivatele a firmu | | Sprava clenstvi z portalu | chybi | memberships jdou zmenit jen v kodu | | Bugs a wishes | chybi | vyvojarska agenda, samostatna evidence vedle ticketu | | Beh automatizaci | chybi | ulozeny strom se nevykonava, neni runtime | diff --git a/documentation/03-architektura-a-mapa-kodu.md b/documentation/03-architektura-a-mapa-kodu.md index 5f6b9cf..e1e5a02 100644 --- a/documentation/03-architektura-a-mapa-kodu.md +++ b/documentation/03-architektura-a-mapa-kodu.md @@ -43,6 +43,8 @@ image jen `dist`, takze staci jedna slozka. | `src/data/people.ts` | resitele ticketu - oddeleni od uzivatelu portalu | | `src/data/tenants.ts` | firmy, ktere portal pouzivaji | | `src/data/access.ts` | kdo co vidi - jedno misto pro cely portal | +| `src/data/widgets.ts` | katalog widgetu prehledu | +| `src/data/dashboardLayouts.ts` | rozlozeni dashboardu za dvojici uzivatel a firma | | `src/data/incidentStore.ts` | incidenty vcetne zmen a udalosti | | `src/data/automationStore.ts` | automatizace, strom akci, tokeny webhooku | | `src/data/connectors.ts` | katalog konektoru, jejich spousteču a akci | diff --git a/documentation/04-api.md b/documentation/04-api.md index 58d6328..2cb1f73 100644 --- a/documentation/04-api.md +++ b/documentation/04-api.md @@ -23,6 +23,10 @@ Vyzaduji `Authorization: Bearer `: | GET | `/api/auth/me` | | POST | `/api/auth/logout` | | GET | `/api/dashboard/access` | +| GET | `/api/dashboard/widgets` | +| GET | `/api/dashboard/layout` | +| PUT | `/api/dashboard/layout` | +| DELETE | `/api/dashboard/layout` | | GET | `/api/dashboard/summary` | | GET | `/api/dashboard/people` | | GET | `/api/dashboard/tickets` | diff --git a/documentation/08-dashboard-widgety.md b/documentation/08-dashboard-widgety.md new file mode 100644 index 0000000..523493e --- /dev/null +++ b/documentation/08-dashboard-widgety.md @@ -0,0 +1,99 @@ +# 08 - Widgety na prehledu + +## Kde se to nastavuje + +`/dashboard`, tlacitko **Upravit dashboard** vpravo nahore. V rezimu uprav +dostane kazdy widget listu s ovladanim: + +- vyber sirky (tretina, polovina, cela sirka), +- sipky vlevo a vpravo pro poradi, +- kos pro odebrani. + +K tomu **Pridat widget** s nabidkou toho, co jeste na dashboardu neni, +**Vychozi** pro vraceni do puvodniho stavu a **Ulozit** nebo **Zrusit**. + +Dokud se neklikne na Ulozit, nic se neuklada. Zrusit vrati puvodni rozlozeni. + +## Ulozeni je za dvojici uzivatel a firma + +Ne za uzivatele. Clovek ve dvou firmach chce v kazde videt neco jineho +a smichat mu to dohromady by bylo horsi nez zadne nastaveni. + +Klic je `${userId}:${tenantId}`, uloziste je `src/data/dashboardLayouts.ts`. +Kdo si dashboard jeste neupravil, dostane vychozi rozlozeni a `custom: false`. + +## Katalog widgetu + +`src/data/widgets.ts`, stejny princip jako katalog konektoru: server je zdroj +pravdy o tom, co jde na dashboard polozit. + +```ts +interface WidgetDefinition { + id: string; + name: string; + description: string; + kind: 'stat' | 'chart' | 'ticketList' | 'incidentList' | 'workload'; + sizes: WidgetSize[]; // ktere sirky ma smysl nabizet + defaultSize: WidgetSize; + metric?: ...; // jen u `stat`: ktere cislo ze souhrnu +} +``` + +`sizes` neni kosmetika. Graf v tretine sloupce se necte, proto tam tretinu +nenabizime vubec, misto abychom cekali, ze si to uzivatel rozmysli. + +`kind` rika klientovi, kterou komponentu vykreslit. Novy druh znamena i novou +komponentu na webu, proto jich je zamerne malo a jsou obecne. Novy **statisticky** +widget je naopak jen zaznam v katalogu, zadny kod navic. + +## Data si nacita prehled, ne widgety + +Widget dostane data v propsu. Kdyby si je nacital sam, deset dlazdic by znamenalo +deset stejnych dotazu na server. + +Prehled drzi ctyri dotazy (souhrn, tickety, incidenty, vytizeni) a rozdava je +vsem widgetum. Dlazdice, ktera zadna data nepotrebuje, o nich proste nevi. + +## Mrizka + +Sest sloupcu, sirky mapuji na `col-span`: tretina 2, polovina 3, cela 6. +Na uzkem displeji zabira vsechno celou sirku. + +Pouzivaji se **container queries** (`@container` a `@2xl:`), ne `sm:` a `lg:`. +Duvod je stejny jako u stromu automatizaci, viz +[05-dashboard-a-builder.md](05-dashboard-a-builder.md), sekce o sirce karet. + +## Validace + +Server overuje ulozene rozlozeni proti katalogu: + +| Situace | Vysledek | +| ----------------------------------- | -------- | +| neznamy widget | 400 | +| sirka, kterou widget nepodporuje | 400 | +| duplicitni ID instance | 400 | +| vic nez 12 widgetu | 400 | + +Neulozit je tady spravne. Klient by dostal zpatky neco, co neumi vykreslit. + +Widget, ktery mezitim z katalogu zmizel, se v prehledu ukaze jako cervena karta +s jeho ID. Nesmi tise vypadnout z rozlozeni. + +## Jak pridat widget + +1. Zaznam do `widgets` v `src/data/widgets.ts`. +2. Kdyz je to `stat`, pridat i metriku do `metricMeta` v `WidgetCard.tsx` + a pole do souhrnu, pokud tam jeste neni. +3. Kdyz je to novy `kind`, dopsat vetev do `WidgetCard.tsx`. + +Nabidka i rozlozeni ho vezmou automaticky. + +## Co chybi + +| Chybi | Poznamka | +| -------------------------- | ------------------------------------------------- | +| Drag and drop | poradi se meni sipkami, stejne jako ve strome | +| Nastaveni jednotlivych widgetu | napr. kolik radku ukazat, za jake obdobi | +| Vlastni metriky | katalog je pevny, nejde si nadefinovat vlastni | +| Sdilene rozlozeni pro firmu| kazdy si upravuje jen to svoje | +| Databaze | rozlozeni je v pameti, restart je vrati na vychozi | diff --git a/documentation/99-zmeny.md b/documentation/99-zmeny.md index 20cb200..2952921 100644 --- a/documentation/99-zmeny.md +++ b/documentation/99-zmeny.md @@ -114,6 +114,22 @@ se nikde nevolal. Popis v [07-firmy-a-prava.md](07-firmy-a-prava.md). `access.ts`, protoze zavisi na tom, ktera firma pozadavek zajima. - Demo ucty pokryvaji vsechny tri situace vcetne cloveka ve dvou firmach. +### Nastavitelny dashboard + +Prehled byl pevne dany. Ted si ho kazdy sklada sam, popis +v [08-dashboard-widgety.md](08-dashboard-widgety.md). + +- Katalog widgetu na serveru vcetne toho, ktere sirky ktery widget unese. + Graf v tretine sloupce se necte, proto se tam ani nenabizi. +- Rezim uprav na `/dashboard`: pridani z nabidky, vyber sirky, poradi sipkami, + odebrani. Ulozi se az tlacitkem, Zrusit vrati puvodni stav. +- Rozlozeni se uklada za **dvojici uzivatel a firma**, ne jen za uzivatele. +- Data nacita prehled a rozdava je widgetum. Deset dlazdic tak neznamena + deset stejnych dotazu. +- Server rozlozeni overuje proti katalogu. Neznamy widget nebo nepodporovana + sirka se neulozi, widget zmizely z katalogu se v prehledu ukaze jako chyba, + ne ze tise zmizi. + ### Zapsano jako otevrene rozhodnuti Vsechny automatizace se stejnym spoustecem se spusti. Doporucene rozdeleni na to diff --git a/src/data/dashboardLayouts.ts b/src/data/dashboardLayouts.ts new file mode 100644 index 0000000..53c0ba1 --- /dev/null +++ b/src/data/dashboardLayouts.ts @@ -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(); + +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(); + 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; +} diff --git a/src/data/widgets.ts b/src/data/widgets.ts new file mode 100644 index 0000000..3c4ee3f --- /dev/null +++ b/src/data/widgets.ts @@ -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); +} diff --git a/src/openapi.ts b/src/openapi.ts index bcd8f2e..1378543 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -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'], diff --git a/src/routes/dashboard.ts b/src/routes/dashboard.ts index d5e931e..29173b1 100644 --- a/src/routes/dashboard.ts +++ b/src/routes/dashboard.ts @@ -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. */ diff --git a/web/src/components/dashboard/widgets/WidgetCard.tsx b/web/src/components/dashboard/widgets/WidgetCard.tsx new file mode 100644 index 0000000..f98569e --- /dev/null +++ b/web/src/components/dashboard/widgets/WidgetCard.tsx @@ -0,0 +1,236 @@ +import { + AlarmClock, + Activity, + Clock, + Gauge, + LifeBuoy, + Play, + Workflow, +} from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { Link } from 'react-router-dom'; +import { DataState } from '@/components/dashboard/DataState'; +import { RunsChart } from '@/components/dashboard/RunsChart'; +import { StatTile } from '@/components/dashboard/StatTile'; +import { IncidentStatusBadge, TicketStatusBadge } from '@/components/dashboard/StatusBadge'; +import { TicketWorkload } from '@/components/dashboard/TicketWorkload'; +import { formatNumber, formatPercent, formatRelative } from '@/lib/format'; +import type { QueryState } from '@/lib/useApiQuery'; +import type { + DashboardSummary, + Incident, + ListResponse, + TicketListResponse, + WidgetDefinition, + WidgetMetric, + Workload, +} from '@/types/dashboard'; + +/** + * Vykresleni jednoho widgetu. Widget si data nenacita sam - dostane je + * z prehledu. Jinak by deset dlazdic znamenalo deset stejnych dotazu. + */ + +export interface WidgetData { + summary: QueryState; + tickets: QueryState; + incidents: QueryState>; + workload: QueryState; +} + +const metricMeta: Record< + WidgetMetric, + { icon: LucideIcon; label: string; format: (summary: DashboardSummary) => string } +> = { + openTickets: { + icon: LifeBuoy, + label: 'Otevřené tickety', + format: (s) => formatNumber(s.openTickets), + }, + activeIncidents: { + icon: AlarmClock, + label: 'Běžící incidenty', + format: (s) => formatNumber(s.activeIncidents), + }, + activeAutomations: { + icon: Workflow, + label: 'Aktivní automatizace', + format: (s) => formatNumber(s.activeAutomations), + }, + runsToday: { icon: Play, label: 'Běhy dnes', format: (s) => formatNumber(s.runsToday) }, + savedHoursMonth: { + icon: Clock, + label: 'Ušetřeno tento měsíc', + format: (s) => `${formatNumber(s.savedHoursMonth)} h`, + }, + uptime: { icon: Gauge, label: 'Dostupnost', format: (s) => formatPercent(s.uptime, 2) }, +}; + +export function WidgetCard({ + definition, + data, +}: { + definition: WidgetDefinition; + data: WidgetData; +}) { + if (definition.kind === 'stat') { + const metric = definition.metric ? metricMeta[definition.metric] : undefined; + if (!metric) { + console.warn(`[widget] ${definition.id} je typu stat, ale nema metriku`); + return ; + } + + return ( + + {data.summary.data && ( + + )} + + ); + } + + if (definition.kind === 'chart') { + return ( + + + {data.summary.data && } + + + ); + } + + if (definition.kind === 'ticketList') { + return ( + + +
    + {data.tickets.data?.items.slice(0, 5).map((ticket) => ( +
  • + {ticket.id} +
    + + {ticket.subject} + +

    + {ticket.customer.company} · {ticket.assignee?.name ?? 've frontě'} ·{' '} + {formatRelative(ticket.updatedAt)} +

    +
    + +
  • + ))} +
+
+
+ ); + } + + if (definition.kind === 'incidentList') { + return ( + + +
    + {data.incidents.data?.items.slice(0, 5).map((incident) => ( +
  • + + + +
    +

    {incident.title}

    +

    + {incident.service} · začátek {formatRelative(incident.startedAt)} +

    +
    + +
  • + ))} +
+
+
+ ); + } + + return ( + + {data.workload.data && ( + // Na prehledu je to jen pohled, filtrovat se chodi na stranku ticketu. + {}} /> + )} + + ); +} + +/** Barva dlazdice nese vyznam jen tam, kde ma. Zbytek zustava neutralni. */ +function toneFor( + metric: WidgetMetric | undefined, + summary: DashboardSummary, +): 'ok' | 'warn' | 'danger' | undefined { + if (metric === 'openTickets') return summary.openTickets > 3 ? 'warn' : 'ok'; + if (metric === 'activeIncidents') return summary.activeIncidents > 0 ? 'danger' : 'ok'; + if (metric === 'savedHoursMonth' || metric === 'uptime') return 'ok'; + return undefined; +} + +function Panel({ + title, + to, + children, +}: { + title?: string; + to?: string; + children: ReactNode; +}) { + return ( +
+ {title && ( +
+

{title}

+ {to && ( + + Všechny + + )} +
+ )} + {children} +
+ ); +} + +/** Widget, ktery se neda vykreslit. Nesmi zmizet potichu. */ +function Broken({ name }: { name: string }) { + return ( +
+ Widget „{name}" se nepodařilo zobrazit. +
+ ); +} diff --git a/web/src/components/dashboard/widgets/WidgetPicker.tsx b/web/src/components/dashboard/widgets/WidgetPicker.tsx new file mode 100644 index 0000000..d421b58 --- /dev/null +++ b/web/src/components/dashboard/widgets/WidgetPicker.tsx @@ -0,0 +1,56 @@ +import { Plus } from 'lucide-react'; +import { Modal } from '@/components/ui/Modal'; +import type { WidgetDefinition } from '@/types/dashboard'; + +/** Nabidka widgetu, ktere jde na dashboard pridat. */ +export function WidgetPicker({ + open, + widgets, + onClose, + onPick, +}: { + open: boolean; + widgets: WidgetDefinition[]; + onClose: () => void; + onPick: (widget: WidgetDefinition) => void; +}) { + return ( + +
+
    + {widgets.map((widget) => ( +
  • + +
  • + ))} +
+ + {widgets.length === 0 && ( +

+ Všechny dostupné widgety už na dashboardu máte. +

+ )} +
+
+ ); +} diff --git a/web/src/lib/useApiQuery.ts b/web/src/lib/useApiQuery.ts index ab0b405..9e9be8d 100644 --- a/web/src/lib/useApiQuery.ts +++ b/web/src/lib/useApiQuery.ts @@ -3,7 +3,7 @@ import { useEventStream } from '@/components/dashboard/EventStreamProvider'; import { apiFetch } from '@/lib/api'; import type { DashboardEventType } from '@/types/events'; -interface QueryState { +export interface QueryState { data: T | null; loading: boolean; error: string | null; diff --git a/web/src/pages/dashboard/Overview.tsx b/web/src/pages/dashboard/Overview.tsx index 4a86c1d..1f62fe4 100644 --- a/web/src/pages/dashboard/Overview.tsx +++ b/web/src/pages/dashboard/Overview.tsx @@ -1,28 +1,70 @@ -import { Activity, AlarmClock, Clock, LifeBuoy, RefreshCw, Workflow } from 'lucide-react'; -import { Link } from 'react-router-dom'; +import { + AlertCircle, + Check, + ChevronLeft, + ChevronRight, + LayoutGrid, + Plus, + RefreshCw, + RotateCcw, + Trash2, + X, +} from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; +import type { ReactNode } from 'react'; import { useAuth } from '@/auth/AuthContext'; import { DataState } from '@/components/dashboard/DataState'; -import { RunsChart } from '@/components/dashboard/RunsChart'; -import { StatTile } from '@/components/dashboard/StatTile'; -import { IncidentStatusBadge, TicketStatusBadge } from '@/components/dashboard/StatusBadge'; +import { WidgetCard, type WidgetData } from '@/components/dashboard/widgets/WidgetCard'; +import { WidgetPicker } from '@/components/dashboard/widgets/WidgetPicker'; import { Button } from '@/components/ui/Button'; -import { formatNumber, formatPercent, formatRelative } from '@/lib/format'; +import { apiFetch } from '@/lib/api'; +import { cn } from '@/lib/cn'; import { useApiQuery } from '@/lib/useApiQuery'; import { usePageMeta } from '@/lib/usePageMeta'; import type { + Access, DashboardSummary, Incident, + LayoutItem, + LayoutResponse, ListResponse, TicketListResponse, + WidgetDefinition, + WidgetSize, + Workload, } from '@/types/dashboard'; +/** Sirka widgetu v mrizce o sesti sloupcich. */ +const span: Record = { + third: 'col-span-6 @2xl:col-span-2', + half: 'col-span-6 @2xl:col-span-3', + full: 'col-span-6', +}; + +const sizeLabels: Record = { + third: 'třetina', + half: 'polovina', + full: 'celá šířka', +}; + export default function Overview() { usePageMeta({ title: 'Přehled — portál Automia' }); const { user } = useAuth(); + const access = useApiQuery('/api/dashboard/access'); - // Prehled se prekresluje na kazdou zmenu, proto sleduje vsechny udalosti. - const summary = useApiQuery('/api/dashboard/summary', { + /** Firma, za kterou se prehled kresli. Rozlozeni se uklada pro ni. */ + const [tenantId, setTenantId] = useState(null); + const activeTenant = tenantId ?? access.data?.defaultTenantId ?? null; + const tenantQuery = activeTenant ? `?tenantId=${activeTenant}` : ''; + + const layout = useApiQuery(`/api/dashboard/layout${tenantQuery}`); + const catalog = useApiQuery>('/api/dashboard/widgets'); + + // Data si nacita prehled, ne jednotlive widgety. Deset dlazdic nesmi + // znamenat deset stejnych dotazu. + const scopeQuery = activeTenant ? `?scope=tenant&tenantId=${activeTenant}` : ''; + const summary = useApiQuery(`/api/dashboard/summary${scopeQuery}`, { refetchOn: [ 'ticket.created', 'ticket.resolved', @@ -35,157 +77,340 @@ export default function Overview() { 'webhook.received', ], }); - const tickets = useApiQuery('/api/dashboard/tickets', { + const tickets = useApiQuery(`/api/dashboard/tickets${scopeQuery}`, { refetchOn: ['ticket.created', 'ticket.updated', 'ticket.assigned', 'ticket.resolved'], }); const incidents = useApiQuery>('/api/dashboard/incidents', { refetchOn: ['incident.started', 'incident.updated', 'incident.resolved'], }); + const workload = useApiQuery(`/api/dashboard/tickets/workload${scopeQuery}`, { + refetchOn: ['ticket.created', 'ticket.updated', 'ticket.assigned', 'ticket.resolved'], + }); + + const data: WidgetData = { summary, tickets, incidents, workload }; + + // ----------------------------------------------------------- rezim uprav + + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState([]); + const [picker, setPicker] = useState(false); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + + // Ulozene rozlozeni prevezmeme do konceptu, dokud se needituje. + useEffect(() => { + if (layout.data && !editing) setDraft(layout.data.items); + }, [layout.data, editing]); + + const definitions = useMemo( + () => new Map((catalog.data?.items ?? []).map((widget) => [widget.id, widget])), + [catalog.data], + ); + + const items = editing ? draft : (layout.data?.items ?? []); + + function move(index: number, offset: number) { + const target = index + offset; + if (target < 0 || target >= draft.length) return; + const next = [...draft]; + const [moved] = next.splice(index, 1); + next.splice(target, 0, moved); + setDraft(next); + } + + function resize(id: string, size: WidgetSize) { + setDraft(draft.map((item) => (item.id === id ? { ...item, size } : item))); + } + + function remove(id: string) { + setDraft(draft.filter((item) => item.id !== id)); + } + + function add(widget: WidgetDefinition) { + setDraft([ + ...draft, + // Instance musi mit vlastni ID, aby sel tentyz widget pridat vickrat. + { id: `w_${Date.now().toString(36)}_${draft.length}`, widgetId: widget.id, size: widget.defaultSize }, + ]); + } + + async function save() { + setSaving(true); + setSaveError(null); + try { + await apiFetch(`/api/dashboard/layout${tenantQuery}`, { + method: 'PUT', + body: { items: draft }, + }); + setEditing(false); + layout.reload(); + } catch (err) { + const message = err instanceof Error ? err.message : 'Rozložení se nepodařilo uložit.'; + console.error('[dashboard] ulozeni rozlozeni selhalo:', err); + setSaveError(message); + } finally { + setSaving(false); + } + } + + async function resetToDefault() { + if (!window.confirm('Vrátit dashboard do výchozího stavu? Vaše rozložení se smaže.')) return; + setSaving(true); + setSaveError(null); + try { + const fresh = await apiFetch(`/api/dashboard/layout${tenantQuery}`, { + method: 'DELETE', + }); + setDraft(fresh.items); + setEditing(false); + layout.reload(); + } catch (err) { + const message = err instanceof Error ? err.message : 'Vrácení na výchozí se nepodařilo.'; + console.error('[dashboard] reset rozlozeni selhal:', err); + setSaveError(message); + } finally { + setSaving(false); + } + } + + const unused = (catalog.data?.items ?? []).filter( + (widget) => !draft.some((item) => item.widgetId === widget.id), + ); return ( -
+
-
+

Dobrý den, {user?.name?.split(' ')[0] ?? 'vítejte'}

- Tady je stav vašich automatizací a požadavků. + {editing + ? 'Uspořádejte si dashboard. Změny se uloží až tlačítkem Uložit.' + : 'Tady je stav vašich automatizací a požadavků.'}

- + +
+ {/* Prepinac firmy davá smysl jen tomu, kdo jich ma vic. */} + {!editing && (access.data?.tenants.length ?? 0) > 1 && ( + + )} + + {editing ? ( + <> + + + + + + ) : ( + <> + + + + )} +
- - {summary.data && ( - <> -
- - 3 ? 'warn' : 'ok'} - /> - 0 ? 'danger' : 'ok'} - /> - -
+ {saveError && ( +

+ + {saveError} +

+ )} -
- -
- - )} + { + layout.reload(); + catalog.reload(); + }} + empty={items.length === 0} + emptyLabel={ + editing + ? 'Dashboard je prázdný. Přidejte widget tlačítkem nahoře.' + : 'Dashboard je prázdný. Klikněte na Upravit dashboard.' + } + > +
+ {items.map((item, index) => { + const definition = definitions.get(item.widgetId); + + if (!definition) { + // Widget zmizel z katalogu. Nesmi to tise vypadnout z rozlozeni. + return ( +
+ Widget „{item.widgetId}" už v katalogu není. +
+ ); + } + + return ( +
+ {editing && ( + 0} + canMoveRight={index < items.length - 1} + onMove={(offset) => move(index, offset)} + onResize={(size) => resize(item.id, size)} + onRemove={() => remove(item.id)} + /> + )} +
+ +
+
+ ); + })} +
-
-
-
-

Poslední tickety

- - Všechny → - -
- - -
    - {tickets.data?.items.slice(0, 4).map((ticket) => ( -
  • - {ticket.id} -
    - - {ticket.subject} - -

    - {ticket.customer.company} · {ticket.assignee?.name ?? 've frontě'} ·{' '} - {formatRelative(ticket.updatedAt)} -

    -
    - -
  • - ))} -
-
-
- -
-
-

Incidenty

- - Všechny → - -
- - -
    - {incidents.data?.items.slice(0, 4).map((incident) => ( -
  • - - - -
    -

    {incident.title}

    -

    - {incident.service} · začátek {formatRelative(incident.startedAt)} -

    -
    - -
  • - ))} -
-
-
-
+ setPicker(false)} + onPick={add} + />
); } + +/** Ovladani jednoho widgetu v rezimu uprav. */ +function EditBar({ + definition, + size, + canMoveLeft, + canMoveRight, + onMove, + onResize, + onRemove, +}: { + definition: WidgetDefinition; + size: WidgetSize; + canMoveLeft: boolean; + canMoveRight: boolean; + onMove: (offset: number) => void; + onResize: (size: WidgetSize) => void; + onRemove: () => void; +}) { + return ( +
+ + {definition.name} + + + {/* Sirku nabizime jen tu, kterou widget unese. */} + + + onMove(-1)}> + + + onMove(1)}> + + + + + +
+ ); +} + +function IconButton({ + label, + disabled, + danger, + onClick, + children, +}: { + label: string; + disabled?: boolean; + danger?: boolean; + onClick: () => void; + children: ReactNode; +}) { + return ( + + ); +} diff --git a/web/src/types/dashboard.ts b/web/src/types/dashboard.ts index c42c1d1..4d8860a 100644 --- a/web/src/types/dashboard.ts +++ b/web/src/types/dashboard.ts @@ -314,3 +314,42 @@ export interface DashboardSummary { export interface ListResponse { items: T[]; } + +// ------------------------------------------------------ rozlozeni dashboardu + +export type WidgetSize = 'third' | 'half' | 'full'; + +export type WidgetKind = 'stat' | 'chart' | 'ticketList' | 'incidentList' | 'workload'; + +export type WidgetMetric = + | 'openTickets' + | 'activeIncidents' + | 'activeAutomations' + | 'runsToday' + | 'savedHoursMonth' + | 'uptime'; + +export interface WidgetDefinition { + id: string; + name: string; + description: string; + kind: WidgetKind; + /** Ktere sirky ma smysl nabizet. */ + sizes: WidgetSize[]; + defaultSize: WidgetSize; + metric?: WidgetMetric; +} + +export interface LayoutItem { + /** Instance widgetu. Tentyz widget muze byt na dashboardu vickrat. */ + id: string; + widgetId: string; + size: WidgetSize; +} + +export interface LayoutResponse { + tenantId: string; + items: LayoutItem[]; + /** false = uzivatel kouka na vychozi rozlozeni, nic si neulozil. */ + custom: boolean; +}