dashboard widgets

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