dashboard widgets
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -23,6 +23,10 @@ Vyzaduji `Authorization: Bearer <token>`:
|
||||
| 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` |
|
||||
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<DashboardSummary>;
|
||||
tickets: QueryState<TicketListResponse>;
|
||||
incidents: QueryState<ListResponse<Incident>>;
|
||||
workload: QueryState<Workload>;
|
||||
}
|
||||
|
||||
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 <Broken name={definition.name} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<DataState loading={data.summary.loading} error={data.summary.error} onRetry={data.summary.reload}>
|
||||
{data.summary.data && (
|
||||
<StatTile
|
||||
icon={metric.icon}
|
||||
label={metric.label}
|
||||
value={metric.format(data.summary.data)}
|
||||
tone={toneFor(definition.metric, data.summary.data)}
|
||||
/>
|
||||
)}
|
||||
</DataState>
|
||||
);
|
||||
}
|
||||
|
||||
if (definition.kind === 'chart') {
|
||||
return (
|
||||
<Panel>
|
||||
<DataState
|
||||
loading={data.summary.loading}
|
||||
error={data.summary.error}
|
||||
onRetry={data.summary.reload}
|
||||
>
|
||||
{data.summary.data && <RunsChart series={data.summary.data.series} />}
|
||||
</DataState>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
if (definition.kind === 'ticketList') {
|
||||
return (
|
||||
<Panel title="Poslední tickety" to="/dashboard/tickety">
|
||||
<DataState
|
||||
loading={data.tickets.loading}
|
||||
error={data.tickets.error}
|
||||
empty={data.tickets.data?.items.length === 0}
|
||||
onRetry={data.tickets.reload}
|
||||
emptyLabel="Žádné tickety, dobrá zpráva."
|
||||
>
|
||||
<ul className="divide-y divide-ink-600/50">
|
||||
{data.tickets.data?.items.slice(0, 5).map((ticket) => (
|
||||
<li key={ticket.id} className="flex items-start gap-3 py-3.5 first:pt-0 last:pb-0">
|
||||
<span className="mt-0.5 font-mono text-xs text-white/35">{ticket.id}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link
|
||||
to={`/dashboard/tickety/${ticket.id}`}
|
||||
className="block truncate text-sm text-white/80 transition-colors hover:text-brand-200"
|
||||
>
|
||||
{ticket.subject}
|
||||
</Link>
|
||||
<p className="mt-0.5 truncate text-xs text-white/40">
|
||||
{ticket.customer.company} · {ticket.assignee?.name ?? 've frontě'} ·{' '}
|
||||
{formatRelative(ticket.updatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<TicketStatusBadge status={ticket.status} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</DataState>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
if (definition.kind === 'incidentList') {
|
||||
return (
|
||||
<Panel title="Incidenty" to="/dashboard/incidenty">
|
||||
<DataState
|
||||
loading={data.incidents.loading}
|
||||
error={data.incidents.error}
|
||||
empty={data.incidents.data?.items.length === 0}
|
||||
onRetry={data.incidents.reload}
|
||||
emptyLabel="Žádné incidenty."
|
||||
>
|
||||
<ul className="divide-y divide-ink-600/50">
|
||||
{data.incidents.data?.items.slice(0, 5).map((incident) => (
|
||||
<li key={incident.id} className="flex items-start gap-3 py-3.5 first:pt-0 last:pb-0">
|
||||
<span className="mt-0.5 grid size-7 shrink-0 place-items-center rounded-lg bg-white/5 text-white/40">
|
||||
<Activity className="size-3.5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-white/80">{incident.title}</p>
|
||||
<p className="mt-0.5 truncate text-xs text-white/40">
|
||||
{incident.service} · začátek {formatRelative(incident.startedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<IncidentStatusBadge status={incident.status} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</DataState>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DataState
|
||||
loading={data.workload.loading}
|
||||
error={data.workload.error}
|
||||
onRetry={data.workload.reload}
|
||||
>
|
||||
{data.workload.data && (
|
||||
// Na prehledu je to jen pohled, filtrovat se chodi na stranku ticketu.
|
||||
<TicketWorkload workload={data.workload.data} active={null} onSelect={() => {}} />
|
||||
)}
|
||||
</DataState>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<section className="glass h-full rounded-card p-5 sm:p-6">
|
||||
{title && (
|
||||
<div className="mb-5 flex items-center justify-between gap-3">
|
||||
<h2 className="font-semibold text-white">{title}</h2>
|
||||
{to && (
|
||||
<Link to={to} className="text-sm font-medium text-brand-300 hover:text-brand-200">
|
||||
Všechny
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Widget, ktery se neda vykreslit. Nesmi zmizet potichu. */
|
||||
function Broken({ name }: { name: string }) {
|
||||
return (
|
||||
<div className="rounded-card border border-danger-400/40 bg-danger-500/8 p-5 text-sm text-danger-400">
|
||||
Widget „{name}" se nepodařilo zobrazit.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Přidat widget"
|
||||
description="Widget se přidá na konec dashboardu, pak ho můžete posunout."
|
||||
>
|
||||
<div className="max-h-[60vh] overflow-y-auto p-5">
|
||||
<ul className="grid gap-2.5 sm:grid-cols-2">
|
||||
{widgets.map((widget) => (
|
||||
<li key={widget.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onPick(widget);
|
||||
onClose();
|
||||
}}
|
||||
className="group flex h-full w-full items-start gap-3 rounded-xl border border-ink-600/60 bg-ink-850/40 p-4 text-left transition-colors hover:border-brand-400/60 hover:bg-brand-500/8"
|
||||
>
|
||||
<span className="mt-0.5 grid size-7 shrink-0 place-items-center rounded-lg bg-white/5 text-white/40 transition-colors group-hover:bg-brand-500/15 group-hover:text-brand-300">
|
||||
<Plus className="size-4" />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block font-medium text-white">{widget.name}</span>
|
||||
<span className="mt-0.5 block text-sm text-white/50">{widget.description}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{widgets.length === 0 && (
|
||||
<p className="py-8 text-center text-sm text-white/45">
|
||||
Všechny dostupné widgety už na dashboardu máte.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { useEventStream } from '@/components/dashboard/EventStreamProvider';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import type { DashboardEventType } from '@/types/events';
|
||||
|
||||
interface QueryState<T> {
|
||||
export interface QueryState<T> {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
@@ -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<WidgetSize, string> = {
|
||||
third: 'col-span-6 @2xl:col-span-2',
|
||||
half: 'col-span-6 @2xl:col-span-3',
|
||||
full: 'col-span-6',
|
||||
};
|
||||
|
||||
const sizeLabels: Record<WidgetSize, string> = {
|
||||
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<Access>('/api/dashboard/access');
|
||||
|
||||
// Prehled se prekresluje na kazdou zmenu, proto sleduje vsechny udalosti.
|
||||
const summary = useApiQuery<DashboardSummary>('/api/dashboard/summary', {
|
||||
/** Firma, za kterou se prehled kresli. Rozlozeni se uklada pro ni. */
|
||||
const [tenantId, setTenantId] = useState<string | null>(null);
|
||||
const activeTenant = tenantId ?? access.data?.defaultTenantId ?? null;
|
||||
const tenantQuery = activeTenant ? `?tenantId=${activeTenant}` : '';
|
||||
|
||||
const layout = useApiQuery<LayoutResponse>(`/api/dashboard/layout${tenantQuery}`);
|
||||
const catalog = useApiQuery<ListResponse<WidgetDefinition>>('/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<DashboardSummary>(`/api/dashboard/summary${scopeQuery}`, {
|
||||
refetchOn: [
|
||||
'ticket.created',
|
||||
'ticket.resolved',
|
||||
@@ -35,157 +77,340 @@ export default function Overview() {
|
||||
'webhook.received',
|
||||
],
|
||||
});
|
||||
const tickets = useApiQuery<TicketListResponse>('/api/dashboard/tickets', {
|
||||
const tickets = useApiQuery<TicketListResponse>(`/api/dashboard/tickets${scopeQuery}`, {
|
||||
refetchOn: ['ticket.created', 'ticket.updated', 'ticket.assigned', 'ticket.resolved'],
|
||||
});
|
||||
const incidents = useApiQuery<ListResponse<Incident>>('/api/dashboard/incidents', {
|
||||
refetchOn: ['incident.started', 'incident.updated', 'incident.resolved'],
|
||||
});
|
||||
const workload = useApiQuery<Workload>(`/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<LayoutItem[]>([]);
|
||||
const [picker, setPicker] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(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<LayoutResponse>(`/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<LayoutResponse>(`/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 (
|
||||
<div className="space-y-7">
|
||||
<div className="@container space-y-6">
|
||||
<header className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-bold text-white">
|
||||
Dobrý den, {user?.name?.split(' ')[0] ?? 'vítejte'}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-white/50">
|
||||
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ů.'}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
summary.reload();
|
||||
tickets.reload();
|
||||
incidents.reload();
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
Obnovit
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Prepinac firmy davá smysl jen tomu, kdo jich ma vic. */}
|
||||
{!editing && (access.data?.tenants.length ?? 0) > 1 && (
|
||||
<select
|
||||
value={activeTenant ?? ''}
|
||||
onChange={(event) => setTenantId(event.target.value)}
|
||||
aria-label="Firma"
|
||||
className="rounded-xl border border-ink-600/70 bg-ink-850/70 px-3 py-2 text-sm text-white focus:border-brand-400/70 focus:outline-none"
|
||||
>
|
||||
{access.data?.tenants.map((tenant) => (
|
||||
<option key={tenant.id} value={tenant.id} className="bg-ink-850">
|
||||
{tenant.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{editing ? (
|
||||
<>
|
||||
<Button variant="secondary" size="sm" onClick={() => setPicker(true)}>
|
||||
<Plus className="size-4" />
|
||||
Přidat widget
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => void resetToDefault()}
|
||||
disabled={saving}
|
||||
>
|
||||
<RotateCcw className="size-4" />
|
||||
Výchozí
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setDraft(layout.data?.items ?? []);
|
||||
setEditing(false);
|
||||
setSaveError(null);
|
||||
}}
|
||||
disabled={saving}
|
||||
>
|
||||
<X className="size-4" />
|
||||
Zrušit
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => void save()} disabled={saving}>
|
||||
<Check className="size-4" />
|
||||
{saving ? 'Ukládám…' : 'Uložit'}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
summary.reload();
|
||||
tickets.reload();
|
||||
incidents.reload();
|
||||
workload.reload();
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
Obnovit
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => setEditing(true)}>
|
||||
<LayoutGrid className="size-4" />
|
||||
Upravit dashboard
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<DataState loading={summary.loading} error={summary.error} onRetry={summary.reload}>
|
||||
{summary.data && (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<StatTile
|
||||
icon={Workflow}
|
||||
label="Aktivní automatizace"
|
||||
value={formatNumber(summary.data.activeAutomations)}
|
||||
hint={`${formatNumber(summary.data.runsToday)} spuštění dnes`}
|
||||
/>
|
||||
<StatTile
|
||||
icon={LifeBuoy}
|
||||
label="Otevřené tickety"
|
||||
value={formatNumber(summary.data.openTickets)}
|
||||
hint="včetně čekajících na vás"
|
||||
tone={summary.data.openTickets > 3 ? 'warn' : 'ok'}
|
||||
/>
|
||||
<StatTile
|
||||
icon={AlarmClock}
|
||||
label="Běžící incidenty"
|
||||
value={formatNumber(summary.data.activeIncidents)}
|
||||
hint={summary.data.activeIncidents === 0 ? 'vše v pořádku' : 'pracujeme na tom'}
|
||||
tone={summary.data.activeIncidents > 0 ? 'danger' : 'ok'}
|
||||
/>
|
||||
<StatTile
|
||||
icon={Clock}
|
||||
label="Ušetřeno tento měsíc"
|
||||
value={`${formatNumber(summary.data.savedHoursMonth)} h`}
|
||||
hint={`dostupnost ${formatPercent(summary.data.uptime, 2)}`}
|
||||
tone="ok"
|
||||
/>
|
||||
</div>
|
||||
{saveError && (
|
||||
<p className="flex items-center gap-2 rounded-xl border border-danger-400/30 bg-danger-500/10 px-4 py-3 text-sm text-danger-400">
|
||||
<AlertCircle className="size-4 shrink-0" />
|
||||
{saveError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="glass rounded-card p-5 sm:p-6">
|
||||
<RunsChart series={summary.data.series} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<DataState
|
||||
loading={layout.loading || catalog.loading}
|
||||
error={layout.error ?? catalog.error}
|
||||
onRetry={() => {
|
||||
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.'
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-6 gap-4">
|
||||
{items.map((item, index) => {
|
||||
const definition = definitions.get(item.widgetId);
|
||||
|
||||
if (!definition) {
|
||||
// Widget zmizel z katalogu. Nesmi to tise vypadnout z rozlozeni.
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="col-span-6 rounded-card border border-danger-400/40 bg-danger-500/8 p-4 text-sm text-danger-400"
|
||||
>
|
||||
Widget „{item.widgetId}" už v katalogu není.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={item.id} className={cn(span[item.size], 'min-w-0')}>
|
||||
{editing && (
|
||||
<EditBar
|
||||
definition={definition}
|
||||
size={item.size}
|
||||
canMoveLeft={index > 0}
|
||||
canMoveRight={index < items.length - 1}
|
||||
onMove={(offset) => move(index, offset)}
|
||||
onResize={(size) => resize(item.id, size)}
|
||||
onRemove={() => remove(item.id)}
|
||||
/>
|
||||
)}
|
||||
<div className={cn(editing && 'pointer-events-none opacity-70')}>
|
||||
<WidgetCard definition={definition} data={data} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DataState>
|
||||
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
<section className="glass rounded-card p-5 sm:p-6">
|
||||
<div className="mb-5 flex items-center justify-between gap-3">
|
||||
<h2 className="font-semibold text-white">Poslední tickety</h2>
|
||||
<Link
|
||||
to="/dashboard/tickety"
|
||||
className="text-sm font-medium text-brand-300 hover:text-brand-200"
|
||||
>
|
||||
Všechny →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<DataState
|
||||
loading={tickets.loading}
|
||||
error={tickets.error}
|
||||
empty={tickets.data?.items.length === 0}
|
||||
onRetry={tickets.reload}
|
||||
emptyLabel="Žádné tickety — dobrá zpráva."
|
||||
>
|
||||
<ul className="divide-y divide-ink-600/50">
|
||||
{tickets.data?.items.slice(0, 4).map((ticket) => (
|
||||
<li key={ticket.id} className="flex items-start gap-3 py-3.5 first:pt-0 last:pb-0">
|
||||
<span className="mt-0.5 font-mono text-xs text-white/35">{ticket.id}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link
|
||||
to={`/dashboard/tickety/${ticket.id}`}
|
||||
className="block truncate text-sm text-white/80 transition-colors hover:text-brand-200"
|
||||
>
|
||||
{ticket.subject}
|
||||
</Link>
|
||||
<p className="mt-0.5 text-xs text-white/40">
|
||||
{ticket.customer.company} · {ticket.assignee?.name ?? 've frontě'} ·{' '}
|
||||
{formatRelative(ticket.updatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<TicketStatusBadge status={ticket.status} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</DataState>
|
||||
</section>
|
||||
|
||||
<section className="glass rounded-card p-5 sm:p-6">
|
||||
<div className="mb-5 flex items-center justify-between gap-3">
|
||||
<h2 className="font-semibold text-white">Incidenty</h2>
|
||||
<Link
|
||||
to="/dashboard/incidenty"
|
||||
className="text-sm font-medium text-brand-300 hover:text-brand-200"
|
||||
>
|
||||
Všechny →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<DataState
|
||||
loading={incidents.loading}
|
||||
error={incidents.error}
|
||||
empty={incidents.data?.items.length === 0}
|
||||
onRetry={incidents.reload}
|
||||
emptyLabel="Žádné incidenty."
|
||||
>
|
||||
<ul className="divide-y divide-ink-600/50">
|
||||
{incidents.data?.items.slice(0, 4).map((incident) => (
|
||||
<li key={incident.id} className="flex items-start gap-3 py-3.5 first:pt-0 last:pb-0">
|
||||
<span className="mt-0.5 grid size-7 shrink-0 place-items-center rounded-lg bg-white/5 text-white/40">
|
||||
<Activity className="size-3.5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-white/80">{incident.title}</p>
|
||||
<p className="mt-0.5 text-xs text-white/40">
|
||||
{incident.service} · začátek {formatRelative(incident.startedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<IncidentStatusBadge status={incident.status} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</DataState>
|
||||
</section>
|
||||
</div>
|
||||
<WidgetPicker
|
||||
open={picker}
|
||||
widgets={unused}
|
||||
onClose={() => setPicker(false)}
|
||||
onPick={add}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className="mb-2 flex flex-wrap items-center gap-1.5 rounded-xl border border-brand-400/35 bg-brand-500/10 px-2.5 py-2">
|
||||
<span className="mr-auto min-w-0 truncate text-xs font-medium text-brand-100">
|
||||
{definition.name}
|
||||
</span>
|
||||
|
||||
{/* Sirku nabizime jen tu, kterou widget unese. */}
|
||||
<select
|
||||
value={size}
|
||||
onChange={(event) => onResize(event.target.value as WidgetSize)}
|
||||
aria-label={`Šířka widgetu ${definition.name}`}
|
||||
className="rounded-lg border border-ink-600/70 bg-ink-900/70 px-2 py-1 text-xs text-white focus:border-brand-400/70 focus:outline-none"
|
||||
>
|
||||
{definition.sizes.map((option) => (
|
||||
<option key={option} value={option} className="bg-ink-850">
|
||||
{sizeLabels[option]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<IconButton label="Posunout dřív" disabled={!canMoveLeft} onClick={() => onMove(-1)}>
|
||||
<ChevronLeft className="size-3.5" />
|
||||
</IconButton>
|
||||
<IconButton label="Posunout později" disabled={!canMoveRight} onClick={() => onMove(1)}>
|
||||
<ChevronRight className="size-3.5" />
|
||||
</IconButton>
|
||||
<IconButton label="Odebrat widget" danger onClick={onRemove}>
|
||||
<Trash2 className="size-3.5" />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IconButton({
|
||||
label,
|
||||
disabled,
|
||||
danger,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
danger?: boolean;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={label}
|
||||
className={cn(
|
||||
'grid size-7 place-items-center rounded-lg text-white/50 transition-colors disabled:pointer-events-none disabled:opacity-25',
|
||||
danger ? 'hover:bg-danger-500/15 hover:text-danger-400' : 'hover:bg-white/8 hover:text-white',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<span className="sr-only">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -314,3 +314,42 @@ export interface DashboardSummary {
|
||||
export interface ListResponse<T> {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user