Ticketovaci system: udalosti, externi ID, statistiky a widgety nad konektory
Jakakoliv udalost se muze stat ticketem. Prijem je verejny endpoint na firmu (`POST /webhook/ticket/:token`), takze zalozit ticket jde i bez stavby stromu. Externi ID je unikatni V RAMCI FIRMY: dalsi zprava se stejnym ID se navesi na existujici ticket misto zalozeni druheho, a stejne ID u jine firmy je jiny ticket. Cislo a retezec jsou tentyz klic. Udalosti se drzi cele vcetne prijatych dat a jdou rozbalit v detailu - je to neco jineho nez log. Ticket nove nese firstResponseAt, resolvedAt, resolvedById a reopenCount. Bez nich neslo rict, kdo kolik odbavil ani jak dlouho zakaznik cekal. `getAgentStats` z toho pocita vykon resitelu vcetne medianovych casu a vracenych ticketu. Pocet vyresenych sam o sobe odmenuje toho, kdo tickety zaviral predcasne, proto je vraceni videt vedle nej. Widgety: klient konecne vola /widget-data. Endpoint existoval, ale nikdo ho nepouzival, takze vlastni widget hlasil "nepodarilo se zobrazit". Pribyl zdroj `connector` - co umi zjistit napojena sluzba, jde vytahnout do dlazdice pres tentyz skript, ktery pouziva krok automatizace. Vysledek se cachuje. Akce a widgety uz nejsou v nastaveni, maji vlastni zalozku vedle automatizaci. Telo akce se sklada stromem, ne JSONem v textarei - je to tentyz editor, jen misto karty spoustece je "spousti clovek tlacitkem na ticketu". Nova zalozka Lide se seznamem a detailem osoby. Seznam ticketu i lidi ma dva pohledy, tabulku a dlazdice. Opraveno: createTicket bral typeId, fields, tags i assigneeGroupId, ale nikdy je neukladal. Ticket zalozeny s typem zustaval bez typu a bez vlastnich poli. Dlouhe pomlcky, sipky, vypustky a bullety pryc z celeho projektu. Overeno 21 kontrolami proti bezicimu serveru v rezimu souboru. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6c90372991
commit
29de584df8
@@ -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 |
|
||||
|
||||
@@ -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 <token>`:
|
||||
|
||||
@@ -30,6 +32,10 @@ Vyzaduji `Authorization: Bearer <token>`:
|
||||
| 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
|
||||
|
||||
@@ -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<T>` | `lib/api.ts` | Jediná cesta na API: base path, token, `ApiError` s celým hlášením ze serveru. |
|
||||
| `useApiQuery<T>` | `lib/useApiQuery.ts` | Načtení dat do stránky včetně `reload`. |
|
||||
| `useApiQuery<T>` | `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. |
|
||||
|
||||
@@ -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>
|
||||
```
|
||||
|
||||
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/<token> {"externalId": 3, "event": "order.created"} -> 201, vznikl TK-4822
|
||||
POST /webhook/ticket/<token> {"externalId": 3, "event": "email.sent"} -> 200, doplněn TK-4822
|
||||
POST /webhook/ticket/<jiná firma> {"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.
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, string>;
|
||||
/** 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;
|
||||
}
|
||||
|
||||
@@ -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'] },
|
||||
|
||||
+29
-3
@@ -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>('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<void> {
|
||||
await cache.refresh();
|
||||
}
|
||||
|
||||
+371
-5
@@ -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<string, unknown>;
|
||||
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<Ticket, 'assignee' | 'typeId' | 'fields' | 'tags' | 'assigneeGroupId'> {
|
||||
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<string, string | number | boolean | null>;
|
||||
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<TicketChannel, string> = {
|
||||
@@ -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<string, TicketEvent[]>();
|
||||
|
||||
const mirror = withMirror(defineStore<PersistedTicket>('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<void> {
|
||||
|
||||
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<string, unknown>;
|
||||
/** Pouzije se jen kdyz ticket teprve vznika. */
|
||||
create?: Partial<Omit<CreateTicketInput, 'tenantId' | 'externalId' | 'externalSource'>>;
|
||||
/** Vlastni pole, ktera se doplni i na existujici ticket. */
|
||||
fields?: Record<string, string | number | boolean | null>;
|
||||
/** 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, unknown>): 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<string | null>(
|
||||
(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' }]);
|
||||
|
||||
|
||||
+22
-1
@@ -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<string, { kind: 'agentStats'; period: '30d' }> = {
|
||||
'panel.agents': { kind: 'agentStats', period: '30d' },
|
||||
};
|
||||
|
||||
export function findWidget(id: string): WidgetDefinition | undefined {
|
||||
return widgets.find((widget) => widget.id === id);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
+87
-3
@@ -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<string>();
|
||||
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({
|
||||
|
||||
+147
-1
@@ -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.' });
|
||||
}
|
||||
|
||||
|
||||
+209
-22
@@ -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<string, { at: number; value: WidgetValue }>();
|
||||
|
||||
/** 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<WidgetSource, { kind: 'connector' }>,
|
||||
scope: ResolvedScope,
|
||||
): Promise<WidgetValue> {
|
||||
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<WidgetSource, { kind: 'connector' }>,
|
||||
): 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<string, unknown>)
|
||||
.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<WidgetResult> => {
|
||||
// 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!, {
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Automia — automatizace, voiceboti a integrace na míru</title>
|
||||
<title>Automia - automatizace, voiceboti a integrace na míru</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Automatizujeme procesy, stavíme voiceboty a propojujeme systémy. K tomu dashboardy, tickety a incident management pod jednou střechou."
|
||||
|
||||
+11
-1
@@ -23,6 +23,11 @@ const Tickets = lazy(() => 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() {
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="grid min-h-screen place-items-center bg-ink-950">
|
||||
<Spinner label="Načítám portál…" />
|
||||
<Spinner label="Načítám portál..." />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -69,6 +74,11 @@ export default function App() {
|
||||
<Route path="tickety/:id" element={<TicketDetail />} />
|
||||
<Route path="incidenty" element={<Incidents />} />
|
||||
<Route path="nastaveni" element={<Settings />} />
|
||||
<Route path="akce" element={<Actions />} />
|
||||
<Route path="akce/:id" element={<ActionDetail />} />
|
||||
<Route path="widgety" element={<Widgets />} />
|
||||
<Route path="lide" element={<People />} />
|
||||
<Route path="lide/:id" element={<PersonDetail />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
@@ -10,7 +10,7 @@ export function RequireAuth() {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-ink-900">
|
||||
<Spinner label="Načítám session…" />
|
||||
<Spinner label="Načítám session..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string, { to: string; icon: typeof LayoutDashboard; e
|
||||
connectors: { to: '/dashboard/konektory', icon: Plug, end: false },
|
||||
scripts: { to: '/dashboard/skripty', icon: ScrollText, end: false },
|
||||
tickets: { to: '/dashboard/tickety', icon: LifeBuoy, end: false },
|
||||
people: { to: '/dashboard/lide', icon: Users, end: false },
|
||||
actions: { to: '/dashboard/akce', icon: MousePointerClick, end: false },
|
||||
widgets: { to: '/dashboard/widgety', icon: LayoutGrid, end: false },
|
||||
incidents: { to: '/dashboard/incidenty', icon: AlarmClock, end: false },
|
||||
settings: { to: '/dashboard/nastaveni', icon: Settings, end: false },
|
||||
};
|
||||
|
||||
@@ -25,7 +25,7 @@ export function DataState({
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="grid min-h-40 place-items-center">
|
||||
<Spinner label="Načítám data…" />
|
||||
<Spinner label="Načítám data..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,6 +71,13 @@ export interface EntityAdminProps<T extends { id: string }> {
|
||||
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<T extends { id: string }>({
|
||||
canWrite = true,
|
||||
canDelete,
|
||||
children,
|
||||
rowActions,
|
||||
onChanged,
|
||||
}: EntityAdminProps<T>) {
|
||||
const [items, setItems] = useState<T[] | null>(null);
|
||||
@@ -161,8 +169,10 @@ export function EntityAdmin<T extends { id: string }>({
|
||||
{column.render(entity)}
|
||||
</td>
|
||||
))}
|
||||
{canWrite && (
|
||||
{(canWrite || rowActions) && (
|
||||
<td className="px-4 py-2 text-right whitespace-nowrap">
|
||||
{rowActions?.(entity)}
|
||||
{canWrite && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@@ -171,7 +181,8 @@ export function EntityAdmin<T extends { id: string }>({
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
{(canDelete?.(entity) ?? true) && (
|
||||
)}
|
||||
{canWrite && (canDelete?.(entity) ?? true) && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
|
||||
@@ -54,9 +54,9 @@ const ticketChannelMap: Record<TicketChannel, { label: string; icon: LucideIcon
|
||||
};
|
||||
|
||||
const incidentSeverityMap: Record<IncidentSeverity, { label: string; tone: BadgeTone }> = {
|
||||
sev1: { label: 'SEV1 — kritický', tone: 'danger' },
|
||||
sev2: { label: 'SEV2 — vážný', tone: 'warn' },
|
||||
sev3: { label: 'SEV3 — menší', tone: 'neutral' },
|
||||
sev1: { label: 'SEV1 - kritický', tone: 'danger' },
|
||||
sev2: { label: 'SEV2 - vážný', tone: 'warn' },
|
||||
sev3: { label: 'SEV3 - menší', tone: 'neutral' },
|
||||
};
|
||||
|
||||
const incidentStatusMap: Record<IncidentStatus, { label: string; tone: BadgeTone }> = {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { ChevronRight, Inbox } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { formatDateTime } from '@/lib/format';
|
||||
import type { TicketEvent } from '@/types/dashboard';
|
||||
|
||||
/**
|
||||
* Prichozi udalosti ticketu.
|
||||
*
|
||||
* Rozdil proti 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, protoze maji
|
||||
* stejne externi ID.
|
||||
*
|
||||
* Data se ukazuji cela a nezkracene. Kdyz se nekdo pta, proc ticket vypada
|
||||
* takhle, je to jedina odpoved.
|
||||
*/
|
||||
export function TicketEvents({ events }: { events: TicketEvent[] }) {
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<p className="rounded-lg border border-dashed border-ink-600/70 px-3 py-2.5 text-xs text-white/40">
|
||||
Žádné příchozí události. Ticket vznikl v portálu nebo ze simulace.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ol className="space-y-2">
|
||||
{events.map((event) => (
|
||||
<EventRow key={event.id} event={event} />
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
function EventRow({ event }: { event: TicketEvent }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const hasPayload = Object.keys(event.payload).length > 0;
|
||||
|
||||
return (
|
||||
<li className="rounded-lg border border-ink-600/50 bg-ink-900/40">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => hasPayload && setOpen(!open)}
|
||||
disabled={!hasPayload}
|
||||
className="flex w-full items-start gap-3 p-3 text-left disabled:cursor-default"
|
||||
>
|
||||
<span className="mt-0.5 grid size-7 shrink-0 place-items-center rounded-lg bg-white/5 text-white/40">
|
||||
<Inbox className="size-3.5" />
|
||||
</span>
|
||||
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm text-white/80">{event.label}</span>
|
||||
<span className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-white/35">
|
||||
<code className="text-brand-300/80">{event.type}</code>
|
||||
<span>{event.source}</span>
|
||||
<span>{formatDateTime(event.at)}</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{hasPayload && (
|
||||
<ChevronRight
|
||||
className={`mt-1 size-4 shrink-0 text-white/30 transition-transform ${open ? 'rotate-90' : ''}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<pre className="mx-3 mb-3 max-h-64 overflow-auto rounded bg-ink-950/70 p-2.5 font-mono text-xs whitespace-pre-wrap text-white/60">
|
||||
{JSON.stringify(event.payload, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex rounded-lg border border-ink-600/70 p-0.5">
|
||||
{options.map((option) => {
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => onChange(option.value)}
|
||||
aria-pressed={view === option.value}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors',
|
||||
view === option.value
|
||||
? 'bg-brand-500/15 text-brand-200'
|
||||
: 'text-white/45 hover:text-white',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex flex-col items-stretch">
|
||||
<TriggerCard
|
||||
flow={flow}
|
||||
connectors={connectors}
|
||||
webhookBaseUrl={webhookBaseUrl}
|
||||
regenerating={regenerating}
|
||||
onPick={onPickTrigger}
|
||||
onChangeFields={onChangeFields}
|
||||
onRegenerateToken={onRegenerateToken}
|
||||
/>
|
||||
{start ? (
|
||||
<StartCard start={start} />
|
||||
) : (
|
||||
<TriggerCard
|
||||
flow={flow}
|
||||
connectors={connectors}
|
||||
webhookBaseUrl={webhookBaseUrl}
|
||||
regenerating={regenerating}
|
||||
onPick={onPickTrigger}
|
||||
onChangeFields={onChangeFields}
|
||||
onRegenerateToken={onRegenerateToken}
|
||||
/>
|
||||
)}
|
||||
|
||||
{flow.trigger && (
|
||||
{fallbackFields && (
|
||||
<StepSequence
|
||||
steps={flow.steps}
|
||||
path={[]}
|
||||
connectors={connectors}
|
||||
scopes={collectScopes(flow, connectors)}
|
||||
fallbackFields={flow.trigger.fields}
|
||||
scopes={collectScopes({ ...flow, trigger: startTrigger(flow, start) }, connectors)}
|
||||
fallbackFields={fallbackFields}
|
||||
{...callbacks}
|
||||
/>
|
||||
)}
|
||||
@@ -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 (
|
||||
<div className="rounded-card border border-brand-400/30 bg-brand-500/8 p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-0.5 grid size-8 shrink-0 place-items-center rounded-lg bg-brand-500/15 text-brand-300">
|
||||
<MousePointerClick className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="font-semibold text-white">{start.label}</p>
|
||||
<p className="mt-0.5 text-sm text-white/50">{start.hint}</p>
|
||||
|
||||
{start.fields.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<p className="text-xs tracking-wide text-white/35 uppercase">
|
||||
Co je v krocích k dispozici
|
||||
</p>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{start.fields.map((field) => (
|
||||
<code
|
||||
key={field.id}
|
||||
className="rounded bg-ink-950/60 px-1.5 py-0.5 font-mono text-xs text-white/60"
|
||||
title={`typ ${field.type}`}
|
||||
>
|
||||
{`{{${field.name}}}`}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Prvni karta stromu - dokud neni vybrany spoustec, je to vyzva ke kliknuti. */
|
||||
function TriggerCard({
|
||||
flow,
|
||||
@@ -421,7 +494,7 @@ function ConditionCard({
|
||||
>
|
||||
{!field && (
|
||||
<option value="" className="bg-ink-850">
|
||||
— vyberte parametr —
|
||||
- vyberte parametr -
|
||||
</option>
|
||||
)}
|
||||
{fields.map((option) => (
|
||||
@@ -502,7 +575,7 @@ function ConditionCard({
|
||||
</p>
|
||||
{step[branch].length === 0 && (
|
||||
<p className="mb-1 text-xs text-white/35">
|
||||
Zatím prázdná větev — přidejte krok tlačítkem níž.
|
||||
Zatím prázdná větev - přidejte krok tlačítkem níž.
|
||||
</p>
|
||||
)}
|
||||
<StepSequence
|
||||
|
||||
@@ -99,7 +99,7 @@ export function StepPicker({
|
||||
title={mode === 'trigger' ? 'Čím má automatizace začít?' : 'Co se má stát?'}
|
||||
description={
|
||||
mode === 'trigger'
|
||||
? 'Vyberte spouštěč — webhook, čas, nebo událost ve službě, kterou máte napojenou.'
|
||||
? 'Vyberte spouštěč - webhook, čas, nebo událost ve službě, kterou máte napojenou.'
|
||||
: 'Vyberte službu nebo konektor a pak konkrétní akci.'
|
||||
}
|
||||
>
|
||||
@@ -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"
|
||||
/>
|
||||
</div>
|
||||
@@ -217,7 +217,7 @@ export function StepPicker({
|
||||
<span className="block font-semibold text-white">Rozvětvit podmínkou</span>
|
||||
<span className="mt-0.5 block text-sm text-white/55">
|
||||
{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.'}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -110,7 +110,7 @@ function CustomFields({
|
||||
|
||||
{fields.length === 0 ? (
|
||||
<p className="mt-3 rounded-xl border border-dashed border-ink-600/70 px-4 py-3 text-xs text-white/40">
|
||||
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.
|
||||
</p>
|
||||
) : (
|
||||
@@ -175,7 +175,7 @@ function CustomFields({
|
||||
|
||||
{duplicate && (
|
||||
<p className="w-full text-xs text-danger-400">
|
||||
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í.
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
@@ -294,7 +294,7 @@ function WebhookAddress({
|
||||
</div>
|
||||
|
||||
<p className="mt-2.5 text-xs leading-relaxed text-white/45">
|
||||
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.
|
||||
</p>
|
||||
|
||||
@@ -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'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
import { AlertTriangle, ArrowDownRight, ArrowUpRight, Clock, RotateCcw } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { TicketStatusBadge } from '@/components/dashboard/StatusBadge';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { formatDuration, formatNumber, formatRelative } from '@/lib/format';
|
||||
import type { AgentStatsRow, WidgetDefinition, WidgetValue } from '@/types/dashboard';
|
||||
|
||||
/**
|
||||
* Vykresleni widgetu, jehoz data pocita server (`/api/dashboard/widget-data`).
|
||||
*
|
||||
* Rozdeleni je zamerne: `render` rika, jak se to kresli, `value.kind` rika,
|
||||
* co prislo. Kombinace, ktere nedavaji smysl, odmitne uz server pri ukladani
|
||||
* definice - tady se jen rekne, ze to nesedi, misto prazdne dlazdice.
|
||||
*
|
||||
* Data si widget **nenacita sam**. Prehled se zepta jednim requestem na
|
||||
* vsechny naraz, jinak by deset dlazdic znamenalo deset volani.
|
||||
*/
|
||||
export function CustomWidgetCard({
|
||||
definition,
|
||||
value,
|
||||
error,
|
||||
loading,
|
||||
}: {
|
||||
definition: WidgetDefinition;
|
||||
value: WidgetValue | undefined;
|
||||
error: string | undefined;
|
||||
loading: boolean;
|
||||
}) {
|
||||
if (loading && !value && !error) {
|
||||
return (
|
||||
<Shell definition={definition}>
|
||||
<div className="h-16 animate-pulse rounded-lg bg-white/5" />
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Shell definition={definition}>
|
||||
<p className="flex items-start gap-2 text-sm text-danger-400">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
|
||||
{/* Cele hlaseni, nezkracene. U konektoru je to jedina stopa, proc to nejde. */}
|
||||
<span className="break-words">{error}</span>
|
||||
</p>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return (
|
||||
<Shell definition={definition}>
|
||||
<p className="text-sm text-white/40">Bez dat.</p>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
if (value.kind === 'number') {
|
||||
return <Number definition={definition} value={value.value} />;
|
||||
}
|
||||
|
||||
if (value.kind === 'external') {
|
||||
if (value.value !== null && value.rows.length === 0) {
|
||||
return <Number definition={definition} value={value.value} at={value.at} stale={value.stale} />;
|
||||
}
|
||||
if (value.rows.length > 0) {
|
||||
return (
|
||||
<Shell definition={definition} at={value.at} stale={value.stale}>
|
||||
<Bars rows={value.rows} />
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Shell definition={definition} at={value.at} stale={value.stale}>
|
||||
<p className="text-sm break-words text-white/70">{value.text ?? 'Bez dat.'}</p>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
if (value.kind === 'groups') {
|
||||
return (
|
||||
<Shell definition={definition}>
|
||||
{value.rows.length === 0 ? (
|
||||
<p className="text-sm text-white/40">Nic, co by sem patřilo.</p>
|
||||
) : (
|
||||
<Bars rows={value.rows} />
|
||||
)}
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
if (value.kind === 'series') {
|
||||
return (
|
||||
<Shell definition={definition}>
|
||||
<Sparkline points={value.points} />
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
if (value.kind === 'tickets') {
|
||||
return (
|
||||
<Shell definition={definition}>
|
||||
{value.items.length === 0 ? (
|
||||
<p className="text-sm text-white/40">Žádné tickety.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-ink-600/50">
|
||||
{value.items.map((ticket) => (
|
||||
<li key={ticket.id} className="flex items-center gap-3 py-2.5 first:pt-0 last:pb-0">
|
||||
<span className="font-mono text-xs text-white/35">{ticket.id}</span>
|
||||
<Link
|
||||
to={`/dashboard/tickety/${ticket.id}`}
|
||||
className="min-w-0 flex-1 truncate text-sm text-white/80 transition-colors hover:text-brand-200"
|
||||
>
|
||||
{ticket.subject}
|
||||
</Link>
|
||||
<span className="hidden text-xs text-white/40 sm:block">
|
||||
{ticket.assignee ?? 've frontě'}
|
||||
</span>
|
||||
<TicketStatusBadge status={ticket.status} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
if (value.kind === 'workload') {
|
||||
return (
|
||||
<Shell definition={definition}>
|
||||
<Bars
|
||||
rows={value.rows.map((row) => ({
|
||||
key: row.name,
|
||||
label: row.name,
|
||||
value: row.open,
|
||||
warn: row.overCapacity,
|
||||
}))}
|
||||
/>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Shell definition={definition}>
|
||||
<AgentTable rows={value.rows} />
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tabulka vykonu resitelu.
|
||||
*
|
||||
* Zamerne vedle sebe **odbaveno** a **vraceno**: pocet vyresenych sam o sobe
|
||||
* odmenuje toho, kdo tickety zaviral predcasne. Median misto prumeru, jeden
|
||||
* ticket zapomenuty pres dovolenou by jinak prebil vsechno ostatni.
|
||||
*/
|
||||
function AgentTable({ rows }: { rows: AgentStatsRow[] }) {
|
||||
if (rows.length === 0) return <p className="text-sm text-white/40">Žádní řešitelé.</p>;
|
||||
|
||||
const best = Math.max(...rows.map((row) => row.resolved));
|
||||
|
||||
return (
|
||||
<div className="-mx-1 overflow-x-auto">
|
||||
<table className="w-full min-w-125 border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs tracking-wide text-white/35 uppercase">
|
||||
<th className="px-1 pb-2 font-medium">Řešitel</th>
|
||||
<th className="px-1 pb-2 text-right font-medium">Odbaveno</th>
|
||||
<th className="px-1 pb-2 text-right font-medium">Ve frontě</th>
|
||||
<th className="px-1 pb-2 text-right font-medium" title="Medián doby do vyřešení">
|
||||
Doba řešení
|
||||
</th>
|
||||
<th className="px-1 pb-2 text-right font-medium" title="Medián doby do první reakce">
|
||||
Reakce
|
||||
</th>
|
||||
<th className="px-1 pb-2 text-right font-medium" title="Kolikrát se ticket vrátil">
|
||||
Vráceno
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-600/50">
|
||||
{rows.map((row) => (
|
||||
<tr key={row.personId} className="text-white/75">
|
||||
<td className="px-1 py-2">
|
||||
<Link
|
||||
to={`/dashboard/lide/${row.personId}`}
|
||||
className="flex flex-wrap items-center gap-2 transition-colors hover:text-brand-200"
|
||||
>
|
||||
{row.name}
|
||||
{row.overloaded && <Badge tone="warn">přetížený</Badge>}
|
||||
{row.critical > 0 && <Badge tone="danger">{row.critical} kritické</Badge>}
|
||||
</Link>
|
||||
{row.oldestOpenAt && (
|
||||
<span className="mt-0.5 flex items-center gap-1 text-xs text-white/35">
|
||||
<Clock className="size-3" />
|
||||
nejstarší {formatRelative(row.oldestOpenAt)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-1 py-2 text-right">
|
||||
<span
|
||||
className={cn(
|
||||
'font-semibold',
|
||||
row.resolved > 0 && row.resolved === best ? 'text-ok-400' : 'text-white',
|
||||
)}
|
||||
>
|
||||
{formatNumber(row.resolved)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-1 py-2 text-right">{formatNumber(row.open)}</td>
|
||||
<td className="px-1 py-2 text-right text-white/60">
|
||||
{row.medianResolveMs === null ? '-' : formatDuration(row.medianResolveMs)}
|
||||
</td>
|
||||
<td className="px-1 py-2 text-right text-white/60">
|
||||
{row.medianResponseMs === null ? '-' : formatDuration(row.medianResponseMs)}
|
||||
</td>
|
||||
<td className="px-1 py-2 text-right">
|
||||
{row.reopened === 0 ? (
|
||||
<span className="text-white/30">-</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-warn-400">
|
||||
<RotateCcw className="size-3" />
|
||||
{row.reopened}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Vodorovne pruhy. Cislo samo o sobe nerekne, jestli je to hodne nebo malo. */
|
||||
function Bars({
|
||||
rows,
|
||||
}: {
|
||||
rows: Array<{ key: string; label: string; value: number; warn?: boolean }>;
|
||||
}) {
|
||||
const max = Math.max(1, ...rows.map((row) => row.value));
|
||||
|
||||
return (
|
||||
<ul className="space-y-2">
|
||||
{rows.slice(0, 12).map((row) => (
|
||||
<li key={row.key}>
|
||||
<div className="flex items-baseline justify-between gap-3 text-sm">
|
||||
<span className="min-w-0 truncate text-white/70">{row.label}</span>
|
||||
<span className={cn('font-semibold', row.warn ? 'text-warn-400' : 'text-white')}>
|
||||
{formatNumber(row.value)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-white/6">
|
||||
<div
|
||||
className={cn('h-full rounded-full', row.warn ? 'bg-warn-400/70' : 'bg-brand-400/70')}
|
||||
style={{ width: `${Math.round((row.value / max) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/** Jedno cislo. Velke, at je citelne pres celou mistnost. */
|
||||
function Number({
|
||||
definition,
|
||||
value,
|
||||
at,
|
||||
stale,
|
||||
}: {
|
||||
definition: WidgetDefinition;
|
||||
value: number | null;
|
||||
at?: string;
|
||||
stale?: boolean;
|
||||
}) {
|
||||
const target = definition.target;
|
||||
const share = target && target > 0 && value !== null ? Math.min(value / target, 1) : null;
|
||||
|
||||
return (
|
||||
<Shell definition={definition} at={at} stale={stale}>
|
||||
<div className="flex items-end gap-2">
|
||||
<span className="text-3xl font-bold text-white tabular-nums">
|
||||
{value === null ? '-' : formatNumber(value)}
|
||||
</span>
|
||||
{target !== undefined && target > 0 && (
|
||||
<span className="mb-1 flex items-center gap-1 text-xs text-white/40">
|
||||
{value !== null && value >= target ? (
|
||||
<ArrowUpRight className="size-3.5 text-ok-400" />
|
||||
) : (
|
||||
<ArrowDownRight className="size-3.5 text-warn-400" />
|
||||
)}
|
||||
z {formatNumber(target)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{share !== null && (
|
||||
<div className="mt-3 h-1.5 overflow-hidden rounded-full bg-white/6">
|
||||
<div
|
||||
className={cn('h-full rounded-full', share >= 1 ? 'bg-ok-400/70' : 'bg-brand-400/70')}
|
||||
style={{ width: `${Math.round(share * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cara z casove rady. Zamerne bez os a mrizky - na dlazdici jde o tvar,
|
||||
* cisla se ctou jinde.
|
||||
*/
|
||||
function Sparkline({ points }: { points: Array<{ date: string; value: number }> }) {
|
||||
if (points.length === 0) return <p className="text-sm text-white/40">Bez dat.</p>;
|
||||
|
||||
const max = Math.max(1, ...points.map((point) => point.value));
|
||||
const step = points.length > 1 ? 100 / (points.length - 1) : 0;
|
||||
const line = points
|
||||
.map((point, index) => `${(index * step).toFixed(2)},${(30 - (point.value / max) * 28).toFixed(2)}`)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<svg viewBox="0 0 100 30" preserveAspectRatio="none" className="h-20 w-full" role="img">
|
||||
<title>Časová řada</title>
|
||||
<polyline
|
||||
points={line}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
className="text-brand-400"
|
||||
/>
|
||||
</svg>
|
||||
<div className="mt-1 flex justify-between text-xs text-white/35">
|
||||
<span>{points[0].date.slice(5)}</span>
|
||||
<span>vrchol {formatNumber(max)}</span>
|
||||
<span>{points[points.length - 1].date.slice(5)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Shell({
|
||||
definition,
|
||||
at,
|
||||
stale,
|
||||
children,
|
||||
}: {
|
||||
definition: WidgetDefinition;
|
||||
at?: string;
|
||||
stale?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="glass h-full rounded-card p-5">
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate font-semibold text-white">{definition.name}</h2>
|
||||
{definition.description && (
|
||||
<p className="mt-0.5 truncate text-xs text-white/35">{definition.description}</p>
|
||||
)}
|
||||
</div>
|
||||
{/* U dat z cizi sluzby je stari podstatne - jinak se ctou cisla z vcerejska. */}
|
||||
{at && (
|
||||
<span
|
||||
className="shrink-0 text-xs text-white/30"
|
||||
title={stale ? 'Z mezipaměti, aby se cizí služba nevolala při každém otevření.' : undefined}
|
||||
>
|
||||
{stale ? 'z mezipaměti ' : ''}
|
||||
{formatRelative(at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export function Hero() {
|
||||
</h1>
|
||||
|
||||
<p className="mt-6 max-w-xl text-lg leading-relaxed text-white/65">
|
||||
Jsme malý tým, který firmám staví automatizace, hlasové asistenty a propojení systémů —
|
||||
Jsme malý tým, který firmám staví automatizace, hlasové asistenty a propojení systémů -
|
||||
a pak je i provozuje. Vy vidíte výsledky v dashboardu, my držíme provoz.
|
||||
</p>
|
||||
|
||||
@@ -78,7 +78,7 @@ function HeroPanel() {
|
||||
<PhoneCall className="size-4" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">Voicebot — příjem poptávek</p>
|
||||
<p className="text-sm font-semibold text-white">Voicebot - příjem poptávek</p>
|
||||
<p className="font-mono text-xs text-white/40">AUT-02 · běží</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,19 +5,19 @@ const steps = [
|
||||
number: '01',
|
||||
title: 'Konzultace a mapování',
|
||||
text: 'Projdeme s vámi proces tak, jak reálně běží. Najdeme místa, kde se ztrácí čas a data.',
|
||||
duration: '1–2 týdny',
|
||||
duration: '1-2 týdny',
|
||||
},
|
||||
{
|
||||
number: '02',
|
||||
title: 'Návrh a prototyp',
|
||||
text: 'Postavíme funkční prototyp na vašich datech. Uvidíte, jak to bude fungovat, než se doplatí zbytek.',
|
||||
duration: '2–3 týdny',
|
||||
duration: '2-3 týdny',
|
||||
},
|
||||
{
|
||||
number: '03',
|
||||
title: 'Nasazení do provozu',
|
||||
text: 'Napojíme reálné systémy, zapneme monitoring a zaškolíme lidi. Přepínáme postupně, ne přes noc.',
|
||||
duration: '2–4 týdny',
|
||||
duration: '2-4 týdny',
|
||||
},
|
||||
{
|
||||
number: '04',
|
||||
|
||||
@@ -14,7 +14,7 @@ export function References() {
|
||||
Co říkají firmy, <span className="text-gradient">se kterými to běží</span>
|
||||
</>
|
||||
}
|
||||
subtitle="Ukázka spoluprací napříč obory. Čísla vždy měříme před nasazením a po něm — jinak by to byl jen marketing."
|
||||
subtitle="Ukázka spoluprací napříč obory. Čísla vždy měříme před nasazením a po něm - jinak by to byl jen marketing."
|
||||
/>
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
|
||||
@@ -73,7 +73,7 @@ export function Footer() {
|
||||
© {year} {brand.legalName} · IČO {brand.ico}
|
||||
</p>
|
||||
<p className="font-mono">
|
||||
Prototyp webu — obsah a reference jsou ukázkové (mockup).
|
||||
Prototyp webu - obsah a reference jsou ukázkové (mockup).
|
||||
</p>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
@@ -26,7 +26,7 @@ export const brand = {
|
||||
},
|
||||
/** Otevírací doba podpory zobrazená na kontaktech. */
|
||||
support: {
|
||||
hours: 'Po–Pá 8:00–18:00',
|
||||
hours: 'Po-Pá 8:00-18:00',
|
||||
sla: 'SLA 24/7 pro klienty s tarifem Provoz',
|
||||
},
|
||||
} as const;
|
||||
|
||||
+1
-1
@@ -129,7 +129,7 @@ export function describeCondition(
|
||||
if (!field) return 'Neznámý parametr';
|
||||
const label = operatorLabel(operator, field.type);
|
||||
if (isUnaryOperator(operator)) return `${field.name} ${label}`;
|
||||
return `${field.name} ${label} ${value?.trim() || '…'}`;
|
||||
return `${field.name} ${label} ${value?.trim() || '...'}`;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------- co je kde videt
|
||||
|
||||
@@ -21,7 +21,7 @@ export function formatDateTime(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
console.warn('[format] neplatne datum:', iso);
|
||||
return '—';
|
||||
return '-';
|
||||
}
|
||||
return dateTimeFormat.format(date);
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export function formatDay(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
console.warn('[format] neplatne datum:', iso);
|
||||
return '—';
|
||||
return '-';
|
||||
}
|
||||
return dayFormat.format(date);
|
||||
}
|
||||
@@ -40,7 +40,7 @@ export function formatRelative(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
console.warn('[format] neplatne datum:', iso);
|
||||
return '—';
|
||||
return '-';
|
||||
}
|
||||
const diffMin = Math.round((Date.now() - date.getTime()) / 60_000);
|
||||
if (diffMin < 1) return 'právě teď';
|
||||
|
||||
@@ -16,6 +16,16 @@ interface QueryOptions {
|
||||
* Diky tomu je dashboard zivy bez pravidelneho dotazovani serveru.
|
||||
*/
|
||||
refetchOn?: DashboardEventType[];
|
||||
/**
|
||||
* Kdyz je vyplnene, posle se POST s timhle telem.
|
||||
*
|
||||
* Je to tady kvuli davkovemu nacitani (`/widget-data`), ktere se ptá na
|
||||
* seznam ID. GET s dvaceti ID v adrese by narazil na delku URL a druhy hook
|
||||
* jen kvuli metode by znamenal dvakrat tentyz refetch a debounce.
|
||||
*/
|
||||
body?: unknown;
|
||||
/** false = nedotazovat se vubec. Pro pripad, kdy jeste neni na co. */
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,12 +40,27 @@ export function useApiQuery<T>(path: string, options: QueryOptions = {}): QueryS
|
||||
|
||||
const reload = useCallback(() => setTick((value) => value + 1), []);
|
||||
|
||||
const enabled = options.enabled !== false;
|
||||
// Telo se do zavislosti effectu neda dat primo - nova reference pri kazdem
|
||||
// vykresleni by znamenala dotaz v nekonecne smycce.
|
||||
const bodyKey = options.body === undefined ? '' : JSON.stringify(options.body);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
apiFetch<T>(path)
|
||||
const request =
|
||||
bodyKey === ''
|
||||
? apiFetch<T>(path)
|
||||
: apiFetch<T>(path, { method: 'POST', body: JSON.parse(bodyKey) as unknown });
|
||||
|
||||
request
|
||||
.then((result) => {
|
||||
if (!cancelled) setData(result);
|
||||
})
|
||||
@@ -51,7 +76,7 @@ export function useApiQuery<T>(path: string, options: QueryOptions = {}): QueryS
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [path, tick]);
|
||||
}, [path, tick, bodyKey, enabled]);
|
||||
|
||||
// Obnoveni dat pri prichozi udalosti ze streamu.
|
||||
const { subscribe } = useEventStream();
|
||||
|
||||
@@ -37,7 +37,7 @@ const team = [
|
||||
];
|
||||
|
||||
const milestones = [
|
||||
{ year: '2018', text: 'Vznik firmy — první integrace e-shopů s účetnictvím.' },
|
||||
{ year: '2018', text: 'Vznik firmy - první integrace e-shopů s účetnictvím.' },
|
||||
{ year: '2020', text: 'První hlasová linka v produkci, tým se rozrůstá na čtyři lidi.' },
|
||||
{ year: '2023', text: 'Spouštíme vlastní klientský portál s tickety a incidenty.' },
|
||||
{ year: '2025', text: 'Provozujeme 120+ automatizací s dostupností 99,98 %.' },
|
||||
@@ -45,9 +45,9 @@ const milestones = [
|
||||
|
||||
export default function About() {
|
||||
usePageMeta({
|
||||
title: 'O nás — Automia',
|
||||
title: 'O nás - Automia',
|
||||
description:
|
||||
'Malý tým, který firmám staví automatizace, voiceboty a integrace — a pak je i provozuje.',
|
||||
'Malý tým, který firmám staví automatizace, voiceboty a integrace - a pak je i provozuje.',
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -59,7 +59,7 @@ export default function About() {
|
||||
Malý tým, <span className="text-gradient">který za svou práci ručí</span>
|
||||
</>
|
||||
}
|
||||
subtitle={`${brand.name} funguje od roku ${brand.founded}. Nejsme softwarový dům na sto lidí — jsme parta, která staví věci, co musí fungovat v pondělí ráno.`}
|
||||
subtitle={`${brand.name} funguje od roku ${brand.founded}. Nejsme softwarový dům na sto lidí - jsme parta, která staví věci, co musí fungovat v pondělí ráno.`}
|
||||
/>
|
||||
|
||||
<Section>
|
||||
@@ -68,13 +68,13 @@ export default function About() {
|
||||
<h2 className="text-2xl font-bold text-white">Jak jsme se k tomu dostali</h2>
|
||||
<p className="leading-relaxed">
|
||||
Začínali jsme tím, že jsme jedné velkoobchodní firmě přestali přepisovat objednávky
|
||||
z e-mailu do skladu. Ukázalo se, že tenhle problém má skoro každý — jen mu každý říká
|
||||
z e-mailu do skladu. Ukázalo se, že tenhle problém má skoro každý - jen mu každý říká
|
||||
jinak. Od té doby jsme postavili automatizace pro firmy od pěti do pěti set lidí.
|
||||
</p>
|
||||
<p className="leading-relaxed">
|
||||
Postupně se k automatizacím přidaly hlasové linky, protože telefon je pořád kanál,
|
||||
kterým přichází nejvíc poptávek. A když jsme začali provozovat věci nonstop, přišly
|
||||
nutně i dashboardy, tickety a incident management — abychom o problémech věděli
|
||||
nutně i dashboardy, tickety a incident management - abychom o problémech věděli
|
||||
dřív než klient.
|
||||
</p>
|
||||
<p className="leading-relaxed">
|
||||
|
||||
@@ -26,7 +26,7 @@ const inputClass =
|
||||
|
||||
export default function Contact() {
|
||||
usePageMeta({
|
||||
title: 'Kontakt — Automia',
|
||||
title: 'Kontakt - Automia',
|
||||
description: 'Napište nám nebo zavolejte. Ozveme se do jednoho pracovního dne.',
|
||||
});
|
||||
|
||||
@@ -62,7 +62,7 @@ export default function Contact() {
|
||||
Ozvěte se. <span className="text-gradient">Odpovídáme rychle.</span>
|
||||
</>
|
||||
}
|
||||
subtitle="Napište, co řešíte — i když ještě nevíte, jak by to mělo fungovat. Na první hovor stačí popis problému."
|
||||
subtitle="Napište, co řešíte - i když ještě nevíte, jak by to mělo fungovat. Na první hovor stačí popis problému."
|
||||
/>
|
||||
|
||||
<Section>
|
||||
@@ -83,7 +83,7 @@ export default function Contact() {
|
||||
<input id="company" name="company" className={inputClass} placeholder="Firma s.r.o." />
|
||||
</Field>
|
||||
<Field label="Telefon" htmlFor="phone" optional>
|
||||
<input id="phone" name="phone" className={inputClass} placeholder="+420 …" />
|
||||
<input id="phone" name="phone" className={inputClass} placeholder="+420 ..." />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
@@ -105,14 +105,14 @@ export default function Contact() {
|
||||
minLength={10}
|
||||
rows={5}
|
||||
className={`${inputClass} resize-y`}
|
||||
placeholder="Např.: Objednávky z e-shopu přepisujeme ručně do skladu, denně asi 60 kusů…"
|
||||
placeholder="Např.: Objednávky z e-shopu přepisujeme ručně do skladu, denně asi 60 kusů..."
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4 pt-2">
|
||||
<Button type="submit" disabled={status.kind === 'sending'}>
|
||||
<Send className="size-4" />
|
||||
{status.kind === 'sending' ? 'Odesílám…' : 'Odeslat poptávku'}
|
||||
{status.kind === 'sending' ? 'Odesílám...' : 'Odeslat poptávku'}
|
||||
</Button>
|
||||
<p className="text-xs text-white/35">
|
||||
Odesláním souhlasíte se zpracováním údajů pro účely odpovědi.
|
||||
@@ -185,14 +185,14 @@ export default function Contact() {
|
||||
<Card className="border-brand-400/25 bg-brand-500/6">
|
||||
<h2 className="font-semibold text-white">Jste náš klient?</h2>
|
||||
<p className="mt-2 text-sm leading-relaxed text-white/60">
|
||||
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>
|
||||
<Link
|
||||
to="/prihlaseni"
|
||||
className="mt-4 inline-flex text-sm font-semibold text-brand-300 hover:text-brand-200"
|
||||
>
|
||||
Přejít do portálu →
|
||||
Přejít do portálu
|
||||
</Link>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -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.',
|
||||
});
|
||||
|
||||
@@ -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="--------"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -153,7 +153,7 @@ export default function Login() {
|
||||
|
||||
<Button type="submit" className="w-full" disabled={submitting}>
|
||||
<LockKeyhole className="size-4" />
|
||||
{submitting ? 'Přihlašuji…' : 'Přihlásit se'}
|
||||
{submitting ? 'Přihlašuji...' : 'Přihlásit se'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -187,7 +187,7 @@ export default function Login() {
|
||||
<Link to="/kontakt" className="font-medium text-brand-300 hover:text-brand-200">
|
||||
Ozvěte se nám
|
||||
</Link>{' '}
|
||||
— portál zřizujeme klientům {brand.name}.
|
||||
- portál zřizujeme klientům {brand.name}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<Container className="flex min-h-[60vh] flex-col items-center justify-center py-20 text-center">
|
||||
|
||||
@@ -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.',
|
||||
});
|
||||
|
||||
@@ -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<ActionRecord>(`/api/dashboard/settings/actions/${id}`);
|
||||
const scope = useApiQuery<ScopeResponse>(`/api/dashboard/settings/actions/${id}/scope`);
|
||||
const catalog = useApiQuery<ServiceCatalog>('/api/dashboard/services');
|
||||
|
||||
const [steps, setSteps] = useState<FlowStep[]>([]);
|
||||
const [picker, setPicker] = useState<PickerTarget | null>(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [savedAt, setSavedAt] = useState<string | null>(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 (
|
||||
<div className="space-y-6">
|
||||
<Link
|
||||
to="/dashboard/akce"
|
||||
className="inline-flex items-center gap-2 text-sm text-white/45 transition-colors hover:text-white"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Zpět na akce
|
||||
</Link>
|
||||
|
||||
<DataState
|
||||
loading={action.loading || catalog.loading}
|
||||
error={action.error ?? catalog.error}
|
||||
onRetry={() => {
|
||||
action.reload();
|
||||
catalog.reload();
|
||||
}}
|
||||
>
|
||||
{action.data && (
|
||||
<div className="space-y-6">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-bold text-white">{action.data.label}</h1>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-xs text-white/35">{action.data.id}</span>
|
||||
<span className="text-xs text-white/40">
|
||||
{stepCount} {stepCount === 1 ? 'krok' : stepCount >= 2 && stepCount <= 4 ? 'kroky' : 'kroků'}
|
||||
</span>
|
||||
{dirty && <Badge tone="warn">Neuložené změny</Badge>}
|
||||
{!dirty && savedAt && (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-ok-400">
|
||||
<Check className="size-3.5" />
|
||||
Uloženo
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button size="sm" onClick={() => void save()} disabled={saving || !dirty}>
|
||||
<Save className="size-4" />
|
||||
{saving ? 'Ukládám...' : 'Uložit strom'}
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{saveError && (
|
||||
<p className="flex items-center gap-2 rounded-xl border border-danger-400/30 bg-danger-500/10 px-4 py-3 text-sm text-danger-400">
|
||||
<AlertCircle className="size-4 shrink-0" />
|
||||
{saveError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1fr_18rem]">
|
||||
<section className="glass rounded-card p-5 sm:p-6">
|
||||
<div className="mb-6 flex items-center justify-between gap-3">
|
||||
<h2 className="font-semibold text-white">Co akce udělá</h2>
|
||||
<p className="text-xs text-white/35">
|
||||
Klikněte na <span className="font-mono">+</span> a vyberte krok
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FlowCanvas
|
||||
flow={flow}
|
||||
connectors={connectors}
|
||||
webhookBaseUrl=""
|
||||
regenerating={false}
|
||||
start={{
|
||||
label: 'Spouští člověk tlačítkem na ticketu',
|
||||
hint: startHint(action.data),
|
||||
fields: startFields,
|
||||
}}
|
||||
onPickTrigger={() => 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))}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<aside className="space-y-4">
|
||||
<div className="glass rounded-card p-5">
|
||||
<h2 className="font-semibold text-white">Jak to funguje</h2>
|
||||
<ol className="mt-3 space-y-2.5 text-sm text-white/55">
|
||||
<li className="flex gap-2">
|
||||
<span className="font-mono text-brand-300">1.</span>
|
||||
Akce se ukáže jako tlačítko na ticketu, který sedí na typ nebo tag.
|
||||
</li>
|
||||
<li className="flex gap-2">
|
||||
<span className="font-mono text-brand-300">2.</span>
|
||||
Kroky se skládají stejně jako v automatizaci, včetně větvení podmínkou.
|
||||
</li>
|
||||
<li className="flex gap-2">
|
||||
<span className="font-mono text-brand-300">3.</span>
|
||||
V polích kroku jde odkazovat na údaje ticketu přes{' '}
|
||||
<span className="font-mono text-xs">{'{{název}}'}</span>.
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<p className="rounded-xl border border-warn-400/30 bg-warn-500/8 px-4 py-3 text-xs leading-relaxed text-white/60">
|
||||
Strom se uloží, ale zatím se nevykoná. Chybí runtime, stejně jako
|
||||
u automatizací. Akce s jednou operací běží.
|
||||
</p>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DataState>
|
||||
|
||||
<StepPicker
|
||||
open={picker !== null}
|
||||
mode="action"
|
||||
connectors={connectors}
|
||||
categories={catalog.data?.categories ?? []}
|
||||
onClose={() => 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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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<string, string>;
|
||||
};
|
||||
const step = createActionStep(operation.serviceId, operation.operationId);
|
||||
return [step.kind === 'action' ? { ...step, inputs: operation.inputs ?? {} } : step];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
@@ -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<Array<{ id: string; name: string }>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<{ items: Array<{ id: string; name: string }> }>(
|
||||
'/api/dashboard/settings/ticket-types',
|
||||
)
|
||||
.then((data) => setTypes(data.items))
|
||||
.catch(() => setTypes([]));
|
||||
}, []);
|
||||
|
||||
const fields: Array<AdminField<ActionRecord>> = [
|
||||
{ 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 (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="text-2xl font-bold text-white">Akce na ticketu</h1>
|
||||
<p className="mt-1 text-sm text-white/50">
|
||||
Tlačítko, které řešitel zmáčkne na ticketu. Váže se na typ nebo tag.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<p className="flex gap-2 rounded-xl border border-ink-600/60 px-4 py-3 text-sm leading-relaxed text-white/45">
|
||||
<Info className="mt-0.5 size-4 shrink-0 text-brand-300" />
|
||||
<span>
|
||||
Akce a automatizace jsou dvě různé věci. Automatizace běží sama, akci spouští člověk.
|
||||
Tady se určí, <strong className="font-semibold text-white/70">kdy se tlačítko ukáže</strong>;
|
||||
co akce dělá, se skládá ze stromu kroků tlačítkem{' '}
|
||||
<span className="whitespace-nowrap">Upravit strom</span>.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<EntityAdmin<ActionRecord>
|
||||
title="Akce"
|
||||
endpoint="/api/dashboard/settings/actions"
|
||||
listEndpoint="/api/dashboard/settings/actions-overview"
|
||||
columns={[
|
||||
{
|
||||
label: 'Akce',
|
||||
render: (action) => (
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
to={`/dashboard/akce/${action.id}`}
|
||||
className="font-medium text-white transition-colors hover:text-brand-200"
|
||||
>
|
||||
{action.label}
|
||||
</Link>
|
||||
{action.style === 'primary' && <Badge tone="brand">hlavní</Badge>}
|
||||
{action.style === 'danger' && <Badge tone="danger">nebezpečná</Badge>}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
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(' | ')
|
||||
) : (
|
||||
<span className="text-warn-400">nikde, chybí typ i tag</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Co dělá',
|
||||
render: (action) => <BodySummary body={action.body} />,
|
||||
},
|
||||
{
|
||||
label: 'Stav',
|
||||
narrow: true,
|
||||
render: (action) =>
|
||||
action.enabled === false ? (
|
||||
<Badge tone="warn">vypnutá</Badge>
|
||||
) : (
|
||||
<Badge tone="ok">zapnutá</Badge>
|
||||
),
|
||||
},
|
||||
]}
|
||||
fields={fields}
|
||||
rowActions={(action) => (
|
||||
<Link
|
||||
to={`/dashboard/akce/${action.id}`}
|
||||
className="mr-1 inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium text-brand-300 transition-colors hover:bg-brand-500/12 hover:text-brand-200"
|
||||
>
|
||||
<GitBranch className="size-3.5" />
|
||||
Upravit strom
|
||||
</Link>
|
||||
)}
|
||||
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,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 ? (
|
||||
<span className="text-warn-400">prázdný strom</span>
|
||||
) : (
|
||||
<span className="text-white/60">
|
||||
strom, {count} {count === 1 ? 'krok' : count <= 4 ? 'kroky' : 'kroků'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (body?.kind === 'operation') return <span className="text-white/60">jedna operace</span>;
|
||||
if (body?.kind === 'script') return <span className="text-white/60">skript</span>;
|
||||
return <span className="text-white/35">nenastaveno</span>;
|
||||
}
|
||||
@@ -54,7 +54,7 @@ export default function AutomationDetail() {
|
||||
/** Co podle serveru chybi k zapnuti. Prepocitava ho server pri kazdem ulozeni. */
|
||||
const [issues, setIssues] = useState<string[]>([]);
|
||||
|
||||
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'}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge tone="warn">Koncept — chybí spouštěč</Badge>
|
||||
<Badge tone="warn">Koncept - chybí spouštěč</Badge>
|
||||
)}
|
||||
<span className="text-xs text-white/40">
|
||||
{stepCount} {stepCount === 1 ? 'krok' : stepCount >= 2 && stepCount <= 4 ? 'kroky' : 'kroků'}
|
||||
@@ -300,7 +300,7 @@ export default function AutomationDetail() {
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => void save()} disabled={saving || !dirty}>
|
||||
<Save className="size-4" />
|
||||
{saving ? 'Ukládám…' : 'Uložit'}
|
||||
{saving ? 'Ukládám...' : 'Uložit'}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -377,7 +377,7 @@ export default function AutomationDetail() {
|
||||
<ol className="mt-3 space-y-2.5 text-sm text-white/55">
|
||||
<li className="flex gap-2">
|
||||
<span className="font-mono text-brand-300">1.</span>
|
||||
Vyberte spouštěč — čím automatizace začne.
|
||||
Vyberte spouštěč - čím automatizace začne.
|
||||
</li>
|
||||
<li className="flex gap-2">
|
||||
<span className="font-mono text-brand-300">2.</span>
|
||||
@@ -386,7 +386,7 @@ export default function AutomationDetail() {
|
||||
</li>
|
||||
<li className="flex gap-2">
|
||||
<span className="font-mono text-brand-300">3.</span>
|
||||
Potřebujete rozhodování? Přidejte podmínku — běh se rozdělí na větev
|
||||
Potřebujete rozhodování? Přidejte podmínku - běh se rozdělí na větev
|
||||
ANO a NE.
|
||||
</li>
|
||||
<li className="flex gap-2">
|
||||
|
||||
@@ -29,7 +29,7 @@ const kindMeta: Record<Automation['kind'], { label: string; icon: LucideIcon }>
|
||||
};
|
||||
|
||||
export default function Automations() {
|
||||
usePageMeta({ title: 'Automatizace — portál Automia' });
|
||||
usePageMeta({ title: 'Automatizace - portál Automia' });
|
||||
const { data, loading, error, reload } = useApiQuery<ListResponse<Automation>>(
|
||||
'/api/dashboard/automations',
|
||||
{
|
||||
@@ -62,7 +62,7 @@ export default function Automations() {
|
||||
to="/dashboard/konektory"
|
||||
className="rounded-full px-4 py-2 text-sm font-medium text-white/55 transition-colors hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
Konektory →
|
||||
Konektory
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
@@ -136,7 +136,7 @@ function AutomationCard({ automation }: { automation: Automation }) {
|
||||
<Metric label="Úspěšnost" value={formatPercent(automation.successRate)} />
|
||||
<Metric
|
||||
label="Prům. běh"
|
||||
value={automation.avgDurationMs === 0 ? '—' : formatDuration(automation.avgDurationMs)}
|
||||
value={automation.avgDurationMs === 0 ? '-' : formatDuration(automation.avgDurationMs)}
|
||||
/>
|
||||
<Metric label="Naposled" value={formatRelative(automation.lastRunAt)} />
|
||||
</dl>
|
||||
@@ -210,7 +210,7 @@ function CreateAutomationModal({
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
required
|
||||
minLength={3}
|
||||
placeholder="Např.: Poptávka z webu → CRM → e-mail"
|
||||
placeholder="Např.: Poptávka z webu na CRM na e-mail"
|
||||
className="w-full rounded-xl border border-ink-600/70 bg-ink-850/70 px-4 py-3 text-sm text-white placeholder:text-white/30 focus:border-brand-400/70 focus:outline-none"
|
||||
/>
|
||||
<p className="mt-2 text-xs text-white/35">
|
||||
@@ -230,7 +230,7 @@ function CreateAutomationModal({
|
||||
Zrušit
|
||||
</Button>
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting ? 'Vytvářím…' : 'Vytvořit a pokračovat'}
|
||||
{submitting ? 'Vytvářím...' : 'Vytvořit a pokračovat'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -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<ListResponse<Incident>>(
|
||||
'/api/dashboard/incidents',
|
||||
{ refetchOn: ['incident.started', 'incident.updated', 'incident.resolved'] },
|
||||
|
||||
@@ -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<WidgetSize, string> = {
|
||||
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<WidgetSize, string> = {
|
||||
third: 'třetina',
|
||||
half: 'polovina',
|
||||
@@ -48,7 +56,7 @@ const sizeLabels: Record<WidgetSize, string> = {
|
||||
};
|
||||
|
||||
export default function Overview() {
|
||||
usePageMeta({ title: 'Přehled — portál Automia' });
|
||||
usePageMeta({ title: 'Přehled - portál Automia' });
|
||||
|
||||
const { user } = useAuth();
|
||||
const access = useApiQuery<Access>('/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<WidgetDataResponse>(
|
||||
`/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() {
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => void save()} disabled={saving}>
|
||||
<Check className="size-4" />
|
||||
{saving ? 'Ukládám…' : 'Uložit'}
|
||||
{saving ? 'Ukládám...' : 'Uložit'}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
@@ -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 (
|
||||
<div
|
||||
key={item.id}
|
||||
className="col-span-6 rounded-card border border-danger-400/40 bg-danger-500/8 p-4 text-sm text-danger-400"
|
||||
className="col-span-6 flex flex-wrap items-center justify-between gap-3 rounded-card border border-danger-400/40 bg-danger-500/8 p-4 text-sm text-danger-400"
|
||||
>
|
||||
Widget „{item.widgetId}" už v katalogu není.
|
||||
<span>
|
||||
Widget <code>{item.widgetId}</code> už v katalogu není. Nejspíš ho někdo smazal.
|
||||
</span>
|
||||
{editing ? (
|
||||
<Button size="sm" variant="secondary" onClick={() => remove(item.id)}>
|
||||
<Trash2 className="size-3.5" />
|
||||
Odebrat z dashboardu
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-xs text-white/40">
|
||||
Odebrat ho jde v režimu Upravit dashboard.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div key={item.id} className={cn(span[item.size], 'min-w-0')}>
|
||||
{editing && (
|
||||
@@ -316,7 +378,16 @@ export default function Overview() {
|
||||
/>
|
||||
)}
|
||||
<div className={cn(editing && 'pointer-events-none opacity-70')}>
|
||||
<WidgetCard definition={definition} data={data} />
|
||||
{usesServerData ? (
|
||||
<CustomWidgetCard
|
||||
definition={definition}
|
||||
value={result?.ok ? result.value : undefined}
|
||||
error={result && !result.ok ? result.error : (widgetData.error ?? undefined)}
|
||||
loading={widgetData.loading}
|
||||
/>
|
||||
) : (
|
||||
<WidgetCard definition={definition} data={data} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<ListView>('table');
|
||||
|
||||
const people = useApiQuery<PeopleResponse>('/api/dashboard/people');
|
||||
const workload = useApiQuery<Workload>('/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 (
|
||||
<div className="space-y-6">
|
||||
<header className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Lidé</h1>
|
||||
<p className="mt-1 text-sm text-white/50">
|
||||
Kdo řeší tickety, co má u sebe a jak dlouho mu to leží.
|
||||
</p>
|
||||
</div>
|
||||
<ViewSwitch view={view} onChange={setView} />
|
||||
</header>
|
||||
|
||||
{workload.data && (
|
||||
<div className="flex flex-wrap gap-3 text-sm">
|
||||
<Stat label="Nevyřízených celkem" value={workload.data.openTotal} />
|
||||
<Stat
|
||||
label="Bez řešitele"
|
||||
value={workload.data.unassigned}
|
||||
tone={workload.data.unassigned > 0 ? 'warn' : undefined}
|
||||
/>
|
||||
<Stat label="Řešitelů" value={items.length} />
|
||||
{(people.data?.groups ?? []).length > 0 && (
|
||||
<Stat label="Skupin" value={(people.data?.groups ?? []).length} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DataState
|
||||
loading={people.loading}
|
||||
error={people.error}
|
||||
onRetry={people.reload}
|
||||
empty={items.length === 0}
|
||||
emptyLabel="Firma zatím nemá žádné řešitele. Přidejte je v Nastavení."
|
||||
>
|
||||
{view === 'table' ? (
|
||||
<div className="glass overflow-x-auto rounded-card">
|
||||
<table className="w-full min-w-150 border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-ink-600/50 text-left text-xs tracking-wide text-white/35 uppercase">
|
||||
<th className="px-4 py-3 font-medium">Jméno</th>
|
||||
<th className="px-4 py-3 font-medium">E-mail</th>
|
||||
<th className="px-4 py-3 font-medium">Zaměření</th>
|
||||
<th className="px-4 py-3 text-right font-medium">U sebe</th>
|
||||
<th className="px-4 py-3 text-right font-medium">Kapacita</th>
|
||||
<th className="px-4 py-3 text-right font-medium">Kritické</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((person) => {
|
||||
const row = byPerson.get(person.id);
|
||||
return (
|
||||
<tr key={person.id} className="border-b border-ink-600/30 last:border-0">
|
||||
<td className="px-4 py-2.5">
|
||||
<Link
|
||||
to={`/dashboard/lide/${person.id}`}
|
||||
className="flex flex-wrap items-center gap-2 font-medium text-white transition-colors hover:text-brand-200"
|
||||
>
|
||||
{person.name}
|
||||
{row?.overloaded && <Badge tone="warn">přetížený</Badge>}
|
||||
{person.id === people.data?.meId && <Badge tone="brand">já</Badge>}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-white/55">{person.email}</td>
|
||||
<td className="px-4 py-2.5 text-white/55">{person.role}</td>
|
||||
<td className="px-4 py-2.5 text-right font-semibold text-white">
|
||||
{row?.open ?? 0}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right text-white/45">{person.capacity}</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
{row && row.critical > 0 ? (
|
||||
<span className="text-danger-400">{row.critical}</span>
|
||||
) : (
|
||||
<span className="text-white/25">-</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{items.map((person) => {
|
||||
const row = byPerson.get(person.id);
|
||||
const share = person.capacity > 0 ? Math.min((row?.open ?? 0) / person.capacity, 1) : 0;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={person.id}
|
||||
to={`/dashboard/lide/${person.id}`}
|
||||
className="glass group rounded-card p-5 transition-colors hover:border-brand-400/40"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold text-white group-hover:text-brand-200">
|
||||
{person.name}
|
||||
</p>
|
||||
<p className="mt-0.5 truncate text-xs text-white/40">{person.role}</p>
|
||||
</div>
|
||||
{row?.overloaded && <Badge tone="warn">přetížený</Badge>}
|
||||
</div>
|
||||
|
||||
<p className="mt-3 flex items-center gap-1.5 truncate text-xs text-white/40">
|
||||
<Mail className="size-3.5 shrink-0" />
|
||||
{person.email}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex items-baseline justify-between">
|
||||
<span className="text-2xl font-bold text-white tabular-nums">
|
||||
{row?.open ?? 0}
|
||||
</span>
|
||||
<span className="text-xs text-white/40">z kapacity {person.capacity}</span>
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-white/6">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full',
|
||||
row?.overloaded ? 'bg-warn-400/70' : 'bg-brand-400/70',
|
||||
)}
|
||||
style={{ width: `${Math.round(share * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</DataState>
|
||||
|
||||
{(people.data?.groups ?? []).length > 0 && (
|
||||
<section className="glass rounded-card p-5">
|
||||
<h2 className="flex items-center gap-2 font-semibold text-white">
|
||||
<Users className="size-4 text-brand-300" />
|
||||
Skupiny
|
||||
</h2>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{(people.data?.groups ?? []).map((group) => (
|
||||
<Badge key={group.id} tone="neutral">
|
||||
{group.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
tone?: 'warn';
|
||||
}) {
|
||||
return (
|
||||
<div className="glass rounded-xl px-4 py-2.5">
|
||||
<span className="text-xs text-white/40">{label}</span>
|
||||
<p className={cn('text-lg font-bold', tone === 'warn' ? 'text-warn-400' : 'text-white')}>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<PersonDetailResponse>(`/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 (
|
||||
<div className="space-y-6">
|
||||
<Link
|
||||
to="/dashboard/lide"
|
||||
className="inline-flex items-center gap-2 text-sm text-white/45 transition-colors hover:text-white"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Zpět na lidi
|
||||
</Link>
|
||||
|
||||
<DataState loading={detail.loading} error={detail.error} onRetry={detail.reload}>
|
||||
{detail.data && (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="text-2xl font-bold text-white">{detail.data.person.name}</h1>
|
||||
{detail.data.stats?.overloaded && <Badge tone="warn">přetížený</Badge>}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-white/45">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Mail className="size-3.5" />
|
||||
{detail.data.person.email}
|
||||
</span>
|
||||
<span>{detail.data.person.role}</span>
|
||||
{detail.data.groups.length > 0 && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Users className="size-3.5" />
|
||||
{detail.data.groups.map((group) => group.name).join(', ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{detail.data.stats && (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-6">
|
||||
<Tile
|
||||
label="Odbaveno za 30 dní"
|
||||
value={formatNumber(detail.data.stats.resolved)}
|
||||
tone="ok"
|
||||
/>
|
||||
<Tile label="Má u sebe" value={formatNumber(detail.data.stats.open)} />
|
||||
<Tile
|
||||
label="Kapacita"
|
||||
value={formatNumber(detail.data.person.capacity)}
|
||||
hint={detail.data.stats.overloaded ? 'překročená' : undefined}
|
||||
/>
|
||||
<Tile
|
||||
label="Doba řešení"
|
||||
value={
|
||||
detail.data.stats.medianResolveMs === null
|
||||
? '-'
|
||||
: formatDuration(detail.data.stats.medianResolveMs)
|
||||
}
|
||||
hint="medián"
|
||||
/>
|
||||
<Tile
|
||||
label="První reakce"
|
||||
value={
|
||||
detail.data.stats.medianResponseMs === null
|
||||
? '-'
|
||||
: formatDuration(detail.data.stats.medianResponseMs)
|
||||
}
|
||||
hint="medián"
|
||||
/>
|
||||
<Tile
|
||||
label="Vrácené tickety"
|
||||
value={formatNumber(detail.data.stats.reopened)}
|
||||
tone={detail.data.stats.reopened > 0 ? 'warn' : undefined}
|
||||
hint="z vyřešeno zpět"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<TicketColumn
|
||||
title="Má u sebe"
|
||||
empty="Nic nemá u sebe."
|
||||
tickets={detail.data.open}
|
||||
showAge
|
||||
/>
|
||||
<TicketColumn
|
||||
title="Naposledy vyřešil"
|
||||
empty="Zatím nic nevyřešil."
|
||||
tickets={detail.data.resolved}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DataState>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TicketColumn({
|
||||
title,
|
||||
empty,
|
||||
tickets,
|
||||
showAge,
|
||||
}: {
|
||||
title: string;
|
||||
empty: string;
|
||||
tickets: Ticket[];
|
||||
showAge?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<section className="glass rounded-card p-5">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<h2 className="font-semibold text-white">{title}</h2>
|
||||
<span className="text-xs text-white/35">{tickets.length}</span>
|
||||
</div>
|
||||
|
||||
{tickets.length === 0 ? (
|
||||
<p className="text-sm text-white/40">{empty}</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-ink-600/50">
|
||||
{tickets.map((ticket) => (
|
||||
<li key={ticket.id} className="py-3 first:pt-0 last:pb-0">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="mt-0.5 font-mono text-xs text-white/35">{ticket.id}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link
|
||||
to={`/dashboard/tickety/${ticket.id}`}
|
||||
className="block truncate text-sm text-white/80 transition-colors hover:text-brand-200"
|
||||
>
|
||||
{ticket.subject}
|
||||
</Link>
|
||||
<p className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-white/40">
|
||||
{ticket.externalId && (
|
||||
<span className="font-mono">externí {ticket.externalId}</span>
|
||||
)}
|
||||
{showAge ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="size-3" />
|
||||
leží {formatRelative(ticket.createdAt)}
|
||||
</span>
|
||||
) : (
|
||||
ticket.resolvedAt && <span>vyřešeno {formatRelative(ticket.resolvedAt)}</span>
|
||||
)}
|
||||
{ticket.reopenCount > 0 && (
|
||||
<span className="flex items-center gap-1 text-warn-400">
|
||||
<RotateCcw className="size-3" />
|
||||
vráceno {ticket.reopenCount}x
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||
<TicketStatusBadge status={ticket.status} />
|
||||
{(ticket.priority === 'critical' || ticket.priority === 'high') && (
|
||||
<TicketPriorityBadge priority={ticket.priority} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Tile({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
hint?: string;
|
||||
tone?: 'ok' | 'warn';
|
||||
}) {
|
||||
return (
|
||||
<div className="glass rounded-card p-4">
|
||||
<p className="text-xs text-white/40">{label}</p>
|
||||
<p
|
||||
className={cn(
|
||||
'mt-1 text-xl font-bold tabular-nums',
|
||||
tone === 'ok' ? 'text-ok-400' : tone === 'warn' ? 'text-warn-400' : 'text-white',
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
{hint && <p className="mt-0.5 text-xs text-white/30">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
<header>
|
||||
<h1 className="text-2xl font-bold text-white">Nastavení</h1>
|
||||
<p className="mt-1 text-sm text-white/50">
|
||||
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í.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -267,98 +265,7 @@ export default function Settings() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === 'actions' && (
|
||||
<EntityAdmin
|
||||
title="Akce na ticketu"
|
||||
description="Tlačítko na ticketu. Váže se na typ nebo tag a spouští operaci služby, strom nebo skript."
|
||||
endpoint="/api/dashboard/settings/actions"
|
||||
listEndpoint="/api/dashboard/settings/actions-overview"
|
||||
columns={[
|
||||
{ label: 'Akce', render: (action: NamedRecord) => 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 <code>{body?.kind ?? '?'}</code>;
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Stav',
|
||||
narrow: true,
|
||||
render: (action: NamedRecord) =>
|
||||
action.enabled === false ? <Badge tone="warn">vypnutá</Badge> : <Badge tone="ok">zapnutá</Badge>,
|
||||
},
|
||||
]}
|
||||
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' && (
|
||||
<EntityAdmin
|
||||
title="Vlastní widgety"
|
||||
description="Dlaždice na přehled. Filtr je tentýž, který umí seznam ticketů."
|
||||
endpoint="/api/dashboard/settings/widgets"
|
||||
listEndpoint="/api/dashboard/settings/widgets-overview"
|
||||
columns={[
|
||||
{ label: 'Název', render: (widget: NamedRecord) => String(widget.name ?? '') },
|
||||
{ label: 'Vykreslení', narrow: true, render: (widget: NamedRecord) => <code>{String(widget.render ?? '')}</code> },
|
||||
{
|
||||
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' && <UsersAdmin />}
|
||||
{tab === 'users' && <UsersAdmin />}
|
||||
|
||||
{tab === 'tenants' && (
|
||||
<EntityAdmin
|
||||
@@ -849,92 +756,3 @@ function AuditView() {
|
||||
|
||||
// ---------------------------------------------------- definice poli formularu
|
||||
|
||||
const actionFields: Array<AdminField<NamedRecord>> = [
|
||||
{ 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<AdminField<NamedRecord>> = [
|
||||
{ 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.',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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<string | null>(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() {
|
||||
<TicketChannelBadge channel={ticket.data.channel} />
|
||||
<TicketStatusBadge status={ticket.data.status} />
|
||||
<TicketPriorityBadge priority={ticket.data.priority} />
|
||||
{ticket.data.externalId && (
|
||||
<Badge tone="neutral">
|
||||
{/* Podle tohohle se na ticket navesuji dalsi udalosti. */}
|
||||
externí ID {ticket.data.externalId}
|
||||
{ticket.data.externalSource ? ` (${ticket.data.externalSource})` : ''}
|
||||
</Badge>
|
||||
)}
|
||||
{ticket.data.reopenCount > 0 && (
|
||||
<Badge tone="warn">vráceno {ticket.data.reopenCount}x</Badge>
|
||||
)}
|
||||
{ticket.data.customer.id === null && (
|
||||
<Badge tone="warn">
|
||||
<AlertCircle className="size-3.5" />
|
||||
@@ -164,6 +176,22 @@ export default function TicketDetail() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="glass rounded-card p-5 sm:p-6">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="flex items-center gap-2 font-semibold text-white">
|
||||
<Inbox className="size-4 text-brand-300" />
|
||||
Příchozí události
|
||||
</h2>
|
||||
<span className="text-xs text-white/35">
|
||||
{ticket.data.events.length}
|
||||
{ticket.data.externalId
|
||||
? ` k externímu ID ${ticket.data.externalId}`
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
<TicketEvents events={ticket.data.events} />
|
||||
</section>
|
||||
|
||||
<section className="glass rounded-card p-5 sm:p-6">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="flex items-center gap-2 font-semibold text-white">
|
||||
@@ -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"
|
||||
/>
|
||||
<Button type="submit" size="sm" disabled={busy || comment.trim().length < 2}>
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
TicketStatusBadge,
|
||||
} from '@/components/dashboard/StatusBadge';
|
||||
import { TicketWorkload } from '@/components/dashboard/TicketWorkload';
|
||||
import { ViewSwitch } from '@/components/dashboard/ViewSwitch';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { formatDateTime, formatRelative } from '@/lib/format';
|
||||
import { useApiQuery } from '@/lib/useApiQuery';
|
||||
@@ -49,7 +51,7 @@ const channelFilters: Array<{ value: TicketChannel; label: string }> = [
|
||||
];
|
||||
|
||||
export default function Tickets() {
|
||||
usePageMeta({ title: 'Tickety — portál Automia' });
|
||||
usePageMeta({ title: 'Tickety - portál Automia' });
|
||||
|
||||
// Co uzivatel smi, rozhoduje server. Klient jen kresli, co prislo.
|
||||
const access = useApiQuery<Access>('/api/dashboard/access');
|
||||
@@ -61,6 +63,8 @@ export default function Tickets() {
|
||||
const [status, setStatus] = useState<TicketStatus | null>(null);
|
||||
const [channel, setChannel] = useState<TicketChannel | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
/** Tabulka na porovnavani, dlazdice na rychly prehled. */
|
||||
const [view, setView] = useState<'table' | 'grid'>('table');
|
||||
|
||||
// Filtrovani resi server, aby seznam a prehled nikdy neukazovaly jina cisla.
|
||||
const path = useMemo(() => {
|
||||
@@ -111,12 +115,15 @@ export default function Tickets() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="text-2xl font-bold text-white">Tickety</h1>
|
||||
<p className="mt-1 text-sm text-white/50">
|
||||
Požadavky ze všech kanálů. Každý má svého řešitele a dohledatelný průběh.
|
||||
V detailu je vidět, co která služba vrátila.
|
||||
</p>
|
||||
<header className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Tickety</h1>
|
||||
<p className="mt-1 text-sm text-white/50">
|
||||
Požadavky ze všech kanálů. Každý má svého řešitele a dohledatelný průběh.
|
||||
V detailu je vidět, co která služba vrátila.
|
||||
</p>
|
||||
</div>
|
||||
<ViewSwitch view={view} onChange={setView} />
|
||||
</header>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1fr_20rem]">
|
||||
@@ -128,7 +135,7 @@ export default function Tickets() {
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Hledat podle čísla, předmětu nebo firmy…"
|
||||
placeholder="Hledat podle čísla, předmětu nebo firmy..."
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
@@ -202,7 +209,7 @@ export default function Tickets() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass overflow-hidden rounded-card">
|
||||
<div className={cn(view === 'table' && 'glass overflow-hidden rounded-card')}>
|
||||
<DataState
|
||||
loading={tickets.loading}
|
||||
error={tickets.error}
|
||||
@@ -214,6 +221,68 @@ export default function Tickets() {
|
||||
: 'Žádné tickety.'
|
||||
}
|
||||
>
|
||||
{view === 'grid' ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{items.map((ticket) => (
|
||||
<Link
|
||||
key={ticket.id}
|
||||
to={`/dashboard/tickety/${ticket.id}`}
|
||||
className="glass group flex flex-col rounded-card p-4 transition-colors hover:border-brand-400/40"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="font-mono text-xs text-white/35">{ticket.id}</span>
|
||||
<TicketStatusBadge status={ticket.status} />
|
||||
</div>
|
||||
|
||||
<p className="mt-2 line-clamp-2 text-sm text-white/85 group-hover:text-brand-200">
|
||||
{ticket.subject}
|
||||
</p>
|
||||
|
||||
<p className="mt-1 truncate text-xs text-white/40">
|
||||
{ticket.customer.id ? (
|
||||
ticket.customer.company
|
||||
) : (
|
||||
<span className="text-warn-400">zákazník nedohledán</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{ticket.externalId && (
|
||||
<p className="mt-1 font-mono text-xs text-white/30">
|
||||
externí {ticket.externalId}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||||
<TicketChannelBadge channel={ticket.channel} />
|
||||
<TicketPriorityBadge priority={ticket.priority} />
|
||||
{ticket.tags.slice(0, 2).map((tag) => (
|
||||
<Badge key={tag} tone="neutral">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between gap-2 border-t border-ink-600/50 pt-3 text-xs">
|
||||
<span
|
||||
className={cn(
|
||||
'truncate',
|
||||
ticket.assignee
|
||||
? ticket.assignee.id === meId
|
||||
? 'text-brand-200'
|
||||
: 'text-white/55'
|
||||
: 'text-white/30',
|
||||
)}
|
||||
>
|
||||
{ticket.assignee?.name ?? 've frontě'}
|
||||
</span>
|
||||
<span className="shrink-0 text-white/35" title={formatDateTime(ticket.updatedAt)}>
|
||||
{formatRelative(ticket.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[58rem] text-left text-sm">
|
||||
<thead className="border-b border-ink-600/60 text-xs tracking-wide text-white/40 uppercase">
|
||||
@@ -295,6 +364,7 @@ export default function Tickets() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</DataState>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<Service[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<{ items: Service[] }>('/api/dashboard/services')
|
||||
.then((data) => setServices(data.items))
|
||||
.catch(() => setServices([]));
|
||||
}, []);
|
||||
|
||||
const fields: Array<AdminField<WidgetRecord>> = [
|
||||
{ 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 (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="text-2xl font-bold text-white">Widgety</h1>
|
||||
<p className="mt-1 text-sm text-white/50">
|
||||
Dlaždice, které si jde položit na přehled. Data počítá server.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<p className="flex gap-2 rounded-xl border border-ink-600/60 px-4 py-3 text-sm leading-relaxed text-white/45">
|
||||
<Info className="mt-0.5 size-4 shrink-0 text-brand-300" />
|
||||
<span>
|
||||
Zdroj <code className="text-white/70">connector</code> vytáhne data z napojené služby:
|
||||
zavolá stejnou operaci jako krok automatizace a z výsledku vezme, co je v{' '}
|
||||
<code className="text-white/70">path</code>. Výsledek se drží v mezipaměti (
|
||||
<code className="text-white/70">ttlSec</code>), 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ší' : ''}.
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<EntityAdmin<WidgetRecord>
|
||||
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) => <code className="text-xs">{describeSource(widget.source)}</code>,
|
||||
},
|
||||
{ label: 'Kreslení', narrow: true, render: (widget) => widget.render },
|
||||
{
|
||||
label: 'Kdo vidí',
|
||||
narrow: true,
|
||||
render: (widget) =>
|
||||
widget.ownerId ? <Badge tone="neutral">jen já</Badge> : <Badge tone="ok">firma</Badge>,
|
||||
},
|
||||
]}
|
||||
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,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
}
|
||||
+100
-1
@@ -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<string, unknown>;
|
||||
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<string, string | number | boolean | null>;
|
||||
/** 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<T> {
|
||||
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user