Runtime vykonava strom, prokliky z widgetu, oprava ukladani rozlozeni
Runtime: `src/runtime/executor.ts` jde krok po kroku, u podminky se vetvi, do poli dosadi parametry, akci pusti pres runScript a vystupy pripise do kontextu pro dalsi krok. Cely prubeh jde do logu ticketu vcetne toho, co sluzba vratila. Pouzivaji ho obe cesty: akce na ticketu i webhook. Opraveno: rozlozeni dashboardu s vlastnim widgetem se NEDALO ULOZIT. `validateLayout` znala jen vestaveny katalog, takze kazdy pokus skoncil hlaskou "widget v katalogu neexistuje" - presne to, co hlasil uzivatel. Katalog je ted jedna funkce a pouziva ji nabidka i kontrola. Zaroven je za konkretni firmu, driv slo polozit dlazdici jedne firmy na dashboard druhe. Prokliky: z widgetu lidi na cloveka, ze seskupeni na vyfiltrovany seznam ticketu. Odkazy sklada server, protoze on jediny zna filtr widgetu. Seznam ticketu cte filtr z adresy a umi filtrovat na typ, tag a skupinu. Tabulky: spolecna `TicketTable` pro seznam i detail osoby. Na mobilu se neposouva do strany, uzka obrazovka dostane karty. Detail osoby ma velkou tabulku se zalozkami "ma u sebe" a "vyresil" a prepinacem pohledu. Odebrano: simulace vcetne tlacitka, dialogu i endpointu. Trojice pohledu nad tickety - vyber firmy je select, "moje" je prepinac, driv to delalo totez dvakrat. Pridan zmereny rozbor kapacity pro 200 firem (19-kapacita-200-firem.md): soucasny stav to nezvladne, protoze data jsou v pameti a vypis je linearni. Zmereno na 5 000 ticketech, vcetne toho, co s tim a kolik serveru to chce. Overeno 7 kontrolami proti bezicimu serveru. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
29de584df8
commit
5d186dcd2e
@@ -16,7 +16,6 @@ React aplikaci ze slozky `dist/public`.
|
||||
| Prihlaseni | hotovo | JWT, demo ucty |
|
||||
| Dashboard | hotovo | prehled, tickety, incidenty, automatizace, nastaveni |
|
||||
| Zivy dashboard pres SSE | hotovo | zmeny se projevi bez obnoveni stranky |
|
||||
| Simulace provozu | hotovo | tlacitko v postrannim menu portalu |
|
||||
| Katalog sluzeb | hotovo | 29 sluzeb, 7 kategorii vcetne Obecne |
|
||||
| Builder automatizaci | hotovo | strom akci, vetveni podminkou |
|
||||
| Webhook s registrovanou adresou | hotovo | token generuje server, verejny endpoint validuje data |
|
||||
@@ -48,7 +47,7 @@ React aplikaci ze slozky `dist/public`.
|
||||
| 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 |
|
||||
| Beh automatizaci | castecne | strom se vykona, ale synchronne a bez fronty |
|
||||
| Uloziste konektoru | hotovo | Postgres, nebo JSON soubor. Udaje vzdy sifrovane |
|
||||
| Uloziste pro zbytek | hotovo | tickety, automatizace, incidenty, rozlozeni, entity |
|
||||
| Monetizace a cena za krok | navrh | popis v 16-monetizace.md, neni naprogramovane |
|
||||
@@ -66,9 +65,11 @@ v portalu i v `/health/ready` a rozhoduje o nem jedno misto, viz
|
||||
| `file` | neni databaze, je `DATA_DIR` | prezije restart, ne redeploy |
|
||||
| `memory` | neni ani `DATA_DIR` | ztrati se pri restartu |
|
||||
|
||||
Beh automatizaci zatim neexistuje: ulozeny strom se nevykonava, provoz se dela
|
||||
simulaci. Popis, jak to ma vypadat, je v
|
||||
[10-runtime-a-kapacita.md](10-runtime-a-kapacita.md).
|
||||
Beh automatizaci uz existuje, ale je **synchronni v requestu**: webhook ceka,
|
||||
nez cely strom dobehne, a pri padu procesu se rozdelany beh ztrati. Neni fronta
|
||||
ani opakovani. Co to znamena pro vetsi provoz a co s tim, je zmerene
|
||||
v [19-kapacita-200-firem.md](19-kapacita-200-firem.md), navrh fronty
|
||||
v [10-runtime-a-kapacita.md](10-runtime-a-kapacita.md).
|
||||
|
||||
Obsah verejneho webu je ukazkovy. Nazev firmy, reference, tym i cisla jsou
|
||||
vymyslene a pred ostrym pouzitim se musi nahradit. Firemni udaje jsou na jednom
|
||||
@@ -77,8 +78,9 @@ miste v `web/src/config/brand.ts`.
|
||||
Zivy stream drzi seznam posluchacu v pameti jedne instance. Pri vice instancich
|
||||
by ho musel nahradit sdileny kanal, napriklad Redis pub/sub.
|
||||
|
||||
Log ticketu zatim plni simulace, ne skutecny beh. Zaznamy jsou realisticke,
|
||||
ale nevznikly vykonanim ulozeneho stromu - runtime neexistuje.
|
||||
Log ticketu uz plni skutecny beh: kazdy krok stromu se do nej zapise vcetne
|
||||
toho, co sluzba vratila. Ukazkova sada ticketu ma log psany rucne, aby bylo
|
||||
co ukazat i na prazdne instanci.
|
||||
|
||||
## Dalsi krok
|
||||
|
||||
@@ -124,4 +126,5 @@ pro frontu, beh kroku a rozpocet na 150 klientu, a
|
||||
| [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 |
|
||||
| [19-kapacita-200-firem.md](19-kapacita-200-firem.md) | zmereno, co zvladne soucasny stav |
|
||||
| [99-zmeny.md](99-zmeny.md) | zaznam zmen, nejnovejsi nahore |
|
||||
|
||||
@@ -80,7 +80,6 @@ Vyzaduji `Authorization: Bearer <token>`:
|
||||
| POST | `/api/admin/impersonate/stop` |
|
||||
| GET | `/api/admin/impersonate/candidates` |
|
||||
| GET | `/api/admin/audit` |
|
||||
| POST | `/api/simulate` |
|
||||
|
||||
Sprava zaznamu ma u kazde entity stejnou petici (seznam, detail, vytvoreni,
|
||||
uprava, mazani) na `/api/dashboard/settings/<entita>`, protoze ji dela jedna
|
||||
@@ -246,21 +245,6 @@ s 403. Kazde prepnuti i ukonceni je v auditu vcetne toho, kdo to byl doopravdy.
|
||||
|
||||
Svuj puvodni token si klient odklada do `sessionStorage`, server o nem nic nevi.
|
||||
|
||||
## Simulace
|
||||
|
||||
`POST /api/simulate` vyvola provozni udalost pro nahled ziveho dashboardu.
|
||||
Zamerne meni skutecna data, ne jen posila falesnou notifikaci.
|
||||
|
||||
Akce: `ticket.created`, `ticket.resolved`, `incident.started`,
|
||||
`incident.resolved`, `automation.run`.
|
||||
|
||||
U `ticket.created` urcuje `channel` (whatsapp, facebook, instagram, email, voice,
|
||||
form, portal), odkud pozadavek prisel, a podle toho se poskladá i log ticketu. `knownCustomer: false` znamena, ze CRM firmu nedohleda -
|
||||
ticket zustane bez zakaznika i bez resitele a v logu je videt proc.
|
||||
|
||||
Nevyplnena pole server doplni ukazkovou hodnotou. U akci s "resolved" se bez
|
||||
zadaneho id pouzije prvni nevyrizeny zaznam.
|
||||
|
||||
## Sluzby a konektory
|
||||
|
||||
Popis modelu je v [12-sluzby-a-konektory.md](12-sluzby-a-konektory.md), tady jen API.
|
||||
|
||||
@@ -37,6 +37,8 @@ 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. |
|
||||
| `runFlow(steps, context, options)` | `src/runtime/executor.ts` | Vykoná strom kroků. Nikdy nevyhodí výjimku, chyba je výsledek. Používá to akce na ticketu i webhook, aby se strom choval všude stejně. |
|
||||
| `widgetCatalog(tenantIds, userId)` | `src/data/widgets.ts` | Jediná definice toho, co jde položit na dashboard. Používá ji nabídka i kontrola ukládaného rozložení. |
|
||||
| `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. |
|
||||
@@ -69,6 +71,7 @@ Viz [11-skripty-konektoru.md](11-skripty-konektoru.md).
|
||||
| `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. |
|
||||
| `TicketTable` | `components/dashboard/TicketTable.tsx` | Tabulka ticketů pro všechna místa. Na mobilu se místo posouvání do strany kreslí karty. |
|
||||
| `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. |
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
# Kapacita: 200 firem, 10 automatizací, 15 lidí na firmu
|
||||
|
||||
Otázka zněla: zvládne to současný stav s Postgresem, a co to bude chtít
|
||||
za server. Odpověď je změřená, ne odhadnutá.
|
||||
|
||||
**Krátce: v současném stavu ne.** Runtime běží, ale datová vrstva je psaná
|
||||
na stovky až tisíce ticketů, ne na miliony. Co konkrétně to zastaví a v jakém
|
||||
pořadí to opravit, je níž.
|
||||
|
||||
## Co se měřilo
|
||||
|
||||
Jeden proces, režim souboru, tickety se posílaly přes příjem událostí.
|
||||
Měřeno na vývojovém stroji, tedy horní hranice latence, ne serveru.
|
||||
|
||||
| Ticketů | Výpis seznamu | Statistiky řešitelů | Soubor |
|
||||
| --- | --- | --- | --- |
|
||||
| 103 | 1,9 ms | 1,6 ms | 153 kB |
|
||||
| 503 | 6,1 ms | 12,1 ms | 729 kB |
|
||||
| 1 003 | 14,2 ms | 2,7 ms | 1,4 MB |
|
||||
| 2 003 | 18,8 ms | 21,1 ms | 2,9 MB |
|
||||
| 5 003 | 52,9 ms | 1,7 ms | 7,2 MB |
|
||||
|
||||
Příjem událostí: **130 až 190 událostí za sekundu** včetně celého kola
|
||||
HTTP, uložení a zápisu do logu.
|
||||
|
||||
Z toho plyne:
|
||||
|
||||
- **jeden ticket = 1,4 kB** v uložišti,
|
||||
- **výpis roste lineárně**, zhruba 10 mikrosekund na ticket, protože se
|
||||
filtruje pole v paměti,
|
||||
- v režimu souboru se **při každé změně přepisuje celý soubor**. Při pěti
|
||||
tisících ticketech je to 7 MB zápisu kvůli jednomu komentáři. S Postgresem
|
||||
to odpadá, tam je to jeden řádek.
|
||||
|
||||
## Kolik toho bude
|
||||
|
||||
200 firem, 10 automatizací každá, 15 lidí. Odhad provozu:
|
||||
|
||||
| Veličina | Výpočet | Za den | Za měsíc |
|
||||
| --- | --- | --- | --- |
|
||||
| Běhy automatizací | 2 000 automatizací, 100 běhů denně | 200 000 | 6 mil. |
|
||||
| Kroky | 3 kroky na běh | 600 000 | 18 mil. |
|
||||
| Tickety | 200 firem, 200 denně | 40 000 | 1,2 mil. |
|
||||
| Řádky logu | 5 na ticket plus kroky | ~800 000 | 24 mil. |
|
||||
|
||||
Špička není průměr. 7 kroků za sekundu v průměru znamená ve špičce klidně
|
||||
100 za sekundu, protože e-shopy neposílají objednávky rovnoměrně.
|
||||
|
||||
## Co to v současném stavu zastaví
|
||||
|
||||
### 1. Všechna data jsou v paměti procesu
|
||||
|
||||
`withMirror` drží tickety, automatizace a incidenty v poli a při startu je
|
||||
**všechny načte**. Při 1,2 milionu ticketů měsíčně je to 1,7 GB jen na tickety,
|
||||
a to bez logu. Aplikace se nenastartuje.
|
||||
|
||||
Opravit se musí tak, že se tickety čtou dotazem s indexem a stránkováním,
|
||||
ne z pole. `withMirror` má zůstat na tom, co je malé a čte se pořád (rozložení
|
||||
dashboardu), ne na provozních datech.
|
||||
|
||||
### 2. Výpis prochází všechno
|
||||
|
||||
`listTickets` filtruje pole přes všechny firmy. Změřeno 10 mikrosekund na
|
||||
ticket, takže při milionu ticketů je jeden výpis 10 sekund. Musí to být
|
||||
`WHERE tenant_id = ... AND status = ... ORDER BY updated_at LIMIT 50` nad
|
||||
indexem, tedy jednotky milisekund bez ohledu na objem.
|
||||
|
||||
### 3. Log je uvnitř ticketu
|
||||
|
||||
Ticket si nese `trace` i `events` jako součást jednoho záznamu. Každý řádek
|
||||
logu tím přepisuje celý ticket i s celým dosavadním logem. Po padesáti krocích
|
||||
je to padesát zápisů, každý delší než ten předchozí.
|
||||
|
||||
Log patří do vlastní tabulky s cizím klíčem na ticket a s retencí. Při 24
|
||||
milionech řádků měsíčně a plné odpovědi služby u každého je to řádově
|
||||
**stovky GB za měsíc**, takže retence není detail, ale podmínka provozu.
|
||||
|
||||
### 4. Runtime běží v požadavku
|
||||
|
||||
`runFlow` vykoná strom rovnou v HTTP požadavku. U ruční akce je to správně,
|
||||
u webhooku ne:
|
||||
|
||||
- odesílatel čeká, než doběhnou všechny kroky,
|
||||
- při pádu procesu se rozdělaný běh ztratí, protože není kde by byl zapsaný,
|
||||
- není retry, není omezení souběhu na jednu službu, nic to nebrzdí.
|
||||
|
||||
### 5. Statistiky se počítají celé
|
||||
|
||||
`getAgentStats` projde všechny tickety firmy při každém zobrazení widgetu.
|
||||
Při desítkách tisíc na firmu to jde, při milionu ne. Patří to do agregace
|
||||
počítané dávkově, nebo aspoň do dotazu s `GROUP BY` nad indexem.
|
||||
|
||||
### 6. Jedna instance
|
||||
|
||||
Živý stream (SSE), mezipaměť widgetů nad konektory i kopie konfigurace
|
||||
v paměti jsou vázané na proces. Při dvou instancích za load balancerem se
|
||||
rozejdou. Řeší to `LISTEN/NOTIFY` na obnovu kopií a sdílený kanál na stream.
|
||||
|
||||
## Konektory nejsou v naší moci
|
||||
|
||||
Za rychlost a výsledek cizí služby neručíme, a proto se s tím musí počítat
|
||||
v návrhu, ne v provozu:
|
||||
|
||||
| Riziko | Co s tím |
|
||||
| --- | --- |
|
||||
| Služba odpovídá pomalu | Timeout na krok, ne na celý běh. Běh se uspí a pokračuje. |
|
||||
| Služba je chvíli mimo | Opakování s rostoucí prodlevou, ne hned a ne donekonečna. |
|
||||
| Služba je mimo dlouho | Vypnout ji po sérii chyb a nezkoušet každý běh znovu, ať netrpí ostatní. |
|
||||
| Služba má limit volání | Strop souběžných volání **na dvojici firma a služba**, ne globálně. |
|
||||
| Služba odpoví dvakrát jinak | Klíč proti dvojímu provedení u kroku, aby se nevystavila druhá faktura. |
|
||||
| Služba je pomalá jen pro jednu firmu | Fronta po firmách, aby jedna firma nezablokovala ostatní. |
|
||||
|
||||
Timeout a rozlišení "zkusit znovu" a "marné" už v `scripts/http.ts` je,
|
||||
klíč proti dvojímu provedení taky. Chybí to, co je nad tím: fronta, opakování
|
||||
a vypínání služby po sérii chyb.
|
||||
|
||||
## Co je potřeba udělat, v pořadí
|
||||
|
||||
1. **Tickety, běhy a log do Postgresu jako tabulky**, ne jako JSON v paměti.
|
||||
Indexy na `(tenant_id, updated_at)`, `(tenant_id, external_id)` unikátní,
|
||||
`(tenant_id, assignee_id, status)`. Stránkování na všech výpisech.
|
||||
2. **Log a události zvlášť** od ticketu, s retencí. Bez toho to zaroste.
|
||||
3. **Fronta běhů.** Tabulka `run_queue`, výběr přes `SELECT ... FOR UPDATE
|
||||
SKIP LOCKED`, worker jako druhý proces téhož obrazu. Webhook jen zapíše
|
||||
událost a odpoví, běh se stane na pozadí.
|
||||
4. **Opakování a vypínání služby** kolem kroku.
|
||||
5. **Agregace statistik** dávkově, ne při každém zobrazení.
|
||||
6. **`LISTEN/NOTIFY`** na obnovu kopií v paměti a sdílený kanál na živý stream.
|
||||
|
||||
Body 1 až 3 jsou podmínka, aby to vůbec šlo pustit. Zbytek je o tom, aby to
|
||||
bylo použitelné.
|
||||
|
||||
## Kolik serveru to bude chtít
|
||||
|
||||
Po těch úpravách, pro zadaných 200 firem:
|
||||
|
||||
| Část | Kolik | Proč |
|
||||
| --- | --- | --- |
|
||||
| Web a API | 2 instance, 1 vCPU a 1 GB každá | Požadavky jsou krátké, jde hlavně o dostupnost při restartu. |
|
||||
| Worker běhů | 2 instance, 1 vCPU a 512 MB každá | Kroky čekají na cizí službu, procesor se skoro nepoužije. Jeden proces zvládne stovky souběžných kroků. |
|
||||
| Postgres | 4 vCPU, 8 GB RAM, 200 GB disku | 10 až 20 zápisů za sekundu v průměru je málo, disk sežere log. |
|
||||
| Celkem | ~8 vCPU, ~11 GB RAM | |
|
||||
|
||||
Kritické číslo není procesor, ale **disk pod databází** a retence logu.
|
||||
S plnou odpovědí služby u každého kroku je to zhruba 20 GB měsíčně na
|
||||
každých 20 milionů řádků, takže 200 GB je půl roku provozu. Buď se odpovědi
|
||||
po měsíci zahazují a nechá se jen shrnutí, nebo se počítá s tím, že disk
|
||||
poroste.
|
||||
|
||||
Škálování je vodorovné: víc událostí znamená víc workerů, ne větší stroj.
|
||||
Jediné, co se škáluje svisle, je databáze, a ta má u tohoto objemu ještě
|
||||
velkou rezervu.
|
||||
|
||||
## Co funguje už teď
|
||||
|
||||
Aby to nevypadalo hůř, než to je: runtime **běží**. Webhook vykoná strom,
|
||||
akce na ticketu vykoná strom, kroky si předávají výstupy, podmínka větví
|
||||
a celý průběh se zapíše do logu ticketu i s tím, co která služba vrátila.
|
||||
Na jednu firmu s desítkami ticketů denně je současný stav použitelný.
|
||||
Na 200 firem ne.
|
||||
@@ -2,6 +2,50 @@
|
||||
|
||||
Nejnovejsi nahore.
|
||||
|
||||
## 2026-08-13 - runtime, prokliky z widgetu a kapacitni rozbor
|
||||
|
||||
### Pridano
|
||||
|
||||
- `src/runtime/executor.ts`: **strom se konecne vykonava**. Jde krok po kroku,
|
||||
u podminky se vetvi, do poli dosadi `{{parametry}}`, akci pusti pres
|
||||
`runScript` a vystupy pripise do kontextu, aby na ne mohl dalsi krok
|
||||
odkazat. Cely prubeh jde do logu ticketu vcetne toho, co sluzba vratila.
|
||||
Pouzivaji ho **obe** cesty: akce na ticketu i webhook automatizace.
|
||||
- Z widgetu se da prokliknout na to, co je za cislem: z lidi na cloveka,
|
||||
ze seskupeni na uz vyfiltrovany seznam ticketu. Odkazy sklada **server**,
|
||||
protoze on jediny zna filtr widgetu.
|
||||
- Seznam ticketu cte filtr z adresy (`?typeId=`, `?tag=`, `?assignee=`, ...),
|
||||
takze proklik vede na spravny vyber a odkaz jde poslat kolegovi.
|
||||
- Seznam ticketu umi filtrovat na typ, tag a skupinu.
|
||||
- Spolecna `TicketTable`: jedna tabulka pro seznam i pro detail osoby.
|
||||
**Na mobilu se neposouva do strany**, uzka obrazovka dostane karty.
|
||||
- [19-kapacita-200-firem.md](19-kapacita-200-firem.md): zmereny rozbor toho,
|
||||
co se stane pri 200 firmach, a co to bude chtit za server.
|
||||
|
||||
### Opraveno
|
||||
|
||||
- **Rozlozeni dashboardu s vlastnim widgetem se nedalo ulozit.** `validateLayout`
|
||||
znala jen vestaveny katalog, takze kazdy pokus skoncil hlaskou, ze widget
|
||||
v katalogu neexistuje. Katalog je ted jedna funkce (`widgetCatalog`) a pouziva
|
||||
ji nabidka i kontrola.
|
||||
- Katalog widgetu je za konkretni firmu, ne za vsechny firmy uzivatele. Driv
|
||||
slo polozit dlazdici jedne firmy na dashboard druhe, kde k ni data nikdy
|
||||
neprisla.
|
||||
|
||||
### Odebrano
|
||||
|
||||
- **Simulace** vcetne tlacitka, dialogu i endpointu `/api/simulate`. Provoz se
|
||||
ted dela prijmem udalosti, ktery je skutecny.
|
||||
- Trojice pohledu nad tickety. Vyber firmy je select, "moje" je prepinac -
|
||||
driv to delalo totez dvakrat.
|
||||
|
||||
### Overeno
|
||||
|
||||
7 kontrol proti bezicimu serveru: ulozeni rozlozeni s vlastnim widgetem,
|
||||
oddeleni katalogu po firmach, vykonani stromu akce i webhooku, zapis behu do
|
||||
logu, odkazy z widgetu a filtrovani podle nich. K tomu mereni na 5 000
|
||||
ticketech, ze ktereho vychazi rozbor kapacity.
|
||||
|
||||
## 2026-08-13 - ticketovaci system: udalosti, externi ID, statistiky, widgety
|
||||
|
||||
Popis v [18-ticketovaci-system.md](18-ticketovaci-system.md).
|
||||
|
||||
@@ -127,12 +127,24 @@ export async function initConnectorStore(options: InitOptions = {}): Promise<Sto
|
||||
flushRepository = () => local.flush();
|
||||
|
||||
if (useFile) {
|
||||
reasons.push('Data se ukládají do JSON souboru, takže přežijí restart, ale ne redeploy.');
|
||||
/*
|
||||
* Soubory lezi uvnitr containeru. Restart je nechá byt, redeploy postavi
|
||||
* novy container a slozka je zas prazdna. Aby data zustala, musi byt
|
||||
* `DATA_DIR` na pripojenem svazku, nebo se musi nastavit `DATABASE_URL` -
|
||||
* obojí je vec nasazeni, ne aplikace, proto se to rekne nahlas.
|
||||
*/
|
||||
reasons.push(
|
||||
`Data se ukládají do souborů v ${config.dataDir}. Přežijí restart, ale ne redeploy, ` +
|
||||
'protože složka je uvnitř kontejneru. Aby zůstala: nastavte DATABASE_URL, ' +
|
||||
'nebo namiřte DATA_DIR na připojený svazek.',
|
||||
);
|
||||
if (!canSealSecrets()) {
|
||||
reasons.push('Přístupové údaje se neukládají, protože není čím je zašifrovat.');
|
||||
}
|
||||
} else {
|
||||
reasons.push('Data jsou jen v paměti procesu, restart je smaže.');
|
||||
reasons.push(
|
||||
'Data jsou jen v paměti procesu, restart je smaže. Nastavte DATA_DIR, nebo DATABASE_URL.',
|
||||
);
|
||||
}
|
||||
|
||||
status = {
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
* prezije restart. Vychozi sada se neuklada - kdo si nic neupravil, nema zaznam.
|
||||
*/
|
||||
|
||||
import { findWidget, type WidgetSize } from './widgets.js';
|
||||
import type { CatalogWidget } from './widgets.js';
|
||||
import type { WidgetSize } from './widgets.js';
|
||||
import { defineStore, nowIso, type TenantEntity } from './store/index.js';
|
||||
import { withMirror } from './store/mirror.js';
|
||||
|
||||
@@ -88,8 +89,12 @@ export function resetLayout(userId: string, tenantId: string): LayoutItem[] {
|
||||
* Overi rozlozeni proti katalogu. Vraci seznam problemu, prazdny znamena v poradku.
|
||||
* Neznamy widget ani nepovolena sirka se neulozi - klient by pak dostal zpatky
|
||||
* neco, co neumi vykreslit.
|
||||
*
|
||||
* Katalog je **parametr**, ne pevny seznam: patri do nej i vlastni widgety
|
||||
* firmy a ty zavisi na tom, kdo se pta. Viz `widgetCatalog`.
|
||||
*/
|
||||
export function validateLayout(items: LayoutItem[]): string[] {
|
||||
export function validateLayout(items: LayoutItem[], catalog: CatalogWidget[]): string[] {
|
||||
const byId = new Map(catalog.map((widget) => [widget.id, widget]));
|
||||
const problems: string[] = [];
|
||||
|
||||
if (items.length > MAX_WIDGETS) {
|
||||
@@ -101,7 +106,7 @@ export function validateLayout(items: LayoutItem[]): string[] {
|
||||
if (seen.has(item.id)) problems.push(`Widget s ID „${item.id}" je uvedený dvakrát.`);
|
||||
seen.add(item.id);
|
||||
|
||||
const definition = findWidget(item.widgetId);
|
||||
const definition = byId.get(item.widgetId);
|
||||
if (!definition) {
|
||||
problems.push(`Widget „${item.widgetId}" v katalogu neexistuje.`);
|
||||
continue;
|
||||
|
||||
@@ -155,7 +155,7 @@ export function updateIncidentStatus(id: string, status: IncidentStatus): Incide
|
||||
return incident;
|
||||
}
|
||||
|
||||
/** Prvni bezici incident - pouziva simulace, kdyz uzivatel neurci ktery. */
|
||||
/** Prvni bezici incident. */
|
||||
export function firstActiveIncident(): Incident | undefined {
|
||||
return incidents.find((i) => i.status !== 'resolved');
|
||||
}
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ export function getSummary(tenantIds: string[]) {
|
||||
const automations = listAutomations(tenantIds);
|
||||
const series = getRunsSeries();
|
||||
|
||||
// Dnesni sloupec grafu doplnujeme o skutecne behy, aby se simulace projevila.
|
||||
// Dnesni sloupec grafu doplnujeme o skutecne behy automatizaci.
|
||||
const runsToday = automations.reduce((sum, a) => sum + a.runsToday, 0);
|
||||
if (series.length > 0) {
|
||||
series[series.length - 1] = { ...series[series.length - 1], runs: runsToday };
|
||||
|
||||
+21
-1
@@ -814,6 +814,12 @@ export interface TicketFilter {
|
||||
assignee?: string;
|
||||
status?: TicketStatus;
|
||||
channel?: TicketChannel;
|
||||
/** Typ ticketu. `none` = tickety bez typu. */
|
||||
typeId?: string;
|
||||
/** Jeden tag. `none` = tickety bez tagu. */
|
||||
tag?: string;
|
||||
/** Skupina resitelu. `none` = bez skupiny. */
|
||||
groupId?: string;
|
||||
}
|
||||
|
||||
export function listTickets(filter: TicketFilter): Ticket[] {
|
||||
@@ -821,6 +827,20 @@ export function listTickets(filter: TicketFilter): Ticket[] {
|
||||
if (!filter.tenantIds.includes(ticket.tenantId)) return false;
|
||||
if (filter.status && ticket.status !== filter.status) return false;
|
||||
if (filter.channel && ticket.channel !== filter.channel) return false;
|
||||
if (filter.typeId === 'none' ? ticket.typeId : filter.typeId && ticket.typeId !== filter.typeId)
|
||||
return false;
|
||||
if (filter.tag === 'none') {
|
||||
if ((ticket.tags ?? []).length > 0) return false;
|
||||
} else if (filter.tag && !(ticket.tags ?? []).includes(filter.tag)) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
filter.groupId === 'none'
|
||||
? ticket.assigneeGroupId
|
||||
: filter.groupId && ticket.assigneeGroupId !== filter.groupId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (filter.assignee === 'unassigned') return ticket.assigneeId === null;
|
||||
if (filter.assignee && ticket.assigneeId !== filter.assignee) return false;
|
||||
return true;
|
||||
@@ -851,7 +871,7 @@ export function getTicket(id: string, tenantIds: string[]): TicketDetail | undef
|
||||
};
|
||||
}
|
||||
|
||||
/** Prvni nevyrizeny ticket - pouziva simulace, kdyz uzivatel neurci ktery. */
|
||||
/** Prvni nevyrizeny ticket. */
|
||||
export function firstOpenTicket(tenantIds: string[]): Ticket | undefined {
|
||||
const stored = tickets.find(
|
||||
(t) => t.status !== 'resolved' && tenantIds.includes(t.tenantId),
|
||||
|
||||
+42
-1
@@ -6,6 +6,8 @@
|
||||
* i novou komponentu na webu, proto jich je zamerne malo a jsou obecne.
|
||||
*/
|
||||
|
||||
import { listCustomWidgets, sizesFor, type WidgetRender, type WidgetSource } from './customWidgets.js';
|
||||
|
||||
export type WidgetSize = 'third' | 'half' | 'full';
|
||||
|
||||
export type WidgetKind =
|
||||
@@ -15,7 +17,9 @@ export type WidgetKind =
|
||||
| 'incidentList'
|
||||
| 'workload'
|
||||
/** Vykon resitelu. Data chodi z `/widget-data`, stejne jako u vlastnich. */
|
||||
| 'agents';
|
||||
| 'agents'
|
||||
/** Vlastni widget: kresleni si urcuje jeho `render`. */
|
||||
| WidgetRender;
|
||||
|
||||
export interface WidgetDefinition {
|
||||
id: string;
|
||||
@@ -144,3 +148,40 @@ export const builtinSources: Record<string, { kind: 'agentStats'; period: '30d'
|
||||
export function findWidget(id: string): WidgetDefinition | undefined {
|
||||
return widgets.find((widget) => widget.id === id);
|
||||
}
|
||||
|
||||
/** Polozka katalogu tak, jak ji vidi klient. Vlastni widget nese i zdroj dat. */
|
||||
export interface CatalogWidget extends WidgetDefinition {
|
||||
/** true = widget si firma nadefinovala sama. */
|
||||
custom: boolean;
|
||||
render?: WidgetRender;
|
||||
source?: WidgetSource;
|
||||
target?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cely katalog: pevne widgety plus vlastni te firmy.
|
||||
*
|
||||
* **Jedina definice toho, co jde polozit na dashboard.** Pouziva ji nabidka
|
||||
* widgetu i kontrola ukladaneho rozlozeni. Kdyz to byly dva seznamy, kontrola
|
||||
* znala jen pevne widgety a ulozit rozlozeni s vlastnim widgetem vzdycky
|
||||
* selhalo hlaskou, ze v katalogu neni.
|
||||
*/
|
||||
export function widgetCatalog(tenantIds: string[], userId: string): CatalogWidget[] {
|
||||
const custom = listCustomWidgets(tenantIds, userId).map(
|
||||
(widget): CatalogWidget => ({
|
||||
id: widget.id,
|
||||
name: widget.name,
|
||||
description: widget.description,
|
||||
// Klient kresli podle `kind`, u vlastniho widgetu je to jeho `render`.
|
||||
kind: widget.render,
|
||||
sizes: sizesFor(widget.render),
|
||||
defaultSize: widget.size,
|
||||
custom: true,
|
||||
render: widget.render,
|
||||
source: widget.source,
|
||||
...(widget.target !== undefined ? { target: widget.target } : {}),
|
||||
}),
|
||||
);
|
||||
|
||||
return [...widgets.map((widget): CatalogWidget => ({ ...widget, custom: false })), ...custom];
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import { buildOpenApiDocument } from './openapi.js';
|
||||
import { authRouter } from './routes/auth.js';
|
||||
import { contactRouter } from './routes/contact.js';
|
||||
import { dashboardRouter } from './routes/dashboard.js';
|
||||
import { simulateRouter } from './routes/simulate.js';
|
||||
import { webhookRouter } from './routes/webhook.js';
|
||||
import { bootstrapData } from './data/bootstrap.js';
|
||||
import { flushConnectorStore, initConnectorStore, storageStatus } from './data/connectorStore.js';
|
||||
@@ -114,7 +113,6 @@ api.use(
|
||||
api.use('/api/auth', authRouter);
|
||||
api.use('/api/dashboard', dashboardRouter);
|
||||
api.use('/api/admin', adminRouter);
|
||||
api.use('/api/simulate', simulateRouter);
|
||||
api.use('/api/contact', contactRouter);
|
||||
api.use('/webhook', webhookRouter);
|
||||
|
||||
|
||||
@@ -136,7 +136,6 @@ export function buildOpenApiDocument() {
|
||||
{ name: 'Skripty', description: 'Vykonna cast sluzby: manifest, kod a zkusebni beh' },
|
||||
{ name: 'Nastaveni', description: 'Firmy, lide, role a prava, typy ticketu, akce, widgety' },
|
||||
{ name: 'Sprava platformy', description: 'Audit a prepnuti na jiny ucet' },
|
||||
{ name: 'Simulace', description: 'Vyvolani provoznich udalosti pro nahled' },
|
||||
{ name: 'Webhook', description: 'Verejny prijem dat do automatizace' },
|
||||
{ name: 'Kontakt', description: 'Poptavkovy formular z webu' },
|
||||
],
|
||||
@@ -1969,98 +1968,6 @@ export function buildOpenApiDocument() {
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/simulate': {
|
||||
post: {
|
||||
tags: ['Simulace'],
|
||||
summary: 'Vyvolat provozni udalost',
|
||||
description:
|
||||
'Meni skutecna data, aby bylo videt, jak dashboard reaguje zive. ' +
|
||||
'Nevyplnena pole server doplni ukazkovou hodnotou.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['action'],
|
||||
properties: {
|
||||
action: {
|
||||
type: 'string',
|
||||
enum: [
|
||||
'ticket.created',
|
||||
'ticket.resolved',
|
||||
'incident.started',
|
||||
'incident.resolved',
|
||||
'automation.run',
|
||||
],
|
||||
},
|
||||
subject: { type: 'string' },
|
||||
body: { type: 'string', description: 'Cely text pozadavku.' },
|
||||
channel: {
|
||||
type: 'string',
|
||||
enum: ['whatsapp', 'facebook', 'instagram', 'email', 'voice', 'form', 'portal'],
|
||||
description: 'Odkud pozadavek prisel. Podle toho se poskladá i log ticketu.',
|
||||
},
|
||||
contact: { type: 'string' },
|
||||
knownCustomer: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'false = CRM firmu nedohleda, ticket zustane bez zakaznika i bez resitele.',
|
||||
},
|
||||
priority: { type: 'string', enum: ['low', 'normal', 'high', 'critical'] },
|
||||
ticketId: { type: 'string' },
|
||||
title: { type: 'string' },
|
||||
service: { type: 'string' },
|
||||
severity: { type: 'string', enum: ['sev1', 'sev2', 'sev3'] },
|
||||
incidentId: { type: 'string' },
|
||||
automationId: { type: 'string' },
|
||||
ok: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
'200': { description: 'Udalost provedena' },
|
||||
'201': { description: 'Zaznam vytvoren' },
|
||||
'409': { description: 'Neni co provest' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/webhook/{token}': {
|
||||
post: {
|
||||
tags: ['Webhook'],
|
||||
summary: 'Prijem dat do automatizace',
|
||||
description:
|
||||
'Verejny endpoint bez prihlaseni. Autorizuje neuhodnutelny token v adrese. ' +
|
||||
'Telo se overuje proti parametrum deklarovanym u spoustece.',
|
||||
parameters: [
|
||||
{
|
||||
name: 'token',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: { type: 'string' },
|
||||
description: '32znakovy token vygenerovany serverem.',
|
||||
},
|
||||
],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: { type: 'object', additionalProperties: true },
|
||||
example: { customer: 'Nordis', score: 18, comment: 'vse ok' },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
'202': { description: 'Prijato' },
|
||||
'400': { description: 'Chybi parametr nebo nesedi typ' },
|
||||
'404': { description: 'Neznamy token' },
|
||||
'409': { description: 'Automatizace je pozastavena' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/contact': {
|
||||
post: {
|
||||
tags: ['Kontakt'],
|
||||
|
||||
+21
-19
@@ -39,8 +39,7 @@ import {
|
||||
validateLayout,
|
||||
} from '../data/dashboardLayouts.js';
|
||||
import { collectScopes } from '../data/flowScope.js';
|
||||
import { widgets } from '../data/widgets.js';
|
||||
import { listCustomWidgets, sizesFor } from '../data/customWidgets.js';
|
||||
import { widgetCatalog } from '../data/widgets.js';
|
||||
import { listIncidents } from '../data/incidentStore.js';
|
||||
import { getSummary } from '../data/mock.js';
|
||||
import { findPersonByEmail, listGroups, listPeople } from '../data/people.js';
|
||||
@@ -125,23 +124,15 @@ dashboardRouter.get('/incidents', (_req, res) => {
|
||||
* Diky tomu se rozlozeni dashboardu nemuselo menit.
|
||||
*/
|
||||
dashboardRouter.get('/widgets', (req, res) => {
|
||||
const access = accessFor(req.user!);
|
||||
const tenantIds = access.tenants.map((tenant) => tenant.id);
|
||||
/*
|
||||
* Katalog je za konkretni firmu, stejne jako rozlozeni. Kdyby vracel widgety
|
||||
* vsech firem uzivatele, sla by polozit dlazdice Automie na dashboard
|
||||
* Nordisu - v nabidce by byla, ale data by k ni nikdy neprisla.
|
||||
*/
|
||||
const tenantId = layoutTenant(req, res);
|
||||
if (!tenantId) return;
|
||||
|
||||
const custom = listCustomWidgets(tenantIds, req.user!.id).map((widget) => ({
|
||||
id: widget.id,
|
||||
name: widget.name,
|
||||
description: widget.description,
|
||||
kind: widget.render,
|
||||
sizes: sizesFor(widget.render),
|
||||
defaultSize: widget.size,
|
||||
custom: true,
|
||||
render: widget.render,
|
||||
source: widget.source,
|
||||
...(widget.target !== undefined ? { target: widget.target } : {}),
|
||||
}));
|
||||
|
||||
res.json({ items: [...widgets.map((widget) => ({ ...widget, custom: false })), ...custom] });
|
||||
return res.json({ items: widgetCatalog([tenantId], req.user!.id) });
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -198,7 +189,10 @@ dashboardRouter.put('/layout', (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
const problems = validateLayout(parsed.data.items);
|
||||
const problems = validateLayout(
|
||||
parsed.data.items,
|
||||
widgetCatalog([tenantId], req.user!.id),
|
||||
);
|
||||
if (problems.length > 0) {
|
||||
console.warn(`[layout] ${req.user!.email}: neplatne rozlozeni - ${problems.join(' ')}`);
|
||||
return res.status(400).json({ error: 'validation_error', message: problems[0] });
|
||||
@@ -384,6 +378,14 @@ const ticketChannels: TicketChannel[] = [
|
||||
function ticketFilterFrom(query: Record<string, unknown>, scope: ResolvedScope): TicketFilter {
|
||||
const filter: TicketFilter = { tenantIds: scope.tenantIds };
|
||||
|
||||
// Filtry, na ktere se odkazuje z widgetu. `none` znamena "bez toho".
|
||||
const typeId = query.typeId;
|
||||
if (typeof typeId === 'string' && typeId !== '') filter.typeId = typeId;
|
||||
const tag = query.tag;
|
||||
if (typeof tag === 'string' && tag !== '') filter.tag = tag;
|
||||
const groupId = query.groupId;
|
||||
if (typeof groupId === 'string' && groupId !== '') filter.groupId = groupId;
|
||||
|
||||
// Pohled "moje" je silnejsi nez rucni filtr na resitele.
|
||||
if (scope.scope === 'mine') {
|
||||
filter.assignee = scope.personId ?? '__nikdo__';
|
||||
|
||||
@@ -1,453 +0,0 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { listAutomations, recordRun } from '../data/automationStore.js';
|
||||
import {
|
||||
createIncident,
|
||||
firstActiveIncident,
|
||||
updateIncidentStatus,
|
||||
} from '../data/incidentStore.js';
|
||||
import {
|
||||
createTicket,
|
||||
firstOpenTicket,
|
||||
updateTicketStatus,
|
||||
type TicketChannel,
|
||||
type TicketPriority,
|
||||
type TraceInput,
|
||||
} from '../data/ticketStore.js';
|
||||
import { accessFor } from '../data/access.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
export const simulateRouter = Router();
|
||||
|
||||
simulateRouter.use(requireAuth);
|
||||
|
||||
/**
|
||||
* Simulace provoznich udalosti pro nahled zivého dashboardu.
|
||||
*
|
||||
* Zamerne MENI skutecna data v ulozisti, ne jen posila falesnou notifikaci.
|
||||
* Diky tomu se zmena projevi i v seznamech a v souhrnu, ne jen v bublinach.
|
||||
*/
|
||||
const schema = z.discriminatedUnion('action', [
|
||||
z.object({
|
||||
action: z.literal('ticket.created'),
|
||||
/** Odkud pozadavek prisel. Podle toho se poskladá i log. */
|
||||
channel: z
|
||||
.enum(['whatsapp', 'facebook', 'instagram', 'email', 'voice', 'form', 'portal'])
|
||||
.optional(),
|
||||
subject: z.string().trim().min(3).optional(),
|
||||
/** Cely text pozadavku. Bez nej se doplni z predmetu a kanalu. */
|
||||
body: z.string().trim().min(3).optional(),
|
||||
contact: z.string().trim().min(2).optional(),
|
||||
priority: z.enum(['low', 'normal', 'high', 'critical']).optional(),
|
||||
/** false = CRM firmu nedohleda, ticket zustane bez zakaznika a bez resitele. */
|
||||
knownCustomer: z.boolean().optional(),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal('ticket.resolved'),
|
||||
ticketId: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal('incident.started'),
|
||||
title: z.string().trim().min(3).optional(),
|
||||
service: z.string().trim().min(2).optional(),
|
||||
severity: z.enum(['sev1', 'sev2', 'sev3']).optional(),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal('incident.resolved'),
|
||||
incidentId: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal('automation.run'),
|
||||
automationId: z.string().optional(),
|
||||
ok: z.boolean().optional(),
|
||||
}),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Ukazkovy prichod pozadavku podle kanalu. Kanal urcuje nejen text,
|
||||
* ale i to, jak vypada zaznam spoustece v logu ticketu.
|
||||
*/
|
||||
const channelPresets: Record<
|
||||
TicketChannel,
|
||||
{
|
||||
serviceId: string;
|
||||
operationId: string;
|
||||
triggerLabel: string;
|
||||
subjects: string[];
|
||||
contact: string;
|
||||
reply: string;
|
||||
/** Odkaz na zdrojovou zpravu u poskytovatele. null tam, kde zadny neni. */
|
||||
sourceRef: string | null;
|
||||
/** Co spoustec vratil. `%s` se nahradi predmetem. */
|
||||
payload: (contact: string, reply: string, subject: string) => string;
|
||||
}
|
||||
> = {
|
||||
whatsapp: {
|
||||
serviceId: 'whatsapp',
|
||||
operationId: 'message-received',
|
||||
triggerLabel: 'Přijata zpráva z WhatsApp',
|
||||
subjects: [
|
||||
'Nechodí ranní report objednávek',
|
||||
'Potřebujeme přidat další číslo na hlášení',
|
||||
'Zákazník si stěžuje na dvojí SMS',
|
||||
],
|
||||
contact: 'Bistro Kolektiv',
|
||||
reply: '+420 774 902 331',
|
||||
sourceRef: 'wamid.HBgLNDIwNzc0OTAyMzMx',
|
||||
payload: (contact, reply, subject) =>
|
||||
`{ "from": "${reply.replace(/\s/g, '')}", "profileName": "${contact}", "text": "${subject}" }`,
|
||||
},
|
||||
facebook: {
|
||||
serviceId: 'facebook',
|
||||
operationId: 'message-received',
|
||||
triggerLabel: 'Přijata zpráva z Facebook Messengeru',
|
||||
subjects: [
|
||||
'Dotaz na otevírací dobu poboček',
|
||||
'Nefunguje objednávkový formulář',
|
||||
'Reklamace poškozené zásilky',
|
||||
],
|
||||
contact: 'Martin Dvořák',
|
||||
reply: 'fb:1029384756',
|
||||
sourceRef: 'mid.simulace.fb',
|
||||
payload: (contact, reply, subject) =>
|
||||
`{ "senderId": "${reply.replace('fb:', '')}", "senderName": "${contact}", "text": "${subject}" }`,
|
||||
},
|
||||
instagram: {
|
||||
serviceId: 'instagram',
|
||||
operationId: 'message-received',
|
||||
triggerLabel: 'Přijata přímá zpráva na Instagramu',
|
||||
subjects: [
|
||||
'Máte to ještě skladem?',
|
||||
'Kdy dorazí objednávka',
|
||||
'Jde změnit adresa doručení',
|
||||
],
|
||||
contact: 'kavarna.uzastavky',
|
||||
reply: 'ig:kavarna.uzastavky',
|
||||
sourceRef: 'ig.simulace',
|
||||
payload: (contact, _reply, subject) =>
|
||||
`{ "username": "${contact}", "text": "${subject}" }`,
|
||||
},
|
||||
email: {
|
||||
serviceId: 'email',
|
||||
operationId: 'received',
|
||||
triggerLabel: 'Přijat e-mail do schránky podpora@',
|
||||
subjects: [
|
||||
'Faktura se nespárovala s platbou',
|
||||
'Žádost o změnu fakturačních údajů',
|
||||
'Export do skladu skončil chybou',
|
||||
],
|
||||
contact: 'Tomáš Beran',
|
||||
reply: 'tomas.beran@nordis.cz',
|
||||
sourceRef: '<sim-4f8a@nordis.cz>',
|
||||
payload: (_contact, reply, subject) =>
|
||||
`{ "from": "${reply}", "subject": "${subject}", "attachments": 1 }`,
|
||||
},
|
||||
voice: {
|
||||
serviceId: 'voicebot',
|
||||
operationId: 'call-received',
|
||||
triggerLabel: 'Příchozí hovor na linku 800 100 200',
|
||||
subjects: [
|
||||
'Voicebot nerozumí názvu ulice',
|
||||
'Hovory se po 18:00 nepřepojují',
|
||||
'Zákazník žádá zpětné volání',
|
||||
],
|
||||
contact: 'Petra Klientová',
|
||||
reply: '+420 601 118 224',
|
||||
sourceRef: 'cl_simulace',
|
||||
payload: (_contact, reply, _subject) =>
|
||||
`{ "callId": "cl_${Math.floor(Math.random() * 90_000) + 10_000}", "from": "${reply.replace(/\s/g, '')}", "durationSec": 74 }`,
|
||||
},
|
||||
form: {
|
||||
serviceId: 'form',
|
||||
operationId: 'submitted',
|
||||
triggerLabel: 'Odeslán formulář Požadavek na úpravu',
|
||||
subjects: [
|
||||
'Rozšíření hlasového scénáře',
|
||||
'Napojení dalšího e-shopu',
|
||||
'Nový report pro management',
|
||||
],
|
||||
contact: 'Jana Sedláčková',
|
||||
reply: 'jana.sedlackova@logitrans.cz',
|
||||
sourceRef: null,
|
||||
payload: (contact, _reply, subject) =>
|
||||
`{ "company": "LogiTrans", "contact": "${contact}", "topic": "${subject}" }`,
|
||||
},
|
||||
portal: {
|
||||
serviceId: 'ticket',
|
||||
operationId: 'created',
|
||||
triggerLabel: 'Založeno ručně z portálu',
|
||||
subjects: [
|
||||
'Interní kontrola nastavení zálohování',
|
||||
'Revize oprávnění v CRM',
|
||||
'Aktualizace hlasového scénáře',
|
||||
],
|
||||
contact: 'Interní požadavek',
|
||||
reply: 'portal@automia.cz',
|
||||
sourceRef: null,
|
||||
payload: (contact, _reply, subject) => `{ "createdBy": "${contact}", "subject": "${subject}" }`,
|
||||
},
|
||||
};
|
||||
|
||||
/** Firmy, ktere CRM zna. Simulace z nich bere zakaznika, kdyz ma byt dohledany. */
|
||||
const knownCompanies = [
|
||||
{ id: 'crm_1042', company: 'Firma s.r.o.' },
|
||||
{ id: 'crm_2210', company: 'Nordis a.s.' },
|
||||
{ id: 'crm_3390', company: 'LogiTrans' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Resitel podle kanalu. Zamerne primitivni pravidlo - jde o to ukazat,
|
||||
* ze ticket nekonci na hromade, ale u konkretniho cloveka.
|
||||
*/
|
||||
const routingRule: Record<TicketChannel, { id: string; name: string; why: string }> = {
|
||||
voice: { id: 'ppl_novakova', name: 'Eva Nováková', why: 'voice -> voiceboti' },
|
||||
email: { id: 'ppl_kriz', name: 'Martin Kříž', why: 'email -> integrace' },
|
||||
whatsapp: { id: 'ppl_vomacka', name: 'Karel Vomáčka', why: 'whatsapp -> servicedesk' },
|
||||
facebook: { id: 'ppl_vomacka', name: 'Karel Vomáčka', why: 'facebook -> servicedesk' },
|
||||
instagram: { id: 'ppl_vomacka', name: 'Karel Vomáčka', why: 'instagram -> servicedesk' },
|
||||
form: { id: 'ppl_vomacka', name: 'Karel Vomáčka', why: 'form -> servicedesk' },
|
||||
portal: { id: 'ppl_uhlir', name: 'Jiří Uhlíř', why: 'portal -> vedoucí týmu' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Log prichodu pozadavku. Presne tohle chce clovek videt, kdyz se pta
|
||||
* "proc ten ticket vypada takhle a co na to rekly jednotlive sluzby".
|
||||
*/
|
||||
function buildIntakeTrace(input: {
|
||||
channel: TicketChannel;
|
||||
subject: string;
|
||||
contact: string;
|
||||
reply: string;
|
||||
priority: TicketPriority;
|
||||
known: (typeof knownCompanies)[number] | null;
|
||||
}): TraceInput[] {
|
||||
const preset = channelPresets[input.channel];
|
||||
const routing = routingRule[input.channel];
|
||||
|
||||
const trace: TraceInput[] = [
|
||||
{
|
||||
kind: 'trigger',
|
||||
serviceId: preset.serviceId,
|
||||
operationId: preset.operationId,
|
||||
label: preset.triggerLabel,
|
||||
status: 'ok',
|
||||
response: preset.payload(input.contact, input.reply, input.subject),
|
||||
durationMs: 60,
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
serviceId: 'ai-text',
|
||||
operationId: 'classify',
|
||||
label: 'Zařazení do kategorie',
|
||||
status: 'ok',
|
||||
response: `{ "category": "pozadavek", "priority": "${input.priority}", "confidence": 0.83 }`,
|
||||
durationMs: 880,
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
serviceId: 'raynet',
|
||||
operationId: 'upsert-contact',
|
||||
label: 'Dohledání firmy v CRM',
|
||||
status: input.known ? 'ok' : 'error',
|
||||
response: input.known
|
||||
? `{ "companyId": "${input.known.id}", "name": "${input.known.company}" }`
|
||||
: `{ "matches": 0, "searchedBy": "reply", "value": "${input.reply}" }`,
|
||||
durationMs: 590,
|
||||
},
|
||||
];
|
||||
|
||||
if (input.known) {
|
||||
trace.push({
|
||||
kind: 'condition',
|
||||
label: 'knownCustomer je splněno',
|
||||
status: 'ok',
|
||||
response: 'true, pokračuje větev ANO',
|
||||
children: [
|
||||
{
|
||||
kind: 'action',
|
||||
serviceId: 'ticket',
|
||||
operationId: 'create',
|
||||
label: 'Založení ticketu',
|
||||
status: 'ok',
|
||||
response: `{ "customerId": "${input.known.id}", "priority": "${input.priority}" }`,
|
||||
durationMs: 70,
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
serviceId: 'ticket',
|
||||
operationId: 'assign',
|
||||
label: 'Přiřazení řešitele podle kanálu',
|
||||
status: 'ok',
|
||||
response: `{ "assignee": "${routing.name}", "rule": "${routing.why}" }`,
|
||||
durationMs: 35,
|
||||
},
|
||||
],
|
||||
});
|
||||
return trace;
|
||||
}
|
||||
|
||||
trace.push({
|
||||
kind: 'condition',
|
||||
label: 'knownCustomer není splněno',
|
||||
status: 'skipped',
|
||||
response: 'false, pokračuje větev NE',
|
||||
children: [
|
||||
{
|
||||
kind: 'action',
|
||||
serviceId: 'ticket',
|
||||
operationId: 'create',
|
||||
label: 'Založení ticketu bez napojení na firmu',
|
||||
status: 'ok',
|
||||
response: `{ "customerId": null, "priority": "${input.priority}" }`,
|
||||
durationMs: 68,
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
serviceId: 'raynet',
|
||||
operationId: 'create-lead',
|
||||
label: 'Založení obchodního případu k dohledání',
|
||||
status: 'ok',
|
||||
response: `{ "leadId": "lead_${Math.floor(Math.random() * 9_000) + 1_000}", "stage": "k overeni" }`,
|
||||
durationMs: 910,
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
serviceId: 'ticket',
|
||||
operationId: 'assign',
|
||||
label: 'Přiřazení řešitele',
|
||||
status: 'skipped',
|
||||
response: 'Přeskočeno, bez známé firmy nelze určit garanta a ticket zůstal ve frontě',
|
||||
},
|
||||
],
|
||||
});
|
||||
return trace;
|
||||
}
|
||||
|
||||
const defaultIncidents = [
|
||||
{ title: 'Výpadek spojení s fakturačním API', service: 'Integrace / iDoklad' },
|
||||
{ title: 'Zpoždění doručování webhooků', service: 'Webhook Router' },
|
||||
{ title: 'Hlasová brána odmítá hovory', service: 'Voicebot Gateway' },
|
||||
];
|
||||
|
||||
/** Nahodny prvek - jen pro rozmanitost ukazkovych dat. */
|
||||
function pick<T>(items: T[]): T {
|
||||
return items[Math.floor(Math.random() * items.length)];
|
||||
}
|
||||
|
||||
simulateRouter.post('/', (req, res) => {
|
||||
const parsed = schema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: parsed.error.issues[0]?.message ?? 'Neplatný vstup simulace.',
|
||||
});
|
||||
}
|
||||
|
||||
// Simulace pisi do dat, takze musi vedet, ci. Bez firmy nema kam.
|
||||
const access = accessFor(req.user!);
|
||||
const tenantId = access.defaultTenantId;
|
||||
if (!tenantId) {
|
||||
return res.status(403).json({
|
||||
error: 'no_tenant',
|
||||
message: 'Účet nepatří do žádné firmy, simulace nemá kam zapsat.',
|
||||
});
|
||||
}
|
||||
const tenantIds = access.tenants.map((tenant) => tenant.id);
|
||||
|
||||
const input = parsed.data;
|
||||
console.info(`[simulace] ${input.action} spustil ${req.user?.email} pro ${tenantId}`);
|
||||
|
||||
switch (input.action) {
|
||||
case 'ticket.created': {
|
||||
const channel = input.channel ?? 'whatsapp';
|
||||
const preset = channelPresets[channel];
|
||||
const priority = input.priority ?? 'normal';
|
||||
const subject = input.subject ?? pick(preset.subjects);
|
||||
const contact = input.contact ?? preset.contact;
|
||||
// Vychozi chovani je "zakaznika zname" - nezname je zajimavejsi, ale vzacnejsi.
|
||||
const known = (input.knownCustomer ?? true) ? pick(knownCompanies) : null;
|
||||
|
||||
const ticket = createTicket({
|
||||
tenantId,
|
||||
subject,
|
||||
// Kanal posila cely text, predmet je jen jeho zkraceni.
|
||||
body: input.body ?? `${subject}. Poslal ${contact} přes ${preset.triggerLabel}.`,
|
||||
sourceRef: preset.sourceRef,
|
||||
channel,
|
||||
priority,
|
||||
customer: {
|
||||
id: known?.id ?? null,
|
||||
company: known?.company ?? 'Neznámá firma',
|
||||
contact,
|
||||
reply: preset.reply,
|
||||
},
|
||||
// Bez znameho zakaznika neni podle ceho resitele urcit, ticket zustane ve fronte.
|
||||
assigneeId: known ? routingRule[channel].id : null,
|
||||
trace: buildIntakeTrace({ channel, subject, contact, reply: preset.reply, priority, known }),
|
||||
});
|
||||
return res.status(201).json({ ok: true, ticket });
|
||||
}
|
||||
|
||||
case 'ticket.resolved': {
|
||||
const target = input.ticketId ? { id: input.ticketId } : firstOpenTicket(tenantIds);
|
||||
if (!target) {
|
||||
return res.status(409).json({
|
||||
error: 'nothing_to_resolve',
|
||||
message: 'Není co vyřešit, všechny tickety jsou hotové.',
|
||||
});
|
||||
}
|
||||
const ticket = updateTicketStatus(target.id, 'resolved', tenantIds);
|
||||
if (!ticket) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
|
||||
}
|
||||
return res.json({ ok: true, ticket });
|
||||
}
|
||||
|
||||
case 'incident.started': {
|
||||
const preset = pick(defaultIncidents);
|
||||
const incident = createIncident({
|
||||
title: input.title ?? preset.title,
|
||||
service: input.service ?? preset.service,
|
||||
severity: input.severity ?? 'sev2',
|
||||
});
|
||||
return res.status(201).json({ ok: true, incident });
|
||||
}
|
||||
|
||||
case 'incident.resolved': {
|
||||
const target = input.incidentId ? { id: input.incidentId } : firstActiveIncident();
|
||||
if (!target) {
|
||||
return res.status(409).json({
|
||||
error: 'nothing_to_resolve',
|
||||
message: 'Není co vyřešit, žádný incident neběží.',
|
||||
});
|
||||
}
|
||||
const incident = updateIncidentStatus(target.id, 'resolved');
|
||||
if (!incident) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Incident neexistuje.' });
|
||||
}
|
||||
return res.json({ ok: true, incident });
|
||||
}
|
||||
|
||||
case 'automation.run': {
|
||||
const automations = listAutomations(tenantIds).filter((a) => a.enabled);
|
||||
const targetId = input.automationId ?? automations[0]?.id;
|
||||
if (!targetId) {
|
||||
return res.status(409).json({
|
||||
error: 'no_automation',
|
||||
message: 'Není co spustit, žádná automatizace není aktivní.',
|
||||
});
|
||||
}
|
||||
const automation = recordRun(targetId, input.ok ?? true, tenantIds);
|
||||
if (!automation) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Automatizace neexistuje.' });
|
||||
}
|
||||
return res.json({ ok: true, automation });
|
||||
}
|
||||
|
||||
default: {
|
||||
// Vetve jsou vycerpane, tohle je jen pojistka pri rozsireni schematu.
|
||||
console.error('[simulace] neosetrena akce:', input);
|
||||
return res.status(400).json({ error: 'unknown_action', message: 'Neznámá akce.' });
|
||||
}
|
||||
}
|
||||
});
|
||||
+38
-10
@@ -37,6 +37,7 @@ import {
|
||||
import { findTicketType, validateTicketFields } from '../data/ticketTypes.js';
|
||||
import { runScript } from '../scripts/runner.js';
|
||||
import { scriptIdFor } from '../scripts/lookup.js';
|
||||
import { runFlow } from '../runtime/executor.js';
|
||||
|
||||
export const ticketActionsRouter = Router();
|
||||
|
||||
@@ -235,9 +236,9 @@ interface ActionResult {
|
||||
/**
|
||||
* Vykonani tela akce.
|
||||
*
|
||||
* Zatim synchronne v requestu. Zamerne: rucni akce nemusi cekat na frontu
|
||||
* a `runScript` je uz hotovy vstupni bod na kroku. Az bude runtime, zavola
|
||||
* se odsud totez z workeru.
|
||||
* Synchronne v requestu. Zamerne: rucni akci clovek odklikl a ceka na vysledek,
|
||||
* fronta by mu ho jen oddalila. Az bude runtime na vic strojich, zavola se
|
||||
* odsud totez z workeru - `runFlow` je stejny vstupni bod.
|
||||
*/
|
||||
async function runAction(
|
||||
action: TicketAction,
|
||||
@@ -250,14 +251,41 @@ async function runAction(
|
||||
const body = action.body;
|
||||
|
||||
if (body.kind === 'tree') {
|
||||
// Strom akce potrebuje runtime, ktery zatim neexistuje. Rekne se to nahlas,
|
||||
// misto aby se tvarilo, ze se neco stalo.
|
||||
if (body.steps.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
summary: 'akce nemá žádné kroky',
|
||||
detail: 'Strom je prázdný. Doplňte kroky v definici akce.',
|
||||
durationMs: Date.now() - startedAt,
|
||||
outputs: {},
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* Kontext stromu: udaje ticketu a hodnoty z doptavaciho dialogu. Na obojí
|
||||
* jde v krocích odkazovat pres `{{jmeno}}`, stejne jako u jedne operace.
|
||||
*/
|
||||
const context: Record<string, unknown> = { ...facts, ...form };
|
||||
const run = await runFlow(body.steps, context, {
|
||||
tenantId: ticket.tenantId,
|
||||
ticketId: ticket.id,
|
||||
idempotencyKey: `ticket:${ticket.id}:${action.id}`,
|
||||
});
|
||||
|
||||
if (!run.ok) {
|
||||
console.warn(`[actions] ${action.id} na ${ticket.id} od ${actorEmail}: ${run.error}`);
|
||||
}
|
||||
|
||||
const failed = run.steps.find((step) => !step.ok);
|
||||
return {
|
||||
ok: false,
|
||||
summary: 'strom akce zatím nejde vykonat, chybí runtime',
|
||||
detail: 'Akce s vlastním stromem půjde spustit až s runtime automatizací.',
|
||||
durationMs: Date.now() - startedAt,
|
||||
outputs: {},
|
||||
ok: run.ok,
|
||||
summary: run.ok
|
||||
? `proběhlo, ${run.steps.length} ${run.steps.length === 1 ? 'krok' : 'kroků'}`
|
||||
: (run.error ?? 'selhalo'),
|
||||
// Cele hlaseni toho kroku, ktery to zastavil. Zbytek je v logu ticketu.
|
||||
detail: failed?.detail ?? null,
|
||||
durationMs: run.durationMs,
|
||||
outputs: run.context as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+50
-8
@@ -3,6 +3,7 @@ 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 { runFlow } from '../runtime/executor.js';
|
||||
import { intakeEvent } from '../data/ticketStore.js';
|
||||
import { findTicketType, listTicketTypes } from '../data/ticketTypes.js';
|
||||
import { publish } from '../events/bus.js';
|
||||
@@ -191,18 +192,59 @@ webhookRouter.post('/:token', (req, res) => {
|
||||
automationId: automation.id,
|
||||
fields: Object.keys(payload),
|
||||
});
|
||||
recordRun(automation.id);
|
||||
console.info(
|
||||
`[webhook] ${automation.id}: prijato (${Object.keys(payload).join(', ') || 'bez dat'})`,
|
||||
);
|
||||
|
||||
// PROTOTYP: strom se nevykonava, jen potvrdime prijem.
|
||||
// Runtime je popsany v docs/08-automatizace-builder.md.
|
||||
return res.status(202).json({
|
||||
accepted: true,
|
||||
automationId: automation.id,
|
||||
message: 'Požadavek přijat. Prototyp strom akcí nevykonává.',
|
||||
});
|
||||
if (automation.flow.steps.length === 0) {
|
||||
recordRun(automation.id);
|
||||
return res.status(202).json({
|
||||
accepted: true,
|
||||
automationId: automation.id,
|
||||
message: 'Přijato. Automatizace nemá žádné kroky.',
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* Strom se vykona hned v requestu. Odesilatel tim dostane skutecny vysledek
|
||||
* misto "prijato" - u desitek kroku je to v poradku, u tisicu udalosti za
|
||||
* minutu to patri do fronty, viz documentation/10-runtime-a-kapacita.md.
|
||||
*/
|
||||
return void runFlow(automation.flow.steps, payload, {
|
||||
tenantId: automation.tenantId,
|
||||
ticketId: null,
|
||||
idempotencyKey: `webhook:${automation.id}:${token.slice(0, 8)}`,
|
||||
})
|
||||
.then((run) => {
|
||||
recordRun(automation.id, run.ok);
|
||||
const failed = run.steps.find((step) => !step.ok);
|
||||
|
||||
if (!run.ok) {
|
||||
console.warn(`[webhook] ${automation.id}: beh selhal - ${run.error}`);
|
||||
}
|
||||
|
||||
// Chyba stromu neni chyba prijmu. Data jsme prevzali, jen se nepovedlo
|
||||
// je zpracovat - proto 200 s `ok: false`, ne 500.
|
||||
return res.status(200).json({
|
||||
accepted: true,
|
||||
automationId: automation.id,
|
||||
ok: run.ok,
|
||||
steps: run.steps.length,
|
||||
durationMs: run.durationMs,
|
||||
error: run.error,
|
||||
// Cele hlaseni toho kroku, ktery to zastavil.
|
||||
detail: failed?.detail ?? null,
|
||||
});
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
// `runFlow` chyby nevyhazuje, tohle je posledni pojistka.
|
||||
console.error(`[webhook] ${automation.id}: neocekavana chyba behu:`, err);
|
||||
recordRun(automation.id, false);
|
||||
return res.status(500).json({
|
||||
error: 'internal_error',
|
||||
message: err instanceof Error ? err.message : 'Běh selhal.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/** Overi prichozi data proti deklarovanym parametrum spoustece. */
|
||||
|
||||
@@ -55,13 +55,30 @@ const requestSchema = z.object({
|
||||
widgetIds: z.array(z.string().min(1)).max(24),
|
||||
});
|
||||
|
||||
/** Jedna hodnota, nebo seskupeny vysledek. Klient podle `render` vi, co s tim. */
|
||||
/**
|
||||
* Jedna hodnota, nebo seskupeny vysledek. Klient podle `render` vi, co s tim.
|
||||
*
|
||||
* U cisel a radku chodi `href`: kam se proklikne ten, kdo chce videt, co je
|
||||
* za tim. Sklada ho **server**, protoze on jediny zna filtr widgetu - klient
|
||||
* by si musel odvozovat, co ktery zdroj znamena, a jednou by se to rozeslo.
|
||||
*/
|
||||
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: string; assignee: string | null }> }
|
||||
| { kind: 'workload'; rows: Array<{ name: string; open: number; overCapacity: boolean }> }
|
||||
| { kind: 'number'; value: number; href?: string }
|
||||
| {
|
||||
kind: 'groups';
|
||||
rows: Array<{ key: string; label: string; value: number; href?: string }>;
|
||||
}
|
||||
| { kind: 'series'; points: Array<{ date: string; value: number }>; href?: string }
|
||||
| {
|
||||
kind: 'tickets';
|
||||
items: Array<{ id: string; subject: string; status: string; assignee: string | null }>;
|
||||
href?: string;
|
||||
}
|
||||
| {
|
||||
kind: 'workload';
|
||||
rows: Array<{ personId: string; name: string; open: number; overCapacity: boolean; href: string }>;
|
||||
href?: string;
|
||||
}
|
||||
| { kind: 'agents'; rows: AgentStatsRow[] }
|
||||
/** Vysledek z konektoru. `stale` rika, ze je to z cache, ne cerstve zavolane. */
|
||||
| {
|
||||
@@ -303,6 +320,48 @@ function toExternal(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Adresa seznamu ticketu s dosazenym filtrem.
|
||||
*
|
||||
* Klic seskupeni se prevadi na parametr, ktery seznam umi. Prazdny klic
|
||||
* znamena "bez toho", proto `none` - prazdna hodnota v adrese by se cetla
|
||||
* jako "nefiltrovat".
|
||||
*/
|
||||
function ticketsHref(
|
||||
filter: WidgetTicketFilter,
|
||||
scope: ResolvedScope,
|
||||
extra?: { groupBy?: WidgetGroupBy; key?: string },
|
||||
): string {
|
||||
const params = new URLSearchParams();
|
||||
const tenantId = scope.tenantIds.length === 1 ? scope.tenantIds[0] : null;
|
||||
if (tenantId) params.set('tenantId', tenantId);
|
||||
|
||||
if (filter.status?.length === 1) params.set('status', filter.status[0]);
|
||||
if (filter.channel?.length === 1) params.set('channel', filter.channel[0]);
|
||||
if (filter.typeIds?.length === 1) params.set('typeId', filter.typeIds[0]);
|
||||
if (filter.tags?.length === 1) params.set('tag', filter.tags[0]);
|
||||
if (filter.assignee?.length === 1) params.set('assignee', filter.assignee[0]);
|
||||
if (filter.groupIds?.length === 1) params.set('groupId', filter.groupIds[0]);
|
||||
|
||||
if (extra?.groupBy) {
|
||||
const key = extra.key === '' || extra.key === undefined ? 'none' : extra.key;
|
||||
const byGroup: Record<WidgetGroupBy, string> = {
|
||||
assignee: 'assignee',
|
||||
group: 'groupId',
|
||||
status: 'status',
|
||||
type: 'typeId',
|
||||
tag: 'tag',
|
||||
channel: 'channel',
|
||||
};
|
||||
// Prazdna skupina u resitele je fronta, na to ma seznam vlastni hodnotu.
|
||||
if (extra.groupBy === 'assignee' && key === 'none') params.set('assignee', 'unassigned');
|
||||
else params.set(byGroup[extra.groupBy], key);
|
||||
}
|
||||
|
||||
const query = params.toString();
|
||||
return query ? `/dashboard/tickety?${query}` : '/dashboard/tickety';
|
||||
}
|
||||
|
||||
function computeSource(source: WidgetSource, scope: ResolvedScope): WidgetValue {
|
||||
if (source.kind === 'agentStats') {
|
||||
return {
|
||||
@@ -316,10 +375,14 @@ function computeSource(source: WidgetSource, scope: ResolvedScope): WidgetValue
|
||||
return {
|
||||
kind: 'workload',
|
||||
rows: workload.rows.map((row) => ({
|
||||
personId: row.person.id,
|
||||
name: row.person.name,
|
||||
open: row.open,
|
||||
overCapacity: row.overloaded,
|
||||
// Z vytizeni se chce clovek dostat na toho cloveka, ne na cisla.
|
||||
href: `/dashboard/lide/${row.person.id}`,
|
||||
})),
|
||||
href: '/dashboard/lide',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -334,7 +397,13 @@ function computeSource(source: WidgetSource, scope: ResolvedScope): WidgetValue
|
||||
);
|
||||
|
||||
if (source.kind === 'ticketCount') {
|
||||
if (!source.groupBy) return { kind: 'number', value: tickets.length };
|
||||
if (!source.groupBy) {
|
||||
return {
|
||||
kind: 'number',
|
||||
value: tickets.length,
|
||||
href: ticketsHref(source.filter, scope),
|
||||
};
|
||||
}
|
||||
|
||||
const counts = new Map<string, number>();
|
||||
for (const ticket of tickets) {
|
||||
@@ -345,7 +414,12 @@ function computeSource(source: WidgetSource, scope: ResolvedScope): WidgetValue
|
||||
return {
|
||||
kind: 'groups',
|
||||
rows: [...counts.entries()]
|
||||
.map(([key, value]) => ({ key, label: groupLabel(key, source.groupBy!), value }))
|
||||
.map(([key, value]) => ({
|
||||
key,
|
||||
label: groupLabel(key, source.groupBy!),
|
||||
value,
|
||||
href: ticketsHref(source.filter, scope, { groupBy: source.groupBy, key }),
|
||||
}))
|
||||
.sort((a, b) => b.value - a.value),
|
||||
};
|
||||
}
|
||||
@@ -353,6 +427,7 @@ function computeSource(source: WidgetSource, scope: ResolvedScope): WidgetValue
|
||||
if (source.kind === 'ticketList') {
|
||||
return {
|
||||
kind: 'tickets',
|
||||
href: ticketsHref(source.filter, scope),
|
||||
items: tickets
|
||||
.slice(0, source.limit)
|
||||
.map((ticket) => ({
|
||||
@@ -374,7 +449,7 @@ function computeSource(source: WidgetSource, scope: ResolvedScope): WidgetValue
|
||||
value: tickets.filter((ticket) => ticket.createdAt.slice(0, 10) === day).length,
|
||||
});
|
||||
}
|
||||
return { kind: 'series', points };
|
||||
return { kind: 'series', points, href: ticketsHref(source.filter, scope) };
|
||||
}
|
||||
|
||||
/** Zdroj vlastniho widgetu. Vestavene maji svuj v `builtinSources`. */
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* Vykonavani stromu kroku.
|
||||
*
|
||||
* Tohle je ta cast, ktera do ted chybela: strom se dal poskladat a ulozit, ale
|
||||
* nic ho nespustilo. Pouziva se na dvou mistech a je zamerne jedno:
|
||||
* - telo akce na ticketu, kterou spusti clovek tlacitkem,
|
||||
* - automatizace spustena webhookem.
|
||||
*
|
||||
* Co dela:
|
||||
* - jde krok po kroku, u podminky se vetvi,
|
||||
* - do poli kroku dosadi `{{parametry}}` z kontextu,
|
||||
* - akci vykona pres `runScript`, tedy tim samym skriptem jako vsude jinde,
|
||||
* - vystupy kroku pripise do kontextu, aby na ne mohl dalsi krok odkazat,
|
||||
* - kazdy krok zapise do logu vcetne toho, co sluzba vratila.
|
||||
*
|
||||
* Co nedela, a je to zamer:
|
||||
* - **nebezi ve fronte**. Beh je synchronni v ramci jednoho requestu, takze
|
||||
* dlouhy strom drzi spojeni. Na desitky kroku to staci, na statisice udalosti
|
||||
* ne - popis fronty a rozdeleni na worker je v documentation/10-runtime-a-kapacita.md.
|
||||
* - **neopakuje se sam**. Chyba krok zastavi a zapise se cela, misto aby se
|
||||
* tise zkousela znovu.
|
||||
*/
|
||||
|
||||
import { defaultConnectorFor, getConnector, type Connector } from '../data/connectorStore.js';
|
||||
import type { FlowStep } from '../data/automationStore.js';
|
||||
import { isUnary, type ConditionOperator } from '../data/conditions.js';
|
||||
import { renderTemplate } from '../data/templates.js';
|
||||
import { appendTrace, type TraceInput } from '../data/ticketStore.js';
|
||||
import { scriptIdFor } from '../scripts/lookup.js';
|
||||
import { runScript } from '../scripts/runner.js';
|
||||
|
||||
/**
|
||||
* Hodnoty, na ktere jde ve strome odkazovat.
|
||||
*
|
||||
* Klic je jmeno, ktere se pise do sablony jako `{{jmeno}}`. Vystupy kroku se
|
||||
* pridavaji pod `krok.vystup`, takze si dva stejne kroky neprepisou hodnoty.
|
||||
*/
|
||||
export type RunContext = Record<string, unknown>;
|
||||
|
||||
export interface RunOptions {
|
||||
/** Firma, jejimz jmenem se to vola. Urcuje, ktery konektor se pouzije. */
|
||||
tenantId: string;
|
||||
/** Ticket, do jehoz logu se zapisuje. null = beh mimo ticket. */
|
||||
ticketId: string | null;
|
||||
/** Stabilni klic proti dvojimu provedeni. */
|
||||
idempotencyKey: string | null;
|
||||
/** Kolik kroku se nejvys vykona. Pojistka proti nechtene dlouhemu stromu. */
|
||||
maxSteps?: number;
|
||||
}
|
||||
|
||||
export interface StepResult {
|
||||
stepId: string;
|
||||
kind: 'action' | 'condition';
|
||||
label: string;
|
||||
ok: boolean;
|
||||
/** U podminky, kterou vetvi se slo. */
|
||||
branch?: 'yes' | 'no';
|
||||
summary: string;
|
||||
/** Cele hlaseni nebo cela odpoved sluzby. Nikdy se nezkracuje. */
|
||||
detail: string | null;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface RunResult {
|
||||
ok: boolean;
|
||||
/** Kolik kroku se opravdu vykonalo, vcetne podminek. */
|
||||
steps: StepResult[];
|
||||
/** Kontext po behu, tedy i vystupy kroku. */
|
||||
context: RunContext;
|
||||
/** Prvni chyba, ktera beh zastavila. */
|
||||
error: string | null;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
/** Vychozi strop kroku. Vic uz neni strom, ale program. */
|
||||
const MAX_STEPS = 50;
|
||||
|
||||
/**
|
||||
* Vykona strom.
|
||||
*
|
||||
* **Nikdy nevyhodi vyjimku.** Chyba je vysledek, ne pad - stejna dohoda jako
|
||||
* u `runScript`. Volajici tak nemusi resit try/catch a chyba se vzdycky dostane
|
||||
* az k uzivateli i do logu ticketu.
|
||||
*/
|
||||
export async function runFlow(
|
||||
steps: FlowStep[],
|
||||
context: RunContext,
|
||||
options: RunOptions,
|
||||
): Promise<RunResult> {
|
||||
const startedAt = Date.now();
|
||||
const results: StepResult[] = [];
|
||||
const working: RunContext = { ...context };
|
||||
const limit = options.maxSteps ?? MAX_STEPS;
|
||||
|
||||
let error: string | null = null;
|
||||
|
||||
async function walk(list: FlowStep[]): Promise<boolean> {
|
||||
for (const step of list) {
|
||||
if (results.length >= limit) {
|
||||
error = `Strom má víc než ${limit} kroků, běh se zastavil.`;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (step.kind === 'condition') {
|
||||
const branch = evaluate(step, working) ? 'yes' : 'no';
|
||||
results.push({
|
||||
stepId: step.id,
|
||||
kind: 'condition',
|
||||
label: describeCondition(step, working),
|
||||
ok: true,
|
||||
branch,
|
||||
summary: branch === 'yes' ? 'splněno' : 'nesplněno',
|
||||
detail: null,
|
||||
durationMs: 0,
|
||||
});
|
||||
|
||||
// Prochazi se jen jedna vetev, ta druha se nevykona.
|
||||
if (!(await walk(branch === 'yes' ? step.yes : step.no))) return false;
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = await runAction(step, working, options);
|
||||
results.push(result);
|
||||
if (!result.ok) {
|
||||
error = result.summary;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
await walk(steps);
|
||||
|
||||
// Log ticketu dostane cely prubeh naraz, ne po jednom zapisu.
|
||||
if (options.ticketId && results.length > 0) {
|
||||
appendTrace(options.ticketId, results.map(toTrace));
|
||||
}
|
||||
|
||||
return {
|
||||
ok: error === null,
|
||||
steps: results,
|
||||
context: working,
|
||||
error,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Vykona jeden krok pres skript sluzby. */
|
||||
async function runAction(
|
||||
step: Extract<FlowStep, { kind: 'action' }>,
|
||||
context: RunContext,
|
||||
options: RunOptions,
|
||||
): Promise<StepResult> {
|
||||
const startedAt = Date.now();
|
||||
const label = `${step.serviceId}/${step.operationId}`;
|
||||
|
||||
const scriptId = scriptIdFor(step.serviceId, step.operationId);
|
||||
if (!scriptId) {
|
||||
return {
|
||||
stepId: step.id,
|
||||
kind: 'action',
|
||||
label,
|
||||
ok: false,
|
||||
summary: 'operace nemá výkonnou část',
|
||||
detail:
|
||||
`Služba ${step.serviceId} nemá pro operaci ${step.operationId} skript, ` +
|
||||
'takže ji nelze vykonat. Zbytek stromu se nespustil.',
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
let connector: Connector | undefined;
|
||||
try {
|
||||
connector = step.connectorId
|
||||
? await getConnector(step.connectorId, [options.tenantId])
|
||||
: await defaultConnectorFor(options.tenantId, step.serviceId);
|
||||
} catch (err) {
|
||||
// Nedostupne uloziste konektoru nesmi vypadat jako chyba cizi sluzby.
|
||||
return {
|
||||
stepId: step.id,
|
||||
kind: 'action',
|
||||
label,
|
||||
ok: false,
|
||||
summary: 'napojení se nepodařilo načíst',
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
const inputs = fillTemplates(step.inputs ?? {}, context);
|
||||
const result = await runScript(scriptId, inputs, {
|
||||
connector: connector ?? null,
|
||||
// Klic je stabilni na dvojici beh a krok, takze opakovane odeslani
|
||||
// nevystavi druhou fakturu.
|
||||
idempotencyKey: options.idempotencyKey ? `${options.idempotencyKey}:${step.id}` : undefined,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
return {
|
||||
stepId: step.id,
|
||||
kind: 'action',
|
||||
label,
|
||||
ok: false,
|
||||
summary: result.error?.message ?? 'krok selhal',
|
||||
detail: result.error?.detail ?? null,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// Vystupy pod jmenem kroku, aby si dva stejne kroky neprepsaly hodnoty.
|
||||
context[step.id] = result.outputs;
|
||||
for (const [key, value] of Object.entries(result.outputs)) {
|
||||
context[`${step.id}.${key}`] = value;
|
||||
}
|
||||
|
||||
return {
|
||||
stepId: step.id,
|
||||
kind: 'action',
|
||||
label,
|
||||
ok: true,
|
||||
summary: 'hotovo',
|
||||
detail: describe(result.outputs),
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Dosadi `{{jmeno}}` z kontextu do vsech poli kroku. */
|
||||
function fillTemplates(
|
||||
inputs: Record<string, string>,
|
||||
context: RunContext,
|
||||
): Record<string, string> {
|
||||
const values: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(context)) {
|
||||
values[key] = value === null || value === undefined ? '' : stringify(value);
|
||||
}
|
||||
|
||||
const filled: Record<string, string> = {};
|
||||
for (const [key, template] of Object.entries(inputs)) {
|
||||
filled[key] = renderTemplate(template, values);
|
||||
}
|
||||
return filled;
|
||||
}
|
||||
|
||||
function stringify(value: unknown): string {
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
/** Vyhodnoti podminku nad kontextem. */
|
||||
function evaluate(
|
||||
step: Extract<FlowStep, { kind: 'condition' }>,
|
||||
context: RunContext,
|
||||
): boolean {
|
||||
const raw = context[step.fieldId] ?? context[nameOf(step.fieldId)];
|
||||
const value = raw === undefined || raw === null ? '' : stringify(raw);
|
||||
const expected = step.value ?? '';
|
||||
|
||||
return compare(step.operator, value, expected);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parametr se v kontextu drzi pod jmenem, ale podminka se odkazuje ID.
|
||||
* `ticket.status` je pod `status`, `krok1.faktura` pod `krok1.faktura`.
|
||||
*/
|
||||
function nameOf(fieldId: string): string {
|
||||
const dot = fieldId.indexOf('.');
|
||||
return dot === -1 ? fieldId : fieldId.slice(dot + 1);
|
||||
}
|
||||
|
||||
function compare(operator: ConditionOperator, value: string, expected: string): boolean {
|
||||
const number = Number(value);
|
||||
const expectedNumber = Number(expected);
|
||||
|
||||
switch (operator) {
|
||||
case 'eq':
|
||||
return value === expected;
|
||||
case 'neq':
|
||||
return value !== expected;
|
||||
case 'contains':
|
||||
return value.toLowerCase().includes(expected.toLowerCase());
|
||||
case 'startsWith':
|
||||
return value.toLowerCase().startsWith(expected.toLowerCase());
|
||||
case 'gt':
|
||||
return Number.isFinite(number) && Number.isFinite(expectedNumber) && number > expectedNumber;
|
||||
case 'gte':
|
||||
return Number.isFinite(number) && Number.isFinite(expectedNumber) && number >= expectedNumber;
|
||||
case 'lt':
|
||||
return Number.isFinite(number) && Number.isFinite(expectedNumber) && number < expectedNumber;
|
||||
case 'lte':
|
||||
return Number.isFinite(number) && Number.isFinite(expectedNumber) && number <= expectedNumber;
|
||||
case 'isEmpty':
|
||||
return value.trim() === '';
|
||||
case 'isNotEmpty':
|
||||
return value.trim() !== '';
|
||||
case 'isTrue':
|
||||
return value === 'true' || value === '1';
|
||||
case 'isFalse':
|
||||
return value === 'false' || value === '0' || value === '';
|
||||
default:
|
||||
// Neznamy operator radeji neprojde, nez aby tise vetvil vzdycky stejne.
|
||||
console.warn(`[runtime] neznamy operator ${operator}, podminka nesplnena`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function describeCondition(
|
||||
step: Extract<FlowStep, { kind: 'condition' }>,
|
||||
context: RunContext,
|
||||
): string {
|
||||
const raw = context[step.fieldId] ?? context[nameOf(step.fieldId)];
|
||||
const value = raw === undefined || raw === null ? '(prázdné)' : stringify(raw);
|
||||
const name = nameOf(step.fieldId);
|
||||
|
||||
if (isUnary(step.operator)) return `Podmínka: ${name} ${step.operator} (${value})`;
|
||||
return `Podmínka: ${name} ${step.operator} ${step.value ?? ''} (${value})`;
|
||||
}
|
||||
|
||||
/** Co krok vratil, do logu. Delsi vystup se orizne, cely je ve vysledku. */
|
||||
function describe(outputs: Record<string, unknown>): string | null {
|
||||
const keys = Object.keys(outputs);
|
||||
if (keys.length === 0) return null;
|
||||
const text = JSON.stringify(outputs);
|
||||
return text.length > 4_000 ? `${text.slice(0, 4_000)}...` : text;
|
||||
}
|
||||
|
||||
function toTrace(step: StepResult): TraceInput {
|
||||
return {
|
||||
kind: step.kind === 'condition' ? 'condition' : 'action',
|
||||
status: step.ok ? 'ok' : 'error',
|
||||
label: `${step.label}: ${step.summary}`,
|
||||
response: step.detail,
|
||||
durationMs: step.durationMs,
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
AlarmClock,
|
||||
Blocks,
|
||||
ExternalLink,
|
||||
FlaskConical,
|
||||
LayoutDashboard,
|
||||
LayoutGrid,
|
||||
LifeBuoy,
|
||||
@@ -23,7 +22,6 @@ import { useAuth, type AuthUser } from '@/auth/AuthContext';
|
||||
import { EventStreamProvider } from '@/components/dashboard/EventStreamProvider';
|
||||
import { EventToasts } from '@/components/dashboard/EventToasts';
|
||||
import { LiveIndicator } from '@/components/dashboard/LiveIndicator';
|
||||
import { SimulationModal } from '@/components/dashboard/SimulationModal';
|
||||
import { Logo } from '@/components/layout/Logo';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { useApiQuery } from '@/lib/useApiQuery';
|
||||
@@ -79,7 +77,6 @@ function DashboardShell() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [simulationOpen, setSimulationOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setSidebarOpen(false);
|
||||
@@ -135,16 +132,6 @@ function DashboardShell() {
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Nastroj pro nahled zivého dashboardu, proto az za beznou navigaci. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSimulationOpen(true)}
|
||||
className="mt-2 flex w-full items-center gap-3 rounded-xl border border-dashed border-ink-600/70 px-3.5 py-2.5 text-sm font-medium text-white/55 transition-colors hover:border-accent-400/60 hover:text-white"
|
||||
>
|
||||
<FlaskConical className="size-4 shrink-0" />
|
||||
Simulace
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-ink-600/60 p-3">
|
||||
@@ -216,7 +203,6 @@ function DashboardShell() {
|
||||
</div>
|
||||
|
||||
<EventToasts />
|
||||
<SimulationModal open={simulationOpen} onClose={() => setSimulationOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,325 +0,0 @@
|
||||
import { AlarmClock, AlertCircle, CheckCircle2, LifeBuoy, Play, Workflow } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import type { FormEvent, ReactNode } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
type Action =
|
||||
| 'ticket.created'
|
||||
| 'ticket.resolved'
|
||||
| 'incident.started'
|
||||
| 'incident.resolved'
|
||||
| 'automation.run';
|
||||
|
||||
interface Result {
|
||||
ok: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const priorities = [
|
||||
{ value: 'low', label: 'Nízká' },
|
||||
{ value: 'normal', label: 'Běžná' },
|
||||
{ value: 'high', label: 'Vysoká' },
|
||||
{ value: 'critical', label: 'Kritická' },
|
||||
] as const;
|
||||
|
||||
/** Kanal urcuje nejen text, ale i to, jak bude vypadat log ticketu. */
|
||||
const channels = [
|
||||
{ value: 'whatsapp', label: 'WhatsApp' },
|
||||
{ value: 'facebook', label: 'Facebook Messenger' },
|
||||
{ value: 'instagram', label: 'Instagram' },
|
||||
{ value: 'email', label: 'E-mail' },
|
||||
{ value: 'voice', label: 'Hlasová linka' },
|
||||
{ value: 'form', label: 'Webový formulář' },
|
||||
{ value: 'portal', label: 'Portál (ručně)' },
|
||||
] as const;
|
||||
|
||||
const severities = [
|
||||
{ value: 'sev3', label: 'SEV3 - menší' },
|
||||
{ value: 'sev2', label: 'SEV2 - vážný' },
|
||||
{ value: 'sev1', label: 'SEV1 - kritický' },
|
||||
] as const;
|
||||
|
||||
const inputClass =
|
||||
'w-full 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';
|
||||
|
||||
/**
|
||||
* Simulace provoznich udalosti. Slouzi k nahledu zivého dashboardu bez toho,
|
||||
* aby se muselo cekat na skutecny provoz.
|
||||
*
|
||||
* Nejde o falesne notifikace - kazda akce opravdu zmeni data na serveru,
|
||||
* takze se projevi i v seznamech a v souhrnu.
|
||||
*/
|
||||
export function SimulationModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const [running, setRunning] = useState<Action | null>(null);
|
||||
const [result, setResult] = useState<Result | null>(null);
|
||||
|
||||
const [subject, setSubject] = useState('');
|
||||
const [contact, setContact] = useState('');
|
||||
const [priority, setPriority] = useState<(typeof priorities)[number]['value']>('normal');
|
||||
const [channel, setChannel] = useState<(typeof channels)[number]['value']>('whatsapp');
|
||||
const [knownCustomer, setKnownCustomer] = useState(true);
|
||||
|
||||
const [title, setTitle] = useState('');
|
||||
const [service, setService] = useState('');
|
||||
const [severity, setSeverity] = useState<(typeof severities)[number]['value']>('sev2');
|
||||
|
||||
async function run(action: Action, body: Record<string, unknown> = {}) {
|
||||
setRunning(action);
|
||||
setResult(null);
|
||||
try {
|
||||
await apiFetch<unknown>('/api/simulate', {
|
||||
method: 'POST',
|
||||
body: { action, ...body },
|
||||
});
|
||||
setResult({ ok: true, message: 'Hotovo. Změna se objevila v dashboardu.' });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Simulaci se nepodařilo spustit.';
|
||||
console.error('[simulace] selhalo:', err);
|
||||
setResult({ ok: false, message });
|
||||
} finally {
|
||||
setRunning(null);
|
||||
}
|
||||
}
|
||||
|
||||
function submitTicket(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
void run('ticket.created', {
|
||||
// Prazdna pole neposilame, server pak doplni ukazkovou hodnotu.
|
||||
...(subject.trim() ? { subject: subject.trim() } : {}),
|
||||
...(contact.trim() ? { contact: contact.trim() } : {}),
|
||||
channel,
|
||||
priority,
|
||||
knownCustomer,
|
||||
});
|
||||
setSubject('');
|
||||
}
|
||||
|
||||
function submitIncident(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
void run('incident.started', {
|
||||
...(title.trim() ? { title: title.trim() } : {}),
|
||||
...(service.trim() ? { service: service.trim() } : {}),
|
||||
severity,
|
||||
});
|
||||
setTitle('');
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Simulace provozu"
|
||||
description="Vyvolá skutečnou událost, aby bylo vidět, jak dashboard reaguje živě."
|
||||
>
|
||||
<div className="max-h-[65vh] space-y-5 overflow-y-auto p-5">
|
||||
<Panel icon={LifeBuoy} title="Nový ticket z kanálu">
|
||||
<form onSubmit={submitTicket} className="space-y-3">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<select
|
||||
value={channel}
|
||||
onChange={(event) =>
|
||||
setChannel(event.target.value as (typeof channels)[number]['value'])
|
||||
}
|
||||
aria-label="Kanál"
|
||||
className={cn(inputClass, 'w-48')}
|
||||
>
|
||||
{channels.map((item) => (
|
||||
<option key={item.value} value={item.value} className="bg-ink-850">
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={priority}
|
||||
onChange={(event) =>
|
||||
setPriority(event.target.value as (typeof priorities)[number]['value'])
|
||||
}
|
||||
aria-label="Priorita"
|
||||
className={cn(inputClass, 'min-w-0 flex-1')}
|
||||
>
|
||||
{priorities.map((item) => (
|
||||
<option key={item.value} value={item.value} className="bg-ink-850">
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<input
|
||||
value={subject}
|
||||
onChange={(event) => setSubject(event.target.value)}
|
||||
placeholder="Předmět (nepovinné, jinak se doplní ukázkový)"
|
||||
className={inputClass}
|
||||
/>
|
||||
<input
|
||||
value={contact}
|
||||
onChange={(event) => setContact(event.target.value)}
|
||||
placeholder="Kdo píše (nepovinné)"
|
||||
className={inputClass}
|
||||
/>
|
||||
<label className="flex cursor-pointer items-start gap-2.5 text-sm text-white/60">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={knownCustomer}
|
||||
onChange={(event) => setKnownCustomer(event.target.checked)}
|
||||
className="mt-0.5 size-4 accent-cyan-400"
|
||||
/>
|
||||
<span>
|
||||
Zákazníka se podaří dohledat v CRM
|
||||
<span className="block text-xs text-white/35">
|
||||
Když vypnete, ticket zůstane bez firmy i bez řešitele a v logu bude vidět,
|
||||
proč se přiřazení přeskočilo.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<Button type="submit" size="sm" disabled={running !== null}>
|
||||
<Play className="size-4" />
|
||||
{running === 'ticket.created' ? 'Zakládám...' : 'Založit ticket'}
|
||||
</Button>
|
||||
</form>
|
||||
</Panel>
|
||||
|
||||
<Panel icon={AlarmClock} title="Nový incident">
|
||||
<form onSubmit={submitIncident} className="space-y-3">
|
||||
<input
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
placeholder="Popis (nepovinné, jinak se doplní ukázkový)"
|
||||
className={inputClass}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<input
|
||||
value={service}
|
||||
onChange={(event) => setService(event.target.value)}
|
||||
placeholder="Služba"
|
||||
className={cn(inputClass, 'min-w-0 flex-1')}
|
||||
/>
|
||||
<select
|
||||
value={severity}
|
||||
onChange={(event) =>
|
||||
setSeverity(event.target.value as (typeof severities)[number]['value'])
|
||||
}
|
||||
aria-label="Závažnost"
|
||||
className={cn(inputClass, 'w-44')}
|
||||
>
|
||||
{severities.map((item) => (
|
||||
<option key={item.value} value={item.value} className="bg-ink-850">
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<Button type="submit" size="sm" disabled={running !== null}>
|
||||
<Play className="size-4" />
|
||||
{running === 'incident.started' ? 'Zakládám...' : 'Vyvolat incident'}
|
||||
</Button>
|
||||
</form>
|
||||
</Panel>
|
||||
|
||||
<Panel icon={Workflow} title="Rychlé akce">
|
||||
<p className="mb-3 text-sm text-white/50">
|
||||
Působí na první vhodný záznam, nemusíte nic vybírat.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<QuickButton
|
||||
onClick={() => void run('ticket.resolved')}
|
||||
disabled={running !== null}
|
||||
busy={running === 'ticket.resolved'}
|
||||
>
|
||||
Vyřešit ticket
|
||||
</QuickButton>
|
||||
<QuickButton
|
||||
onClick={() => void run('incident.resolved')}
|
||||
disabled={running !== null}
|
||||
busy={running === 'incident.resolved'}
|
||||
>
|
||||
Vyřešit incident
|
||||
</QuickButton>
|
||||
<QuickButton
|
||||
onClick={() => void run('automation.run', { ok: true })}
|
||||
disabled={running !== null}
|
||||
busy={running === 'automation.run'}
|
||||
>
|
||||
Spustit automatizaci
|
||||
</QuickButton>
|
||||
<QuickButton
|
||||
onClick={() => void run('automation.run', { ok: false })}
|
||||
disabled={running !== null}
|
||||
busy={running === 'automation.run'}
|
||||
>
|
||||
Neúspěšný běh
|
||||
</QuickButton>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{result && (
|
||||
<p
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-xl border px-4 py-3 text-sm',
|
||||
result.ok
|
||||
? 'border-ok-400/30 bg-ok-500/10 text-ok-400'
|
||||
: 'border-danger-400/30 bg-danger-500/10 text-danger-400',
|
||||
)}
|
||||
>
|
||||
{result.ok ? (
|
||||
<CheckCircle2 className="size-4 shrink-0" />
|
||||
) : (
|
||||
<AlertCircle className="size-4 shrink-0" />
|
||||
)}
|
||||
{result.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="rounded-xl border border-ink-600/60 px-4 py-3 text-xs leading-relaxed text-white/40">
|
||||
Simulace zapisuje do stejných dat jako běžný provoz. Data jsou v paměti API,
|
||||
takže se restartem serveru vrátí do výchozího stavu.
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function Panel({
|
||||
icon: Icon,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
icon: typeof LifeBuoy;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-xl border border-ink-600/60 bg-ink-850/40 p-4">
|
||||
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-white">
|
||||
<Icon className="size-4 text-brand-300" />
|
||||
{title}
|
||||
</h3>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickButton({
|
||||
onClick,
|
||||
disabled,
|
||||
busy,
|
||||
children,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
disabled: boolean;
|
||||
busy: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className="rounded-lg border border-ink-600/70 px-3.5 py-2 text-sm text-white/70 transition-colors hover:border-brand-400/60 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{busy ? 'Spouštím...' : children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { Clock, RotateCcw } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
TicketChannelBadge,
|
||||
TicketPriorityBadge,
|
||||
TicketStatusBadge,
|
||||
} from '@/components/dashboard/StatusBadge';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { formatDateTime, formatRelative } from '@/lib/format';
|
||||
import type { Ticket } from '@/types/dashboard';
|
||||
|
||||
/**
|
||||
* Tabulka ticketu.
|
||||
*
|
||||
* Jedna komponenta pro vsechna mista, kde se tickety vypisuji - seznam,
|
||||
* detail osoby, cokoliv dalsiho. Kdyby si to kazda stranka kreslila po svem,
|
||||
* jedna by casem ukazovala jine sloupce nez druha.
|
||||
*
|
||||
* **Na mobilu se nescroluje do strany.** Uzka obrazovka dostane misto tabulky
|
||||
* karty se stejnymi udaji: vodorovne posouvani v tabulce o osmi sloupcich je
|
||||
* na telefonu nepouzitelne a schovavat sloupce znamena, ze udaj proste zmizi.
|
||||
*/
|
||||
export function TicketTable({
|
||||
tickets,
|
||||
meId,
|
||||
/** Sloupce, ktere nemaji smysl v danem kontextu. */
|
||||
hide,
|
||||
empty = 'Žádné tickety.',
|
||||
}: {
|
||||
tickets: Ticket[];
|
||||
meId?: string | null;
|
||||
hide?: Array<'assignee' | 'customer' | 'channel'>;
|
||||
empty?: string;
|
||||
}) {
|
||||
if (tickets.length === 0) {
|
||||
return (
|
||||
<p className="rounded-xl border border-dashed border-ink-600/70 px-4 py-6 text-center text-sm text-white/40">
|
||||
{empty}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const skip = new Set(hide ?? []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Tabulka od tabletu vys. */}
|
||||
<div className="hidden overflow-x-auto md:block">
|
||||
<table className="w-full border-collapse text-left text-sm">
|
||||
<thead className="border-b border-ink-600/60 text-xs tracking-wide text-white/40 uppercase">
|
||||
<tr>
|
||||
<th className="px-3 py-3 font-medium">Ticket</th>
|
||||
{!skip.has('customer') && <th className="px-3 py-3 font-medium">Zákazník</th>}
|
||||
{!skip.has('channel') && <th className="px-3 py-3 font-medium">Kanál</th>}
|
||||
<th className="px-3 py-3 font-medium">Stav</th>
|
||||
<th className="px-3 py-3 font-medium">Priorita</th>
|
||||
{!skip.has('assignee') && <th className="px-3 py-3 font-medium">Řeší</th>}
|
||||
<th className="px-3 py-3 text-right font-medium">Aktualizace</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-600/40">
|
||||
{tickets.map((ticket) => (
|
||||
<tr key={ticket.id} className="transition-colors hover:bg-white/[0.03]">
|
||||
<td className="px-3 py-3">
|
||||
<Link
|
||||
to={`/dashboard/tickety/${ticket.id}`}
|
||||
className="block text-white/85 transition-colors hover:text-brand-200"
|
||||
>
|
||||
{ticket.subject}
|
||||
</Link>
|
||||
<span className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 font-mono text-xs text-white/35">
|
||||
{ticket.id}
|
||||
{ticket.externalId && <span>externí {ticket.externalId}</span>}
|
||||
{ticket.reopenCount > 0 && (
|
||||
<span className="flex items-center gap-1 font-sans text-warn-400">
|
||||
<RotateCcw className="size-3" />
|
||||
{ticket.reopenCount}x vráceno
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{!skip.has('customer') && (
|
||||
<td className="px-3 py-3">
|
||||
{ticket.customer.id ? (
|
||||
<span className="text-white/55">{ticket.customer.company}</span>
|
||||
) : (
|
||||
<span className="text-warn-400">nedohledáno</span>
|
||||
)}
|
||||
{ticket.customer.contact && (
|
||||
<span className="block text-xs text-white/30">{ticket.customer.contact}</span>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
|
||||
{!skip.has('channel') && (
|
||||
<td className="px-3 py-3">
|
||||
<TicketChannelBadge channel={ticket.channel} />
|
||||
</td>
|
||||
)}
|
||||
|
||||
<td className="px-3 py-3">
|
||||
<TicketStatusBadge status={ticket.status} />
|
||||
</td>
|
||||
<td className="px-3 py-3">
|
||||
<TicketPriorityBadge priority={ticket.priority} />
|
||||
</td>
|
||||
|
||||
{!skip.has('assignee') && (
|
||||
<td className="px-3 py-3">
|
||||
{ticket.assignee ? (
|
||||
<Link
|
||||
to={`/dashboard/lide/${ticket.assignee.id}`}
|
||||
className={cn(
|
||||
'transition-colors hover:text-brand-200',
|
||||
ticket.assignee.id === meId ? 'text-brand-200' : 'text-white/55',
|
||||
)}
|
||||
>
|
||||
{ticket.assignee.name}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-white/30">ve frontě</span>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
|
||||
<td className="px-3 py-3 text-right whitespace-nowrap text-white/45">
|
||||
<span title={formatDateTime(ticket.updatedAt)}>
|
||||
{formatRelative(ticket.updatedAt)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Na uzke obrazovce karty misto posouvani do strany. */}
|
||||
<ul className="divide-y divide-ink-600/40 md:hidden">
|
||||
{tickets.map((ticket) => (
|
||||
<li key={ticket.id} className="py-3 first:pt-0 last:pb-0">
|
||||
<Link to={`/dashboard/tickety/${ticket.id}`} className="block">
|
||||
<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-1 text-sm text-white/85">{ticket.subject}</p>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
{!skip.has('channel') && <TicketChannelBadge channel={ticket.channel} />}
|
||||
<TicketPriorityBadge priority={ticket.priority} />
|
||||
{ticket.externalId && <Badge tone="neutral">externí {ticket.externalId}</Badge>}
|
||||
{ticket.reopenCount > 0 && <Badge tone="warn">{ticket.reopenCount}x vráceno</Badge>}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-xs text-white/40">
|
||||
{!skip.has('customer') && (
|
||||
<span className="truncate">
|
||||
{ticket.customer.id ? (
|
||||
ticket.customer.company
|
||||
) : (
|
||||
<span className="text-warn-400">zákazník nedohledán</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{!skip.has('assignee') && (
|
||||
<span className={cn(ticket.assignee?.id === meId && 'text-brand-200')}>
|
||||
{ticket.assignee?.name ?? 've frontě'}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="size-3" />
|
||||
{formatRelative(ticket.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AlertTriangle, Inbox, Users } from 'lucide-react';
|
||||
import { AlertTriangle, ChevronRight, Inbox, Users } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { formatRelative } from '@/lib/format';
|
||||
import type { Workload } from '@/types/dashboard';
|
||||
@@ -39,19 +40,35 @@ export function TicketWorkload({
|
||||
const width = busiest === 0 ? 0 : Math.round((row.open / busiest) * 100);
|
||||
|
||||
return (
|
||||
<li key={row.person.id}>
|
||||
<li
|
||||
key={row.person.id}
|
||||
className={cn(
|
||||
'relative rounded-xl border transition-colors',
|
||||
isActive
|
||||
? 'border-brand-400/50 bg-brand-500/10'
|
||||
: 'border-transparent hover:border-ink-600/70 hover:bg-white/[0.03]',
|
||||
)}
|
||||
>
|
||||
{/*
|
||||
Radek filtruje seznam, sipka vpravo vede na cloveka. Odkaz nesmi
|
||||
byt uvnitr tlacitka - dva ovladace v sobe jsou neplatne HTML
|
||||
a klavesnici se v tom neda pohybovat.
|
||||
*/}
|
||||
<Link
|
||||
to={`/dashboard/lide/${row.person.id}`}
|
||||
aria-label={`Detail ${row.person.name}`}
|
||||
className="absolute top-2 right-2 z-10 grid size-7 place-items-center rounded-lg text-white/25 transition-colors hover:bg-white/8 hover:text-brand-200"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(isActive ? null : row.person.id)}
|
||||
aria-pressed={isActive}
|
||||
className={cn(
|
||||
'w-full rounded-xl border px-3.5 py-2.5 text-left transition-colors',
|
||||
isActive
|
||||
? 'border-brand-400/50 bg-brand-500/10'
|
||||
: 'border-transparent hover:border-ink-600/70 hover:bg-white/[0.03]',
|
||||
)}
|
||||
className="w-full rounded-xl px-3.5 py-2.5 text-left"
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<div className="flex items-baseline justify-between gap-3 pr-8">
|
||||
<span className="truncate text-sm font-medium text-white/85">
|
||||
{row.person.name}
|
||||
</span>
|
||||
|
||||
@@ -56,7 +56,7 @@ export function CustomWidgetCard({
|
||||
}
|
||||
|
||||
if (value.kind === 'number') {
|
||||
return <Number definition={definition} value={value.value} />;
|
||||
return <Number definition={definition} value={value.value} href={value.href} />;
|
||||
}
|
||||
|
||||
if (value.kind === 'external') {
|
||||
@@ -91,7 +91,7 @@ export function CustomWidgetCard({
|
||||
|
||||
if (value.kind === 'series') {
|
||||
return (
|
||||
<Shell definition={definition}>
|
||||
<Shell definition={definition} href={value.href}>
|
||||
<Sparkline points={value.points} />
|
||||
</Shell>
|
||||
);
|
||||
@@ -99,7 +99,7 @@ export function CustomWidgetCard({
|
||||
|
||||
if (value.kind === 'tickets') {
|
||||
return (
|
||||
<Shell definition={definition}>
|
||||
<Shell definition={definition} href={value.href}>
|
||||
{value.items.length === 0 ? (
|
||||
<p className="text-sm text-white/40">Žádné tickety.</p>
|
||||
) : (
|
||||
@@ -127,13 +127,15 @@ export function CustomWidgetCard({
|
||||
|
||||
if (value.kind === 'workload') {
|
||||
return (
|
||||
<Shell definition={definition}>
|
||||
<Shell definition={definition} href={value.href}>
|
||||
<Bars
|
||||
rows={value.rows.map((row) => ({
|
||||
key: row.name,
|
||||
key: row.personId,
|
||||
label: row.name,
|
||||
value: row.open,
|
||||
warn: row.overCapacity,
|
||||
// Z pruhu s jmenem se chce clovek dostat na toho cloveka.
|
||||
href: row.href,
|
||||
}))}
|
||||
/>
|
||||
</Shell>
|
||||
@@ -232,32 +234,56 @@ function AgentTable({ rows }: { rows: AgentStatsRow[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Vodorovne pruhy. Cislo samo o sobe nerekne, jestli je to hodne nebo malo. */
|
||||
/**
|
||||
* Vodorovne pruhy. Cislo samo o sobe nerekne, jestli je to hodne nebo malo.
|
||||
*
|
||||
* Radek s `href` je odkaz na to, co je za nim: u lidi na cloveka, u seskupeni
|
||||
* na uz vyfiltrovany seznam ticketu.
|
||||
*/
|
||||
function Bars({
|
||||
rows,
|
||||
}: {
|
||||
rows: Array<{ key: string; label: string; value: number; warn?: boolean }>;
|
||||
rows: Array<{ key: string; label: string; value: number; warn?: boolean; href?: string }>;
|
||||
}) {
|
||||
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>
|
||||
))}
|
||||
{rows.slice(0, 12).map((row) => {
|
||||
const body = (
|
||||
<>
|
||||
<div className="flex items-baseline justify-between gap-3 text-sm">
|
||||
<span className="min-w-0 truncate text-white/70 group-hover:text-brand-200">
|
||||
{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>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<li key={row.key}>
|
||||
{row.href ? (
|
||||
<Link to={row.href} className="group block">
|
||||
{body}
|
||||
</Link>
|
||||
) : (
|
||||
body
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -268,21 +294,38 @@ function Number({
|
||||
value,
|
||||
at,
|
||||
stale,
|
||||
href,
|
||||
}: {
|
||||
definition: WidgetDefinition;
|
||||
value: number | null;
|
||||
at?: string;
|
||||
stale?: boolean;
|
||||
href?: string;
|
||||
}) {
|
||||
const target = definition.target;
|
||||
const share = target && target > 0 && value !== null ? Math.min(value / target, 1) : null;
|
||||
|
||||
const number = (
|
||||
<span
|
||||
className={cn(
|
||||
'text-3xl font-bold tabular-nums',
|
||||
href ? 'text-white transition-colors group-hover:text-brand-200' : 'text-white',
|
||||
)}
|
||||
>
|
||||
{value === null ? '-' : formatNumber(value)}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<Shell definition={definition} at={at} stale={stale}>
|
||||
<Shell definition={definition} at={at} stale={stale} href={href}>
|
||||
<div className="flex items-end gap-2">
|
||||
<span className="text-3xl font-bold text-white tabular-nums">
|
||||
{value === null ? '-' : formatNumber(value)}
|
||||
</span>
|
||||
{href ? (
|
||||
<Link to={href} className="group">
|
||||
{number}
|
||||
</Link>
|
||||
) : (
|
||||
number
|
||||
)}
|
||||
{target !== undefined && target > 0 && (
|
||||
<span className="mb-1 flex items-center gap-1 text-xs text-white/40">
|
||||
{value !== null && value >= target ? (
|
||||
@@ -345,18 +388,30 @@ function Shell({
|
||||
definition,
|
||||
at,
|
||||
stale,
|
||||
href,
|
||||
children,
|
||||
}: {
|
||||
definition: WidgetDefinition;
|
||||
at?: string;
|
||||
stale?: boolean;
|
||||
/** Kam vede nadpis. Bez nej je to jen text. */
|
||||
href?: string;
|
||||
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>
|
||||
{href ? (
|
||||
<Link
|
||||
to={href}
|
||||
className="block truncate font-semibold text-white transition-colors hover:text-brand-200"
|
||||
>
|
||||
{definition.name}
|
||||
</Link>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { ArrowLeft, Clock, Mail, RotateCcw, Users } from 'lucide-react';
|
||||
import { ArrowLeft, Mail, Users } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { DataState } from '@/components/dashboard/DataState';
|
||||
import { TicketPriorityBadge, TicketStatusBadge } from '@/components/dashboard/StatusBadge';
|
||||
import { TicketTable } from '@/components/dashboard/TicketTable';
|
||||
import { ViewSwitch, type ListView } from '@/components/dashboard/ViewSwitch';
|
||||
import { TicketStatusBadge } from '@/components/dashboard/StatusBadge';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { formatDuration, formatNumber, formatRelative } from '@/lib/format';
|
||||
@@ -26,6 +29,10 @@ interface PersonDetailResponse {
|
||||
export default function PersonDetail() {
|
||||
const { id = '' } = useParams();
|
||||
|
||||
/** Co ukazat: co ma u sebe, nebo co vyresil. */
|
||||
const [tab, setTab] = useState<'open' | 'resolved'>('open');
|
||||
const [view, setView] = useState<ListView>('table');
|
||||
|
||||
const detail = useApiQuery<PersonDetailResponse>(`/api/dashboard/people/${id}`, {
|
||||
refetchOn: ['ticket.created', 'ticket.updated', 'ticket.assigned', 'ticket.resolved'],
|
||||
});
|
||||
@@ -105,19 +112,34 @@ export default function PersonDetail() {
|
||||
</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>
|
||||
<section className="glass rounded-card p-4 sm:p-5">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<TabChip active={tab === 'open'} onClick={() => setTab('open')}>
|
||||
Má u sebe ({detail.data.open.length})
|
||||
</TabChip>
|
||||
<TabChip active={tab === 'resolved'} onClick={() => setTab('resolved')}>
|
||||
Vyřešil ({detail.data.resolved.length})
|
||||
</TabChip>
|
||||
</div>
|
||||
<ViewSwitch view={view} onChange={setView} />
|
||||
</div>
|
||||
|
||||
{view === 'table' ? (
|
||||
<TicketTable
|
||||
tickets={tab === 'open' ? detail.data.open : detail.data.resolved}
|
||||
// Sloupec Resi je u detailu jednoho cloveka k nicemu.
|
||||
hide={['assignee']}
|
||||
empty={tab === 'open' ? 'Nic nemá u sebe.' : 'Zatím nic nevyřešil.'}
|
||||
/>
|
||||
) : (
|
||||
<TicketCards
|
||||
tickets={tab === 'open' ? detail.data.open : detail.data.resolved}
|
||||
showAge={tab === 'open'}
|
||||
empty={tab === 'open' ? 'Nic nemá u sebe.' : 'Zatím nic nevyřešil.'}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</DataState>
|
||||
@@ -125,71 +147,82 @@ export default function PersonDetail() {
|
||||
);
|
||||
}
|
||||
|
||||
function TicketColumn({
|
||||
title,
|
||||
empty,
|
||||
tickets,
|
||||
showAge,
|
||||
/** Zalozka nad tabulkou. Maly prvek, patri k teto strance. */
|
||||
function TabChip({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
empty: string;
|
||||
tickets: Ticket[];
|
||||
showAge?: boolean;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
'rounded-full border px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
active
|
||||
? 'border-brand-400/50 bg-brand-500/15 text-brand-200'
|
||||
: 'border-ink-600/70 text-white/50 hover:border-white/20 hover:text-white',
|
||||
)}
|
||||
</section>
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Dlazdicovy pohled na tytéz tickety. */
|
||||
function TicketCards({
|
||||
tickets,
|
||||
showAge,
|
||||
empty,
|
||||
}: {
|
||||
tickets: Ticket[];
|
||||
showAge: boolean;
|
||||
empty: string;
|
||||
}) {
|
||||
if (tickets.length === 0) {
|
||||
return (
|
||||
<p className="rounded-xl border border-dashed border-ink-600/70 px-4 py-6 text-center text-sm text-white/40">
|
||||
{empty}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{tickets.map((ticket) => (
|
||||
<Link
|
||||
key={ticket.id}
|
||||
to={`/dashboard/tickety/${ticket.id}`}
|
||||
className="group rounded-card border border-ink-600/50 bg-ink-900/40 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-1.5 line-clamp-2 text-sm text-white/85 group-hover:text-brand-200">
|
||||
{ticket.subject}
|
||||
</p>
|
||||
<p className="mt-2 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>}
|
||||
<span>
|
||||
{showAge
|
||||
? `leží ${formatRelative(ticket.createdAt)}`
|
||||
: ticket.resolvedAt
|
||||
? `vyřešeno ${formatRelative(ticket.resolvedAt)}`
|
||||
: ''}
|
||||
</span>
|
||||
{ticket.reopenCount > 0 && (
|
||||
<span className="text-warn-400">vráceno {ticket.reopenCount}x</span>
|
||||
)}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+104
-119
@@ -1,7 +1,7 @@
|
||||
import { ChevronRight, Search } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { DataState } from '@/components/dashboard/DataState';
|
||||
import {
|
||||
TicketChannelBadge,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
TicketStatusBadge,
|
||||
} from '@/components/dashboard/StatusBadge';
|
||||
import { TicketWorkload } from '@/components/dashboard/TicketWorkload';
|
||||
import { TicketTable } from '@/components/dashboard/TicketTable';
|
||||
import { ViewSwitch } from '@/components/dashboard/ViewSwitch';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { cn } from '@/lib/cn';
|
||||
@@ -34,12 +35,6 @@ const statusFilters: Array<{ value: TicketStatus; label: string }> = [
|
||||
{ value: 'resolved', label: 'Vyřešené' },
|
||||
];
|
||||
|
||||
const scopeOptions: Array<{ value: TicketScope; label: string; hint: string }> = [
|
||||
{ value: 'all', label: 'Vše', hint: 'Napříč všemi firmami. Jen pro správce platformy.' },
|
||||
{ value: 'tenant', label: 'Firma', hint: 'Všechny tickety vybrané firmy.' },
|
||||
{ value: 'mine', label: 'Moje', hint: 'Jen tickety, které mám u sebe.' },
|
||||
];
|
||||
|
||||
const channelFilters: Array<{ value: TicketChannel; label: string }> = [
|
||||
{ value: 'whatsapp', label: 'WhatsApp' },
|
||||
{ value: 'facebook', label: 'Facebook' },
|
||||
@@ -56,15 +51,36 @@ export default function Tickets() {
|
||||
// Co uzivatel smi, rozhoduje server. Klient jen kresli, co prislo.
|
||||
const access = useApiQuery<Access>('/api/dashboard/access');
|
||||
|
||||
const [scope, setScope] = useState<TicketScope | null>(null);
|
||||
const [tenantId, setTenantId] = useState<string | null>(null);
|
||||
/*
|
||||
* Pocatecni filtr z adresy. Diky tomu vede proklik z widgetu na uz
|
||||
* vyfiltrovany seznam a odkaz jde poslat kolegovi.
|
||||
*/
|
||||
const [params] = useSearchParams();
|
||||
const initial = useRef({
|
||||
tenantId: params.get('tenantId'),
|
||||
assignee: params.get('assignee'),
|
||||
status: params.get('status') as TicketStatus | null,
|
||||
channel: params.get('channel') as TicketChannel | null,
|
||||
typeId: params.get('typeId'),
|
||||
tag: params.get('tag'),
|
||||
groupId: params.get('groupId'),
|
||||
}).current;
|
||||
|
||||
const [scope, setScope] = useState<TicketScope | null>(initial.tenantId ? 'tenant' : null);
|
||||
const [tenantId, setTenantId] = useState<string | null>(initial.tenantId);
|
||||
/** null = vsichni, 'unassigned' = fronta, jinak ID resitele. */
|
||||
const [assignee, setAssignee] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<TicketStatus | null>(null);
|
||||
const [channel, setChannel] = useState<TicketChannel | null>(null);
|
||||
const [assignee, setAssignee] = useState<string | null>(initial.assignee);
|
||||
const [status, setStatus] = useState<TicketStatus | null>(initial.status);
|
||||
const [channel, setChannel] = useState<TicketChannel | null>(initial.channel);
|
||||
/** Filtry, ktere se daji nastavit jen odkazem. V UI k nim tlacitko neni. */
|
||||
const [typeId, setTypeId] = useState<string | null>(initial.typeId);
|
||||
const [tag, setTag] = useState<string | null>(initial.tag);
|
||||
const [groupId, setGroupId] = useState<string | null>(initial.groupId);
|
||||
const [query, setQuery] = useState('');
|
||||
/** Tabulka na porovnavani, dlazdice na rychly prehled. */
|
||||
const [view, setView] = useState<'table' | 'grid'>('table');
|
||||
/** Jen moje. Server to resi pohledem `mine`, klient jen prepina. */
|
||||
const [mine, setMine] = useState(false);
|
||||
|
||||
// Filtrovani resi server, aby seznam a prehled nikdy neukazovaly jina cisla.
|
||||
const path = useMemo(() => {
|
||||
@@ -72,6 +88,9 @@ export default function Tickets() {
|
||||
if (scope) params.set('scope', scope);
|
||||
if (tenantId) params.set('tenantId', tenantId);
|
||||
if (assignee) params.set('assignee', assignee);
|
||||
if (typeId) params.set('typeId', typeId);
|
||||
if (tag) params.set('tag', tag);
|
||||
if (groupId) params.set('groupId', groupId);
|
||||
if (status) params.set('status', status);
|
||||
if (channel) params.set('channel', channel);
|
||||
const search = params.toString();
|
||||
@@ -140,29 +159,34 @@ export default function Tickets() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pohled je jina osa nez filtry - urcuje, ktera data vubec dorazi. */}
|
||||
{/*
|
||||
Dva ovladace, ne tri. Firma se vybira selectem, "moje" prepinacem.
|
||||
Puvodni trojice pohledu delala totez podruhe: pohled Firma znamenal
|
||||
tentyz vyber jako select vedle nej a pohled Moje totez co prepinac.
|
||||
*/}
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
{scopeOptions
|
||||
.filter((option) => allowedScopes.includes(option.value))
|
||||
.map((option) => (
|
||||
<Chip
|
||||
key={option.value}
|
||||
active={activeScope === option.value}
|
||||
onClick={() => setScope(option.value)}
|
||||
title={option.hint}
|
||||
>
|
||||
{option.label}
|
||||
</Chip>
|
||||
))}
|
||||
|
||||
{/* Prepinac firmy davá smysl jen tomu, kdo jich ma vic. */}
|
||||
{tenants.length > 1 && activeScope !== 'all' && (
|
||||
{tenants.length > 1 && (
|
||||
<select
|
||||
value={tenantId ?? data?.tenantId ?? ''}
|
||||
onChange={(event) => setTenantId(event.target.value)}
|
||||
value={activeScope === 'all' ? '' : (tenantId ?? data?.tenantId ?? '')}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
if (value === '') {
|
||||
// Prazdna volba = napric firmami. Smi ji jen ten, kdo na ni ma pravo.
|
||||
setScope('all');
|
||||
setTenantId(null);
|
||||
} else {
|
||||
setScope(mine ? 'mine' : 'tenant');
|
||||
setTenantId(value);
|
||||
}
|
||||
}}
|
||||
aria-label="Firma"
|
||||
className="rounded-full border border-ink-600/70 bg-ink-850/70 px-3 py-1.5 text-xs text-white focus:border-brand-400/70 focus:outline-none"
|
||||
>
|
||||
{allowedScopes.includes('all') && (
|
||||
<option value="" className="bg-ink-850">
|
||||
Všechny firmy
|
||||
</option>
|
||||
)}
|
||||
{tenants.map((tenant) => (
|
||||
<option key={tenant.id} value={tenant.id} className="bg-ink-850">
|
||||
{tenant.name}
|
||||
@@ -170,18 +194,56 @@ export default function Tickets() {
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<Chip active={assignee === null} onClick={() => setAssignee(null)}>
|
||||
Kdokoliv
|
||||
</Chip>
|
||||
{allowedScopes.includes('mine') && (
|
||||
<Chip
|
||||
active={mine}
|
||||
onClick={() => {
|
||||
const next = !mine;
|
||||
setMine(next);
|
||||
setScope(next ? 'mine' : activeScope === 'all' ? 'all' : 'tenant');
|
||||
if (next) setAssignee(null);
|
||||
}}
|
||||
title="Jen tickety, které mám u sebe."
|
||||
>
|
||||
Moje tickety
|
||||
</Chip>
|
||||
)}
|
||||
|
||||
<Chip
|
||||
active={assignee === 'unassigned'}
|
||||
onClick={() => setAssignee(assignee === 'unassigned' ? null : 'unassigned')}
|
||||
onClick={() => {
|
||||
setAssignee(assignee === 'unassigned' ? null : 'unassigned');
|
||||
// Fronta a moje se vylucuji, ticket ve fronte nema resitele.
|
||||
if (assignee !== 'unassigned' && mine) {
|
||||
setMine(false);
|
||||
setScope('tenant');
|
||||
}
|
||||
}}
|
||||
title="Tickety, které nemá nikdo u sebe."
|
||||
>
|
||||
Ve frontě
|
||||
</Chip>
|
||||
|
||||
{/*
|
||||
Filtry z odkazu. V UI k nim tlacitko neni, ale musi jit zrusit -
|
||||
jinak clovek prijde z widgetu a uz se z uzkeho vyberu nedostane.
|
||||
*/}
|
||||
{typeId && (
|
||||
<Chip active onClick={() => setTypeId(null)} title="Zrušit filtr na typ">
|
||||
typ: {typeId === 'none' ? 'bez typu' : typeId} x
|
||||
</Chip>
|
||||
)}
|
||||
{tag && (
|
||||
<Chip active onClick={() => setTag(null)} title="Zrušit filtr na tag">
|
||||
tag: {tag === 'none' ? 'bez tagu' : tag} x
|
||||
</Chip>
|
||||
)}
|
||||
{groupId && (
|
||||
<Chip active onClick={() => setGroupId(null)} title="Zrušit filtr na skupinu">
|
||||
skupina: {groupId === 'none' ? 'bez skupiny' : groupId} x
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
@@ -283,87 +345,10 @@ export default function Tickets() {
|
||||
))}
|
||||
</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">
|
||||
<tr>
|
||||
<th className="px-5 py-3.5 font-medium">ID</th>
|
||||
<th className="px-5 py-3.5 font-medium">Předmět</th>
|
||||
<th className="px-5 py-3.5 font-medium">Zákazník</th>
|
||||
<th className="px-5 py-3.5 font-medium">Kanál</th>
|
||||
<th className="px-5 py-3.5 font-medium">Stav</th>
|
||||
<th className="px-5 py-3.5 font-medium">Priorita</th>
|
||||
<th className="px-5 py-3.5 font-medium">Řeší</th>
|
||||
<th className="px-5 py-3.5 font-medium">Aktualizace</th>
|
||||
<th className="px-5 py-3.5">
|
||||
<span className="sr-only">Detail</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-600/40">
|
||||
{items.map((ticket) => (
|
||||
<tr key={ticket.id} className="group transition-colors hover:bg-white/[0.03]">
|
||||
<td className="px-5 py-4 font-mono text-xs text-white/45">{ticket.id}</td>
|
||||
<td className="px-5 py-4">
|
||||
<Link
|
||||
to={`/dashboard/tickety/${ticket.id}`}
|
||||
className="text-white/85 transition-colors hover:text-brand-200"
|
||||
>
|
||||
{ticket.subject}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
{ticket.customer.id ? (
|
||||
<span className="text-white/55">{ticket.customer.company}</span>
|
||||
) : (
|
||||
<span className="text-warn-400">nedohledáno</span>
|
||||
)}
|
||||
<span className="block text-xs text-white/30">
|
||||
{ticket.customer.contact}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<TicketChannelBadge channel={ticket.channel} />
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<TicketStatusBadge status={ticket.status} />
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<TicketPriorityBadge priority={ticket.priority} />
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
{ticket.assignee ? (
|
||||
<span
|
||||
className={cn(
|
||||
'text-white/55',
|
||||
ticket.assignee.id === meId && 'text-brand-200',
|
||||
)}
|
||||
>
|
||||
{ticket.assignee.name}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-white/30">ve frontě</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-4 text-white/45">
|
||||
<span title={formatDateTime(ticket.updatedAt)}>
|
||||
{formatRelative(ticket.updatedAt)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<Link
|
||||
to={`/dashboard/tickety/${ticket.id}`}
|
||||
aria-label={`Detail ticketu ${ticket.id}`}
|
||||
className="grid size-8 place-items-center rounded-lg text-white/25 transition-colors hover:bg-white/5 hover:text-white group-hover:text-white/60"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
/* Tatáz tabulka jako na detailu osoby, vcetne chovani na mobilu. */
|
||||
<div className="p-2 sm:p-3">
|
||||
<TicketTable tickets={items} meId={meId} />
|
||||
</div>
|
||||
)}
|
||||
</DataState>
|
||||
</div>
|
||||
|
||||
@@ -494,14 +494,29 @@ 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 }> }
|
||||
/** `href` je odkaz na to, co je za cislem. Sklada ho server, ne klient. */
|
||||
| { kind: 'number'; value: number; href?: string }
|
||||
| {
|
||||
kind: 'groups';
|
||||
rows: Array<{ key: string; label: string; value: number; href?: string }>;
|
||||
}
|
||||
| { kind: 'series'; points: Array<{ date: string; value: number }>; href?: string }
|
||||
| {
|
||||
kind: 'tickets';
|
||||
items: Array<{ id: string; subject: string; status: TicketStatus; assignee: string | null }>;
|
||||
href?: string;
|
||||
}
|
||||
| {
|
||||
kind: 'workload';
|
||||
rows: Array<{
|
||||
personId: string;
|
||||
name: string;
|
||||
open: number;
|
||||
overCapacity: boolean;
|
||||
href: string;
|
||||
}>;
|
||||
href?: string;
|
||||
}
|
||||
| { 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 -
|
||||
|
||||
Reference in New Issue
Block a user