diff --git a/documentation/01-prehled-a-stav.md b/documentation/01-prehled-a-stav.md index 65ea3b5..56259b8 100644 --- a/documentation/01-prehled-a-stav.md +++ b/documentation/01-prehled-a-stav.md @@ -21,6 +21,11 @@ React aplikaci ze slozky `dist/public`. | Builder automatizaci | hotovo | strom akci, vetveni podminkou | | Webhook s registrovanou adresou | hotovo | token generuje server, verejny endpoint validuje data | | Tickety na konkretni lidi | hotovo | resitel, filtr moje, prehled vytizeni tymu | +| Prijem udalosti do ticketu | hotovo | webhook na firmu, externi ID unikatni za firmu | +| Udalosti na ticketu | hotovo | dalsi zprava se navesi na tentyz ticket | +| Statistiky resitelu | hotovo | odbaveno, mediany casu, vracene, fronta | +| Pohledy tabulka a dlazdice | hotovo | tickety i lide | +| Stranka Lide a detail osoby | hotovo | vykon a co ma u sebe | | Log ticketu ve strome | hotovo | vcetne toho, co ktera sluzba vratila | | Kanaly do ticketu | hotovo | WhatsApp, e-mail, hlas a formular jako spoustece | | Parametry od sluzby | hotovo | katalog je deklaruje, server je dosazuje pri ulozeni | @@ -39,7 +44,8 @@ React aplikaci ze slozky `dist/public`. | Osoby a skupiny resitelu | hotovo | ticket lze prehodit na skupinu, ne jen na cloveka | | Typy ticketu a vlastni pole | hotovo | typ rozhoduje, ktere akce se na ticketu ukazou | | Vydefinovane akce na ticketu | hotovo | vazba na typ nebo tag, telo je operace, strom, skript | -| Vlastni widgety | hotovo | zdroj dat z ticketu nebo automatizaci, seskupeni | +| Vlastni widgety | hotovo | vcetne zdroje z konektoru a vykonu resitelu | +| Telo akce jako strom | hotovo | tentyz editor jako automatizace | | Audit a prepnuti na jiny ucet | hotovo | prepnuti je vychozi jen pro cteni, vse v auditu | | Bugs a wishes | chybi | vyvojarska agenda, samostatna evidence vedle ticketu | | Beh automatizaci | chybi | ulozeny strom se nevykonava, neni runtime | @@ -117,4 +123,5 @@ pro frontu, beh kroku a rozpocet na 150 klientu, a | [15-rejstrik-funkci.md](15-rejstrik-funkci.md) | k cemu je jaka funkce a komponenta | | [16-monetizace.md](16-monetizace.md) | **navrh**: cena za krok a balicky | | [17-nastaveni-a-prava.md](17-nastaveni-a-prava.md) | prava, typy, akce, widgety, prepnuti uctu | +| [18-ticketovaci-system.md](18-ticketovaci-system.md) | udalosti, externi ID, statistiky, pohledy | | [99-zmeny.md](99-zmeny.md) | zaznam zmen, nejnovejsi nahore | diff --git a/documentation/04-api.md b/documentation/04-api.md index 673c5a7..90e1b81 100644 --- a/documentation/04-api.md +++ b/documentation/04-api.md @@ -16,6 +16,8 @@ Verejne: | POST | `/api/auth/login` | prihlaseni, vraci JWT | | POST | `/api/contact` | poptavka z webu | | POST | `/webhook/:token` | prijem dat do automatizace | +| POST | `/webhook/ticket/:token` | prijem udalosti do ticketu | +| GET | `/webhook/ticket/:token` | napoveda k prijmu | Vyzaduji `Authorization: Bearer `: @@ -30,6 +32,10 @@ Vyzaduji `Authorization: Bearer `: | DELETE | `/api/dashboard/layout` | | GET | `/api/dashboard/summary` | | GET | `/api/dashboard/people` | +| GET | `/api/dashboard/people/:id` | +| GET | `/api/dashboard/intake` | +| POST | `/api/dashboard/intake/regenerate` | +| GET | `/api/dashboard/settings/actions/:id/scope` | | GET | `/api/dashboard/tickets` | | GET | `/api/dashboard/tickets/workload` | | GET | `/api/dashboard/tickets/:id` | @@ -204,6 +210,24 @@ s celym chybovym hlasenim a `durationMs`. Cely prubeh se zapise do logu ticketu. Vestavene akce (`type`, `tags`, `group`, `assign`, `status`, `comment`) jsou zvlast: meni ticket sam, ne cizi sluzbu, a kazda ma vlastni pravo. +## Prijem udalosti do ticketu + +Popis modelu je v [18-ticketovaci-system.md](18-ticketovaci-system.md). + +`POST /webhook/ticket/:token` je **verejny**, autorizuje token firmy v adrese. +Vraci 201 kdyz ticket vznikl, 200 kdyz se udalost navesila na existujici: + +```json +{ "ok": true, "created": false, "ticketId": "TK-4822", "externalId": "3", "eventId": "tev_2" } +``` + +`externalId` je unikatni **v ramci firmy**. Token urcuje firmu, takze dve firmy +mohou obe poslat objednavku cislo 3 a nedojde ke smichani. Cislo i retezec jsou +tentyz klic. + +Neznamy `typeId` se zahodi a zaloguje, ticket vznikne bez typu. Odmitnout celou +udalost kvuli jednomu poli by znamenalo ztratu dat. + ## Prava a navigace `GET /api/dashboard/access` vraci `permissions` (efektivni prava po slouceni diff --git a/documentation/15-rejstrik-funkci.md b/documentation/15-rejstrik-funkci.md index a4c1eeb..46a92bc 100644 --- a/documentation/15-rejstrik-funkci.md +++ b/documentation/15-rejstrik-funkci.md @@ -37,6 +37,10 @@ Volající nikdy nezjišťuje, jestli běží Postgres, soubor, nebo pamět. | `hasPermission(...)` | `src/data/permissions.ts` | Jedna kontrola. Používá ji `crudRouter` i ruční handlery. | | `navFor(...)` | `src/data/tenantFeatures.ts` | Průnik toho, co firma má, a toho, na co má člověk právo. Navigace chodí ze serveru. | | `recordAudit(input)` | `src/data/audit.ts` | Zápis do auditu. Nevrací chybu a nečeká se - rozbitý audit nesmí rozbít aplikaci. | +| `intakeEvent(input)` | `src/data/ticketStore.ts` | Přijme událost zvenku: podle externího ID buď založí ticket, nebo ji navěsí na existující. Jediná cesta, kterou se událost stává ticketem. | +| `getAgentStats(...)` | `src/data/ticketStore.ts` | Výkon řešitelů: odbavené, mediány časů, vrácené, fronta. Používá to widget i detail osoby, aby čísla seděla. | +| `findByExternalId(...)` | `src/data/ticketStore.ts` | Ticket firmy podle externího ID. Klíč je dvojice firma a ID. | +| `findByIntakeToken(token)` | `src/data/tenants.ts` | Firma podle tokenu příjmu. Určuje i to, v jakém rozsahu je externí ID unikátní. | | `refreshCaches()` | `src/data/bootstrap.ts` | Obnoví všechny kopie v paměti. Volá se po zápisu, který je může změnit. | | `bootstrapData({databaseReady})` | `src/data/bootstrap.ts` | Seznam všech entit a provozních dat. **Nová entita se přidává tady**, ne rozesetě po modulech. | @@ -64,10 +68,14 @@ Viz [11-skripty-konektoru.md](11-skripty-konektoru.md). | `parseJsonField` | `components/dashboard/EntityAdmin.tsx` | Textové pole s JSONem na hodnotu, s hlášením, kde je chyba. | | `TicketActions` | `components/dashboard/TicketActions.tsx` | CTA akcí na ticketu plus typ, tagy a vlastní pole. Seznam akcí chodí ze serveru už vyfiltrovaný. | | `ErrorDetail` | `components/dashboard/ErrorDetail.tsx` | Rozbalovací celé chybové hlášení s kopírováním. Chyba se nikdy nezkracuje. | +| `CustomWidgetCard` | `components/dashboard/widgets/CustomWidget.tsx` | Vykreslí widget, jehož data počítá server: číslo, pruhy, tabulka výkonu, časová řada, seznam, data z konektoru. | +| `TicketEvents` | `components/dashboard/TicketEvents.tsx` | Příchozí události ticketu včetně celého přijatého JSONu. | +| `ViewSwitch` | `components/dashboard/ViewSwitch.tsx` | Přepínač tabulka nebo dlaždice. Používají ho všechny seznamy. | +| `FlowCanvas` s `start` | `components/dashboard/flow/FlowCanvas.tsx` | Tentýž strom kroků i bez spouštěče - pro tělo akce, které spouští člověk. | | `MappingEditor` | `components/dashboard/flow/MappingEditor.tsx` | Editor transformací v obou režimech (pole na pole, JSON). | | `DataState` | `components/dashboard/DataState.tsx` | Načítání, chyba, prázdno. Ať to každá stránka nekreslí po svém. | | `apiFetch` | `lib/api.ts` | Jediná cesta na API: base path, token, `ApiError` s celým hlášením ze serveru. | -| `useApiQuery` | `lib/useApiQuery.ts` | Načtení dat do stránky včetně `reload`. | +| `useApiQuery` | `lib/useApiQuery.ts` | Načtení dat do stránky včetně `reload`. S `body` pošle POST (dávkové načtení), s `enabled: false` se neptá vůbec. | | `cn(...)` | `lib/cn.ts` | Skládání tříd. Podmíněné třídy nikdy ručně přes šablonu. | | `format*` | `lib/format.ts` | Čísla, procenta, datum, relativní čas, trvání. Formátování se nepíše v komponentě. | | `serviceIcon(key)` | `lib/serviceIcons.ts` | Klíč ikony ze serveru na komponentu. Server neposílá komponenty. | diff --git a/documentation/18-ticketovaci-system.md b/documentation/18-ticketovaci-system.md new file mode 100644 index 0000000..4a69116 --- /dev/null +++ b/documentation/18-ticketovaci-system.md @@ -0,0 +1,188 @@ +# Ticketovací systém + +Jak se z události stane ticket, jak se k němu navěsí další, a co se z toho dá +vyčíst. Model práv a typů je v [17-nastaveni-a-prava.md](17-nastaveni-a-prava.md). + +## Ticketem se může stát jakákoliv událost + +Vstup je jeden veřejný endpoint na firmu: + +``` +POST /webhook/ticket/ +``` + +Token patří **firmě**, ne automatizaci. Založit ticket má jít i bez toho, aby +se kvůli tomu stavěl strom. Adresu najde ten, kdo spravuje napojení, na +`GET /api/dashboard/intake`; přegeneruje se `POST /api/dashboard/intake/regenerate` +a stará okamžitě přestane platit. + +Tělo: + +```json +{ + "externalId": 3, + "source": "eshop", + "event": "order.created", + "subject": "Objednávka 3", + "typeId": "tt_order", + "fields": { "orderNumber": "3", "orderTotal": 2450 }, + "tags": ["vip"], + "payload": { "cokoliv": "co se má uložit celé" } +} +``` + +Povinné není nic kromě těla samotného. Chybí předmět? Použije se popisek nebo +typ události. Neznámý `typeId`? Zahodí se a zaloguje, ticket vznikne bez typu - +odmítnout celou událost kvůli překlepu v jednom poli by znamenalo ztrátu dat. + +## Externí ID a navěšování + +`externalId` je ID u odesílatele, typicky číslo objednávky. Je **unikátní +v rámci firmy**, ne globálně: dvě firmy můžou mít objednávku číslo 3 a nesmí si +o sebe zavadit. Firmu určuje token v adrese. + +Když už ticket se stejným externím ID v té firmě je, událost se na něj +**navěsí** místo založení druhého: + +``` +POST /webhook/ticket/ {"externalId": 3, "event": "order.created"} -> 201, vznikl TK-4822 +POST /webhook/ticket/ {"externalId": 3, "event": "email.sent"} -> 200, doplněn TK-4822 +POST /webhook/ticket/ {"externalId": 3} -> 201, vznikl TK-4823 +``` + +Číslo a řetězec jsou tentýž klíč: odesílatel pošle `3`, my držíme `"3"`. Jinak +by `3` a `"3"` byly dva tickety a nikdo by nepoznal proč. + +Bez `externalId` se vždycky zakládá nový ticket. Hádat podle předmětu by +slučovalo věci, které spolu nesouvisí. + +### Událost není řádek logu + +Dvě různé věci, které se snadno pletou: + +| | Co to je | Kde se bere | +| --- | --- | --- | +| **Událost** | fakt zvenku, celá přijatá data | poslal odesílatel | +| **Řádek logu** | naše stopa toho, co se dělo uvnitř | zapsala aplikace | + +Události se ukazují na detailu ticketu nad logem a dají se rozbalit na celý +přijatý JSON. Když se někdo ptá, proč ticket vypadá takhle, je to jediná +odpověď. Drží se jich nejvýš 200 na ticket, starší se odmazávají - nekonečně +rostoucí ticket by při každém zápisu přepisoval víc a víc dat. + +## Co se u ticketu měří + +Aby šlo říct, kdo kolik odbavil a komu to nejde, nestačí počítat vyřešené. +Ticket proto nese: + +| Pole | Kdy se zapíše | K čemu | +| --- | --- | --- | +| `firstResponseAt` | při prvním přiřazení, komentáři nebo změně stavu | jak dlouho zákazník čekal na reakci | +| `resolvedAt` | při přechodu na vyřešeno | doba řešení | +| `resolvedById` | tamtéž, je to ten, kdo ho měl u sebe | komu se vyřešení připíše | +| `reopenCount` | při návratu z vyřešeno | kolikrát to hotové nebylo | + +`firstResponseAt` se zapisuje **jednou a nepřepisuje**. Je to okamžik, kdy +zákazník přestal čekat. Kdyby se přepisoval při každé změně, měřil by poslední +dotek, což je úplně jiná veličina. + +`reopenCount` je záměrně vedle počtu vyřešených. Samotný počet vyřešených +odměňuje toho, kdo tickety zavíral předčasně. + +## Výkon řešitelů + +Widget **Výkon řešitelů** (`panel.agents`) a detail osoby ukazují za 30 dní: + +- **odbaveno** - kolik vyřešil, +- **ve frontě** - kolik má právě teď, +- **doba řešení** a **reakce** - mediány, +- **vráceno** - kolik se mu jich vrátilo, +- **nejstarší** - co mu leží nejdéle. + +Medián, ne průměr: jeden ticket zapomenutý přes dovolenou by průměr úplně +rozhodil. Fronta se počítá vždycky celá, bez ohledu na období - leží tam bez +ohledu na to, na co se zrovna díváme. + +Čísla počítá `getAgentStats` v `src/data/ticketStore.ts` a používá je widget +i detail osoby. Kdyby si je stránka počítala sama, na dvou místech by vyšlo +něco jiného. + +## Pohledy + +| Stránka | Co ukazuje | +| --- | --- | +| Tickety | seznam s filtry, **tabulka nebo dlaždice** | +| Detail ticketu | obsah, události, log, akce, typ, tagy, řešitel, skupina | +| Lidé | řešitelé firmy a jejich vytížení, **tabulka nebo dlaždice** | +| Detail osoby | její výkon, co má u sebe, co naposledy vyřešila | + +Přepínač pohledu je jedna komponenta (`components/dashboard/ViewSwitch.tsx`) +a používají ji obě stránky se seznamem. + +## Widgety nad daty i nad konektory + +Widget je dvojice: `render` (jak se to kreslí) a `source` (odkud jsou data). +Zdroje: + +| Zdroj | Co dělá | +| --- | --- | +| `ticketCount` | počet ticketů podle filtru, volitelně seskupený | +| `ticketList` | seznam ticketů | +| `ticketSeries` | časová řada | +| `workload` | kdo co má u sebe | +| `agentStats` | výkon řešitelů | +| `connector` | **data z napojené služby** | + +Zdroj `connector` zavolá **tentýž skript**, který používá krok automatizace +i akce na ticketu, a z výsledku vezme, co je v `path`. Widget nemá vlastní +cestu k cizí službě - jinak by se chovala jinak než zbytek aplikace. + +```json +{ + "kind": "connector", + "serviceId": "idoklad", + "operationId": "find-issued-invoice", + "connectorId": null, + "inputs": {}, + "path": "total", + "ttlSec": 300 +} +``` + +Výsledek se **drží v mezipaměti** (`ttlSec`, nejméně 30 s). Bez toho by každé +otevření přehledu znamenalo volání cizího API za každou dlaždici, a to má +limity a někdy se za něj platí. U dat z konektoru se vedle čísla ukazuje jejich +stáří a jestli jsou z mezipaměti. + +Data všech dlaždic chodí **jedním requestem** (`POST /api/dashboard/widget-data`). +Widget, který selže, hlásí chybu **na své pozici** a celou, nezkrácenou - jeden +rozbitý zdroj nesmí zhasnout celý přehled. + +## Kde se co definuje + +Akce a widgety **nejsou v nastavení**. Je to definice toho, co aplikace umí, +stejná úroveň jako automatizace, a mají vlastní záložku: + +| Záložka | Co tam patří | +| --- | --- | +| Automatizace | stromy, které běží samy | +| Akce | tlačítka na ticketu, tělo je **tentýž strom** | +| Widgety | dlaždice na přehled | +| Nastavení | firmy, lidé, role, typy ticketů, audit | + +Tělo akce se skládá stejným editorem jako automatizace. Místo karty spouštěče +je karta "spouští člověk tlačítkem na ticketu" a parametry, na které jde +v krocích odkazovat, jsou údaje ticketu plus vlastní pole jeho typu. Ty počítá +server (`GET /api/dashboard/settings/actions/:id/scope`), aby si klient nedělal +druhý seznam, který se časem rozejde. + +## Co zatím nejde + +Uložený strom se **nevykoná** - chybí runtime, stejně jako u automatizací. +Akce s jednou operací běží, protože ta se dá poslat rovnou do skriptu. Popis +toho, jak má runtime vypadat, je v +[10-runtime-a-kapacita.md](10-runtime-a-kapacita.md). + +Externí ID hlídá jedinečnost v paměti procesu. Nad databází k tomu patří +částečný unikátní index na dvojici `(tenant_id, external_id)`, aby to platilo +i při běhu na víc strojích. diff --git a/documentation/99-zmeny.md b/documentation/99-zmeny.md index 1f89360..ea6e68b 100644 --- a/documentation/99-zmeny.md +++ b/documentation/99-zmeny.md @@ -2,6 +2,62 @@ Nejnovejsi nahore. +## 2026-08-13 - ticketovaci system: udalosti, externi ID, statistiky, widgety + +Popis v [18-ticketovaci-system.md](18-ticketovaci-system.md). + +### Pridano - prijem udalosti + +- `POST /webhook/ticket/:token`: **jakakoliv udalost se muze stat ticketem**. + Token patri firme, ne automatizaci, takze zalozit ticket jde i bez stromu. +- `externalId` na ticketu, **unikatni v ramci firmy**. Dalsi udalost se stejnym + ID se navesi na existujici ticket misto zalozeni druheho. Cislo a retezec + jsou tentyz klic, jinak by `3` a `"3"` byly dva tickety. +- Udalosti se u ticketu drzi cele vcetne prijatych dat a jdou rozbalit + v detailu. Je to neco jineho nez log: log je nase stopa, udalost fakt zvenku. + +### Pridano - co se meri + +- `firstResponseAt`, `resolvedAt`, `resolvedById` a `reopenCount` na ticketu. + Bez nich neslo rict, kdo kolik odbavil ani jak dlouho zakaznik cekal. +- `getAgentStats`: odbavene za obdobi, fronta, medianove casy do vyreseni + a do prvni reakce, vracene tickety. Median zamerne, ne prumer. +- Widget **Vykon resitelu** a detail osoby pouzivaji tutéž funkci, takze + cisla sedi na obou mistech. + +### Pridano - widgety + +- Klient konecne vola `/api/dashboard/widget-data`. Predtim endpoint existoval, + ale nikdo ho nepouzival, takze vlastni widget hlasil "nepodarilo se zobrazit". +- Novy zdroj `connector`: co umi zjistit napojena sluzba, jde vytahnout do + dlazdice. Vola se tentyz skript jako v kroku automatizace, vysledek se + cachuje (`ttlSec`, nejmene 30 s). +- Mrtvy odkaz v rozlozeni jde v rezimu uprav odstranit, ne jen precist. + +### Zmeneno + +- **Akce a widgety maji vlastni zalozku**, uz nejsou v nastaveni. Je to + definice toho, co aplikace umi, stejna uroven jako automatizace. +- **Telo akce se sklada stromem**, ne JSONem v textarei. Je to tentyz editor + jako u automatizaci, jen misto karty spoustece je "spousti clovek". +- Nova zalozka **Lide** se seznamem resitelu a detailem osoby. +- Seznam ticketu i lidi ma **dva pohledy**, tabulku a dlazdice. +- Dlouhe pomlcky pryc z celeho projektu (48 znaku ve 23 souborech). + +### Opraveno + +- `createTicket` bral `typeId`, `fields`, `tags` i `assigneeGroupId`, ale + **nikdy je neukladal**. Ticket zalozeny s typem tak zustaval bez typu + a bez vlastnich poli. + +### Overeno + +21 kontrol proti bezicimu serveru v rezimu souboru: navazani druhe udalosti na +tentyz ticket, oddeleni firem pri stejnem externim ID, ulozeni typu a poli +z prijmu, vyreseni s casem i clovekem, zapocteni navratu z vyreseno, cisla ve +widgetu vykonu, cele chybove hlaseni u widgetu nad nenapojenym konektorem, +detail osoby, rozsah parametru akce, ulozeni stromu akce a navigace. + ## 2026-08-13 - firmy, prava, typy ticketu, akce, widgety a uloziste pro vsechno Dodelany cely [navrh rozsireni](09-navrh-rozsireni.md) a vsechna data se diff --git a/src/data/customWidgets.ts b/src/data/customWidgets.ts index abca3fe..491d3f9 100644 --- a/src/data/customWidgets.ts +++ b/src/data/customWidgets.ts @@ -42,7 +42,38 @@ export type WidgetSource = | { kind: 'ticketCount'; filter: WidgetTicketFilter; groupBy?: WidgetGroupBy } | { kind: 'ticketList'; filter: WidgetTicketFilter; limit: number } | { kind: 'ticketSeries'; filter: WidgetTicketFilter; bucket: 'day' | 'week' } - | { kind: 'workload'; groupIds?: string[] }; + | { kind: 'workload'; groupIds?: string[] } + /** + * Vykon resitelu: kolik kdo odbavil, jak dlouho mu to trva a kolik se mu + * toho vratilo. `period` plati jen na vyresene - fronta neni vec obdobi. + */ + | { kind: 'agentStats'; period?: WidgetPeriod } + /** + * Data z napojene sluzby. Co umi zjistit konektor, jde vytahnout do dlazdice. + * + * Vola se **tentyz skript**, ktery pouziva krok automatizace a akce na + * ticketu. Widget si nic vlastniho nevymysli, jen vybere z vysledku: + * - `path` je cesta ve vystupu skriptu, napr. `total` nebo `items`, + * - cislo se ukaze jako cislo, seznam jako radky. + * + * Vysledek se **cachuje** (`ttlSec`), aby otevreni prehledu neznamenalo + * deset volani cizi sluzby. Cizi API ma limity a plati se za ne. + */ + | { + kind: 'connector'; + serviceId: string; + operationId: string; + /** null = vychozi konektor firmy pro tuhle sluzbu. */ + connectorId: string | null; + inputs: Record; + /** Cesta ve vystupu skriptu. Prazdne = cely vystup. */ + path?: string; + /** U seznamu: co v radku ukazat jako popisek a co jako hodnotu. */ + labelPath?: string; + valuePath?: string; + /** Jak dlouho se vysledek drzi. Vychozi 300 s, nejmene 30. */ + ttlSec?: number; + }; export type WidgetSize = 'third' | 'half' | 'full'; @@ -67,6 +98,32 @@ export function seedCustomWidgets(): CustomWidget[] { const base = { tenantId: 'tnt_automia', ownerId: null, createdAt: timestamp, updatedAt: timestamp }; return [ + { + ...base, + id: 'cw_idoklad_invoices', + name: 'Faktury v iDokladu', + description: 'Ukázka widgetu nad konektorem. Bez napojení hlásí, co chybí.', + render: 'stat', + source: { + kind: 'connector', + serviceId: 'idoklad', + operationId: 'find-issued-invoice', + connectorId: null, + inputs: {}, + path: 'total', + ttlSec: 300, + }, + size: 'third', + }, + { + ...base, + id: 'cw_agents_30d', + name: 'Kdo kolik odbavil', + description: 'Vyřešené za 30 dní, doba řešení, vrácené tickety a fronta.', + render: 'table', + source: { kind: 'agentStats', period: '30d' }, + size: 'full', + }, { ...base, id: 'cw_orders_open', @@ -141,12 +198,26 @@ export function validateWidget(widget: CustomWidget): string[] { } // Cislo ze seznamu nebo casove rady nejde vykreslit jako jedna hodnota. - if (widget.render === 'stat' && widget.source.kind !== 'ticketCount') { - problems.push('Jedno číslo umí jen zdroj Počet ticketů.'); + if (widget.render === 'stat' && !['ticketCount', 'connector'].includes(widget.source.kind)) { + problems.push('Jedno číslo umí jen zdroj Počet ticketů nebo Konektor.'); } if (widget.render === 'chart' && widget.source.kind !== 'ticketSeries') { problems.push('Graf umí jen zdroj Časová řada.'); } + if (widget.render === 'table' && widget.source.kind === 'ticketList') { + problems.push('Tabulka potřebuje seskupení nebo výkon řešitelů, ne seznam ticketů.'); + } + + if (widget.source.kind === 'connector') { + const { serviceId, operationId, ttlSec } = widget.source; + if (!serviceId || !operationId) { + problems.push('U zdroje Konektor vyberte službu i operaci.'); + } + // Bez rozumneho minima by kazde otevreni prehledu volalo cizi sluzbu znovu. + if (ttlSec !== undefined && ttlSec < 30) { + problems.push('Data z konektoru se drží nejméně 30 sekund.'); + } + } return problems; } diff --git a/src/data/tenantFeatures.ts b/src/data/tenantFeatures.ts index 23dbd56..58cc446 100644 --- a/src/data/tenantFeatures.ts +++ b/src/data/tenantFeatures.ts @@ -24,10 +24,13 @@ import type { User } from '../types.js'; export type ModuleKey = | 'overview' | 'automations' + | 'actions' + | 'widgets' | 'services' | 'connectors' | 'scripts' | 'tickets' + | 'people' | 'incidents' | 'settings'; @@ -49,7 +52,17 @@ export interface ModuleDefinition { export const moduleCatalog: ModuleDefinition[] = [ { key: 'overview', label: 'Přehled', permissions: [], required: true }, { key: 'tickets', label: 'Tickety', permissions: ['ticket.view'] }, + { key: 'people', label: 'Lidé', permissions: ['ticket.view'], required: true }, { key: 'automations', label: 'Automatizace', permissions: ['automation.edit'] }, + /* + * Akce a widgety jsou **stejna uroven jako automatizace**, ne polozka + * v nastaveni. Je to definice toho, co aplikace umi, ne nastaveni uctu. + * + * `required` je tu schvalne: firmy zalozene driv maji v ulozisti seznam + * modulu bez techhle klicu a bez toho by se jim zalozky nikdy neobjevily. + */ + { key: 'actions', label: 'Akce', permissions: ['action.manage'], required: true }, + { key: 'widgets', label: 'Widgety', permissions: ['widget.manage'], required: true }, { key: 'services', label: 'Služby', permissions: ['connector.manage'] }, { key: 'connectors', label: 'Konektory', permissions: ['connector.manage'] }, { key: 'incidents', label: 'Incidenty', permissions: ['ticket.view'] }, diff --git a/src/data/tenants.ts b/src/data/tenants.ts index 1a68b9b..49f64da 100644 --- a/src/data/tenants.ts +++ b/src/data/tenants.ts @@ -13,6 +13,7 @@ * Vzor je popsany v `store/cached.ts`. */ +import { randomBytes } from 'node:crypto'; import { defineStore, nowIso, type TenantEntity } from './store/index.js'; import { withCache } from './store/cached.js'; @@ -21,6 +22,14 @@ export interface Tenant extends TenantEntity { /** Kratka poznamka pro nas, klient ji nevidi. */ note: string; enabled: boolean; + /** + * Token pro prijem udalosti na `/webhook/ticket/:token`. + * + * Je na firme, ne na automatizaci: prijem ticketu ma fungovat i bez toho, + * aby se kvuli nemu stavel strom. Token urcuje, ktere firme udalost patri, + * a tim i to, v jakem rozsahu je externi ID unikatni. + */ + intakeToken: string; } /** @@ -31,16 +40,33 @@ export interface Tenant extends TenantEntity { export const tenantStore = defineStore('tenant'); const cache = withCache(tenantStore); +/** Neodhadnutelny token do adresy. Stejny tvar jako u webhooku automatizace. */ +export function generateIntakeToken(): string { + return randomBytes(24).toString('base64url'); +} + export function seedTenants(): Tenant[] { const timestamp = nowIso(); const base = { tenantId: null, note: '', enabled: true, createdAt: timestamp, updatedAt: timestamp }; return [ - { ...base, id: 'tnt_automia', name: 'Automia' }, - { ...base, id: 'tnt_nordis', name: 'Nordis a.s.' }, - { ...base, id: 'tnt_logitrans', name: 'LogiTrans' }, + { ...base, id: 'tnt_automia', name: 'Automia', intakeToken: generateIntakeToken() }, + { ...base, id: 'tnt_nordis', name: 'Nordis a.s.', intakeToken: generateIntakeToken() }, + { ...base, id: 'tnt_logitrans', name: 'LogiTrans', intakeToken: generateIntakeToken() }, ]; } +/** + * Firma podle tokenu prijmu. + * + * Prochazi se linearne, firem jsou desitky. Az jich budou tisice, patri sem + * index - ne driv, protoze udrzovat dve mapy kvuli desitkam zaznamu je zbytecna + * prilezitost k chybe. + */ +export function findByIntakeToken(token: string): Tenant | undefined { + if (token.length < 16) return undefined; + return cache.all().find((tenant) => tenant.intakeToken === token && tenant.enabled); +} + export async function refreshTenants(): Promise { await cache.refresh(); } diff --git a/src/data/ticketStore.ts b/src/data/ticketStore.ts index 73622ca..00b8fd0 100644 --- a/src/data/ticketStore.ts +++ b/src/data/ticketStore.ts @@ -76,10 +76,41 @@ export interface TicketTraceEntry { at: string; } +/** + * Jedna prichozi udalost k ticketu. + * + * Rozdil proti radku logu: log je nase stopa toho, co se delo uvnitr, udalost + * je **fakt zvenku**. Odesilatel posle "objednavka 3 vznikla" a pozdeji + * "objednavka 3 vyfakturovana" - obojí patri k jednomu ticketu a obojí musi + * zustat v puvodni podobe, vcetne cele prijate zpravy. + */ +export interface TicketEvent { + id: string; + /** Typ od odesilatele, napr. `order.created`, `email.sent`. */ + type: string; + /** Kdo to poslal: `webhook`, `portal`, ID automatizace. */ + source: string; + /** Popisek do casove osy. */ + label: string; + /** Cela prijata data. Nic se nezahazuje, at jde dohledat, co presne prislo. */ + payload: Record; + at: string; +} + export interface Ticket { id: string; /** Firma, ktere ticket patri. Hranice viditelnosti, viz data/access.ts. */ tenantId: string; + /** + * ID u odesilatele, napr. cislo objednavky. + * + * **Unikatni v ramci firmy**, ne globalne: dve firmy mohou mit objednavku + * cislo 3 a nesmi si o sebe zavadit. Dalsi zprava se stejnym externim ID se + * **navesi na existujici ticket** misto zalozeni druheho, viz `intakeEvent`. + */ + externalId: string | null; + /** Odkud externi ID pochazi, napr. `eshop`. Jen informace, do klice nevstupuje. */ + externalSource: string | null; subject: string; /** * Cely text pozadavku - telo e-mailu, zprava z WhatsApp, prepis hovoru. @@ -114,12 +145,30 @@ export interface Ticket { tags: string[]; /** Automatizace, ktera ticket zalozila. null = zalozeno rucne. */ automationId: string | null; + /** + * Kdy se ticketu prvne nekdo venoval: prirazeni, komentar, zmena stavu. + * Bez toho nejde rict, jak dlouho zakaznik cekal na reakci. + */ + firstResponseAt: string | null; + /** Kdy byl vyresen. Znovuotevreni to vynuluje. */ + resolvedAt: string | null; + /** Kdo ho vyresil. Bez toho nejde rict, kdo kolik odbavil. */ + resolvedById: string | null; + /** + * Kolikrat se vratil z vyreseno zpatky do prace. + * + * Nejlepsi jednoduchy ukazatel toho, ze prace nebyla hotova. Pocet vyresenych + * sam o sobe odmenuje toho, kdo tickety zaviral predcasne. + */ + reopenCount: number; createdAt: string; updatedAt: string; } export interface TicketDetail extends Ticket { trace: TicketTraceEntry[]; + /** Prichozi udalosti, nejstarsi nahore. */ + events: TicketEvent[]; } /** @@ -130,12 +179,32 @@ export interface TicketDetail extends Ticket { * `toTicket`, takze navenek je `Ticket` uplny. */ interface StoredTicket - extends Omit { + extends Omit< + Ticket, + | 'assignee' + | 'typeId' + | 'fields' + | 'tags' + | 'assigneeGroupId' + | 'externalId' + | 'externalSource' + | 'firstResponseAt' + | 'resolvedAt' + | 'resolvedById' + | 'reopenCount' + > { assigneeId: string | null; assigneeGroupId?: string | null; typeId?: string | null; fields?: Record; tags?: string[]; + externalId?: string | null; + externalSource?: string | null; + firstResponseAt?: string | null; + resolvedAt?: string | null; + resolvedById?: string | null; + reopenCount?: number; + events?: TicketEvent[]; } export const channelLabels: Record = { @@ -167,11 +236,18 @@ interface PersistedTicket extends StoredTicket { trace: TicketTraceEntry[]; } +/** Udalosti drzime u ticketu stejne jako log - jina zivotnost, stejny zaznam. */ +const events = new Map(); + const mirror = withMirror(defineStore('ticket')); /** Ulozi ticket vcetne logu. Necekana se, chyba se loguje. */ function persist(ticket: StoredTicket): void { - mirror.save({ ...ticket, trace: traces.get(ticket.id) ?? [] }); + mirror.save({ + ...ticket, + trace: traces.get(ticket.id) ?? [], + events: events.get(ticket.id) ?? [], + }); } /** @@ -198,10 +274,12 @@ export async function initTickets(): Promise { tickets.length = 0; traces.clear(); + events.clear(); for (const row of rows) { - const { trace, ...stored } = row; + const { trace, events: rowEvents, ...stored } = row; tickets.push(stored); traces.set(row.id, trace ?? []); + events.set(row.id, rowEvents ?? []); } // Citac musi pokracovat za nejvyssim ulozenym cislem, jinak by nove tickety @@ -697,7 +775,7 @@ seed( // ------------------------------------------------------------------ prevody function toTicket(stored: StoredTicket): Ticket { - const { assigneeId, ...rest } = stored; + const { assigneeId, events: _events, ...rest } = stored; // Chybejici nova pole dostanou vychozi hodnotu, aby navenek byl Ticket uplny. const base = { ...rest, @@ -705,6 +783,12 @@ function toTicket(stored: StoredTicket): Ticket { typeId: stored.typeId ?? null, fields: stored.fields ?? {}, tags: stored.tags ?? [], + externalId: stored.externalId ?? null, + externalSource: stored.externalSource ?? null, + firstResponseAt: stored.firstResponseAt ?? null, + resolvedAt: stored.resolvedAt ?? null, + resolvedById: stored.resolvedById ?? null, + reopenCount: stored.reopenCount ?? 0, }; if (!assigneeId) return { ...base, assignee: null }; @@ -760,7 +844,11 @@ export function getTicket(id: string, tenantIds: string[]): TicketDetail | undef console.warn(`[tickets] pokus o cteni ticketu ${id} mimo povolene firmy`); return undefined; } - return { ...toTicket(stored), trace: traces.get(id) ?? [] }; + return { + ...toTicket(stored), + trace: traces.get(id) ?? [], + events: events.get(id) ?? [], + }; } /** Prvni nevyrizeny ticket - pouziva simulace, kdyz uzivatel neurci ktery. */ @@ -825,10 +913,259 @@ export function getWorkload(everyone: Person[], tenantIds: string[]): Workload { }; } +/** + * Prvni reakce se zapisuje jednou a uz se neprepisuje. + * + * Je to cas, kdy zakaznik prestal cekat. Kdyby se prepisoval pri kazde zmene, + * merilo by to posledni dotek, coz je uplne jina velicina. + */ +function markResponded(ticket: StoredTicket): void { + if (!ticket.firstResponseAt) ticket.firstResponseAt = new Date().toISOString(); +} + +/** Ticket firmy podle externiho ID. Klic je dvojice firma a ID, ne ID samotne. */ +export function findByExternalId(tenantId: string, externalId: string): Ticket | undefined { + const stored = tickets.find( + (ticket) => ticket.tenantId === tenantId && ticket.externalId === externalId, + ); + return stored ? toTicket(stored) : undefined; +} + +export interface IntakeInput { + tenantId: string; + /** Bez nej se navazani nema o co oprit a zalozi se novy ticket. */ + externalId: string | null; + externalSource?: string | null; + /** Typ udalosti od odesilatele, napr. `order.created`. */ + type: string; + /** Popisek do casove osy. Bez nej se pouzije typ udalosti. */ + label?: string; + /** Cela prijata data. */ + payload?: Record; + /** Pouzije se jen kdyz ticket teprve vznika. */ + create?: Partial>; + /** Vlastni pole, ktera se doplni i na existujici ticket. */ + fields?: Record; + /** Tagy, ktere se **pridaji**. Existujici se nemazou. */ + addTags?: string[]; +} + +export interface IntakeResult { + ticket: Ticket; + /** true = ticket teprve ted vznikl, false = udalost se navesila na existujici. */ + created: boolean; + event: TicketEvent; +} + +/** + * Prijem udalosti zvenku. + * + * Tohle je vstup do ticketovaciho systemu: **jakakoliv udalost se muze stat + * ticketem**. Kdyz uz ticket se stejnym externim ID ve **stejne firme** je, + * udalost se na nej navesi misto zalozeni druheho. Diky tomu muze odesilatel + * poslat "objednavka 3 vznikla" a za hodinu "objednavka 3 vyfakturovana" + * a obojí skonci na jednom miste. + * + * Bez externiho ID se vzdy zaklada novy ticket - nemame podle ceho navazovat + * a hadat podle predmetu by slucovalo veci, ktere spolu nesouvisi. + */ +export function intakeEvent(input: IntakeInput): IntakeResult { + const timestamp = new Date().toISOString(); + const event: TicketEvent = { + id: nextEventId(), + type: input.type, + source: input.externalSource ?? 'webhook', + label: input.label ?? input.type, + payload: input.payload ?? {}, + at: timestamp, + }; + + const existing = input.externalId + ? tickets.find( + (ticket) => ticket.tenantId === input.tenantId && ticket.externalId === input.externalId, + ) + : undefined; + + if (existing) { + appendEvent(existing, event); + + if (input.fields) existing.fields = { ...(existing.fields ?? {}), ...input.fields }; + if (input.addTags && input.addTags.length > 0) { + existing.tags = [...new Set([...(existing.tags ?? []), ...input.addTags])]; + } + touch(existing); + + appendTrace(existing.id, [ + { kind: 'trigger', status: 'info', label: `Přijata událost: ${event.label}`, response: describePayload(event.payload) }, + ]); + publish('ticket.updated', `Ticket ${existing.id}: ${event.label}`, { + ticketId: existing.id, + externalId: existing.externalId, + }); + + return { ticket: toTicket(existing), created: false, event }; + } + + const created = createTicket({ + tenantId: input.tenantId, + externalId: input.externalId, + externalSource: input.externalSource ?? null, + subject: input.create?.subject ?? event.label, + body: input.create?.body ?? '', + sourceRef: input.create?.sourceRef ?? null, + channel: input.create?.channel ?? 'form', + customer: input.create?.customer ?? { id: null, company: '', contact: '', reply: '' }, + priority: input.create?.priority ?? 'normal', + assigneeId: input.create?.assigneeId ?? null, + assigneeGroupId: input.create?.assigneeGroupId ?? null, + typeId: input.create?.typeId ?? null, + fields: { ...(input.create?.fields ?? {}), ...(input.fields ?? {}) }, + tags: [...new Set([...(input.create?.tags ?? []), ...(input.addTags ?? [])])], + automationId: input.create?.automationId ?? null, + trace: [ + { + kind: 'trigger', + status: 'info', + label: `Ticket vznikl z události: ${event.label}`, + response: describePayload(event.payload), + }, + ], + }); + + const stored = tickets.find((ticket) => ticket.id === created.id); + if (stored) { + appendEvent(stored, event); + persist(stored); + } + + return { ticket: toTicket(stored ?? created as unknown as StoredTicket), created: true, event }; +} + +/** Kolik udalosti se u jednoho ticketu drzi. Starsi se odmazavaji. */ +const MAX_EVENTS = 200; + +function appendEvent(ticket: StoredTicket, event: TicketEvent): void { + const list = events.get(ticket.id) ?? []; + list.push(event); + // Nekonecne rostouci ticket by pri kazdem zapisu prepisoval vic a vic dat. + if (list.length > MAX_EVENTS) list.splice(0, list.length - MAX_EVENTS); + events.set(ticket.id, list); +} + +let eventCounter = 0; + +function nextEventId(): string { + eventCounter += 1; + return `tev_${eventCounter.toString(36)}`; +} + +/** Kratky popis dat do logu. Cela data zustavaji v udalosti. */ +function describePayload(payload: Record): string | null { + const keys = Object.keys(payload); + if (keys.length === 0) return null; + const text = JSON.stringify(payload); + return text.length > 2_000 ? `${text.slice(0, 2_000)}...` : text; +} + +// ---------------------------------------------------------------- statistiky + +export interface AgentStatsRow { + personId: string; + name: string; + resolved: number; + open: number; + critical: number; + medianResolveMs: number | null; + medianResponseMs: number | null; + reopened: number; + oldestOpenAt: string | null; + overloaded: boolean; +} + +/** Median, ne prumer: jeden ticket zapomenuty pres dovolenou jinak prebije vsechno. */ +function median(values: number[]): number | null { + if (values.length === 0) return null; + const sorted = [...values].sort((a, b) => a - b); + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 === 1 + ? sorted[middle] + : Math.round((sorted[middle - 1] + sorted[middle]) / 2); +} + +/** + * Vykon resitelu za obdobi. + * + * `since` je hranice pro **vyresene** tickety, tedy "kolik toho odbavil za + * poslednich 30 dni". Nevyrizene se pocitaji vzdy vsechny - fronta neni + * vec obdobi, lezi tam bez ohledu na to, na co se zrovna divame. + */ +export function getAgentStats( + everyone: Person[], + tenantIds: string[], + since: number | null, +): AgentStatsRow[] { + const visible = tickets.filter((ticket) => tenantIds.includes(ticket.tenantId)); + + const rows = everyone.map((person) => { + const open = visible.filter((t) => t.assigneeId === person.id && t.status !== 'resolved'); + + /** + * Cas z nepovinneho pole. `undefined` znamena starsi ticket zalozeny driv, + * nez se to zacalo evidovat - takovy se do statistiky nepocita. + */ + function at(value: string | null | undefined): number | null { + if (!value) return null; + const time = new Date(value).getTime(); + return Number.isFinite(time) ? time : null; + } + + const resolved = visible.filter((t) => { + if (t.resolvedById !== person.id) return false; + const time = at(t.resolvedAt); + if (time === null) return false; + return since === null || time >= since; + }); + + const resolveTimes = resolved + .map((t) => (at(t.resolvedAt) ?? 0) - (at(t.createdAt) ?? 0)) + .filter((value) => value >= 0); + + const responseTimes = visible + .filter((t) => t.assigneeId === person.id && at(t.firstResponseAt) !== null) + .map((t) => (at(t.firstResponseAt) ?? 0) - (at(t.createdAt) ?? 0)) + .filter((value) => value >= 0); + + const oldest = open.reduce( + (acc, t) => (acc === null || t.createdAt < acc ? t.createdAt : acc), + null, + ); + + return { + personId: person.id, + name: person.name, + resolved: resolved.length, + open: open.length, + critical: open.filter((t) => t.priority === 'critical').length, + medianResolveMs: median(resolveTimes), + medianResponseMs: median(responseTimes), + reopened: resolved.reduce((sum, t) => sum + (t.reopenCount ?? 0), 0), + oldestOpenAt: oldest, + overloaded: open.length > person.capacity, + }; + }); + + // Nejvic odbavenych nahoru. Prehled ma odpovedet na "kdo toho udelal nejvic". + rows.sort((a, b) => b.resolved - a.resolved || a.name.localeCompare(b.name, 'cs')); + return rows; +} + // --------------------------------------------------------------------- zmeny export interface CreateTicketInput { tenantId: string; + /** ID u odesilatele. Unikatni za firmu, viz `Ticket.externalId`. */ + externalId?: string | null; + externalSource?: string | null; subject: string; body?: string; sourceRef?: string | null; @@ -859,6 +1196,12 @@ export function createTicket(input: CreateTicketInput): Ticket { const stored: StoredTicket = { id: `TK-${ticketCounter}`, tenantId: input.tenantId, + externalId: input.externalId ?? null, + externalSource: input.externalSource ?? null, + firstResponseAt: null, + resolvedAt: null, + resolvedById: null, + reopenCount: 0, subject: input.subject, body: input.body ?? '', sourceRef: input.sourceRef ?? null, @@ -867,12 +1210,19 @@ export function createTicket(input: CreateTicketInput): Ticket { status: 'new', priority: input.priority, assigneeId, + // Tyhle ctyri se driv zahazovaly: vstup je nabizel, ale zaznam je nemel. + // Ticket zalozeny s typem tak zustaval bez typu a bez vlastnich poli. + assigneeGroupId: input.assigneeGroupId ?? null, + typeId: input.typeId ?? null, + fields: input.fields ?? {}, + tags: input.tags ?? [], automationId: input.automationId ?? null, createdAt: now, updatedAt: now, }; tickets.unshift(stored); traces.set(stored.id, flattenTrace(input.trace ?? [], null, [])); + events.set(stored.id, []); persist(stored); publish('ticket.created', `Nový ticket ${stored.id}: ${stored.subject}`, { @@ -908,6 +1258,20 @@ export function updateTicketStatus( const previous = ticket.status; ticket.status = status; + + if (status === 'resolved') { + ticket.resolvedAt = new Date().toISOString(); + // Vyresil ten, kdo ho mel u sebe. Kdyz nikdo, zustane to nekomu nepripsane - + // radeji nez pripsat vyreseni cloveku, ktery s tim nic nemel. + ticket.resolvedById = ticket.assigneeId; + } else if (previous === 'resolved') { + // Navrat z vyreseno je nejlepsi ukazatel toho, ze hotovo nebylo. + ticket.reopenCount = (ticket.reopenCount ?? 0) + 1; + ticket.resolvedAt = null; + ticket.resolvedById = null; + } + + markResponded(ticket); touch(ticket); appendTrace(id, [ @@ -956,6 +1320,7 @@ export function assignTicket( } ticket.assigneeId = person?.id ?? null; + if (person) markResponded(ticket); touch(ticket); appendTrace(id, [ @@ -1055,6 +1420,7 @@ export function addComment( return undefined; } + markResponded(ticket); touch(ticket); appendTrace(id, [{ kind: 'note', label: `${author}: ${text}`, status: 'info' }]); diff --git a/src/data/widgets.ts b/src/data/widgets.ts index 3c4ee3f..3e2939e 100644 --- a/src/data/widgets.ts +++ b/src/data/widgets.ts @@ -13,7 +13,9 @@ export type WidgetKind = | 'chart' | 'ticketList' | 'incidentList' - | 'workload'; + | 'workload' + /** Vykon resitelu. Data chodi z `/widget-data`, stejne jako u vlastnich. */ + | 'agents'; export interface WidgetDefinition { id: string; @@ -118,8 +120,27 @@ export const widgets: WidgetDefinition[] = [ sizes: ['half', 'full'], defaultSize: 'half', }, + { + id: 'panel.agents', + name: 'Výkon řešitelů', + description: 'Kdo kolik odbavil za 30 dní, jak dlouho mu to trvá a co se mu vrací.', + kind: 'agents', + sizes: ['half', 'full'], + defaultSize: 'full', + }, ]; +/** + * Vestavene widgety, jejichz data se pocitaji stejne jako u vlastnich, tedy + * pres `/widget-data`. Klic je ID widgetu, hodnota jeho zdroj dat. + * + * Diky tomu nemusi mit "Vykon resitelu" vlastni endpoint ani vlastni cestu + * v klientovi - je to tentyz zdroj, jen ho nesklada uzivatel. + */ +export const builtinSources: Record = { + 'panel.agents': { kind: 'agentStats', period: '30d' }, +}; + export function findWidget(id: string): WidgetDefinition | undefined { return widgets.find((widget) => widget.id === id); } diff --git a/src/routes/dashboard.ts b/src/routes/dashboard.ts index 5e6af37..a00fe9f 100644 --- a/src/routes/dashboard.ts +++ b/src/routes/dashboard.ts @@ -44,9 +44,14 @@ import { listCustomWidgets, sizesFor } from '../data/customWidgets.js'; import { listIncidents } from '../data/incidentStore.js'; import { getSummary } from '../data/mock.js'; import { findPersonByEmail, listGroups, listPeople } from '../data/people.js'; +import { hasPermission } from '../data/permissions.js'; +import { recordAudit } from '../data/audit.js'; +import { findTenant, generateIntakeToken, refreshTenants, tenantStore } from '../data/tenants.js'; +import { listTicketTypes } from '../data/ticketTypes.js'; import { addComment, assignTicket, + getAgentStats, getTicket, getWorkload, listTickets, @@ -215,6 +220,131 @@ dashboardRouter.delete('/layout', (req, res) => { }); }); +/** + * Detail resitele: kdo to je, jak mu to jde a co ma u sebe. + * + * Statistika i tickety chodi jednim requestem. Stranka o jednom cloveku by + * jinak delala tri dotazy na tri veci, ktere se pocitaji z tehoz seznamu. + */ +dashboardRouter.get('/people/:id', (req, res) => { + const scope = scopeOrDeny(req, res); + if (!scope) return; + + const person = listPeople(scope.tenantIds).find((item) => item.id === req.params.id); + if (!person) { + // Cizi resitel se chova jako neexistujici, ne jako odepreni prava. + return res.status(404).json({ error: 'not_found', message: 'Řešitel neexistuje.' }); + } + + const tickets = listTickets({ tenantIds: scope.tenantIds, assignee: person.id }); + const resolvedRecently = listTickets({ tenantIds: scope.tenantIds }).filter( + (ticket) => ticket.resolvedById === person.id, + ); + + // Obdobi drzime stejne jako u widgetu vykonu, aby cisla sedela na obou mistech. + const since = Date.now() - 30 * 86_400_000; + const stats = getAgentStats([person], scope.tenantIds, since)[0] ?? null; + + return res.json({ + person, + stats, + groups: listGroups(scope.tenantIds) + .filter((group) => group.personIds.includes(person.id)) + .map((group) => ({ id: group.id, name: group.name })), + /** Co ma prave ted u sebe. */ + open: tickets.filter((ticket) => ticket.status !== 'resolved'), + /** Co za posledni dobu vyresil, nejnovejsi nahore. */ + resolved: resolvedRecently + .filter((ticket) => ticket.status === 'resolved') + .sort((a, b) => (b.resolvedAt ?? '').localeCompare(a.resolvedAt ?? '')) + .slice(0, 20), + }); +}); + +// ---------------------------------------------------- prijem udalosti zvenku + +/** + * Adresa pro prijem udalosti do ticketu. + * + * Token je pristupovy udaj, proto ho vidi jen ten, kdo spravuje napojeni. + * Kdo umi zalozit konektor, umi zaridit i prijem - je to tatáz prace. + */ +dashboardRouter.get('/intake', (req, res) => { + const scope = scopeOrDeny(req, res); + if (!scope) return; + + if (!hasPermission(req.user!, 'connector.manage')) { + return res.status(403).json({ + error: 'forbidden', + message: 'Adresu pro příjem vidí ten, kdo spravuje napojení.', + }); + } + + const tenantId = scope.tenantIds[0]; + const tenant = tenantId ? findTenant(tenantId) : undefined; + if (!tenant) { + return res.status(404).json({ error: 'not_found', message: 'Firma neexistuje.' }); + } + + return res.json({ + tenantId: tenant.id, + tenantName: tenant.name, + url: `${publicBaseUrl()}/webhook/ticket/${tenant.intakeToken}`, + ticketTypes: listTicketTypes([tenant.id]).map((type) => ({ + id: type.id, + name: type.name, + fields: type.fields.map((field) => ({ + key: field.key, + label: field.label, + type: field.type, + required: field.required, + })), + })), + }); +}); + +/** Nova adresa. Stara okamzite prestane fungovat. */ +dashboardRouter.post('/intake/regenerate', (req, res) => { + const scope = scopeOrDeny(req, res); + if (!scope) return; + + if (!hasPermission(req.user!, 'connector.manage')) { + return res.status(403).json({ + error: 'forbidden', + message: 'Adresu pro příjem mění ten, kdo spravuje napojení.', + }); + } + + const tenantId = scope.tenantIds[0]; + if (!tenantId) { + return res.status(400).json({ error: 'no_tenant', message: 'Vyberte firmu.' }); + } + + return void tenantStore + .update(tenantId, { intakeToken: generateIntakeToken() }, { tenantIds: [], includeGlobal: true }) + .then(async (updated) => { + if (!updated) { + return res.status(404).json({ error: 'not_found', message: 'Firma neexistuje.' }); + } + await refreshTenants(); + recordAudit({ + userId: req.user!.id, + userEmail: req.user!.email, + tenantId, + action: 'intake.regenerate', + target: tenantId, + }); + console.info(`[intake] ${tenantId}: adresa pregenerovana, stara neplati`); + return res.json({ url: `${publicBaseUrl()}/webhook/ticket/${updated.intakeToken}` }); + }) + .catch((err: unknown) => { + console.error('[intake] regenerace selhala:', err); + return res + .status(500) + .json({ error: 'internal_error', message: 'Adresu se nepodařilo změnit.' }); + }); +}); + // ------------------------------------------------------------------- tickety /** diff --git a/src/routes/settings.ts b/src/routes/settings.ts index d1388a3..531dc05 100644 --- a/src/routes/settings.ts +++ b/src/routes/settings.ts @@ -36,8 +36,18 @@ import { featuresOf, type TenantFeatures, } from '../data/tenantFeatures.js'; -import { listTenants, tenantStore, type Tenant } from '../data/tenants.js'; -import { listTicketTypes, ticketTypeStore, type TicketType } from '../data/ticketTypes.js'; +import { + generateIntakeToken, + listTenants, + tenantStore, + type Tenant, +} from '../data/tenants.js'; +import { + listTicketTypes, + ticketScopeFields, + ticketTypeStore, + type TicketType, +} from '../data/ticketTypes.js'; import { actionStore, listActions, @@ -97,7 +107,15 @@ settingsRouter.use( writePermission: 'tenant.manage', platformOnly: true, // Firma nepatri jine firme, proto tenantId null. - build: (input) => ({ name: input.name, note: input.note ?? '', enabled: true, tenantId: null }), + build: (input) => ({ + name: input.name, + note: input.note ?? '', + enabled: true, + tenantId: null, + // Token dostane firma hned pri zalozeni, aby prijem udalosti fungoval + // bez dalsiho kroku. Menit ho zvenku nejde, viz updateSchema vyse. + intakeToken: generateIntakeToken(), + }), validate: (tenant, all) => all.some((other) => other.name.toLowerCase() === tenant.name.toLowerCase()) ? [`Firma ${tenant.name} už existuje.`] @@ -589,6 +607,52 @@ settingsRouter.get('/actions-overview', (req, res) => { return res.json({ items: listActions([tenantId]) }); }); +/** + * Na co se da ve stromu akce odkazovat. + * + * Akci nespousti sluzba, ale clovek na ticketu, takze parametry nejsou od + * spoustece - jsou to udaje ticketu plus vlastni pole jeho typu. Pocita to + * server, aby si klient nedelal druhy seznam, ktery se casem rozejde. + */ +settingsRouter.get('/actions/:id/scope', (req, res) => { + const tenantId = currentTenant(req, res); + if (!tenantId) return; + + const action = listActions([tenantId]).find((item) => item.id === req.params.id); + if (!action) { + return res.status(404).json({ error: 'not_found', message: 'Akce neexistuje.' }); + } + + /* + * Kdyz je akce navazana na vic typu, nabizeji se pole vsech. Krok je pak + * muze pouzit, i kdyz u konkretniho ticketu nekterá chybi - to uz je vec + * behu, ne definice. + */ + const types = listTicketTypes([tenantId]).filter((type) => + action.ticketTypeIds.includes(type.id), + ); + + const seen = new Set(); + const fields = (types.length > 0 ? types : [undefined]) + .flatMap((type) => ticketScopeFields(type)) + .filter((field) => { + if (seen.has(field.name)) return false; + seen.add(field.name); + return true; + }); + + return res.json({ + fields, + // Doptavaci pole akce jsou v sablonach taky k dispozici. + formFields: action.form.map((field) => ({ + id: `form.${field.id}`, + name: field.id, + type: 'string' as const, + required: field.required, + })), + }); +}); + // -------------------------------------------------------------- vlastni widgety const sourceSchema = z.discriminatedUnion('kind', [ @@ -608,6 +672,26 @@ const sourceSchema = z.discriminatedUnion('kind', [ bucket: z.enum(['day', 'week']).default('day'), }), z.object({ kind: z.literal('workload'), groupIds: z.array(z.string()).optional() }), + z.object({ + kind: z.literal('agentStats'), + period: z.enum(['today', '7d', '30d', 'month', 'all']).default('30d'), + }), + z.object({ + kind: z.literal('connector'), + serviceId: z.string().trim().min(1), + operationId: z.string().trim().min(1), + /** null = vychozi konektor firmy pro tuhle sluzbu. */ + connectorId: z.string().trim().min(1).nullable().default(null), + inputs: z.record(z.string()).default({}), + path: z.string().trim().max(200).optional(), + labelPath: z.string().trim().max(200).optional(), + valuePath: z.string().trim().max(200).optional(), + /** + * Jak dlouho se vysledek drzi. Minimum je 30 s: bez nej by kazde otevreni + * prehledu volalo cizi sluzbu znovu a limity jsou nase i klientovy. + */ + ttlSec: z.number().int().min(30).max(86_400).default(300), + }), ]); const widgetCreate = z.object({ diff --git a/src/routes/webhook.ts b/src/routes/webhook.ts index 9387f25..0f38c05 100644 --- a/src/routes/webhook.ts +++ b/src/routes/webhook.ts @@ -1,10 +1,156 @@ import { Router } from 'express'; +import { z } from 'zod'; import { findByWebhookToken, recordRun, type TriggerField } from '../data/automationStore.js'; import type { FieldType } from '../data/conditions.js'; +import { findByIntakeToken } from '../data/tenants.js'; +import { intakeEvent } from '../data/ticketStore.js'; +import { findTicketType, listTicketTypes } from '../data/ticketTypes.js'; import { publish } from '../events/bus.js'; export const webhookRouter = Router(); +/** + * Prijem udalosti do ticketu. VEREJNY endpoint, autorizuje token firmy. + * + * Tohle je vstup do ticketovaciho systemu: **jakakoliv udalost se muze stat + * ticketem**. Kdyz uz ticket se stejnym `externalId` v te firme existuje, + * udalost se na nej **navesi** misto zalozeni druheho. + * + * Externi ID je unikatni **v ramci firmy**. Token urcuje firmu, takze dve firmy + * mohou obe poslat objednavku cislo 3 a nedojde ke smichani. + * + * Je to zamerne zvlast od `/webhook/:token`, ktery patri automatizaci: zalozit + * ticket ma jit i bez toho, aby se kvuli tomu stavel strom. + */ +const intakeSchema = z.object({ + /** ID u odesilatele. Bez nej se vzdy zaklada novy ticket. */ + externalId: z.union([z.string().trim().min(1).max(120), z.number()]).nullish(), + /** Odkud to je, napr. `eshop`. Do klice nevstupuje, je to informace. */ + source: z.string().trim().min(1).max(60).optional(), + /** Typ udalosti, napr. `order.created`. */ + event: z.string().trim().min(1).max(80).default('event'), + /** Popisek do casove osy a predmet noveho ticketu. */ + label: z.string().trim().min(1).max(200).optional(), + subject: z.string().trim().min(1).max(200).optional(), + body: z.string().max(20_000).optional(), + priority: z.enum(['low', 'normal', 'high', 'critical']).optional(), + channel: z.enum(['whatsapp', 'facebook', 'instagram', 'email', 'voice', 'form', 'portal']).optional(), + typeId: z.string().trim().min(1).optional(), + tags: z.array(z.string().trim().min(1).max(40)).max(20).optional(), + /** Vlastni pole typu ticketu. Doplni se i na uz existujici ticket. */ + fields: z.record(z.union([z.string(), z.number(), z.boolean(), z.null()])).optional(), + customer: z + .object({ + company: z.string().max(160).optional(), + contact: z.string().max(160).optional(), + reply: z.string().max(200).optional(), + }) + .optional(), + /** Cokoliv dalsiho. Ulozi se cele k udalosti. */ + payload: z.record(z.unknown()).optional(), +}); + +webhookRouter.post('/ticket/:token', (req, res) => { + const { token } = req.params; + const tenant = findByIntakeToken(token); + + if (!tenant) { + console.warn(`[intake] volani na neznamy token (${token.slice(0, 6)}...)`); + return res.status(404).json({ error: 'not_found', message: 'Příjem neexistuje.' }); + } + + const parsed = intakeSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + const first = parsed.error.issues[0]; + console.warn(`[intake] ${tenant.id}: neplatna data - ${first?.message}`); + return res.status(400).json({ + error: 'validation_error', + message: first ? `${first.path.join('.')}: ${first.message}` : 'Neplatná data.', + issues: parsed.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`), + }); + } + + const input = parsed.data; + // Cislo i retezec jsou pro nas tentyz klic. Odesilatel posle 3, my drzime "3". + const externalId = + input.externalId === undefined || input.externalId === null ? null : String(input.externalId); + + // Neznamy typ radeji zahodime, nez abychom ticketu dali cizi pole. + let typeId = input.typeId ?? null; + if (typeId) { + const type = findTicketType(typeId); + if (!type || type.tenantId !== tenant.id) { + console.warn(`[intake] ${tenant.id}: neznamy typ ticketu ${typeId}, ignoruji`); + typeId = null; + } + } + + const result = intakeEvent({ + tenantId: tenant.id, + externalId, + externalSource: input.source ?? null, + type: input.event, + label: input.label ?? input.subject ?? input.event, + payload: { ...(input.payload ?? {}), ...(input.fields ?? {}) }, + fields: input.fields, + addTags: input.tags, + create: { + subject: input.subject ?? input.label ?? input.event, + body: input.body ?? '', + channel: input.channel ?? 'form', + priority: input.priority ?? 'normal', + typeId, + customer: { + id: null, + company: input.customer?.company ?? '', + contact: input.customer?.contact ?? '', + reply: input.customer?.reply ?? '', + }, + }, + }); + + console.info( + `[intake] ${tenant.id}: ${result.created ? 'zalozen' : 'doplnen'} ${result.ticket.id}` + + `${externalId ? ` (externí ID ${externalId})` : ''} udalosti ${input.event}`, + ); + + return res.status(result.created ? 201 : 200).json({ + ok: true, + created: result.created, + ticketId: result.ticket.id, + externalId: result.ticket.externalId, + eventId: result.event.id, + }); +}); + +/** + * Napoveda k prijmu. Sama o sobe nic nemeni a nic tajneho nevraci, jen rekne, + * jak se ma volat a jake typy ticketu firma ma. + */ +webhookRouter.get('/ticket/:token', (req, res) => { + const tenant = findByIntakeToken(req.params.token); + if (!tenant) return res.status(404).json({ error: 'not_found', message: 'Příjem neexistuje.' }); + + return res.json({ + tenant: tenant.name, + howTo: + 'POST na tuhle adresu. Se stejným externalId se událost navěsí na existující ticket, ' + + 'jinak vznikne nový. externalId je unikátní v rámci firmy.', + example: { + externalId: 3, + source: 'eshop', + event: 'order.created', + label: 'Nová objednávka 3', + fields: { orderNumber: '3' }, + }, + ticketTypes: listTicketTypes([tenant.id]).map((type) => ({ + id: type.id, + name: type.name, + fields: type.fields.map((field) => ({ key: field.key, type: field.type, required: field.required })), + })), + }); +}); + /** * VEREJNY endpoint - zamerne BEZ prihlaseni. Autorizaci resi neodhadnutelny * token v adrese (32 znaku, base64url), presne tak, jak to dela vetsina @@ -17,7 +163,7 @@ webhookRouter.post('/:token', (req, res) => { const automation = findByWebhookToken(token); if (!automation || !automation.flow.trigger) { - console.warn(`[webhook] volani na neznamy token (${token.slice(0, 6)}…)`); + console.warn(`[webhook] volani na neznamy token (${token.slice(0, 6)}...)`); return res.status(404).json({ error: 'not_found', message: 'Webhook neexistuje.' }); } diff --git a/src/routes/widgetData.ts b/src/routes/widgetData.ts index cab4dba..78ed602 100644 --- a/src/routes/widgetData.ts +++ b/src/routes/widgetData.ts @@ -19,11 +19,24 @@ import { findCustomWidget, type CustomWidget, type WidgetGroupBy, + type WidgetSource, type WidgetTicketFilter, } from '../data/customWidgets.js'; +import { defaultConnectorFor, getConnector } from '../data/connectorStore.js'; import { findGroup, findPerson, listGroups, listPeople } from '../data/people.js'; -import { channelLabels, getWorkload, listTickets, type Ticket } from '../data/ticketStore.js'; +import { getPath } from '../scripts/mapping.js'; +import { runScript } from '../scripts/runner.js'; +import { scriptIdFor } from '../scripts/lookup.js'; +import { + channelLabels, + getAgentStats, + getWorkload, + listTickets, + type AgentStatsRow, + type Ticket, +} from '../data/ticketStore.js'; import { findTicketType, listTicketTypes } from '../data/ticketTypes.js'; +import { builtinSources } from '../data/widgets.js'; export const widgetDataRouter = Router(); @@ -48,7 +61,17 @@ type WidgetValue = | { kind: 'groups'; rows: Array<{ key: string; label: string; value: number }> } | { kind: 'series'; points: Array<{ date: string; value: number }> } | { kind: 'tickets'; items: Array<{ id: string; subject: string; status: string; assignee: string | null }> } - | { kind: 'workload'; rows: Array<{ name: string; open: number; overCapacity: boolean }> }; + | { kind: 'workload'; rows: Array<{ name: string; open: number; overCapacity: boolean }> } + | { kind: 'agents'; rows: AgentStatsRow[] } + /** Vysledek z konektoru. `stale` rika, ze je to z cache, ne cerstve zavolane. */ + | { + kind: 'external'; + value: number | null; + text: string | null; + rows: Array<{ key: string; label: string; value: number }>; + at: string; + stale: boolean; + }; interface WidgetResult { id: string; @@ -154,8 +177,139 @@ function groupKeys(ticket: Ticket, groupBy: string): string[] { } } -function computeWidget(widget: CustomWidget, scope: ResolvedScope): WidgetValue { - const source = widget.source; +/** + * Cache vysledku z konektoru. + * + * Bez ni by kazde otevreni prehledu znamenalo volani ciziho API za kazdou + * dlazdici. Cizi sluzby maji limity a nekdy se za volani plati, takze je to + * ochrana nas i zakaznika. Klic je widget a firma, hodnota se drzi `ttlSec`. + */ +const externalCache = new Map(); + +/** Kolik zaznamu se v cache drzi, aby nerostla bez konce. */ +const CACHE_LIMIT = 500; + +function cacheKey(widgetId: string, tenantIds: string[]): string { + return `${widgetId}|${[...tenantIds].sort().join(',')}`; +} + +function rememberExternal(key: string, value: WidgetValue): void { + if (externalCache.size >= CACHE_LIMIT) { + // Nejstarsi ven. Mapa si drzi poradi vkladani, takze staci prvni klic. + const oldest = externalCache.keys().next().value; + if (oldest !== undefined) externalCache.delete(oldest); + } + externalCache.set(key, { at: Date.now(), value }); +} + +/** + * Data z konektoru. + * + * Vola se tentyz skript, ktery pouziva krok automatizace i akce na ticketu - + * widget nema vlastni cestu k cizi sluzbe, jinak by se chovala jinak nez zbytek. + */ +async function computeConnector( + widgetId: string, + source: Extract, + scope: ResolvedScope, +): Promise { + const key = cacheKey(widgetId, scope.tenantIds); + const ttl = Math.max(source.ttlSec ?? 300, 30) * 1_000; + const cached = externalCache.get(key); + if (cached && Date.now() - cached.at < ttl) { + return cached.value.kind === 'external' ? { ...cached.value, stale: true } : cached.value; + } + + const scriptId = scriptIdFor(source.serviceId, source.operationId); + if (!scriptId) { + throw new Error(`Operace ${source.serviceId}/${source.operationId} nemá výkonnou část.`); + } + + const tenantId = scope.tenantIds[0]; + if (!tenantId) throw new Error('Widget nad konektorem potřebuje vybranou firmu.'); + + const connector = source.connectorId + ? await getConnector(source.connectorId, scope.tenantIds) + : await defaultConnectorFor(tenantId, source.serviceId); + + const result = await runScript(scriptId, source.inputs, { + connector: connector ?? null, + }); + + if (!result.ok) { + // Cela chyba, nezkracena. Widget ji ukaze na sve pozici. + throw new Error(result.error?.message ?? 'Konektor nevrátil data.'); + } + + const picked = source.path ? getPath(result.outputs, source.path) : result.outputs; + const value = toExternal(picked, source); + rememberExternal(key, value); + return value; +} + +/** Prevede cokoliv, co skript vratil, na neco, co jde nakreslit. */ +function toExternal( + picked: unknown, + source: Extract, +): WidgetValue { + const at = new Date().toISOString(); + + if (typeof picked === 'number') { + return { kind: 'external', value: picked, text: null, rows: [], at, stale: false }; + } + + if (Array.isArray(picked)) { + const rows = picked.slice(0, 50).map((item, index) => { + const label = source.labelPath ? getPath(item, source.labelPath) : undefined; + const value = source.valuePath ? getPath(item, source.valuePath) : undefined; + return { + key: String(index), + label: label === undefined || label === null ? `#${index + 1}` : String(label), + value: typeof value === 'number' ? value : Number(value ?? 0) || 0, + }; + }); + // Delka pole je casto to jedine cislo, ktere dava smysl. + return { kind: 'external', value: picked.length, text: null, rows, at, stale: false }; + } + + if (picked === null || picked === undefined) { + return { kind: 'external', value: null, text: null, rows: [], at, stale: false }; + } + + if (typeof picked === 'object') { + const rows = Object.entries(picked as Record) + .filter(([, value]) => typeof value === 'number') + .slice(0, 50) + .map(([key, value]) => ({ key, label: key, value: value as number })); + return { + kind: 'external', + value: null, + text: rows.length > 0 ? null : JSON.stringify(picked).slice(0, 200), + rows, + at, + stale: false, + }; + } + + const text = String(picked); + const asNumber = Number(text); + return { + kind: 'external', + value: Number.isFinite(asNumber) && text.trim() !== '' ? asNumber : null, + text, + rows: [], + at, + stale: false, + }; +} + +function computeSource(source: WidgetSource, scope: ResolvedScope): WidgetValue { + if (source.kind === 'agentStats') { + return { + kind: 'agents', + rows: getAgentStats(listPeople(scope.tenantIds), scope.tenantIds, since(source.period)), + }; + } if (source.kind === 'workload') { const workload = getWorkload(listPeople(scope.tenantIds), scope.tenantIds); @@ -169,6 +323,12 @@ function computeWidget(widget: CustomWidget, scope: ResolvedScope): WidgetValue }; } + if (source.kind === 'connector') { + // Konektor se pocita jinou cestou, protoze cizi sluzba je asynchronni + // a vysledek se cachuje. Sem se to nikdy nedostane, viz volajici. + throw new Error('Zdroj Konektor se počítá zvlášť.'); + } + const tickets = listTickets({ tenantIds: scope.tenantIds }).filter((ticket) => matches(ticket, source.filter, scope.personId), ); @@ -217,7 +377,12 @@ function computeWidget(widget: CustomWidget, scope: ResolvedScope): WidgetValue return { kind: 'series', points }; } -widgetDataRouter.post('/', (req, res) => { +/** Zdroj vlastniho widgetu. Vestavene maji svuj v `builtinSources`. */ +function computeWidget(widget: CustomWidget, scope: ResolvedScope): WidgetValue { + return computeSource(widget.source, scope); +} + +widgetDataRouter.post('/', async (req, res) => { const scope = resolveScope(req.user!, { scope: typeof req.query.scope === 'string' ? req.query.scope : undefined, tenantId: typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined, @@ -234,28 +399,50 @@ widgetDataRouter.post('/', (req, res) => { }); } - const results: WidgetResult[] = parsed.data.widgetIds.map((id) => { - const widget = findCustomWidget(id); - if (!widget || !scope.tenantIds.includes(widget.tenantId)) { - return { id, ok: false, error: 'Widget neexistuje.' }; - } - // Osobni widget vidi jen jeho autor. - if (widget.ownerId !== null && widget.ownerId !== req.user!.id) { - return { id, ok: false, error: 'Widget neexistuje.' }; - } + const results: WidgetResult[] = await Promise.all( + parsed.data.widgetIds.map(async (id): Promise => { + // Vestaveny widget s vypoctem. Nema definici v ulozisti, jen zdroj dat. + const builtin = builtinSources[id]; + if (builtin) { + try { + return { id, ok: true, value: computeSource(builtin, scope) }; + } catch (err) { + console.error(`[widgets] ${id} selhal:`, err); + return { id, ok: false, error: message(err) }; + } + } - try { - return { id, ok: true, value: computeWidget(widget, scope) }; - } catch (err) { - // Jeden rozbity zdroj nesmi zhasnout cely prehled. - console.error(`[widgets] ${id} selhal:`, err); - return { id, ok: false, error: err instanceof Error ? err.message : 'Data se nepodařilo spočítat.' }; - } - }); + const widget = findCustomWidget(id); + if (!widget || !scope.tenantIds.includes(widget.tenantId)) { + return { id, ok: false, error: 'Widget neexistuje.' }; + } + // Osobni widget vidi jen jeho autor. + if (widget.ownerId !== null && widget.ownerId !== req.user!.id) { + return { id, ok: false, error: 'Widget neexistuje.' }; + } + + try { + const value = + widget.source.kind === 'connector' + ? await computeConnector(widget.id, widget.source, scope) + : computeWidget(widget, scope); + return { id, ok: true, value }; + } catch (err) { + // Jeden rozbity zdroj nesmi zhasnout cely prehled. + console.error(`[widgets] ${id} selhal:`, err); + return { id, ok: false, error: message(err) }; + } + }), + ); return res.json({ items: results }); }); +/** Cele hlaseni, nezkracene. U konektoru je to jedina stopa, proc to nejde. */ +function message(err: unknown): string { + return err instanceof Error ? err.message : 'Data se nepodařilo spočítat.'; +} + /** Co lze do filtru vybrat. Klient si nesklada vlastni seznam. */ widgetDataRouter.get('/options', (req, res) => { const scope = resolveScope(req.user!, { diff --git a/web/index.html b/web/index.html index 8e9a80f..85ef640 100644 --- a/web/index.html +++ b/web/index.html @@ -3,7 +3,7 @@ - Automia — automatizace, voiceboti a integrace na míru + Automia - automatizace, voiceboti a integrace na míru import('@/pages/dashboard/Tickets')); const TicketDetail = lazy(() => import('@/pages/dashboard/TicketDetail')); const Incidents = lazy(() => import('@/pages/dashboard/Incidents')); const Settings = lazy(() => import('@/pages/dashboard/Settings')); +const Actions = lazy(() => import('@/pages/dashboard/Actions')); +const ActionDetail = lazy(() => import('@/pages/dashboard/ActionDetail')); +const Widgets = lazy(() => import('@/pages/dashboard/Widgets')); +const People = lazy(() => import('@/pages/dashboard/People')); +const PersonDetail = lazy(() => import('@/pages/dashboard/PersonDetail')); /** * Routovani. Cesty jsou zamerne cesky (SEO + citelnost URL), @@ -51,7 +56,7 @@ export default function App() { - + } > @@ -69,6 +74,11 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> + } /> + } /> } /> diff --git a/web/src/auth/RequireAuth.tsx b/web/src/auth/RequireAuth.tsx index 949ed5f..f027b7e 100644 --- a/web/src/auth/RequireAuth.tsx +++ b/web/src/auth/RequireAuth.tsx @@ -10,7 +10,7 @@ export function RequireAuth() { if (loading) { return (
- +
); } diff --git a/web/src/components/dashboard/DashboardLayout.tsx b/web/src/components/dashboard/DashboardLayout.tsx index 0fe9565..63edc08 100644 --- a/web/src/components/dashboard/DashboardLayout.tsx +++ b/web/src/components/dashboard/DashboardLayout.tsx @@ -1,16 +1,19 @@ import { AlarmClock, + Blocks, ExternalLink, FlaskConical, LayoutDashboard, + LayoutGrid, LifeBuoy, LogOut, Menu, + MousePointerClick, Plug, - Blocks, ScrollText, Settings, ShieldAlert, + Users, Workflow, X, } from 'lucide-react'; @@ -40,6 +43,9 @@ const moduleRoutes: Record - + ); } diff --git a/web/src/components/dashboard/EntityAdmin.tsx b/web/src/components/dashboard/EntityAdmin.tsx index c227b78..ae2d25a 100644 --- a/web/src/components/dashboard/EntityAdmin.tsx +++ b/web/src/components/dashboard/EntityAdmin.tsx @@ -71,6 +71,13 @@ export interface EntityAdminProps { canDelete?: (entity: T) => boolean; /** Doplnujici obsah nad tabulkou. */ children?: ReactNode; + /** + * Vlastni ovladani u radku, vlevo od tuzky a kose. + * + * Je to tady kvuli zaznamum, ktere se z casti upravuji jinde nez v dialogu - + * telo akce je strom a ten se kresli na vlastni strance. + */ + rowActions?: (entity: T) => ReactNode; /** Zavola se po kazdem uspesnem zapisu. */ onChanged?: () => void; } @@ -90,6 +97,7 @@ export function EntityAdmin({ canWrite = true, canDelete, children, + rowActions, onChanged, }: EntityAdminProps) { const [items, setItems] = useState(null); @@ -161,8 +169,10 @@ export function EntityAdmin({ {column.render(entity)} ))} - {canWrite && ( + {(canWrite || rowActions) && ( + {rowActions?.(entity)} + {canWrite && ( - {(canDelete?.(entity) ?? true) && ( + )} + {canWrite && (canDelete?.(entity) ?? true) && ( + + {open && ( +
+          {JSON.stringify(event.payload, null, 2)}
+        
+ )} + + ); +} diff --git a/web/src/components/dashboard/ViewSwitch.tsx b/web/src/components/dashboard/ViewSwitch.tsx new file mode 100644 index 0000000..088b1e0 --- /dev/null +++ b/web/src/components/dashboard/ViewSwitch.tsx @@ -0,0 +1,49 @@ +import { LayoutGrid, Table2 } from 'lucide-react'; +import { cn } from '@/lib/cn'; + +/** + * Prepinac tabulka nebo dlazdice. + * + * Tabulka se hodi na porovnavani cisel a hledani v mnoha radcich, dlazdice + * na rychly prehled. Kazdy seznam v portalu tim umi obojí a nikdo se nemusi + * rozhodovat za uzivatele. + */ +export type ListView = 'table' | 'grid'; + +export function ViewSwitch({ + view, + onChange, +}: { + view: ListView; + onChange: (view: ListView) => void; +}) { + const options = [ + { value: 'table' as const, label: 'Tabulka', icon: Table2 }, + { value: 'grid' as const, label: 'Dlaždice', icon: LayoutGrid }, + ]; + + return ( +
+ {options.map((option) => { + const Icon = option.icon; + return ( + + ); + })} +
+ ); +} diff --git a/web/src/components/dashboard/flow/FlowCanvas.tsx b/web/src/components/dashboard/flow/FlowCanvas.tsx index f6696a7..ffd3467 100644 --- a/web/src/components/dashboard/flow/FlowCanvas.tsx +++ b/web/src/components/dashboard/flow/FlowCanvas.tsx @@ -1,4 +1,12 @@ -import { ChevronDown, ChevronUp, GitBranch, Plus, Trash2, Zap } from 'lucide-react'; +import { + ChevronDown, + ChevronUp, + GitBranch, + MousePointerClick, + Plus, + Trash2, + Zap, +} from 'lucide-react'; import { StepInputs } from '@/components/dashboard/flow/StepInputs'; import { TriggerConfig } from '@/components/dashboard/flow/TriggerConfig'; import { Badge } from '@/components/ui/Badge'; @@ -45,6 +53,7 @@ export function FlowCanvas({ connectors, webhookBaseUrl, regenerating, + start, onPickTrigger, onChangeFields, onRegenerateToken, @@ -54,29 +63,44 @@ export function FlowCanvas({ connectors: Service[]; webhookBaseUrl: string; regenerating: boolean; + /** + * Cim strom zacina, kdyz to neni spoustec. + * + * Telo akce na ticketu spousti clovek tlacitkem, takze karta spoustece by + * tam lhala. Misto ni se ukaze tohle a `fields` jsou parametry, ktere ma + * strom k dispozici - u akce jsou to udaje ticketu. + */ + start?: { label: string; hint: string; fields: TriggerField[] }; onPickTrigger: () => void; onChangeFields: (fields: TriggerField[]) => void; onRegenerateToken: () => void; }) { + // Bez spoustece se kroky kresli jen v rezimu akce, jinak neni na cem stavet. + const fallbackFields = start ? start.fields : flow.trigger?.fields; + return (
- + {start ? ( + + ) : ( + + )} - {flow.trigger && ( + {fallbackFields && ( )} @@ -84,6 +108,55 @@ export function FlowCanvas({ ); } +/** + * Aby `collectScopes` videlo parametry i bez spoustece, podstrci se mu + * nahradni. Do ulozeneho stromu se to nikdy nedostane, je to jen pro vypocet, + * na co se da v krocich odkazat. + */ +function startTrigger( + flow: AutomationFlow, + start?: { fields: TriggerField[] }, +): AutomationFlow['trigger'] { + if (!start) return flow.trigger; + return { serviceId: 'ticket', operationId: 'action', fields: start.fields }; +} + +/** Prvni karta u akce: spousti to clovek, ne sluzba. */ +function StartCard({ start }: { start: { label: string; hint: string; fields: TriggerField[] } }) { + return ( +
+
+ + + +
+

{start.label}

+

{start.hint}

+ + {start.fields.length > 0 && ( +
+

+ Co je v krocích k dispozici +

+
+ {start.fields.map((field) => ( + + {`{{${field.name}}}`} + + ))} +
+
+ )} +
+
+
+ ); +} + /** Prvni karta stromu - dokud neni vybrany spoustec, je to vyzva ke kliknuti. */ function TriggerCard({ flow, @@ -421,7 +494,7 @@ function ConditionCard({ > {!field && ( )} {fields.map((option) => ( @@ -502,7 +575,7 @@ function ConditionCard({

{step[branch].length === 0 && (

- Zatím prázdná větev — přidejte krok tlačítkem níž. + Zatím prázdná větev - přidejte krok tlačítkem níž.

)} @@ -110,7 +110,7 @@ export function StepPicker({ type="search" value={query} onChange={(event) => setQuery(event.target.value)} - placeholder={mode === 'trigger' ? 'Hledat spouštěč…' : 'Hledat akci nebo službu…'} + placeholder={mode === 'trigger' ? 'Hledat spouštěč...' : 'Hledat akci nebo službu...'} className="w-full rounded-xl border border-ink-600/70 bg-ink-850/70 py-2.5 pr-4 pl-10 text-sm text-white placeholder:text-white/30 focus:border-brand-400/70 focus:outline-none" />
@@ -217,7 +217,7 @@ export function StepPicker({ Rozvětvit podmínkou {canAddCondition === false - ? 'Nejdřív u spouštěče nadeklarujte vstupní parametry — bez nich není podle čeho se rozhodovat.' + ? 'Nejdřív u spouštěče nadeklarujte vstupní parametry - bez nich není podle čeho se rozhodovat.' : 'Porovná hodnotu vstupního parametru (např. score ≥ 15) a rozdělí běh na větev ANO a NE.'} diff --git a/web/src/components/dashboard/flow/TriggerConfig.tsx b/web/src/components/dashboard/flow/TriggerConfig.tsx index a9461e8..599ac9e 100644 --- a/web/src/components/dashboard/flow/TriggerConfig.tsx +++ b/web/src/components/dashboard/flow/TriggerConfig.tsx @@ -110,7 +110,7 @@ function CustomFields({ {fields.length === 0 ? (

- Zatím žádné parametry. Bez nich nelze přidat podmínku — nebylo by podle čeho + Zatím žádné parametry. Bez nich nelze přidat podmínku - nebylo by podle čeho se rozhodovat.

) : ( @@ -175,7 +175,7 @@ function CustomFields({ {duplicate && (

- Tento název už je použitý — parametry musí být unikátní. + Tento název už je použitý - parametry musí být unikátní.

)} @@ -294,7 +294,7 @@ function WebhookAddress({

- Token v adrese je jediná ochrana — kdo ji zná, může automatizaci spustit. + Token v adrese je jediná ochrana - kdo ji zná, může automatizaci spustit. Nesdílejte ji veřejně a neposílejte ji v odkazech.

@@ -314,7 +314,7 @@ function WebhookAddress({ disabled={regenerating} className="rounded-lg bg-danger-500/20 px-3 py-1.5 text-xs font-semibold text-danger-400 transition-colors hover:bg-danger-500/30 disabled:opacity-50" > - {regenerating ? 'Generuji…' : 'Ano, vygenerovat novou'} + {regenerating ? 'Generuji...' : 'Ano, vygenerovat novou'}

Odesláním souhlasíte se zpracováním údajů pro účely odpovědi. @@ -185,14 +185,14 @@ export default function Contact() {

Jste náš klient?

- Požadavky a incidenty zadávejte přímo v klientském portálu — mají tam SLA a vidíte + Požadavky a incidenty zadávejte přímo v klientském portálu - mají tam SLA a vidíte jejich stav.

- Přejít do portálu → + Přejít do portálu diff --git a/web/src/pages/Home.tsx b/web/src/pages/Home.tsx index 4d68fe2..e2245df 100644 --- a/web/src/pages/Home.tsx +++ b/web/src/pages/Home.tsx @@ -9,7 +9,7 @@ import { usePageMeta } from '@/lib/usePageMeta'; export default function Home() { usePageMeta({ - title: 'Automia — automatizace, voiceboti a integrace na míru', + title: 'Automia - automatizace, voiceboti a integrace na míru', description: 'Automatizujeme firemní procesy, stavíme voiceboty a propojujeme systémy. K tomu dashboardy, tickety a incident management.', }); diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx index 9b13a36..8841371 100644 --- a/web/src/pages/Login.tsx +++ b/web/src/pages/Login.tsx @@ -32,7 +32,7 @@ const inputClass = /** Prihlaseni do klientskeho portalu. Demo ucty jsou vypsane pod formularem. */ export default function Login() { usePageMeta({ - title: 'Přihlášení — Automia', + title: 'Přihlášení - Automia', description: 'Přihlášení do klientského portálu Automia.', }); @@ -140,7 +140,7 @@ export default function Login() { autoComplete="current-password" defaultValue={demoAccounts[0].password} className={inputClass} - placeholder="••••••••" + placeholder="--------" /> @@ -153,7 +153,7 @@ export default function Login() { @@ -187,7 +187,7 @@ export default function Login() { Ozvěte se nám {' '} - — portál zřizujeme klientům {brand.name}. + - portál zřizujeme klientům {brand.name}.

diff --git a/web/src/pages/NotFound.tsx b/web/src/pages/NotFound.tsx index 72ab9ce..055f2c0 100644 --- a/web/src/pages/NotFound.tsx +++ b/web/src/pages/NotFound.tsx @@ -3,7 +3,7 @@ import { Container } from '@/components/ui/Container'; import { usePageMeta } from '@/lib/usePageMeta'; export default function NotFound() { - usePageMeta({ title: 'Stránka nenalezena — Automia' }); + usePageMeta({ title: 'Stránka nenalezena - Automia' }); return ( diff --git a/web/src/pages/Services.tsx b/web/src/pages/Services.tsx index 92fbadd..22d7d20 100644 --- a/web/src/pages/Services.tsx +++ b/web/src/pages/Services.tsx @@ -8,7 +8,7 @@ import { cn } from '@/lib/cn'; export default function Services() { usePageMeta({ - title: 'Služby — Automia', + title: 'Služby - Automia', description: 'Automatizace procesů, voiceboti, integrace systémů, dashboardy, tickety a incident management.', }); diff --git a/web/src/pages/dashboard/ActionDetail.tsx b/web/src/pages/dashboard/ActionDetail.tsx new file mode 100644 index 0000000..c5c961c --- /dev/null +++ b/web/src/pages/dashboard/ActionDetail.tsx @@ -0,0 +1,288 @@ +import { AlertCircle, ArrowLeft, Check, Save } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Link, useParams } from 'react-router-dom'; +import { DataState } from '@/components/dashboard/DataState'; +import { FlowCanvas } from '@/components/dashboard/flow/FlowCanvas'; +import { StepPicker } from '@/components/dashboard/flow/StepPicker'; +import { Badge } from '@/components/ui/Badge'; +import { Button } from '@/components/ui/Button'; +import { apiFetch } from '@/lib/api'; +import { + countSteps, + createActionStep, + createConditionStep, + insertStep, + moveStep, + removeStep, + updateCondition, + updateStepInputs, + type FlowPath, +} from '@/lib/flow'; +import { useApiQuery } from '@/lib/useApiQuery'; +import { usePageMeta } from '@/lib/usePageMeta'; +import type { + AutomationFlow, + FlowStep, + ServiceCatalog, + TriggerField, +} from '@/types/dashboard'; + +/** + * Editor tela akce. + * + * Zamerne **tentyz editor jako u automatizaci** - je to tentyz strom kroku, + * jen ho nespousti sluzba, ale clovek tlacitkem na ticketu. Druhy editor pro + * totez by znamenal dve mista, ktera se casem rozejdou. + * + * Misto karty spoustece je karta "spousti clovek" a parametry, na ktere jde + * v krocích odkazovat, jsou udaje ticketu. Ty pocita server podle typu, na + * ktery je akce navazana. + */ + +interface ActionRecord { + id: string; + label: string; + ticketTypeIds: string[]; + tags: string[]; + body: { kind: string; steps?: FlowStep[] }; +} + +interface ScopeResponse { + fields: TriggerField[]; + formFields: TriggerField[]; +} + +interface PickerTarget { + path: FlowPath; + index: number; +} + +export default function ActionDetail() { + const { id = '' } = useParams(); + + const action = useApiQuery(`/api/dashboard/settings/actions/${id}`); + const scope = useApiQuery(`/api/dashboard/settings/actions/${id}/scope`); + const catalog = useApiQuery('/api/dashboard/services'); + + const [steps, setSteps] = useState([]); + const [picker, setPicker] = useState(null); + const [dirty, setDirty] = useState(false); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + const [savedAt, setSavedAt] = useState(null); + + usePageMeta({ title: `${action.data?.label ?? 'Akce'} - portál Automia` }); + + useEffect(() => { + if (!action.data) return; + // Akce mohla byt driv nastavena jako jedna operace. Ta se ukaze jako + // strom o jednom kroku, aby se dala upravit stejnou cestou jako zbytek. + setSteps(bodyToSteps(action.data.body)); + setDirty(false); + }, [action.data]); + + const connectors = catalog.data?.items ?? []; + const stepCount = useMemo(() => countSteps(steps), [steps]); + + /** Parametry, na ktere jde v krocich odkazovat: udaje ticketu plus dialog. */ + const startFields = useMemo( + () => [...(scope.data?.fields ?? []), ...(scope.data?.formFields ?? [])], + [scope.data], + ); + + /** FlowCanvas pracuje s celym tokem. Akce ma jen kroky, spoustec je clovek. */ + const flow: AutomationFlow = useMemo(() => ({ trigger: null, steps }), [steps]); + + const change = useCallback((next: FlowStep[]) => { + setSteps(next); + setDirty(true); + setSavedAt(null); + }, []); + + async function save() { + setSaving(true); + setSaveError(null); + try { + await apiFetch(`/api/dashboard/settings/actions/${id}`, { + method: 'PATCH', + body: { body: { kind: 'tree', steps } }, + }); + setDirty(false); + setSavedAt(new Date().toISOString()); + action.reload(); + } catch (err: unknown) { + setSaveError(err instanceof Error ? err.message : 'Uložení se nepodařilo.'); + } finally { + setSaving(false); + } + } + + return ( +
+ + + Zpět na akce + + + { + action.reload(); + catalog.reload(); + }} + > + {action.data && ( +
+
+
+

{action.data.label}

+
+ {action.data.id} + + {stepCount} {stepCount === 1 ? 'krok' : stepCount >= 2 && stepCount <= 4 ? 'kroky' : 'kroků'} + + {dirty && Neuložené změny} + {!dirty && savedAt && ( + + + Uloženo + + )} +
+
+ + +
+ + {saveError && ( +

+ + {saveError} +

+ )} + +
+
+
+

Co akce udělá

+

+ Klikněte na + a vyberte krok +

+
+ + undefined} + onChangeFields={() => undefined} + onRegenerateToken={() => undefined} + onAddStep={(path, index) => setPicker({ path, index })} + onRemoveStep={(stepId) => change(removeStep(steps, stepId))} + onUpdateCondition={(stepId, patch) => + change(updateCondition(steps, stepId, patch)) + } + onUpdateInputs={(stepId, inputs) => change(updateStepInputs(steps, stepId, inputs))} + onMoveStep={(stepId, offset) => change(moveStep(steps, stepId, offset))} + /> +
+ + +
+
+ )} +
+ + setPicker(null)} + onPickOperation={(serviceId, operationId) => { + if (!picker) return; + change(insertStep(steps, picker.path, picker.index, createActionStep(serviceId, operationId))); + setPicker(null); + }} + onPickCondition={() => { + const first = startFields[0]; + if (!picker || !first) return; + change(insertStep(steps, picker.path, picker.index, createConditionStep(first))); + setPicker(null); + }} + canAddCondition={startFields.length > 0} + /> +
+ ); +} + +/** Popis toho, kde se akce ukaze. Prazdna vazba je chyba, ne detail. */ +function startHint(action: ActionRecord): string { + const parts = [ + action.ticketTypeIds.length > 0 ? `typ ${action.ticketTypeIds.join(', ')}` : null, + action.tags.length > 0 ? `tag ${action.tags.join(', ')}` : null, + ].filter(Boolean); + + return parts.length > 0 + ? `Ukáže se na ticketech, kde sedí ${parts.join(' nebo ')}.` + : 'Akce nemá typ ani tag, takže se zatím neukáže nikde.'; +} + +/** + * Telo akce na kroky. + * + * Jedna operace je strom o jednom kroku - tim se stara definice da upravit + * stejnou cestou jako nova a nemusi existovat druhy editor. + */ +function bodyToSteps(body: ActionRecord['body']): FlowStep[] { + if (body?.kind === 'tree') return body.steps ?? []; + + if (body?.kind === 'operation') { + const operation = body as unknown as { + serviceId: string; + operationId: string; + inputs?: Record; + }; + const step = createActionStep(operation.serviceId, operation.operationId); + return [step.kind === 'action' ? { ...step, inputs: operation.inputs ?? {} } : step]; + } + + return []; +} diff --git a/web/src/pages/dashboard/Actions.tsx b/web/src/pages/dashboard/Actions.tsx new file mode 100644 index 0000000..dc37be2 --- /dev/null +++ b/web/src/pages/dashboard/Actions.tsx @@ -0,0 +1,224 @@ +import { GitBranch, Info } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { EntityAdmin, parseJsonField, type AdminField } from '@/components/dashboard/EntityAdmin'; +import { Badge } from '@/components/ui/Badge'; +import { apiFetch } from '@/lib/api'; +import { usePageMeta } from '@/lib/usePageMeta'; + +/** + * Definice akci na ticketu. + * + * Je to **stejna uroven jako automatizace**, ne polozka v nastaveni: je to + * definice toho, co aplikace umi, ne nastaveni uctu. + * + * Rozdeleni prace: tady se nastavi, **kdy se tlacitko ukaze** (typ, tag, + * podminky) a na co se doptat. **Co akce dela** je strom a ten se kresli + * na vlastni strance stejnym editorem jako automatizace. + */ + +interface ActionRecord { + id: string; + label: string; + icon: string; + style: 'primary' | 'default' | 'danger'; + order: number; + ticketTypeIds: string[]; + tags: string[]; + confirm: string | null; + enabled: boolean; + form: unknown[]; + visibleWhen: unknown[]; + body: { kind: string; steps?: unknown[] }; + [key: string]: unknown; +} + +export default function Actions() { + usePageMeta({ title: 'Akce na ticketu - portál Automia' }); + + const [types, setTypes] = useState>([]); + + useEffect(() => { + apiFetch<{ items: Array<{ id: string; name: string }> }>( + '/api/dashboard/settings/ticket-types', + ) + .then((data) => setTypes(data.items)) + .catch(() => setTypes([])); + }, []); + + const fields: Array> = [ + { name: 'label', label: 'Nápis na tlačítku', kind: 'text', required: true }, + { name: 'icon', label: 'Ikona', kind: 'text', initial: 'Play', hint: 'Například Play, Send, FileText.' }, + { + name: 'style', + label: 'Vzhled', + kind: 'select', + initial: 'default', + options: [ + { value: 'primary', label: 'Hlavní' }, + { value: 'default', label: 'Běžný' }, + { value: 'danger', label: 'Nebezpečný' }, + ], + }, + { name: 'order', label: 'Pořadí', kind: 'number', initial: 10 }, + { + name: 'ticketTypeIds', + label: 'Typy ticketu', + kind: 'multiselect', + options: types.map((type) => ({ value: type.id, label: type.name })), + hint: 'Za typem stojí vlastní pole, takže z nich akce může čerpat.', + }, + { + name: 'tags', + label: 'Tagy', + kind: 'tags', + hint: 'Za tagem žádná pole nestojí, akce pak umí jen vestavěné údaje ticketu.', + }, + { + name: 'confirm', + label: 'Text potvrzení', + kind: 'text', + hint: 'Prázdné = akce se spustí hned po kliknutí.', + }, + { + name: 'visibleWhen', + label: 'Kdy se tlačítko ukáže', + kind: 'json', + initial: '[]', + hint: 'Podmínky nad ticketem, například [{"fieldId":"ticket.status","operator":"neq","value":"resolved"}]. Prázdné = vždy.', + }, + { + name: 'form', + label: 'Na co se zeptat před spuštěním', + kind: 'json', + initial: '[]', + hint: 'Pole dialogu: id, label, kind (text, longtext, choice), required.', + }, + { name: 'enabled', label: 'Zapnutá', kind: 'checkbox', initial: true }, + ]; + + return ( +
+
+

Akce na ticketu

+

+ Tlačítko, které řešitel zmáčkne na ticketu. Váže se na typ nebo tag. +

+
+ +

+ + + Akce a automatizace jsou dvě různé věci. Automatizace běží sama, akci spouští člověk. + Tady se určí, kdy se tlačítko ukáže; + co akce dělá, se skládá ze stromu kroků tlačítkem{' '} + Upravit strom. + +

+ + + title="Akce" + endpoint="/api/dashboard/settings/actions" + listEndpoint="/api/dashboard/settings/actions-overview" + columns={[ + { + label: 'Akce', + render: (action) => ( + + + {action.label} + + {action.style === 'primary' && hlavní} + {action.style === 'danger' && nebezpečná} + + ), + }, + { + label: 'Na čem se ukáže', + render: (action) => { + const names = action.ticketTypeIds.map( + (id) => types.find((type) => type.id === id)?.name ?? id, + ); + const parts = [ + names.length > 0 ? `typ: ${names.join(', ')}` : null, + action.tags.length > 0 ? `tag: ${action.tags.join(', ')}` : null, + ].filter(Boolean); + return parts.length > 0 ? ( + parts.join(' | ') + ) : ( + nikde, chybí typ i tag + ); + }, + }, + { + label: 'Co dělá', + render: (action) => , + }, + { + label: 'Stav', + narrow: true, + render: (action) => + action.enabled === false ? ( + vypnutá + ) : ( + zapnutá + ), + }, + ]} + fields={fields} + rowActions={(action) => ( + + + Upravit strom + + )} + toBody={(values) => ({ + label: values.label, + icon: values.icon, + style: values.style || 'default', + order: values.order, + ticketTypeIds: values.ticketTypeIds, + tags: values.tags, + confirm: values.confirm === '' ? null : values.confirm, + visibleWhen: parseJsonField(values.visibleWhen, []), + form: parseJsonField(values.form, []), + // Telo se tady nemeni. Nova akce zacina prazdnym stromem a naplni + // se v editoru, jinak by tu byl JSON, ktery nikdo psat nechce. + ...(values.body === undefined ? { body: { kind: 'tree', steps: [] } } : {}), + ...(values.enabled !== undefined ? { enabled: values.enabled } : {}), + })} + toForm={(action) => ({ + ...action, + confirm: action.confirm ?? '', + visibleWhen: JSON.stringify(action.visibleWhen ?? [], null, 2), + form: JSON.stringify(action.form ?? [], null, 2), + // Telo se do formulare zamerne nedava, upravuje se stromem. + body: undefined, + })} + /> +
+ ); +} + +/** Jednou vetou, co akce dela. Podrobnosti jsou ve stromu. */ +function BodySummary({ body }: { body: { kind: string; steps?: unknown[] } }) { + if (body?.kind === 'tree') { + const count = body.steps?.length ?? 0; + return count === 0 ? ( + prázdný strom + ) : ( + + strom, {count} {count === 1 ? 'krok' : count <= 4 ? 'kroky' : 'kroků'} + + ); + } + if (body?.kind === 'operation') return jedna operace; + if (body?.kind === 'script') return skript; + return nenastaveno; +} diff --git a/web/src/pages/dashboard/AutomationDetail.tsx b/web/src/pages/dashboard/AutomationDetail.tsx index 1cb6a8f..20940f6 100644 --- a/web/src/pages/dashboard/AutomationDetail.tsx +++ b/web/src/pages/dashboard/AutomationDetail.tsx @@ -54,7 +54,7 @@ export default function AutomationDetail() { /** Co podle serveru chybi k zapnuti. Prepocitava ho server pri kazdem ulozeni. */ const [issues, setIssues] = useState([]); - usePageMeta({ title: `${name || 'Automatizace'} — portál Automia` }); + usePageMeta({ title: `${name || 'Automatizace'} - portál Automia` }); // Prevzeti dat ze serveru do editovatelneho stavu. useEffect(() => { @@ -272,7 +272,7 @@ export default function AutomationDetail() { {enabled ? 'Aktivní' : 'Pozastaveno'} ) : ( - Koncept — chybí spouštěč + Koncept - chybí spouštěč )} {stepCount} {stepCount === 1 ? 'krok' : stepCount >= 2 && stepCount <= 4 ? 'kroky' : 'kroků'} @@ -300,7 +300,7 @@ export default function AutomationDetail() { diff --git a/web/src/pages/dashboard/Incidents.tsx b/web/src/pages/dashboard/Incidents.tsx index 93b8a0c..92e504c 100644 --- a/web/src/pages/dashboard/Incidents.tsx +++ b/web/src/pages/dashboard/Incidents.tsx @@ -6,7 +6,7 @@ import { usePageMeta } from '@/lib/usePageMeta'; import type { Incident, ListResponse } from '@/types/dashboard'; export default function Incidents() { - usePageMeta({ title: 'Incidenty — portál Automia' }); + usePageMeta({ title: 'Incidenty - portál Automia' }); const { data, loading, error, reload } = useApiQuery>( '/api/dashboard/incidents', { refetchOn: ['incident.started', 'incident.updated', 'incident.resolved'] }, diff --git a/web/src/pages/dashboard/Overview.tsx b/web/src/pages/dashboard/Overview.tsx index 1f62fe4..6c7b3d9 100644 --- a/web/src/pages/dashboard/Overview.tsx +++ b/web/src/pages/dashboard/Overview.tsx @@ -14,6 +14,7 @@ import { useEffect, useMemo, useState } from 'react'; import type { ReactNode } from 'react'; import { useAuth } from '@/auth/AuthContext'; import { DataState } from '@/components/dashboard/DataState'; +import { CustomWidgetCard } from '@/components/dashboard/widgets/CustomWidget'; import { WidgetCard, type WidgetData } from '@/components/dashboard/widgets/WidgetCard'; import { WidgetPicker } from '@/components/dashboard/widgets/WidgetPicker'; import { Button } from '@/components/ui/Button'; @@ -29,6 +30,7 @@ import type { LayoutResponse, ListResponse, TicketListResponse, + WidgetDataResponse, WidgetDefinition, WidgetSize, Workload, @@ -41,6 +43,12 @@ const span: Record = { full: 'col-span-6', }; +/** + * Vestavene widgety, jejichz data pocita server stejne jako u vlastnich. + * Musi sedet s `builtinSources` v `src/data/widgets.ts`. + */ +const COMPUTED_BUILTINS = new Set(['panel.agents']); + const sizeLabels: Record = { third: 'třetina', half: 'polovina', @@ -48,7 +56,7 @@ const sizeLabels: Record = { }; export default function Overview() { - usePageMeta({ title: 'Přehled — portál Automia' }); + usePageMeta({ title: 'Přehled - portál Automia' }); const { user } = useAuth(); const access = useApiQuery('/api/dashboard/access'); @@ -89,6 +97,38 @@ export default function Overview() { const data: WidgetData = { summary, tickets, incidents, workload }; + // ------------------------------------------------- data vlastnich widgetu + + const layoutItems = layout.data?.items ?? []; + + /** + * Ktere dlazdice si data pocitaji na serveru. Krome vlastnich jsou to + * i vestavene, ktere maji zdroj (vykon resitelu) - klient nepozna rozdil + * a nemusi. + */ + const computedIds = useMemo(() => { + const byId = new Map((catalog.data?.items ?? []).map((widget) => [widget.id, widget])); + const ids = layoutItems + .map((item) => item.widgetId) + .filter((id) => byId.get(id)?.custom === true || COMPUTED_BUILTINS.has(id)); + // Tentyz widget muze byt na dashboardu vickrat, data se ptaji jednou. + return [...new Set(ids)].sort(); + }, [layoutItems, catalog.data]); + + const widgetData = useApiQuery( + `/api/dashboard/widget-data${scopeQuery}`, + { + body: { widgetIds: computedIds }, + enabled: computedIds.length > 0, + refetchOn: ['ticket.created', 'ticket.updated', 'ticket.assigned', 'ticket.resolved'], + }, + ); + + const computed = useMemo( + () => new Map((widgetData.data?.items ?? []).map((item) => [item.id, item])), + [widgetData.data], + ); + // ----------------------------------------------------------- rezim uprav const [editing, setEditing] = useState(false); @@ -238,7 +278,7 @@ export default function Overview() { ) : ( @@ -291,17 +331,39 @@ export default function Overview() { const definition = definitions.get(item.widgetId); if (!definition) { - // Widget zmizel z katalogu. Nesmi to tise vypadnout z rozlozeni. + /* + * Widget zmizel z katalogu, typicky ho nekdo smazal v definicich. + * Nesmi to tise vypadnout z rozlozeni, ale musi jit odstranit - + * jinak by mrtvy odkaz zustal na dashboardu napord. + */ return (
- Widget „{item.widgetId}" už v katalogu není. + + Widget {item.widgetId} už v katalogu není. Nejspíš ho někdo smazal. + + {editing ? ( + + ) : ( + + Odebrat ho jde v režimu Upravit dashboard. + + )}
); } + // Widget s vypoctem na serveru se kresli z `/widget-data`, + // pevny widget z dat, ktera uz prehled ma. + const result = computed.get(item.widgetId); + const usesServerData = + definition.custom === true || COMPUTED_BUILTINS.has(item.widgetId); + return (
{editing && ( @@ -316,7 +378,16 @@ export default function Overview() { /> )}
- + {usesServerData ? ( + + ) : ( + + )}
); diff --git a/web/src/pages/dashboard/People.tsx b/web/src/pages/dashboard/People.tsx new file mode 100644 index 0000000..7837c38 --- /dev/null +++ b/web/src/pages/dashboard/People.tsx @@ -0,0 +1,198 @@ +import { Mail, Users } from 'lucide-react'; +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { DataState } from '@/components/dashboard/DataState'; +import { ViewSwitch, type ListView } from '@/components/dashboard/ViewSwitch'; +import { Badge } from '@/components/ui/Badge'; +import { cn } from '@/lib/cn'; +import { useApiQuery } from '@/lib/useApiQuery'; +import { usePageMeta } from '@/lib/usePageMeta'; +import type { PeopleResponse, Workload } from '@/types/dashboard'; + +/** + * Lide firmy: kdo resi tickety a jak na tom je. + * + * Dva pohledy, tabulka a dlazdice. Tabulka se hodi na porovnavani cisel, + * dlazdice na rychly prehled - proto obojí, ne jen jedno. + */ +export default function People() { + usePageMeta({ title: 'Lidé - portál Automia' }); + + const [view, setView] = useState('table'); + + const people = useApiQuery('/api/dashboard/people'); + const workload = useApiQuery('/api/dashboard/tickets/workload', { + refetchOn: ['ticket.created', 'ticket.updated', 'ticket.assigned', 'ticket.resolved'], + }); + + const rows = workload.data?.rows ?? []; + const byPerson = new Map(rows.map((row) => [row.person.id, row])); + const items = people.data?.items ?? []; + + return ( +
+
+
+

Lidé

+

+ Kdo řeší tickety, co má u sebe a jak dlouho mu to leží. +

+
+ +
+ + {workload.data && ( +
+ + 0 ? 'warn' : undefined} + /> + + {(people.data?.groups ?? []).length > 0 && ( + + )} +
+ )} + + + {view === 'table' ? ( +
+ + + + + + + + + + + + + {items.map((person) => { + const row = byPerson.get(person.id); + return ( + + + + + + + + + ); + })} + +
JménoE-mailZaměřeníU sebeKapacitaKritické
+ + {person.name} + {row?.overloaded && přetížený} + {person.id === people.data?.meId && já} + + {person.email}{person.role} + {row?.open ?? 0} + {person.capacity} + {row && row.critical > 0 ? ( + {row.critical} + ) : ( + - + )} +
+
+ ) : ( +
+ {items.map((person) => { + const row = byPerson.get(person.id); + const share = person.capacity > 0 ? Math.min((row?.open ?? 0) / person.capacity, 1) : 0; + + return ( + +
+
+

+ {person.name} +

+

{person.role}

+
+ {row?.overloaded && přetížený} +
+ +

+ + {person.email} +

+ +
+ + {row?.open ?? 0} + + z kapacity {person.capacity} +
+
+
+
+ + ); + })} +
+ )} + + + {(people.data?.groups ?? []).length > 0 && ( +
+

+ + Skupiny +

+
+ {(people.data?.groups ?? []).map((group) => ( + + {group.name} + + ))} +
+
+ )} +
+ ); +} + +function Stat({ + label, + value, + tone, +}: { + label: string; + value: number; + tone?: 'warn'; +}) { + return ( +
+ {label} +

+ {value} +

+
+ ); +} diff --git a/web/src/pages/dashboard/PersonDetail.tsx b/web/src/pages/dashboard/PersonDetail.tsx new file mode 100644 index 0000000..f59c2df --- /dev/null +++ b/web/src/pages/dashboard/PersonDetail.tsx @@ -0,0 +1,221 @@ +import { ArrowLeft, Clock, Mail, RotateCcw, Users } from 'lucide-react'; +import { Link, useParams } from 'react-router-dom'; +import { DataState } from '@/components/dashboard/DataState'; +import { TicketPriorityBadge, TicketStatusBadge } from '@/components/dashboard/StatusBadge'; +import { Badge } from '@/components/ui/Badge'; +import { cn } from '@/lib/cn'; +import { formatDuration, formatNumber, formatRelative } from '@/lib/format'; +import { useApiQuery } from '@/lib/useApiQuery'; +import { usePageMeta } from '@/lib/usePageMeta'; +import type { AgentStatsRow, Person, Ticket } from '@/types/dashboard'; + +/** + * Detail resitele: kdo to je, jak mu to jde a co ma u sebe. + * + * Cisla sedi s widgetem vykonu, protoze je pocita tatáz funkce na serveru. + * Kdyby si je stranka pocitala sama, na dvou mistech by vyslo neco jineho. + */ +interface PersonDetailResponse { + person: Person; + stats: AgentStatsRow | null; + groups: Array<{ id: string; name: string }>; + open: Ticket[]; + resolved: Ticket[]; +} + +export default function PersonDetail() { + const { id = '' } = useParams(); + + const detail = useApiQuery(`/api/dashboard/people/${id}`, { + refetchOn: ['ticket.created', 'ticket.updated', 'ticket.assigned', 'ticket.resolved'], + }); + + usePageMeta({ title: `${detail.data?.person.name ?? 'Řešitel'} - portál Automia` }); + + return ( +
+ + + Zpět na lidi + + + + {detail.data && ( +
+
+
+

{detail.data.person.name}

+ {detail.data.stats?.overloaded && přetížený} +
+
+ + + {detail.data.person.email} + + {detail.data.person.role} + {detail.data.groups.length > 0 && ( + + + {detail.data.groups.map((group) => group.name).join(', ')} + + )} +
+
+ + {detail.data.stats && ( +
+ + + + + + 0 ? 'warn' : undefined} + hint="z vyřešeno zpět" + /> +
+ )} + +
+ + +
+
+ )} +
+
+ ); +} + +function TicketColumn({ + title, + empty, + tickets, + showAge, +}: { + title: string; + empty: string; + tickets: Ticket[]; + showAge?: boolean; +}) { + return ( +
+
+

{title}

+ {tickets.length} +
+ + {tickets.length === 0 ? ( +

{empty}

+ ) : ( +
    + {tickets.map((ticket) => ( +
  • +
    + {ticket.id} +
    + + {ticket.subject} + +

    + {ticket.externalId && ( + externí {ticket.externalId} + )} + {showAge ? ( + + + leží {formatRelative(ticket.createdAt)} + + ) : ( + ticket.resolvedAt && vyřešeno {formatRelative(ticket.resolvedAt)} + )} + {ticket.reopenCount > 0 && ( + + + vráceno {ticket.reopenCount}x + + )} +

    +
    +
    + + {(ticket.priority === 'critical' || ticket.priority === 'high') && ( + + )} +
    +
    +
  • + ))} +
+ )} +
+ ); +} + +function Tile({ + label, + value, + hint, + tone, +}: { + label: string; + value: string; + hint?: string; + tone?: 'ok' | 'warn'; +}) { + return ( +
+

{label}

+

+ {value} +

+ {hint &&

{hint}

} +
+ ); +} diff --git a/web/src/pages/dashboard/Settings.tsx b/web/src/pages/dashboard/Settings.tsx index bf86409..c2551ab 100644 --- a/web/src/pages/dashboard/Settings.tsx +++ b/web/src/pages/dashboard/Settings.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import { useAuth } from '@/auth/AuthContext'; -import { EntityAdmin, parseJsonField, type AdminField } from '@/components/dashboard/EntityAdmin'; +import { EntityAdmin, parseJsonField } from '@/components/dashboard/EntityAdmin'; import { Badge } from '@/components/ui/Badge'; import { Button } from '@/components/ui/Button'; import { apiFetch } from '@/lib/api'; @@ -16,9 +16,8 @@ import type { Access } from '@/types/dashboard'; * u dane entity jine: sloupce tabulky a pole formulare. Nova entita znamena * jeden zaznam v `tabs` nize. * - * Slozitejsi struktury (pole typu ticketu, telo akce, zdroj widgetu) se upravuji - * jako JSON. Klikaci editor by u nich byl vetsi prace nez cely zbytek nastaveni - * a JSON je zatim citelnejsi nez deset vnorenych formularu. + * Akce a widgety tady **nejsou**: jsou to definice toho, co aplikace umi, + * stejna uroven jako automatizace, a maji vlastni zalozku. */ interface CatalogItem { @@ -79,8 +78,6 @@ export default function Settings() { { key: 'groups', label: 'Skupiny', show: can('group.manage') }, { key: 'roles', label: 'Role a práva', show: can('role.manage') }, { key: 'types', label: 'Typy ticketů', show: can('ticketType.manage') }, - { key: 'actions', label: 'Akce na ticketu', show: can('action.manage') }, - { key: 'widgets', label: 'Vlastní widgety', show: can('widget.manage') }, { key: 'users', label: 'Uživatelé', show: platformAdmin }, { key: 'tenants', label: 'Firmy', show: platformAdmin }, { key: 'features', label: 'Záložky firmy', show: platformAdmin }, @@ -94,7 +91,8 @@ export default function Settings() {

Nastavení

- Co která firma má, kdo co smí a co se dá na ticketu udělat. + Firmy, lidé, role a typy ticketů. Akce a widgety mají vlastní záložku, + jsou to definice, ne nastavení.

@@ -267,98 +265,7 @@ export default function Settings() { /> )} - {tab === 'actions' && ( - String(action.label ?? '') }, - { - label: 'Na čem', - render: (action: NamedRecord) => { - const types = (action.ticketTypeIds as string[] | undefined) ?? []; - const tags = (action.tags as string[] | undefined) ?? []; - return [types.length > 0 ? `typy: ${types.join(', ')}` : null, tags.length > 0 ? `tagy: ${tags.join(', ')}` : null] - .filter(Boolean) - .join(' | '); - }, - }, - { - label: 'Tělo', - narrow: true, - render: (action: NamedRecord) => { - const body = action.body as { kind?: string } | undefined; - return {body?.kind ?? '?'}; - }, - }, - { - label: 'Stav', - narrow: true, - render: (action: NamedRecord) => - action.enabled === false ? vypnutá : zapnutá, - }, - ]} - fields={actionFields} - toBody={(values, _entity) => ({ - label: values.label, - icon: values.icon, - style: values.style || 'default', - order: values.order, - ticketTypeIds: values.ticketTypeIds, - tags: values.tags, - confirm: values.confirm === '' ? null : values.confirm, - visibleWhen: parseJsonField(values.visibleWhen, []), - form: parseJsonField(values.form, []), - body: parseJsonField(values.body, { kind: 'tree', steps: [] }), - ...(values.enabled !== undefined ? { enabled: values.enabled } : {}), - })} - toForm={(action: NamedRecord) => ({ - ...action, - confirm: action.confirm ?? '', - visibleWhen: JSON.stringify(action.visibleWhen ?? [], null, 2), - form: JSON.stringify(action.form ?? [], null, 2), - body: JSON.stringify(action.body ?? {}, null, 2), - })} - /> - )} - - {tab === 'widgets' && ( - String(widget.name ?? '') }, - { label: 'Vykreslení', narrow: true, render: (widget: NamedRecord) => {String(widget.render ?? '')} }, - { - label: 'Zdroj', - render: (widget: NamedRecord) => { - const source = widget.source as { kind?: string; groupBy?: string } | undefined; - return `${source?.kind ?? '?'}${source?.groupBy ? ` podle ${source.groupBy}` : ''}`; - }, - }, - { label: 'Šířka', narrow: true, render: (widget: NamedRecord) => String(widget.size ?? '') }, - ]} - fields={widgetFields} - toBody={(values) => ({ - name: values.name, - description: values.description, - render: values.render, - size: values.size, - ...(values.target ? { target: Number(values.target) } : {}), - source: parseJsonField(values.source, { kind: 'ticketCount', filter: {} }), - })} - toForm={(widget: NamedRecord) => ({ - ...widget, - source: JSON.stringify(widget.source ?? {}, null, 2), - })} - /> - )} - - {tab === 'users' && } + {tab === 'users' && } {tab === 'tenants' && ( > = [ - { name: 'label', label: 'Nápis na tlačítku', kind: 'text', required: true }, - { name: 'icon', label: 'Ikona', kind: 'text', initial: 'Play' }, - { - name: 'style', - label: 'Vzhled', - kind: 'select', - initial: 'default', - options: [ - { value: 'primary', label: 'Hlavní' }, - { value: 'default', label: 'Běžný' }, - { value: 'danger', label: 'Nebezpečný' }, - ], - }, - { name: 'order', label: 'Pořadí', kind: 'number', initial: 10 }, - { - name: 'ticketTypeIds', - label: 'Typy ticketu', - kind: 'tags', - hint: 'ID typů oddělená čárkou. Za typem stojí pole, takže akce z nich může čerpat.', - }, - { - name: 'tags', - label: 'Tagy', - kind: 'tags', - hint: 'Za tagem nestojí žádná pole, takže akce umí jen vestavěná pole ticketu.', - }, - { name: 'confirm', label: 'Text potvrzení', kind: 'text', hint: 'Prázdné = spustí se hned.' }, - { - name: 'visibleWhen', - label: 'Kdy se tlačítko ukáže', - kind: 'json', - initial: '[]', - hint: 'Podmínky: fieldId, operator, value. Například [{"fieldId":"ticket.status","operator":"neq","value":"resolved"}]', - }, - { - name: 'form', - label: 'Doptávací formulář', - kind: 'json', - initial: '[]', - hint: 'Na co se zeptat před spuštěním: id, label, kind, required.', - }, - { - name: 'body', - label: 'Co akce dělá', - kind: 'json', - initial: '{\n "kind": "operation",\n "serviceId": "idoklad",\n "operationId": "create-issued-invoice",\n "connectorId": null,\n "inputs": {}\n}', - hint: 'kind: operation (jeden krok služby), tree (vlastní strom) nebo script.', - }, - { name: 'enabled', label: 'Zapnutá', kind: 'checkbox', initial: true }, -]; - -const widgetFields: Array> = [ - { name: 'name', label: 'Název', kind: 'text', required: true }, - { name: 'description', label: 'Popis', kind: 'text' }, - { - name: 'render', - label: 'Vykreslení', - kind: 'select', - initial: 'stat', - options: [ - { value: 'stat', label: 'Jedno číslo' }, - { value: 'table', label: 'Tabulka' }, - { value: 'list', label: 'Seznam' }, - { value: 'chart', label: 'Graf' }, - { value: 'gauge', label: 'Ukazatel' }, - ], - }, - { - name: 'size', - label: 'Šířka', - kind: 'select', - initial: 'third', - options: [ - { value: 'third', label: 'Třetina' }, - { value: 'half', label: 'Polovina' }, - { value: 'full', label: 'Celá šířka' }, - ], - hint: 'Číslo a ukazatel umí třetinu a polovinu, ostatní polovinu a celou šířku.', - }, - { name: 'target', label: 'Cílová hodnota', kind: 'number', hint: 'Jen u ukazatele.' }, - { - name: 'source', - label: 'Zdroj dat', - kind: 'json', - initial: '{\n "kind": "ticketCount",\n "filter": { "status": ["new", "open", "waiting"] }\n}', - hint: 'kind: ticketCount, ticketList, ticketSeries nebo workload. U ticketCount lze groupBy.', - }, -]; diff --git a/web/src/pages/dashboard/TicketDetail.tsx b/web/src/pages/dashboard/TicketDetail.tsx index 692fecc..82e05fc 100644 --- a/web/src/pages/dashboard/TicketDetail.tsx +++ b/web/src/pages/dashboard/TicketDetail.tsx @@ -3,6 +3,7 @@ import { ArrowLeft, Building2, FileText, + Inbox, MessageSquarePlus, ScrollText, UserCheck, @@ -17,6 +18,7 @@ import { TicketStatusBadge, } from '@/components/dashboard/StatusBadge'; import { TicketActions } from '@/components/dashboard/TicketActions'; +import { TicketEvents } from '@/components/dashboard/TicketEvents'; import { TicketTrace, TraceSummary } from '@/components/dashboard/TicketTrace'; import { Badge } from '@/components/ui/Badge'; import { Button } from '@/components/ui/Button'; @@ -55,7 +57,7 @@ export default function TicketDetail() { const [actionError, setActionError] = useState(null); const [comment, setComment] = useState(''); - usePageMeta({ title: `${ticket.data ? ticket.data.id : 'Ticket'} — portál Automia` }); + usePageMeta({ title: `${ticket.data ? ticket.data.id : 'Ticket'} - portál Automia` }); // Po zmene ticketu zmizi stara chyba, at nevisi u uz opraveneho stavu. useEffect(() => { @@ -117,6 +119,16 @@ export default function TicketDetail() { + {ticket.data.externalId && ( + + {/* Podle tohohle se na ticket navesuji dalsi udalosti. */} + externí ID {ticket.data.externalId} + {ticket.data.externalSource ? ` (${ticket.data.externalSource})` : ''} + + )} + {ticket.data.reopenCount > 0 && ( + vráceno {ticket.data.reopenCount}x + )} {ticket.data.customer.id === null && ( @@ -164,6 +176,22 @@ export default function TicketDetail() { )} +
+
+

+ + Příchozí události +

+ + {ticket.data.events.length} + {ticket.data.externalId + ? ` k externímu ID ${ticket.data.externalId}` + : ''} + +
+ +
+

@@ -193,7 +221,7 @@ export default function TicketDetail() { id="ticket-comment" value={comment} onChange={(event) => setComment(event.target.value)} - placeholder="Co jste zjistili nebo udělali…" + placeholder="Co jste zjistili nebo udělali..." className="min-w-0 flex-1 rounded-xl border border-ink-600/70 bg-ink-850/70 px-3.5 py-2.5 text-sm text-white placeholder:text-white/30 focus:border-brand-400/70 focus:outline-none" />

-
+
+ {view === 'grid' ? ( +
+ {items.map((ticket) => ( + +
+ {ticket.id} + +
+ +

+ {ticket.subject} +

+ +

+ {ticket.customer.id ? ( + ticket.customer.company + ) : ( + zákazník nedohledán + )} +

+ + {ticket.externalId && ( +

+ externí {ticket.externalId} +

+ )} + +
+ + + {ticket.tags.slice(0, 2).map((tag) => ( + + {tag} + + ))} +
+ +
+ + {ticket.assignee?.name ?? 've frontě'} + + + {formatRelative(ticket.updatedAt)} + +
+ + ))} +
+ ) : (
@@ -295,6 +364,7 @@ export default function Tickets() {
+ )}
diff --git a/web/src/pages/dashboard/Widgets.tsx b/web/src/pages/dashboard/Widgets.tsx new file mode 100644 index 0000000..53290b2 --- /dev/null +++ b/web/src/pages/dashboard/Widgets.tsx @@ -0,0 +1,186 @@ +import { Info } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { EntityAdmin, parseJsonField, type AdminField } from '@/components/dashboard/EntityAdmin'; +import { Badge } from '@/components/ui/Badge'; +import { apiFetch } from '@/lib/api'; +import { usePageMeta } from '@/lib/usePageMeta'; +import type { Service } from '@/types/dashboard'; + +/** + * Definice vlastnich widgetu. + * + * Stejna uroven jako automatizace a akce. Widget je dvojice: + * - `render` jak se to kresli, + * - `source` odkud jsou data. + * + * Zdroj muze byt i **konektor**: co umi zjistit napojena sluzba, jde vytahnout + * do dlazdice. Vola se pritom tentyz skript jako v kroku automatizace, takze + * widget nema vlastni cestu k cizi sluzbe. + */ + +interface WidgetRecord { + id: string; + name: string; + description: string; + render: string; + size: string; + source: { kind: string; [key: string]: unknown }; + ownerId: string | null; + target?: number; + [key: string]: unknown; +} + +const sourceHint = `Příklady: +{"kind":"ticketCount","filter":{"status":["new","open","waiting"]}} +{"kind":"ticketCount","filter":{},"groupBy":"assignee"} +{"kind":"ticketList","filter":{"status":["new"]},"limit":5} +{"kind":"ticketSeries","filter":{},"bucket":"day"} +{"kind":"agentStats","period":"30d"} +{"kind":"connector","serviceId":"idoklad","operationId":"find-issued-invoice","connectorId":null,"inputs":{},"path":"total","ttlSec":300}`; + +export default function Widgets() { + usePageMeta({ title: 'Widgety - portál Automia' }); + + const [services, setServices] = useState([]); + + useEffect(() => { + apiFetch<{ items: Service[] }>('/api/dashboard/services') + .then((data) => setServices(data.items)) + .catch(() => setServices([])); + }, []); + + const fields: Array> = [ + { name: 'name', label: 'Název', kind: 'text', required: true }, + { name: 'description', label: 'Popis', kind: 'text' }, + { + name: 'render', + label: 'Jak se kreslí', + kind: 'select', + initial: 'stat', + options: [ + { value: 'stat', label: 'Jedno číslo' }, + { value: 'table', label: 'Tabulka' }, + { value: 'list', label: 'Seznam' }, + { value: 'chart', label: 'Graf' }, + { value: 'gauge', label: 'Ukazatel k cíli' }, + ], + }, + { + name: 'size', + label: 'Šířka', + kind: 'select', + initial: 'third', + options: [ + { value: 'third', label: 'Třetina' }, + { value: 'half', label: 'Polovina' }, + { value: 'full', label: 'Celá šířka' }, + ], + }, + { + name: 'target', + label: 'Cílová hodnota', + kind: 'number', + hint: 'Jen u ukazatele. Podle ní se kreslí, jak daleko k cíli je.', + }, + { + name: 'source', + label: 'Odkud data', + kind: 'json', + required: true, + initial: '{"kind":"ticketCount","filter":{"status":["new","open","waiting"]}}', + hint: sourceHint, + }, + { + name: 'ownerId', + label: 'Jen můj widget', + kind: 'checkbox', + hint: 'Zaškrtnuté = vidíte ho jen vy. Nezaškrtnuté = celá firma.', + }, + ]; + + return ( +
+
+

Widgety

+

+ Dlaždice, které si jde položit na přehled. Data počítá server. +

+
+ +

+ + + Zdroj connector vytáhne data z napojené služby: + zavolá stejnou operaci jako krok automatizace a z výsledku vezme, co je v{' '} + path. Výsledek se drží v mezipaměti ( + ttlSec), aby otevření přehledu neznamenalo + volání cizí služby za každou dlaždici. + {services.length > 0 && ( + <> + {' '} + Napojitelné služby: {services.slice(0, 8).map((service) => service.id).join(', ')} + {services.length > 8 ? ' a další' : ''}. + + )} + +

+ + + title="Vlastní widgety" + endpoint="/api/dashboard/settings/widgets" + listEndpoint="/api/dashboard/settings/widgets-overview" + columns={[ + { label: 'Název', render: (widget) => widget.name }, + { + label: 'Zdroj', + render: (widget) => {describeSource(widget.source)}, + }, + { label: 'Kreslení', narrow: true, render: (widget) => widget.render }, + { + label: 'Kdo vidí', + narrow: true, + render: (widget) => + widget.ownerId ? jen já : firma, + }, + ]} + fields={fields} + toBody={(values) => ({ + name: values.name, + description: values.description ?? '', + render: values.render, + size: values.size, + source: parseJsonField(values.source, { kind: 'ticketCount', filter: {} }), + ...(values.target ? { target: values.target } : {}), + // Zaskrtnute pole znamena osobni widget. Server dosadi ID prihlaseneho, + // klient sve ID posilat nemusi a nema. + personal: values.ownerId === true, + })} + toForm={(widget) => ({ + ...widget, + source: JSON.stringify(widget.source, null, 2), + ownerId: widget.ownerId !== null, + })} + /> +
+ ); +} + +/** Zdroj jednou radkou, aby se tabulka dala precist. */ +function describeSource(source: { kind: string; [key: string]: unknown }): string { + switch (source.kind) { + case 'ticketCount': + return source.groupBy ? `počet ticketů podle ${String(source.groupBy)}` : 'počet ticketů'; + case 'ticketList': + return 'seznam ticketů'; + case 'ticketSeries': + return 'časová řada ticketů'; + case 'workload': + return 'vytížení týmu'; + case 'agentStats': + return `výkon řešitelů (${String(source.period ?? 'vše')})`; + case 'connector': + return `konektor ${String(source.serviceId)}/${String(source.operationId)}`; + default: + return source.kind; + } +} diff --git a/web/src/types/dashboard.ts b/web/src/types/dashboard.ts index e106949..27be978 100644 --- a/web/src/types/dashboard.ts +++ b/web/src/types/dashboard.ts @@ -63,10 +63,28 @@ export interface TicketCustomer { reply: string; } +/** Prichozi udalost k ticketu. Fakt zvenku, na rozdil od radku logu. */ +export interface TicketEvent { + id: string; + /** Typ od odesilatele, napr. `order.created`. */ + type: string; + source: string; + label: string; + payload: Record; + at: string; +} + export interface Ticket { id: string; /** Firma, ktere ticket patri. */ tenantId: string; + /** + * ID u odesilatele, napr. cislo objednavky. **Unikatni v ramci firmy.** + * Dalsi zprava se stejnym ID se navesi na tenhle ticket. + */ + externalId: string | null; + /** Odkud externi ID pochazi. Jen informace, do klice nevstupuje. */ + externalSource: string | null; subject: string; /** Cely text pozadavku. Prazdny = krok "Zalozit ticket" obsah nenaplnil. */ body: string; @@ -88,6 +106,14 @@ export interface Ticket { fields: Record; /** Volne tagy. Akce se muze vazat na typ **nebo** na tag. */ tags: string[]; + /** Kdy se ticketu prvne nekdo venoval. */ + firstResponseAt: string | null; + /** Kdy byl vyresen. Znovuotevreni to vynuluje. */ + resolvedAt: string | null; + /** Kdo ho vyresil. */ + resolvedById: string | null; + /** Kolikrat se vratil z vyreseno zpatky do prace. */ + reopenCount: number; createdAt: string; updatedAt: string; } @@ -112,6 +138,8 @@ export interface TicketTraceEntry { export interface TicketDetail extends Ticket { trace: TicketTraceEntry[]; + /** Prichozi udalosti, nejstarsi nahore. */ + events: TicketEvent[]; } /** Odpoved seznamu ticketu - `meId` rika, ktery resitel je prihlaseny uzivatel. */ @@ -449,7 +477,74 @@ export interface ListResponse { export type WidgetSize = 'third' | 'half' | 'full'; -export type WidgetKind = 'stat' | 'chart' | 'ticketList' | 'incidentList' | 'workload'; +/** + * Pevny widget z katalogu. Vlastni widget pouziva `WidgetRender`, protoze + * u nej je kresleni a zdroj dat rozdelene. + */ +export type WidgetKind = + | 'stat' + | 'chart' + | 'ticketList' + | 'incidentList' + | 'workload' + | WidgetRender; + +/** Jak se vlastni widget kresli. Odkud bere data, rika `WidgetSource`. */ +export type WidgetRender = 'stat' | 'chart' | 'list' | 'table' | 'gauge'; + +/** Hodnota vlastniho widgetu ze serveru. Podle `kind` se pozna, co s tim. */ +export type WidgetValue = + | { kind: 'number'; value: number } + | { kind: 'groups'; rows: Array<{ key: string; label: string; value: number }> } + | { kind: 'series'; points: Array<{ date: string; value: number }> } + | { + kind: 'tickets'; + items: Array<{ id: string; subject: string; status: TicketStatus; assignee: string | null }>; + } + | { kind: 'workload'; rows: Array<{ name: string; open: number; overCapacity: boolean }> } + | { kind: 'agents'; rows: AgentStatsRow[] } + /** + * Vysledek z konektoru. `stale` znamena, ze je to z mezipameti serveru - + * cizi sluzba se nevola pri kazdem otevreni prehledu. + */ + | { + kind: 'external'; + value: number | null; + text: string | null; + rows: Array<{ key: string; label: string; value: number }>; + at: string; + stale: boolean; + }; + +/** + * Vykon jednoho resitele. Slouzi k tomu, aby slo videt, kdo kolik odbavil + * a komu to nejde - proto vedle poctu i casy a vraceni. + */ +export interface AgentStatsRow { + personId: string; + name: string; + /** Kolik vyresil za sledovane obdobi. */ + resolved: number; + /** Kolik ma prave ted nevyrizenych. */ + open: number; + /** Kolik z nevyrizenych je po lhute vyssi priority. */ + critical: number; + /** Medianovy cas do vyreseni v milisekundach. null = nic nevyresil. */ + medianResolveMs: number | null; + /** Medianovy cas do prvni reakce. null = nikde nereagoval. */ + medianResponseMs: number | null; + /** Kolikrat se mu ticket vratil z vyreseno zpatky. */ + reopened: number; + /** Nejstarsi nevyrizeny, aby bylo videt, co lezi. */ + oldestOpenAt: string | null; + /** Ma vic prace, nez unese. */ + overloaded: boolean; +} + +/** Odpoved davkoveho nacteni dat vlastnich widgetu. */ +export interface WidgetDataResponse { + items: Array<{ id: string; ok: boolean; value?: WidgetValue; error?: string }>; +} export type WidgetMetric = | 'openTickets' @@ -468,6 +563,10 @@ export interface WidgetDefinition { sizes: WidgetSize[]; defaultSize: WidgetSize; metric?: WidgetMetric; + /** true = widget si firma nadefinovala sama, data chodi z `/widget-data`. */ + custom?: boolean; + /** Porovnavaci hodnota u ukazatele. */ + target?: number; } export interface LayoutItem {