updated tickets
This commit is contained in:
@@ -17,9 +17,14 @@ React aplikaci ze slozky `dist/public`.
|
||||
| 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 konektoru | hotovo | 25 sluzeb, 8 kategorii |
|
||||
| Katalog konektoru | hotovo | 26 sluzeb, 9 kategorii |
|
||||
| Builder automatizaci | hotovo | strom akci, vetveni podminkou |
|
||||
| Webhook s registrovanou adresou | hotovo | token generuje server, verejny endpoint validuje data |
|
||||
| Tickety na konkretni lidi | hotovo | resitel, filtr moje, prehled vytizeni tymu |
|
||||
| Log ticketu ve strome | hotovo | vcetne toho, co ktera sluzba vratila |
|
||||
| Kanaly do ticketu | hotovo | WhatsApp, e-mail, hlas a formular jako spoustece |
|
||||
| Parametry od sluzby | hotovo | katalog je deklaruje, server je dosazuje pri ulozeni |
|
||||
| Bugs a wishes | chybi | vyvojarska agenda, samostatna evidence vedle ticketu |
|
||||
| Nastaveni poli akci | chybi | akce zatim neumi cerpat z parametru spoustece |
|
||||
| Beh automatizaci | chybi | ulozeny strom se nevykonava, neni runtime |
|
||||
| Databaze | chybi | data jsou v pameti, restart je vrati na vychozi stav |
|
||||
@@ -37,9 +42,15 @@ 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.
|
||||
|
||||
## Dalsi krok
|
||||
|
||||
Nejuzitecnejsi pristavek je nastaveni poli akci a s nim predavani dat mezi kroky,
|
||||
aby slo rict "do e-mailu dej parametr customer ze spoustece". Je to zasah do
|
||||
datoveho modelu, vyplati se navrhnout drive nez se builder rozsiri dal.
|
||||
Podrobnosti v [05-dashboard-a-builder.md](05-dashboard-a-builder.md).
|
||||
aby slo rict "do e-mailu dej parametr customer ze spoustece". Parametry spoustece
|
||||
uz existuji na obou stranach, chybi jen jejich pouziti v akcich. Podrobnosti
|
||||
v [05-dashboard-a-builder.md](05-dashboard-a-builder.md).
|
||||
|
||||
Vedle toho stoji za rozmysleni evidence bugs a wishes. Zamerne to nejsou tickety,
|
||||
duvod je v [06-tickety.md](06-tickety.md).
|
||||
|
||||
@@ -48,6 +48,33 @@ Vite build ma `base: './'`, tedy relativni odkazy na soubory. Server pri odeslan
|
||||
Bez `<base>` by prohlizec hledal soubory v `/apps/<app-id>/dashboard/assets/...`
|
||||
a dostal by HTML aplikace misto skriptu.
|
||||
|
||||
## Koncove lomitko v adrese aplikace
|
||||
|
||||
Aplikaci je nutne otevirat **s koncovym lomitkem**:
|
||||
|
||||
```
|
||||
https://services.csbot.cz/apps/<app-id>/ funguje
|
||||
https://services.csbot.cz/apps/<app-id> vraci prazdnou odpoved
|
||||
```
|
||||
|
||||
Neni to chyba aplikace. Caddy routuje pres `handle_path /apps/<app-id>/*`,
|
||||
coz sedi na cestu s lomitkem a na vse pod ni, ale ne na holou cestu bez nej.
|
||||
Pozadavek se pak do containeru vubec nedostane.
|
||||
|
||||
Pozna se to podle hlavicek. Odpoved z aplikace ma `X-Powered-By: Express`
|
||||
a `Content-Type`, kdezto prazdna odpoved ma jen `Server: Caddy`:
|
||||
|
||||
```bash
|
||||
curl -sI https://services.csbot.cz/apps/<app-id> | grep -i x-powered-by # nic
|
||||
curl -sI https://services.csbot.cz/apps/<app-id>/ | grep -i x-powered-by # Express
|
||||
```
|
||||
|
||||
Ostatni cesty (`/health`, `/docs/`, `/api/...`) lomitko v sobe uz maji,
|
||||
takze se jich to netyka.
|
||||
|
||||
Opravit se to da jen presmerovanim v konfiguraci Caddy, tedy v repozitari
|
||||
AppFactory. Z aplikacniho repozitare se infrastruktura nemeni, viz AGENTS.md.
|
||||
|
||||
## Povinne endpointy
|
||||
|
||||
| Verejna cesta | Vraci |
|
||||
|
||||
@@ -39,7 +39,8 @@ image jen `dist`, takze staci jedna slozka.
|
||||
| `src/routes/simulate.ts` | vyvolani provoznich udalosti |
|
||||
| `src/routes/webhook.ts` | verejny prijem dat do automatizace |
|
||||
| `src/routes/contact.ts` | poptavkovy formular z webu |
|
||||
| `src/data/ticketStore.ts` | tickety vcetne zmen a udalosti |
|
||||
| `src/data/ticketStore.ts` | tickety, jejich resitele, log prubehu, prehled vytizeni |
|
||||
| `src/data/people.ts` | resitele ticketu - oddeleni od uzivatelu portalu |
|
||||
| `src/data/incidentStore.ts` | incidenty vcetne zmen a udalosti |
|
||||
| `src/data/automationStore.ts` | automatizace, strom akci, tokeny webhooku |
|
||||
| `src/data/connectors.ts` | katalog konektoru, jejich spousteču a akci |
|
||||
@@ -61,6 +62,8 @@ image jen `dist`, takze staci jedna slozka.
|
||||
| `web/src/lib/flow.ts` | ciste funkce nad stromem automatizace |
|
||||
| `web/src/components/dashboard/` | shell portalu, dlazdice, graf, stream, simulace |
|
||||
| `web/src/components/dashboard/flow/` | strom akci a vyber kroku |
|
||||
| `web/src/components/dashboard/TicketTrace.tsx` | log ticketu jako strom |
|
||||
| `web/src/components/dashboard/TicketWorkload.tsx` | prehled, kdo co ma u sebe |
|
||||
| `web/src/components/home/` | sekce homepage |
|
||||
| `web/src/pages/` | jedna stranka je jeden soubor |
|
||||
|
||||
@@ -82,4 +85,11 @@ Kod zustava anglicky.
|
||||
**Data v pameti.** Vedome zjednoduseni prototypu. Uloziste jsou oddelena od rout,
|
||||
takze napojeni na databazi znamena prepsat soubory v `src/data/`, ne endpointy.
|
||||
|
||||
**Resitel neni uzivatel.** Uzivatel se prihlasuje do portalu, resitel ma u sebe
|
||||
tickety. Technik muze mit tickety a ucet nikdy nemit. Spojka je e-mail,
|
||||
podrobnosti v [06-tickety.md](06-tickety.md).
|
||||
|
||||
**Filtrovani ticketu dela server.** Klient posila query parametry a dostane hotovy
|
||||
seznam. Kdyby filtroval sam, ukazoval by jina cisla nez prehled vytizeni.
|
||||
|
||||
**Zadna ticha selhani.** Kazdy `catch` loguje a uzivatel se o chybe dozvi.
|
||||
|
||||
+35
-2
@@ -23,7 +23,13 @@ Vyzaduji `Authorization: Bearer <token>`:
|
||||
| GET | `/api/auth/me` |
|
||||
| POST | `/api/auth/logout` |
|
||||
| GET | `/api/dashboard/summary` |
|
||||
| GET | `/api/dashboard/people` |
|
||||
| GET | `/api/dashboard/tickets` |
|
||||
| GET | `/api/dashboard/tickets/workload` |
|
||||
| GET | `/api/dashboard/tickets/:id` |
|
||||
| POST | `/api/dashboard/tickets/:id/assign` |
|
||||
| POST | `/api/dashboard/tickets/:id/status` |
|
||||
| POST | `/api/dashboard/tickets/:id/comment` |
|
||||
| GET | `/api/dashboard/incidents` |
|
||||
| GET | `/api/dashboard/connectors` |
|
||||
| GET | `/api/dashboard/stream` |
|
||||
@@ -72,8 +78,8 @@ cookie se `Secure` a `SameSite` plus CSRF token.
|
||||
a poslednich par udalosti, pak uz jen nove. Kazdych 25 sekund jde komentarovy
|
||||
radek, aby spojeni neuspalo proxy.
|
||||
|
||||
Typy udalosti: `ticket.created`, `ticket.updated`, `ticket.resolved`,
|
||||
`incident.started`, `incident.updated`, `incident.resolved`,
|
||||
Typy udalosti: `ticket.created`, `ticket.updated`, `ticket.assigned`,
|
||||
`ticket.resolved`, `incident.started`, `incident.updated`, `incident.resolved`,
|
||||
`automation.created`, `automation.updated`, `automation.deleted`,
|
||||
`automation.run`, `webhook.received`.
|
||||
|
||||
@@ -107,6 +113,29 @@ curl -X POST https://services.csbot.cz/apps/<app-id>/webhook/<token> \
|
||||
Prototyp pozadavek prijme, zvaliduje a zapocita do metrik, ale strom akci
|
||||
nevykona - runtime neexistuje.
|
||||
|
||||
## Tickety
|
||||
|
||||
Popis modelu je v [06-tickety.md](06-tickety.md), tady jen to, co se tyka API.
|
||||
|
||||
`GET /api/dashboard/tickets` bere filtry v query: `assignee`, `status`, `channel`.
|
||||
U `assignee` jsou dve zvlastni hodnoty: `me` znamena resitele odpovidajiciho
|
||||
prihlasenemu uzivateli, `unassigned` frontu bez resitele. Neznama hodnota filtru
|
||||
se zaloguje a ignoruje - je lepsi ukazat vic ticketu nez prazdny seznam
|
||||
bez vysvetleni.
|
||||
|
||||
Odpoved nese vedle `items` jeste `meId`. Klient podle nej pozna, ktere tickety
|
||||
jsou jeho, a jestli ma vubec smysl nabizet filtr "moje".
|
||||
|
||||
Filtrovani dela **server**, ne klient. Seznam a prehled vytizeni tak nikdy
|
||||
neukazuji jina cisla. Vyhledavaci pole v portalu je jina vec - to jen dohledava
|
||||
v uz nactenem seznamu.
|
||||
|
||||
`GET /api/dashboard/tickets/:id` vraci navic `trace`, tedy log prubehu vcetne
|
||||
toho, co ktera volana sluzba vratila.
|
||||
|
||||
`POST /api/dashboard/tickets/:id/assign` s telem `{"assigneeId": null}` vrati
|
||||
ticket do fronty. Neznamy resitel vraci 404, ne tiche odpojeni.
|
||||
|
||||
## Simulace
|
||||
|
||||
`POST /api/simulate` vyvola provozni udalost pro nahled ziveho dashboardu.
|
||||
@@ -115,6 +144,10 @@ 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`, 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.
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
/dashboard/automatizace seznam a zalozeni nove
|
||||
/dashboard/automatizace/:id builder: strom akci
|
||||
/dashboard/konektory katalog sluzeb, jejich spousteču a akci
|
||||
/dashboard/tickety tabulka ticketu
|
||||
/dashboard/tickety seznam, filtry, prehled vytizeni tymu
|
||||
/dashboard/tickety/:id detail ticketu: prubeh a log, resitel, zakaznik
|
||||
/dashboard/incidenty prehled incidentu
|
||||
/dashboard/nastaveni udaje o uctu
|
||||
```
|
||||
@@ -33,7 +34,8 @@ az do 15 sekund, aby pri vypadku serveru neubijel provoz.
|
||||
|
||||
Modalni okno se otevre tlacitkem Simulace. Umoznuje:
|
||||
|
||||
- zalozit ticket s vlastnim predmetem, zadavatelem a prioritou,
|
||||
- zalozit ticket z vybraneho kanalu vcetne celeho logu, ktery k nemu vede,
|
||||
a s prepinacem, jestli se zakaznik v CRM dohleda nebo ne,
|
||||
- vyvolat incident s vlastnim popisem, sluzbou a zavaznosti,
|
||||
- vyresit prvni nevyrizeny ticket nebo bezici incident,
|
||||
- spustit automatizaci uspesne nebo s chybou.
|
||||
@@ -69,11 +71,21 @@ existujici podminky nerozbije.
|
||||
Misto vlozeni urcuje `FlowPath` v `web/src/lib/flow.ts`: prazdne pole je hlavni
|
||||
sekvence, `[{ stepId, branch }]` je vetev konkretni podminky.
|
||||
|
||||
## Webhook a vstupni parametry
|
||||
## Odkud se berou vstupni parametry
|
||||
|
||||
Jsou dva druhy spousteču a lisi se tim, kdo urcuje jejich parametry.
|
||||
|
||||
**Parametry deklaruje uzivatel** (webhook, formular). V katalogu maji
|
||||
`customPayload: true`. V builderu se pridavaji rucne: nazev, typ (text, cislo,
|
||||
ano-ne, datum) a povinnost.
|
||||
|
||||
**Parametry urcuje sluzba** (e-mail, WhatsApp, hlasova linka, ticket). V katalogu
|
||||
je nese `providedFields`. Builder je ukazuje jen ke cteni a server je pri ulozeni
|
||||
stromu vzdy dosadi z katalogu, jeste pred validaci. Podrobnosti a duvody jsou
|
||||
v [06-tickety.md](06-tickety.md), sekce "Parametry od sluzby".
|
||||
|
||||
U spoustece typu webhook vygeneruje server pri ulozeni adresu
|
||||
`POST <verejna-adresa>/webhook/<token>`. U spoustece se deklaruji vstupni
|
||||
parametry: nazev, typ (text, cislo, ano-ne, datum) a povinnost.
|
||||
`POST <verejna-adresa>/webhook/<token>`.
|
||||
|
||||
Podminky pak porovnavaji hodnotu parametru, napriklad `score >= 15`. Nabidka
|
||||
operatoru se ridi typem, na cislo nejde pustit "obsahuje". Tabulka operatoru je
|
||||
@@ -104,6 +116,8 @@ Rozdelana prace se nikdy nezahazuje.
|
||||
Musi existovat v `lucide-react`.
|
||||
3. Pokud patri do nove kategorie, doplnit ji do `connectorCategories` a do typu
|
||||
`ConnectorCategory` na obou stranach.
|
||||
4. Pokud spoustec predava vlastni data, deklarovat je v `providedFields`. ID
|
||||
parametru musi zustat stabilni, odkazuji se na ne podminky v ulozenych stromech.
|
||||
|
||||
Builder i katalog ji vezmou automaticky.
|
||||
|
||||
@@ -112,6 +126,7 @@ Builder i katalog ji vezmou automaticky.
|
||||
| Chybi | Poznamka |
|
||||
| --------------------------- | -------------------------------------------------------- |
|
||||
| Nastaveni poli akci | `fields` u akci se zobrazuji jen jako napoveda |
|
||||
| Vazba logu ticketu na beh | log plni simulace, ne vykonany strom |
|
||||
| Predavani dat do akci | chybi syntaxe odkazu, navrh je `{{trigger.customer}}` |
|
||||
| Kombinovane podminky | jedna podminka je jedno porovnani, AND a OR jen vnorenim |
|
||||
| Beh automatizaci | ulozeny strom se nevykonava |
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
# 06 - Tickety
|
||||
|
||||
## Co ticket je a co neni
|
||||
|
||||
Ticket je **prichozi pozadavek odkudkoliv**. Prisla zprava na WhatsApp, prisel
|
||||
e-mail, nekdo zavolal na hlasovou linku, odeslal formular. Z toho vznikne ticket,
|
||||
ktery ma sveho cloveka a dohledatelny prubeh.
|
||||
|
||||
Ticket **neni** bug ani wish. Vyvojarska agenda je jina vec s jinym zivotnim
|
||||
cyklem a v prototypu zatim neexistuje. Michat je do jedne evidence by znamenalo,
|
||||
ze ani jedna nefunguje poradne.
|
||||
|
||||
## Tri veci, na kterych to stoji
|
||||
|
||||
**Kanaly ustuji do ticketu.** WhatsApp, e-mail, hlasova linka a formular jsou
|
||||
plnohodnotne spoustece. Automatizace zacne prichozi zpravou a skonci zalozenym
|
||||
ticketem.
|
||||
|
||||
**Ticket ma sveho cloveka.** `assignee` neni volny text, ale odkaz na konkretniho
|
||||
resitele. Da se rict "hod to na Karla Vomacku" a Karel to ma mezi svymi tickety.
|
||||
Nad tym je prehled pres cely tym, kde je videt, kdo co u sebe ma.
|
||||
|
||||
**Kazdy ticket je dohledatelny.** Nese si strom zaznamu o tom, co se s nim delo
|
||||
a **co ktera sluzba vratila**. Kdyz neco nesedi, neni potreba hadat.
|
||||
|
||||
## Datovy model
|
||||
|
||||
`src/data/ticketStore.ts`
|
||||
|
||||
```ts
|
||||
interface Ticket {
|
||||
id: string;
|
||||
subject: string;
|
||||
channel: 'whatsapp' | 'email' | 'voice' | 'form' | 'portal';
|
||||
customer: TicketCustomer;
|
||||
status: 'new' | 'open' | 'waiting' | 'resolved';
|
||||
priority: 'low' | 'normal' | 'high' | 'critical';
|
||||
assignee: { id: string; name: string } | null; // null = ceka ve fronte
|
||||
automationId: string | null; // null = zalozeno rucne
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface TicketCustomer {
|
||||
id: string | null; // null = firmu se nepodarilo dohledat v CRM
|
||||
company: string;
|
||||
contact: string;
|
||||
reply: string; // adresa nebo cislo, kam se odpovida
|
||||
}
|
||||
```
|
||||
|
||||
`customer.id` je zamerne nullable. Prave na nej se pta podminka "mame zakaznika?"
|
||||
ve strome automatizace. Bez toho by nesla postavit vetev "firmu neznam, zaloz
|
||||
obchodni pripad a nekomu to dej".
|
||||
|
||||
Uvnitr ulozista se drzi jen `assigneeId`, jmeno se dopocitava pri cteni. Kdyz
|
||||
resitel ze seznamu zmizi, ticket nespadne - jen se zaloguje a tvari se jako
|
||||
neprirazeny.
|
||||
|
||||
## Resitele
|
||||
|
||||
`src/data/people.ts`
|
||||
|
||||
Resitel je oddeleny od uzivatele. **Uzivatel** je ten, kdo se prihlasi do portalu,
|
||||
**resitel** je ten, na koho jde ticket. Casto je to tyz clovek, ale ne vzdy -
|
||||
technik muze mit tickety a do portalu se nikdy neprihlasit.
|
||||
|
||||
Spojka mezi obojim je e-mail. Podle ni funguje filtr "moje tickety"
|
||||
(`?assignee=me`). Kdyz prihlaseny ucet zadnemu resiteli neodpovida, filtr se
|
||||
v portalu nabidne jako nedostupny misto toho, aby vracel prazdno bez vysvetleni.
|
||||
|
||||
Kazdy resitel ma `capacity`, tedy pocet nevyrizenych ticketu, ktery je pro nej
|
||||
jeste zdrava zatez. Neni to limit, nic se podle nej neodmita - jen se v prehledu
|
||||
oznaci, kdo je nad ni.
|
||||
|
||||
## Prehled nad firmou
|
||||
|
||||
`GET /api/dashboard/tickets/workload` vraci pres cely tym: kolik ma kdo
|
||||
nevyrizenych, kolik celkem, kolik kritickych, jak stary je jeho nejstarsi ticket
|
||||
a jestli je nad kapacitu. K tomu pocet ticketu ve fronte bez resitele.
|
||||
|
||||
V portalu je to postranni panel na strance Tickety. Radek je zaroven filtr
|
||||
seznamu - kliknutim na cloveka se seznam zuzi na jeho tickety. Bez toho by to
|
||||
byl jen obrazek.
|
||||
|
||||
Sirka pruhu se pocita proti nejvytizenejsimu clenovi tymu, ne proti kapacite.
|
||||
Jde o porovnani lidi mezi sebou.
|
||||
|
||||
## Log ticketu
|
||||
|
||||
Log je **strom**, ne seznam. Vetev podminky visi na zaznamu te podminky, takze je
|
||||
videt i to, ktera cast behu se vubec nespustila.
|
||||
|
||||
```ts
|
||||
interface TicketTraceEntry {
|
||||
id: string;
|
||||
parentId: string | null; // null = hlavni sekvence
|
||||
kind: 'trigger' | 'action' | 'condition' | 'note';
|
||||
connectorId: string | null; // ktera sluzba to byla
|
||||
operationId: string | null;
|
||||
label: string;
|
||||
status: 'ok' | 'error' | 'skipped' | 'info';
|
||||
response: string | null; // co sluzba vratila
|
||||
durationMs: number | null;
|
||||
at: string;
|
||||
}
|
||||
```
|
||||
|
||||
`response` je duvod, proc log existuje. V portalu se zobrazuje rovnou, ne po
|
||||
rozkliknuti - kvuli nemu se do logu chodi.
|
||||
|
||||
Zapisuje se zanorene (`TraceInput` s `children`) a uklada zplostele s `parentId`.
|
||||
Klient si strom zase poskladá. Zaznam s neznamym rodicem se nezahodi, prida se
|
||||
do korene a zaloguje - ztratit radek logu je horsi nez ho ukazat spatne zanoreny.
|
||||
|
||||
Komentare jsou taky zaznamy logu (`kind: 'note'`). Diky tomu je vsechno na jedne
|
||||
casove ose a nemusi se nikde skladat dohromady dva ruzne seznamy.
|
||||
|
||||
## Konektor Tickety
|
||||
|
||||
Kategorie `servicedesk`, driv byl pod Nastroji. Ma obe strany:
|
||||
|
||||
| Spoustec | Kdy |
|
||||
| ------------------ | ------------------------------------------------------ |
|
||||
| `created` | zalozen ticket, at uz z kanalu nebo rucne |
|
||||
| `unknown-customer` | k ticketu se nepodarilo dohledat firmu |
|
||||
| `assigned` | ticket dostal konkretniho cloveka |
|
||||
| `status-changed` | prechod do jineho stavu vcetne vyreseni |
|
||||
|
||||
| Akce | Co dela |
|
||||
| --------------- | --------------------------------------------- |
|
||||
| `create` | zalozi pozadavek |
|
||||
| `assign` | preda ticket cloveku |
|
||||
| `set-status` | posune stav |
|
||||
| `link-customer` | doplni firmu z CRM |
|
||||
| `comment` | zapise komentar do logu |
|
||||
|
||||
Typicky retez, ktery z toho jde postavit:
|
||||
|
||||
```
|
||||
Prijata zprava z WhatsApp
|
||||
Zaradit do kategorie (AI)
|
||||
Dohledat firmu podle telefonu (CRM)
|
||||
knownCustomer?
|
||||
ANO Zalozit ticket, Priradit resiteli
|
||||
NE Zalozit ticket, Zalozit obchodni pripad, Upozornit servicedesk
|
||||
```
|
||||
|
||||
## Parametry od sluzby
|
||||
|
||||
Nektere spoustece si data urcuji samy. E-mail posila `from`, `subject`, `body`.
|
||||
WhatsApp posila `phone`, `text`. Ticket posila `knownCustomer`. Uzivatel si je
|
||||
nevymysli, ale potrebuje nad nimi stavet podminky.
|
||||
|
||||
Katalog je proto deklaruje v `providedFields` u operace. Chovaji se pak takhle:
|
||||
|
||||
- builder je ukazuje **jen ke cteni**, pridat ani prejmenovat nejdou,
|
||||
- server je pri ulozeni stromu **vzdy dosadi z katalogu** a to, co poslal klient,
|
||||
zahodi (`normalizeTriggerFields` v `src/routes/dashboard.ts`),
|
||||
- dosazeni probiha **pred validaci**, jinak by podminky odkazujici na katalogova
|
||||
ID vypadaly jako rozbite.
|
||||
|
||||
ID techto parametru (`email.from`, `ticket.knownCustomer`) musi zustat stabilni.
|
||||
Odkazuji se na ne podminky v ulozenych stromech, prejmenovani ID je rozbije.
|
||||
|
||||
Spoustece bez `providedFields` (webhook, formular) funguji jako predtim -
|
||||
parametry si deklaruje uzivatel.
|
||||
|
||||
## Stranky portalu
|
||||
|
||||
```
|
||||
/dashboard/tickety seznam, filtry, prehled vytizeni
|
||||
/dashboard/tickety/:id detail: prubeh a log, resitel, zakaznik, puvod
|
||||
```
|
||||
|
||||
## Co chybi
|
||||
|
||||
| Chybi | Poznamka |
|
||||
| ---------------------------- | ------------------------------------------------------- |
|
||||
| Bugs a wishes | vyvojarska agenda, samostatna evidence |
|
||||
| Skutecny beh automatizaci | log ticketu zatim plni simulace, ne runtime |
|
||||
| Napojeni logu na beh | `automationId` je odkaz, historie behu ale neexistuje |
|
||||
| Odpoved zakaznikovi z detailu| akce `send` u kanalu se z portalu nevola |
|
||||
| SLA a eskalace | zadne lhuty, `capacity` je jen orientacni |
|
||||
| Databaze | data v pameti, restart je vrati na vychozi sadu |
|
||||
@@ -2,6 +2,48 @@
|
||||
|
||||
Nejnovejsi nahore.
|
||||
|
||||
## 2026-08-03
|
||||
|
||||
Tickety predelane na plnohodnotny konektor. Prestavaji byt polozkou v seznamu
|
||||
a stavaji se prichozim pozadavkem, ktery ma sveho cloveka a dohledatelny prubeh.
|
||||
Popis modelu je v [06-tickety.md](06-tickety.md).
|
||||
|
||||
### Pridano
|
||||
|
||||
- Kanaly do ticketu: WhatsApp jako novy konektor, e-mail a hlasova linka
|
||||
jako plnohodnotne spoustece.
|
||||
- Konektor Tickety presunut do nove kategorie `servicedesk`, rozsiren
|
||||
o spoustece `created`, `unknown-customer`, `assigned`, `status-changed`
|
||||
a akce `assign`, `set-status`, `link-customer`.
|
||||
- Resitele (`src/data/people.ts`) oddelene od uzivatelu portalu, spojka e-mailem.
|
||||
- Log ticketu ve strome vcetne toho, co ktera volana sluzba vratila.
|
||||
- Prehled vytizeni tymu, kdo co ma u sebe, zaroven jako filtr seznamu.
|
||||
- Detail ticketu `/dashboard/tickety/:id`: prubeh a log, prirazeni, stav,
|
||||
zakaznik, komentare.
|
||||
- Filtry seznamu ticketu na serveru: resitel (vcetne `me` a `unassigned`),
|
||||
stav, kanal.
|
||||
- `providedFields` v katalogu konektoru: parametry, ktere spoustec predava sam.
|
||||
- Udalost `ticket.assigned` na sbernici i v portalu.
|
||||
- Endpointy `/api/dashboard/people`, `/tickets/workload`, `/tickets/:id`
|
||||
a POST varianty pro assign, status a comment. Vse ve Swaggeru.
|
||||
|
||||
### Zmeneno
|
||||
|
||||
- `Ticket` ma misto volneho `requester` strukturovaneho `customer` s nullable
|
||||
`id` firmy v CRM, k tomu `channel`, `assignee` jako odkaz na cloveka
|
||||
a `automationId`.
|
||||
- `customer.id === null` je nosna informace, ne chybejici udaj. Prave na ni se
|
||||
pta podminka "mame zakaznika?" ve strome automatizace.
|
||||
- Simulace ticketu bere kanal a prepinac, jestli se zakaznik dohleda.
|
||||
Zakladany ticket dostane cely realisticky log.
|
||||
- Builder ukazuje parametry od sluzby jen ke cteni. Server je pri ulozeni
|
||||
vzdy dosadi z katalogu, a to jeste pred validaci stromu.
|
||||
|
||||
### Vedome neudelano
|
||||
|
||||
Bugs a wishes zustavaji mimo. Vyvojarska agenda ma jiny zivotni cyklus a slucovat
|
||||
ji s tickety by znamenalo, ze ani jedna evidence nefunguje poradne.
|
||||
|
||||
## 2026-07-31
|
||||
|
||||
Prvni nasazeni aplikace do repozitare csbot-prototype.
|
||||
|
||||
+193
-33
@@ -6,11 +6,14 @@
|
||||
* - actions: co se s nim da UDELAT uprostred behu
|
||||
*
|
||||
* Konektor muze mit jen triggery (webhook), jen akce (odeslani e-mailu), nebo obojí.
|
||||
* Jak pridat novy konektor: docs/08-automatizace-builder.md
|
||||
* Jak pridat novy konektor: documentation/05-dashboard-a-builder.md
|
||||
*/
|
||||
|
||||
import type { FieldType } from './conditions.js';
|
||||
|
||||
export type ConnectorCategory =
|
||||
| 'spoustece'
|
||||
| 'servicedesk'
|
||||
| 'crm'
|
||||
| 'ekonomika'
|
||||
| 'logistika'
|
||||
@@ -22,6 +25,21 @@ export type ConnectorCategory =
|
||||
/** connected = klient ho ma napojeny, available = umime napojit, planned = na roadmape */
|
||||
export type ConnectorStatus = 'connected' | 'available' | 'planned';
|
||||
|
||||
/**
|
||||
* Parametr, ktery spoustec sam preda do stromu. Tvarem odpovida `TriggerField`
|
||||
* v automationStore, ale definuje se tady - katalog je zdroj pravdy o tom,
|
||||
* co ktera sluzba posila.
|
||||
*
|
||||
* ID musi byt stabilni, odkazuji se na nej podminky v ulozenych stromech.
|
||||
* Prejmenovani ID rozbije existujici automatizace.
|
||||
*/
|
||||
export interface ProvidedField {
|
||||
id: string;
|
||||
name: string;
|
||||
type: FieldType;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
export interface ConnectorOperation {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -33,6 +51,12 @@ export interface ConnectorOperation {
|
||||
* (webhook, formular). false/chybi = data urcuje sluzba.
|
||||
*/
|
||||
customPayload?: boolean;
|
||||
/**
|
||||
* Jen u triggeru: parametry, ktere sluzba predava sama. Uzivatel je nemeni,
|
||||
* server je pri ukladani stromu dosadi misto toho, co poslal klient.
|
||||
* Diky nim jde stavet podminky nad daty, ktera si nikdo nevymyslel.
|
||||
*/
|
||||
providedFields?: ProvidedField[];
|
||||
}
|
||||
|
||||
export interface Connector {
|
||||
@@ -49,6 +73,7 @@ export interface Connector {
|
||||
|
||||
export const connectorCategories: Array<{ id: ConnectorCategory; label: string }> = [
|
||||
{ id: 'spoustece', label: 'Spouštěče' },
|
||||
{ id: 'servicedesk', label: 'Tickety a servicedesk' },
|
||||
{ id: 'crm', label: 'CRM' },
|
||||
{ id: 'ekonomika', label: 'Ekonomika a banky' },
|
||||
{ id: 'logistika', label: 'Logistika' },
|
||||
@@ -129,6 +154,111 @@ export const connectors: Connector[] = [
|
||||
actions: [],
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------- servicedesk
|
||||
/**
|
||||
* Tickety nejsou jen akce na konci stromu. Jsou to obe strany:
|
||||
* kanaly do nich ustuji (WhatsApp, e-mail, hlas) a zalozeny ticket
|
||||
* je zase spoustecem navazne automatizace - typicky "mame zakaznika?".
|
||||
*/
|
||||
{
|
||||
id: 'ticket',
|
||||
name: 'Tickety',
|
||||
category: 'servicedesk',
|
||||
description:
|
||||
'Servicedesk. Požadavek od zákazníka, který má svého řešitele a dohledatelný průběh.',
|
||||
icon: 'LifeBuoy',
|
||||
status: 'connected',
|
||||
triggers: [
|
||||
{
|
||||
id: 'created',
|
||||
name: 'Založen ticket',
|
||||
description:
|
||||
'Spustí se při každém novém ticketu, ať vznikl z kanálu nebo ručně. ' +
|
||||
'Podle parametru knownCustomer se pozná, jestli se firma dohledala v CRM.',
|
||||
providedFields: [
|
||||
{ id: 'ticket.id', name: 'ticketId', type: 'string', required: true },
|
||||
{ id: 'ticket.subject', name: 'subject', type: 'string', required: true },
|
||||
{ id: 'ticket.channel', name: 'channel', type: 'string', required: true },
|
||||
{ id: 'ticket.company', name: 'company', type: 'string', required: false },
|
||||
{ id: 'ticket.contact', name: 'contact', type: 'string', required: false },
|
||||
{ id: 'ticket.priority', name: 'priority', type: 'string', required: true },
|
||||
{ id: 'ticket.knownCustomer', name: 'knownCustomer', type: 'boolean', required: true },
|
||||
{ id: 'ticket.assigned', name: 'assigned', type: 'boolean', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'unknown-customer',
|
||||
name: 'Ticket bez zákazníka',
|
||||
description:
|
||||
'Spustí se, když se k ticketu nepodařilo dohledat firmu. Sem patří založení ' +
|
||||
'obchodního případu nebo dotaz zpět na zadavatele.',
|
||||
providedFields: [
|
||||
{ id: 'ticket.id', name: 'ticketId', type: 'string', required: true },
|
||||
{ id: 'ticket.subject', name: 'subject', type: 'string', required: true },
|
||||
{ id: 'ticket.channel', name: 'channel', type: 'string', required: true },
|
||||
{ id: 'ticket.contact', name: 'contact', type: 'string', required: false },
|
||||
{ id: 'ticket.reply', name: 'reply', type: 'string', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'assigned',
|
||||
name: 'Ticket přiřazen řešiteli',
|
||||
description: 'Spustí se, jakmile ticket dostane konkrétního člověka.',
|
||||
providedFields: [
|
||||
{ id: 'ticket.id', name: 'ticketId', type: 'string', required: true },
|
||||
{ id: 'ticket.subject', name: 'subject', type: 'string', required: true },
|
||||
{ id: 'ticket.assignee', name: 'assignee', type: 'string', required: true },
|
||||
{ id: 'ticket.assigneeEmail', name: 'assigneeEmail', type: 'string', required: true },
|
||||
{ id: 'ticket.priority', name: 'priority', type: 'string', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'status-changed',
|
||||
name: 'Změna stavu ticketu',
|
||||
description: 'Spustí se při přechodu do jiného stavu, včetně vyřešení.',
|
||||
providedFields: [
|
||||
{ id: 'ticket.id', name: 'ticketId', type: 'string', required: true },
|
||||
{ id: 'ticket.subject', name: 'subject', type: 'string', required: true },
|
||||
{ id: 'ticket.status', name: 'status', type: 'string', required: true },
|
||||
{ id: 'ticket.previousStatus', name: 'previousStatus', type: 'string', required: true },
|
||||
{ id: 'ticket.assignee', name: 'assignee', type: 'string', required: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
id: 'create',
|
||||
name: 'Založit ticket',
|
||||
description: 'Vytvoří požadavek včetně kanálu, firmy, priority a volitelně řešitele.',
|
||||
fields: ['Předmět', 'Kanál', 'Firma', 'Kontakt', 'Adresa pro odpověď', 'Priorita', 'Řešitel'],
|
||||
},
|
||||
{
|
||||
id: 'assign',
|
||||
name: 'Přiřadit řešiteli',
|
||||
description: 'Předá ticket konkrétnímu člověku. Objeví se mu mezi jeho tickety.',
|
||||
fields: ['ID ticketu', 'Řešitel'],
|
||||
},
|
||||
{
|
||||
id: 'set-status',
|
||||
name: 'Změnit stav',
|
||||
description: 'Posune ticket na nový, v řešení, čeká na klienta nebo vyřešeno.',
|
||||
fields: ['ID ticketu', 'Nový stav'],
|
||||
},
|
||||
{
|
||||
id: 'link-customer',
|
||||
name: 'Napojit na zákazníka',
|
||||
description: 'Doplní ticketu firmu z CRM. Používá se poté, co se zákazník dohledá.',
|
||||
fields: ['ID ticketu', 'ID firmy v CRM'],
|
||||
},
|
||||
{
|
||||
id: 'comment',
|
||||
name: 'Přidat komentář',
|
||||
description: 'Zapíše komentář do logu ticketu, aby byl na stejné časové ose jako běh.',
|
||||
fields: ['ID ticketu', 'Autor', 'Text'],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// --------------------------------------------------------------------- crm
|
||||
{
|
||||
id: 'raynet',
|
||||
@@ -325,8 +455,15 @@ export const connectors: Connector[] = [
|
||||
{
|
||||
id: 'received',
|
||||
name: 'Přijat e-mail',
|
||||
description: 'Spustí se při doručení e-mailu do sledované schránky.',
|
||||
fields: ['Schránka', 'Filtr odesílatele nebo předmětu'],
|
||||
description:
|
||||
'Spustí se při doručení e-mailu do sledované schránky. Typický začátek ticketu.',
|
||||
providedFields: [
|
||||
{ id: 'email.from', name: 'from', type: 'string', required: true },
|
||||
{ id: 'email.subject', name: 'subject', type: 'string', required: true },
|
||||
{ id: 'email.body', name: 'body', type: 'string', required: false },
|
||||
{ id: 'email.hasAttachment', name: 'hasAttachment', type: 'boolean', required: false },
|
||||
{ id: 'email.receivedAt', name: 'receivedAt', type: 'date', required: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
@@ -338,6 +475,42 @@ export const connectors: Connector[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'whatsapp',
|
||||
name: 'WhatsApp',
|
||||
category: 'komunikace',
|
||||
description: 'Příjem a odesílání zpráv přes WhatsApp Business. Nejrychlejší cesta k ticketu.',
|
||||
icon: 'MessageCircle',
|
||||
status: 'connected',
|
||||
triggers: [
|
||||
{
|
||||
id: 'message-received',
|
||||
name: 'Přijata zpráva',
|
||||
description: 'Spustí se při doručení zprávy na firemní číslo.',
|
||||
providedFields: [
|
||||
{ id: 'whatsapp.phone', name: 'phone', type: 'string', required: true },
|
||||
{ id: 'whatsapp.profileName', name: 'profileName', type: 'string', required: false },
|
||||
{ id: 'whatsapp.text', name: 'text', type: 'string', required: true },
|
||||
{ id: 'whatsapp.hasMedia', name: 'hasMedia', type: 'boolean', required: false },
|
||||
{ id: 'whatsapp.receivedAt', name: 'receivedAt', type: 'date', required: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
id: 'send',
|
||||
name: 'Odeslat zprávu',
|
||||
description: 'Odpoví na číslo, ze kterého zpráva přišla, nebo na zadané číslo.',
|
||||
fields: ['Telefon', 'Text'],
|
||||
},
|
||||
{
|
||||
id: 'send-template',
|
||||
name: 'Odeslat schválenou šablonu',
|
||||
description: 'Pošle předschválenou šablonu. Nutné mimo 24hodinové okno konverzace.',
|
||||
fields: ['Telefon', 'Šablona', 'Proměnné'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'microsoft365',
|
||||
name: 'Microsoft 365',
|
||||
@@ -491,7 +664,12 @@ export const connectors: Connector[] = [
|
||||
id: 'call-received',
|
||||
name: 'Příchozí hovor',
|
||||
description: 'Spustí se při přijetí hovoru na hlasovou linku.',
|
||||
fields: ['Linka', 'Jazyk'],
|
||||
providedFields: [
|
||||
{ id: 'voicebot.callerNumber', name: 'callerNumber', type: 'string', required: true },
|
||||
{ id: 'voicebot.line', name: 'line', type: 'string', required: true },
|
||||
{ id: 'voicebot.wantsOperator', name: 'wantsOperator', type: 'boolean', required: false },
|
||||
{ id: 'voicebot.startedAt', name: 'startedAt', type: 'date', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'call-ended',
|
||||
@@ -631,35 +809,6 @@ export const connectors: Connector[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ticket',
|
||||
name: 'Tickety',
|
||||
category: 'nastroje',
|
||||
description: 'Servicedesk — zakládání a aktualizace požadavků.',
|
||||
icon: 'LifeBuoy',
|
||||
status: 'connected',
|
||||
triggers: [
|
||||
{
|
||||
id: 'created',
|
||||
name: 'Nový ticket',
|
||||
description: 'Spustí se při založení ticketu.',
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
id: 'create',
|
||||
name: 'Založit ticket',
|
||||
description: 'Vytvoří požadavek včetně priority a přiřazení.',
|
||||
fields: ['Předmět', 'Popis', 'Priorita', 'Řešitel'],
|
||||
},
|
||||
{
|
||||
id: 'comment',
|
||||
name: 'Přidat komentář',
|
||||
description: 'Zapíše komentář k existujícímu ticketu.',
|
||||
fields: ['ID ticketu', 'Text'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'log',
|
||||
name: 'Zápis do logu',
|
||||
@@ -697,3 +846,14 @@ export function findOperation(
|
||||
const pool = type === 'trigger' ? connector.triggers : connector.actions;
|
||||
return pool.find((op) => op.id === operationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parametry, ktere spoustec predava sam. `undefined` znamena, ze si je
|
||||
* deklaruje uzivatel (webhook, formular) - katalog do toho nemluvi.
|
||||
*/
|
||||
export function providedFieldsFor(
|
||||
connectorId: string,
|
||||
operationId: string,
|
||||
): ProvidedField[] | undefined {
|
||||
return findOperation(connectorId, operationId, 'trigger')?.providedFields;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Resitele ticketu - lide, kteri maji pozadavek "u sebe".
|
||||
*
|
||||
* Zamerne oddelene od `users.ts`. Uzivatel je ten, kdo se prihlasi do portalu,
|
||||
* resitel je ten, na koho jde ticket. Casto je to tyz clovek, ale ne vzdy:
|
||||
* technik muze mit tickety a do portalu se nikdy neprihlasit.
|
||||
* Spojka mezi obojim je e-mail.
|
||||
*
|
||||
* POZOR: data jsou v pameti procesu, restart je vrati na vychozi sadu.
|
||||
*/
|
||||
|
||||
export interface Person {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
/** Cim se v tymu zabyva. Jen popisek, nic nerozhoduje. */
|
||||
role: string;
|
||||
/** Kolik nevyrizenych ticketu je pro nej jeste zdrava zatez. */
|
||||
capacity: number;
|
||||
}
|
||||
|
||||
export const people: Person[] = [
|
||||
{
|
||||
id: 'ppl_vomacka',
|
||||
name: 'Karel Vomáčka',
|
||||
email: 'karel.vomacka@automia.cz',
|
||||
role: 'Servicedesk',
|
||||
capacity: 8,
|
||||
},
|
||||
{
|
||||
id: 'ppl_uhlir',
|
||||
name: 'Jiří Uhlíř',
|
||||
email: 'admin@automia.cz',
|
||||
role: 'Vedoucí týmu',
|
||||
capacity: 5,
|
||||
},
|
||||
{
|
||||
id: 'ppl_kriz',
|
||||
name: 'Martin Kříž',
|
||||
email: 'martin.kriz@automia.cz',
|
||||
role: 'Integrace a API',
|
||||
capacity: 6,
|
||||
},
|
||||
{
|
||||
id: 'ppl_novakova',
|
||||
name: 'Eva Nováková',
|
||||
email: 'eva.novakova@automia.cz',
|
||||
role: 'Voiceboti',
|
||||
capacity: 6,
|
||||
},
|
||||
];
|
||||
|
||||
export function listPeople(): Person[] {
|
||||
return [...people].sort((a, b) => a.name.localeCompare(b.name, 'cs'));
|
||||
}
|
||||
|
||||
export function findPerson(id: string): Person | undefined {
|
||||
return people.find((p) => p.id === id);
|
||||
}
|
||||
|
||||
/** Spojka na prihlaseneho uzivatele - podle ni funguje filtr "moje tickety". */
|
||||
export function findPersonByEmail(email: string): Person | undefined {
|
||||
const normalized = email.trim().toLowerCase();
|
||||
return people.find((p) => p.email.toLowerCase() === normalized);
|
||||
}
|
||||
+684
-40
@@ -2,121 +2,711 @@
|
||||
* Uloziste ticketu. Zmeny posilaji udalost na sbernici, takze se projevi
|
||||
* v dashboardu okamzite bez obnoveni stranky.
|
||||
*
|
||||
* Ticket je prichozi pozadavek odkudkoliv (WhatsApp, e-mail, hlasova linka,
|
||||
* formular, portal). NENI to bug ani wish - vyvojarska agenda ma vlastni
|
||||
* evidenci a s ticketem se plete jen v hlave.
|
||||
*
|
||||
* Dve veci, na kterych model stoji:
|
||||
* - ticket ma vzdy jednoho resitele (nebo zadneho), aby slo rict "mas to u sebe",
|
||||
* - ticket si nese strom zaznamu o tom, co se s nim delo a co ktera sluzba vratila.
|
||||
*
|
||||
* POZOR: data jsou v pameti procesu, restart API je vrati na vychozi sadu.
|
||||
*/
|
||||
|
||||
import { publish } from '../events/bus.js';
|
||||
import { findPerson, type Person } from './people.js';
|
||||
|
||||
export type TicketStatus = 'new' | 'open' | 'waiting' | 'resolved';
|
||||
export type TicketPriority = 'low' | 'normal' | 'high' | 'critical';
|
||||
|
||||
/** Odkud pozadavek prisel. Podle toho se pozna, co ma smysl hledat v logu. */
|
||||
export type TicketChannel = 'whatsapp' | 'email' | 'voice' | 'form' | 'portal';
|
||||
|
||||
export interface TicketCustomer {
|
||||
/**
|
||||
* ID firmy v CRM. null = zakaznika se nepodarilo dohledat.
|
||||
* Prave na tohle se pta podminka "mame zakaznika?" ve stromu automatizace.
|
||||
*/
|
||||
id: string | null;
|
||||
company: string;
|
||||
/** Konkretni clovek, ktery pozadavek poslal. */
|
||||
contact: string;
|
||||
/** Adresa nebo cislo, odkud to prislo a kam se odpovida. */
|
||||
reply: string;
|
||||
}
|
||||
|
||||
/** Odkaz na resitele v odpovedi API. Uvnitr se drzi jen jeho ID. */
|
||||
export interface TicketAssignee {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export type TraceStatus = 'ok' | 'error' | 'skipped' | 'info';
|
||||
export type TraceKind = 'trigger' | 'action' | 'condition' | 'note';
|
||||
|
||||
/**
|
||||
* Jeden radek v logu ticketu. Strom se sklada pres `parentId` -
|
||||
* vetev podminky visi na zaznamu te podminky.
|
||||
*/
|
||||
export interface TicketTraceEntry {
|
||||
id: string;
|
||||
/** null = zaznam v hlavni sekvenci. */
|
||||
parentId: string | null;
|
||||
kind: TraceKind;
|
||||
/** Ktera sluzba to byla. null u poznamek a podminek. */
|
||||
connectorId: string | null;
|
||||
operationId: string | null;
|
||||
label: string;
|
||||
status: TraceStatus;
|
||||
/** Co sluzba vratila. Tohle je duvod, proc log existuje. */
|
||||
response: string | null;
|
||||
durationMs: number | null;
|
||||
at: string;
|
||||
}
|
||||
|
||||
export interface Ticket {
|
||||
id: string;
|
||||
subject: string;
|
||||
requester: string;
|
||||
channel: TicketChannel;
|
||||
customer: TicketCustomer;
|
||||
status: TicketStatus;
|
||||
priority: TicketPriority;
|
||||
assignee: string | null;
|
||||
assignee: TicketAssignee | null;
|
||||
/** Automatizace, ktera ticket zalozila. null = zalozeno rucne. */
|
||||
automationId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface TicketDetail extends Ticket {
|
||||
trace: TicketTraceEntry[];
|
||||
}
|
||||
|
||||
interface StoredTicket extends Omit<Ticket, 'assignee'> {
|
||||
assigneeId: string | null;
|
||||
}
|
||||
|
||||
export const channelLabels: Record<TicketChannel, string> = {
|
||||
whatsapp: 'WhatsApp',
|
||||
email: 'E-mail',
|
||||
voice: 'Hlasová linka',
|
||||
form: 'Webový formulář',
|
||||
portal: 'Portál',
|
||||
};
|
||||
|
||||
function minutesAgo(minutes: number): string {
|
||||
return new Date(Date.now() - minutes * 60_000).toISOString();
|
||||
}
|
||||
|
||||
const tickets: Ticket[] = [
|
||||
// ------------------------------------------------------------------- uloziste
|
||||
|
||||
const tickets: StoredTicket[] = [];
|
||||
/** Log ticketu drzime zvlast - je to jina zivotnost i jiny objem dat. */
|
||||
const traces = new Map<string, TicketTraceEntry[]>();
|
||||
|
||||
let ticketCounter = 4_821;
|
||||
let traceCounter = 0;
|
||||
|
||||
function nextTraceId(): string {
|
||||
traceCounter += 1;
|
||||
return `tr_${traceCounter}`;
|
||||
}
|
||||
|
||||
/** Zaznam v logu tak, jak se zapisuje - strom je zanoreny, ulozeni ho zplosti. */
|
||||
export interface TraceInput {
|
||||
kind: TraceKind;
|
||||
label: string;
|
||||
status: TraceStatus;
|
||||
connectorId?: string | null;
|
||||
operationId?: string | null;
|
||||
response?: string | null;
|
||||
durationMs?: number | null;
|
||||
/** Posun proti "ted" v minutach. Pouziva jen ukazkova data. */
|
||||
agoMinutes?: number;
|
||||
children?: TraceInput[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Zplosti zanoreny zapis do seznamu s `parentId`.
|
||||
* Poradi se zachovava, aby se strom dal vykreslit jednim pruchodem.
|
||||
*/
|
||||
function flattenTrace(
|
||||
inputs: TraceInput[],
|
||||
parentId: string | null,
|
||||
into: TicketTraceEntry[],
|
||||
): TicketTraceEntry[] {
|
||||
for (const input of inputs) {
|
||||
const entry: TicketTraceEntry = {
|
||||
id: nextTraceId(),
|
||||
parentId,
|
||||
kind: input.kind,
|
||||
connectorId: input.connectorId ?? null,
|
||||
operationId: input.operationId ?? null,
|
||||
label: input.label,
|
||||
status: input.status,
|
||||
response: input.response ?? null,
|
||||
durationMs: input.durationMs ?? null,
|
||||
at: minutesAgo(input.agoMinutes ?? 0),
|
||||
};
|
||||
into.push(entry);
|
||||
if (input.children && input.children.length > 0) {
|
||||
flattenTrace(input.children, entry.id, into);
|
||||
}
|
||||
}
|
||||
return into;
|
||||
}
|
||||
|
||||
/** Prida zaznamy do logu ticketu. Vraci, kolik radku pribylo. */
|
||||
export function appendTrace(ticketId: string, inputs: TraceInput[]): number {
|
||||
const existing = traces.get(ticketId);
|
||||
if (!existing) {
|
||||
console.warn(`[tickets] zapis do logu neexistujiciho ticketu: ${ticketId}`);
|
||||
return 0;
|
||||
}
|
||||
const before = existing.length;
|
||||
flattenTrace(inputs, null, existing);
|
||||
return existing.length - before;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- ukazkova data
|
||||
|
||||
function seed(ticket: StoredTicket, trace: TraceInput[]) {
|
||||
tickets.push(ticket);
|
||||
traces.set(ticket.id, flattenTrace(trace, null, []));
|
||||
}
|
||||
|
||||
seed(
|
||||
{
|
||||
id: 'TK-4821',
|
||||
subject: 'Voicebot neodpovídá na volání po 18:00',
|
||||
requester: 'Firma s.r.o.',
|
||||
channel: 'voice',
|
||||
customer: {
|
||||
id: 'crm_1042',
|
||||
company: 'Firma s.r.o.',
|
||||
contact: 'Petra Klientová',
|
||||
reply: '+420 601 118 224',
|
||||
},
|
||||
status: 'open',
|
||||
priority: 'high',
|
||||
assignee: 'Jiří U.',
|
||||
assigneeId: 'ppl_novakova',
|
||||
automationId: 'AUT-02',
|
||||
createdAt: minutesAgo(310),
|
||||
updatedAt: minutesAgo(42),
|
||||
},
|
||||
[
|
||||
{
|
||||
kind: 'trigger',
|
||||
connectorId: 'voicebot',
|
||||
operationId: 'call-received',
|
||||
label: 'Příchozí hovor na linku 800 100 200',
|
||||
status: 'ok',
|
||||
response: '{ "callId": "cl_88213", "from": "+420601118224", "durationSec": 96 }',
|
||||
durationMs: 120,
|
||||
agoMinutes: 310,
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'transcription',
|
||||
operationId: 'transcribe',
|
||||
label: 'Přepis nahrávky',
|
||||
status: 'ok',
|
||||
response:
|
||||
'{ "language": "cs", "confidence": 0.94, "text": "Dobrý den, po šesté hodině to nikdo nebere..." }',
|
||||
durationMs: 4_180,
|
||||
agoMinutes: 309,
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'ai-text',
|
||||
operationId: 'classify',
|
||||
label: 'Zařazení do kategorie',
|
||||
status: 'ok',
|
||||
response: '{ "category": "porucha", "priority": "high", "confidence": 0.88 }',
|
||||
durationMs: 910,
|
||||
agoMinutes: 309,
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'raynet',
|
||||
operationId: 'upsert-contact',
|
||||
label: 'Dohledání firmy podle telefonu',
|
||||
status: 'ok',
|
||||
response: '{ "companyId": "crm_1042", "name": "Firma s.r.o.", "matchedBy": "phone" }',
|
||||
durationMs: 640,
|
||||
agoMinutes: 309,
|
||||
},
|
||||
{
|
||||
kind: 'condition',
|
||||
label: 'knownCustomer je splněno',
|
||||
status: 'ok',
|
||||
response: 'true, pokračuje větev ANO',
|
||||
agoMinutes: 309,
|
||||
children: [
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'ticket',
|
||||
operationId: 'create',
|
||||
label: 'Založení ticketu',
|
||||
status: 'ok',
|
||||
response: '{ "ticketId": "TK-4821", "priority": "high" }',
|
||||
durationMs: 85,
|
||||
agoMinutes: 309,
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'ticket',
|
||||
operationId: 'assign',
|
||||
label: 'Přiřazení řešitele podle služby',
|
||||
status: 'ok',
|
||||
response: '{ "assignee": "Eva Nováková", "rule": "voicebot -> voiceboti" }',
|
||||
durationMs: 40,
|
||||
agoMinutes: 309,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'microsoft365',
|
||||
operationId: 'post-teams',
|
||||
label: 'Upozornění do Teams',
|
||||
status: 'error',
|
||||
response: 'HTTP 429 Too Many Requests, kanál "Servicedesk" překročil limit, zpráva neodešla',
|
||||
durationMs: 2_400,
|
||||
agoMinutes: 308,
|
||||
},
|
||||
{
|
||||
kind: 'note',
|
||||
label: 'Eva Nováková: Reprodukováno, chyba je v nočním režimu scénáře.',
|
||||
status: 'info',
|
||||
agoMinutes: 42,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
seed(
|
||||
{
|
||||
id: 'TK-4820',
|
||||
subject: 'Přidat pole IČO do synchronizace CRM a fakturace',
|
||||
requester: 'Nordis a.s.',
|
||||
channel: 'email',
|
||||
customer: {
|
||||
id: 'crm_2210',
|
||||
company: 'Nordis a.s.',
|
||||
contact: 'Tomáš Beran',
|
||||
reply: 'tomas.beran@nordis.cz',
|
||||
},
|
||||
status: 'waiting',
|
||||
priority: 'normal',
|
||||
assignee: 'Martin K.',
|
||||
assigneeId: 'ppl_kriz',
|
||||
automationId: 'AUT-03',
|
||||
createdAt: minutesAgo(1_180),
|
||||
updatedAt: minutesAgo(190),
|
||||
},
|
||||
[
|
||||
{
|
||||
kind: 'trigger',
|
||||
connectorId: 'email',
|
||||
operationId: 'received',
|
||||
label: 'Přijat e-mail do schránky podpora@',
|
||||
status: 'ok',
|
||||
response: '{ "from": "tomas.beran@nordis.cz", "subject": "IČO v synchronizaci", "attachments": 0 }',
|
||||
durationMs: 60,
|
||||
agoMinutes: 1_180,
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'ai-text',
|
||||
operationId: 'extract',
|
||||
label: 'Vytažení údajů z textu',
|
||||
status: 'ok',
|
||||
response: '{ "type": "pozadavek na zmenu", "system": "CRM + iDoklad", "urgent": false }',
|
||||
durationMs: 1_120,
|
||||
agoMinutes: 1_180,
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'raynet',
|
||||
operationId: 'upsert-contact',
|
||||
label: 'Dohledání firmy podle e-mailu',
|
||||
status: 'ok',
|
||||
response: '{ "companyId": "crm_2210", "name": "Nordis a.s.", "matchedBy": "emailDomain" }',
|
||||
durationMs: 520,
|
||||
agoMinutes: 1_180,
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'ticket',
|
||||
operationId: 'create',
|
||||
label: 'Založení ticketu',
|
||||
status: 'ok',
|
||||
response: '{ "ticketId": "TK-4820", "priority": "normal" }',
|
||||
durationMs: 74,
|
||||
agoMinutes: 1_179,
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'email',
|
||||
operationId: 'send',
|
||||
label: 'Potvrzení zadavateli',
|
||||
status: 'ok',
|
||||
response: '{ "messageId": "<a41c@automia.cz>", "to": "tomas.beran@nordis.cz" }',
|
||||
durationMs: 380,
|
||||
agoMinutes: 1_179,
|
||||
},
|
||||
{
|
||||
kind: 'note',
|
||||
label: 'Martin Kříž: Čekáme na potvrzení rozsahu od zákazníka.',
|
||||
status: 'info',
|
||||
agoMinutes: 190,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
seed(
|
||||
{
|
||||
id: 'TK-4819',
|
||||
subject: 'Chybí denní report objednávek v e-mailu',
|
||||
requester: 'Bistro Kolektiv',
|
||||
channel: 'whatsapp',
|
||||
customer: {
|
||||
id: null,
|
||||
company: 'Neznámá firma',
|
||||
contact: 'Bistro Kolektiv',
|
||||
reply: '+420 774 902 331',
|
||||
},
|
||||
status: 'new',
|
||||
priority: 'normal',
|
||||
assignee: null,
|
||||
assigneeId: null,
|
||||
automationId: 'AUT-01',
|
||||
createdAt: minutesAgo(95),
|
||||
updatedAt: minutesAgo(95),
|
||||
},
|
||||
[
|
||||
{
|
||||
kind: 'trigger',
|
||||
connectorId: 'whatsapp',
|
||||
operationId: 'message-received',
|
||||
label: 'Přijata zpráva z WhatsApp',
|
||||
status: 'ok',
|
||||
response:
|
||||
'{ "from": "+420774902331", "profileName": "Bistro Kolektiv", "text": "Dobrý den, už třetí den nechodí ranní report." }',
|
||||
durationMs: 55,
|
||||
agoMinutes: 95,
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'ai-text',
|
||||
operationId: 'classify',
|
||||
label: 'Zařazení do kategorie',
|
||||
status: 'ok',
|
||||
response: '{ "category": "vypadek reportu", "priority": "normal", "confidence": 0.79 }',
|
||||
durationMs: 870,
|
||||
agoMinutes: 95,
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'raynet',
|
||||
operationId: 'upsert-contact',
|
||||
label: 'Dohledání firmy podle telefonu',
|
||||
status: 'error',
|
||||
response: '{ "matches": 0, "searchedBy": "phone", "value": "+420774902331" }',
|
||||
durationMs: 610,
|
||||
agoMinutes: 95,
|
||||
},
|
||||
{
|
||||
kind: 'condition',
|
||||
label: 'knownCustomer není splněno',
|
||||
status: 'skipped',
|
||||
response: 'false, pokračuje větev NE',
|
||||
agoMinutes: 95,
|
||||
children: [
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'ticket',
|
||||
operationId: 'create',
|
||||
label: 'Založení ticketu bez napojení na firmu',
|
||||
status: 'ok',
|
||||
response: '{ "ticketId": "TK-4819", "customerId": null }',
|
||||
durationMs: 68,
|
||||
agoMinutes: 95,
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'raynet',
|
||||
operationId: 'create-lead',
|
||||
label: 'Založení obchodního případu k dohledání',
|
||||
status: 'ok',
|
||||
response: '{ "leadId": "lead_7781", "stage": "k overeni" }',
|
||||
durationMs: 940,
|
||||
agoMinutes: 95,
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: '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 nepřiřazený',
|
||||
agoMinutes: 95,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
seed(
|
||||
{
|
||||
id: 'TK-4817',
|
||||
subject: 'Rozšíření hlasového scénáře o objednávku svozu',
|
||||
requester: 'LogiTrans',
|
||||
channel: 'form',
|
||||
customer: {
|
||||
id: 'crm_3390',
|
||||
company: 'LogiTrans',
|
||||
contact: 'Jana Sedláčková',
|
||||
reply: 'jana.sedlackova@logitrans.cz',
|
||||
},
|
||||
status: 'open',
|
||||
priority: 'low',
|
||||
assignee: 'Eva N.',
|
||||
assigneeId: 'ppl_vomacka',
|
||||
automationId: null,
|
||||
createdAt: minutesAgo(2_600),
|
||||
updatedAt: minutesAgo(420),
|
||||
},
|
||||
[
|
||||
{
|
||||
kind: 'trigger',
|
||||
connectorId: 'form',
|
||||
operationId: 'submitted',
|
||||
label: 'Odeslán formulář Požadavek na úpravu',
|
||||
status: 'ok',
|
||||
response: '{ "company": "LogiTrans", "topic": "voicebot", "budget": "do 40 000" }',
|
||||
durationMs: 45,
|
||||
agoMinutes: 2_600,
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'ticket',
|
||||
operationId: 'create',
|
||||
label: 'Založení ticketu',
|
||||
status: 'ok',
|
||||
response: '{ "ticketId": "TK-4817", "priority": "low" }',
|
||||
durationMs: 71,
|
||||
agoMinutes: 2_600,
|
||||
},
|
||||
{
|
||||
kind: 'note',
|
||||
label: 'Ticket ručně přiřazen na Karla Vomáčku.',
|
||||
status: 'info',
|
||||
agoMinutes: 2_580,
|
||||
},
|
||||
{
|
||||
kind: 'note',
|
||||
label: 'Karel Vomáčka: Odhad odeslán, čeká se na objednávku.',
|
||||
status: 'info',
|
||||
agoMinutes: 420,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
seed(
|
||||
{
|
||||
id: 'TK-4812',
|
||||
subject: 'Duplicitní zápis kontaktů z webového formuláře',
|
||||
requester: 'Firma s.r.o.',
|
||||
channel: 'portal',
|
||||
customer: {
|
||||
id: 'crm_1042',
|
||||
company: 'Firma s.r.o.',
|
||||
contact: 'Petra Klientová',
|
||||
reply: 'petra.klientova@firma.cz',
|
||||
},
|
||||
status: 'resolved',
|
||||
priority: 'critical',
|
||||
assignee: 'Jiří U.',
|
||||
assigneeId: 'ppl_uhlir',
|
||||
automationId: null,
|
||||
createdAt: minutesAgo(5_100),
|
||||
updatedAt: minutesAgo(1_500),
|
||||
},
|
||||
];
|
||||
[
|
||||
{
|
||||
kind: 'trigger',
|
||||
connectorId: 'form',
|
||||
operationId: 'submitted',
|
||||
label: 'Nahlášeno z portálu',
|
||||
status: 'ok',
|
||||
response: '{ "reportedBy": "petra.klientova@firma.cz" }',
|
||||
durationMs: 38,
|
||||
agoMinutes: 5_100,
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'transform',
|
||||
operationId: 'deduplicate',
|
||||
label: 'Kontrola duplicit v CRM',
|
||||
status: 'ok',
|
||||
response: '{ "scanned": 1284, "duplicates": 37, "key": "email" }',
|
||||
durationMs: 6_700,
|
||||
agoMinutes: 4_900,
|
||||
},
|
||||
{
|
||||
kind: 'note',
|
||||
label: 'Jiří Uhlíř: Do formuláře doplněna kontrola podle e-mailu, duplicity sloučeny.',
|
||||
status: 'info',
|
||||
agoMinutes: 1_500,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
let counter = 4_821;
|
||||
// ------------------------------------------------------------------ prevody
|
||||
|
||||
export function listTickets(): Ticket[] {
|
||||
// Nejdriv nevyrizene, uvnitr od nejnovejsi upravy.
|
||||
return [...tickets].sort((a, b) => {
|
||||
if (a.status === 'resolved' && b.status !== 'resolved') return 1;
|
||||
if (b.status === 'resolved' && a.status !== 'resolved') return -1;
|
||||
return b.updatedAt.localeCompare(a.updatedAt);
|
||||
function toTicket(stored: StoredTicket): Ticket {
|
||||
const { assigneeId, ...rest } = stored;
|
||||
if (!assigneeId) return { ...rest, assignee: null };
|
||||
|
||||
const person = findPerson(assigneeId);
|
||||
if (!person) {
|
||||
// Resitel zmizel ze seznamu - ticket nesmi spadnout, ale chceme o tom vedet.
|
||||
console.warn(`[tickets] ${stored.id}: resitel ${assigneeId} uz neexistuje`);
|
||||
return { ...rest, assignee: null };
|
||||
}
|
||||
return { ...rest, assignee: { id: person.id, name: person.name } };
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- dotazy
|
||||
|
||||
export interface TicketFilter {
|
||||
/** ID resitele, nebo 'unassigned' pro nepridelene. */
|
||||
assignee?: string;
|
||||
status?: TicketStatus;
|
||||
channel?: TicketChannel;
|
||||
}
|
||||
|
||||
export function listTickets(filter: TicketFilter = {}): Ticket[] {
|
||||
const selected = tickets.filter((ticket) => {
|
||||
if (filter.status && ticket.status !== filter.status) return false;
|
||||
if (filter.channel && ticket.channel !== filter.channel) return false;
|
||||
if (filter.assignee === 'unassigned') return ticket.assigneeId === null;
|
||||
if (filter.assignee && ticket.assigneeId !== filter.assignee) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Nejdriv nevyrizene, uvnitr od nejnovejsi upravy.
|
||||
return selected
|
||||
.sort((a, b) => {
|
||||
if (a.status === 'resolved' && b.status !== 'resolved') return 1;
|
||||
if (b.status === 'resolved' && a.status !== 'resolved') return -1;
|
||||
return b.updatedAt.localeCompare(a.updatedAt);
|
||||
})
|
||||
.map(toTicket);
|
||||
}
|
||||
|
||||
export function getTicket(id: string): Ticket | undefined {
|
||||
return tickets.find((t) => t.id === id);
|
||||
export function getTicket(id: string): TicketDetail | undefined {
|
||||
const stored = tickets.find((t) => t.id === id);
|
||||
if (!stored) return undefined;
|
||||
return { ...toTicket(stored), trace: traces.get(id) ?? [] };
|
||||
}
|
||||
|
||||
export function createTicket(input: {
|
||||
/** Prvni nevyrizeny ticket - pouziva simulace, kdyz uzivatel neurci ktery. */
|
||||
export function firstOpenTicket(): Ticket | undefined {
|
||||
const stored = tickets.find((t) => t.status !== 'resolved');
|
||||
return stored ? toTicket(stored) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kdo co ma u sebe. Prehled pres cely tym, ne jen pres prihlaseneho cloveka -
|
||||
* bez nej nejde poznat, ze jeden clovek utahuje polovinu servicedesku.
|
||||
*/
|
||||
export interface WorkloadRow {
|
||||
person: Person;
|
||||
/** Nevyresene tickety. Tohle je to cislo, na ktere se lidi divaji. */
|
||||
open: number;
|
||||
/** Vcetne vyresenych, aby bylo videt, kolik uz ma za sebou. */
|
||||
total: number;
|
||||
critical: number;
|
||||
/** Nejstarsi nevyreseny ticket. null = nic nevisi. */
|
||||
oldestOpenAt: string | null;
|
||||
/** true = ma vic nevyrizenych, nez je jeho kapacita. */
|
||||
overloaded: boolean;
|
||||
}
|
||||
|
||||
export interface Workload {
|
||||
rows: WorkloadRow[];
|
||||
unassigned: number;
|
||||
openTotal: number;
|
||||
}
|
||||
|
||||
export function getWorkload(everyone: Person[]): Workload {
|
||||
const rows = everyone.map((person) => {
|
||||
const mine = tickets.filter((t) => t.assigneeId === person.id);
|
||||
const open = mine.filter((t) => t.status !== 'resolved');
|
||||
const oldest = open.reduce<string | null>(
|
||||
(acc, t) => (acc === null || t.createdAt < acc ? t.createdAt : acc),
|
||||
null,
|
||||
);
|
||||
|
||||
return {
|
||||
person,
|
||||
open: open.length,
|
||||
total: mine.length,
|
||||
critical: open.filter((t) => t.priority === 'critical').length,
|
||||
oldestOpenAt: oldest,
|
||||
overloaded: open.length > person.capacity,
|
||||
};
|
||||
});
|
||||
|
||||
// Nejvytizenejsi nahoru - prehled ma odpovedet na "kdo toho ma nejvic".
|
||||
rows.sort((a, b) => b.open - a.open || a.person.name.localeCompare(b.person.name, 'cs'));
|
||||
|
||||
return {
|
||||
rows,
|
||||
unassigned: tickets.filter((t) => t.assigneeId === null && t.status !== 'resolved').length,
|
||||
openTotal: tickets.filter((t) => t.status !== 'resolved').length,
|
||||
};
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------- zmeny
|
||||
|
||||
export interface CreateTicketInput {
|
||||
subject: string;
|
||||
requester: string;
|
||||
channel: TicketChannel;
|
||||
customer: TicketCustomer;
|
||||
priority: TicketPriority;
|
||||
}): Ticket {
|
||||
counter += 1;
|
||||
assigneeId?: string | null;
|
||||
automationId?: string | null;
|
||||
/** Log toho, jak ticket vznikl. Bez nej je ticket nedohledatelny. */
|
||||
trace?: TraceInput[];
|
||||
}
|
||||
|
||||
export function createTicket(input: CreateTicketInput): Ticket {
|
||||
ticketCounter += 1;
|
||||
const now = new Date().toISOString();
|
||||
const ticket: Ticket = {
|
||||
id: `TK-${counter}`,
|
||||
|
||||
// Neexistujiciho resitele radeji zahodime, nez abychom ulozili mrtvy odkaz.
|
||||
let assigneeId = input.assigneeId ?? null;
|
||||
if (assigneeId && !findPerson(assigneeId)) {
|
||||
console.warn(`[tickets] neznamy resitel ${assigneeId}, ticket zustava neprirazeny`);
|
||||
assigneeId = null;
|
||||
}
|
||||
|
||||
const stored: StoredTicket = {
|
||||
id: `TK-${ticketCounter}`,
|
||||
subject: input.subject,
|
||||
requester: input.requester,
|
||||
channel: input.channel,
|
||||
customer: input.customer,
|
||||
status: 'new',
|
||||
priority: input.priority,
|
||||
assignee: null,
|
||||
assigneeId,
|
||||
automationId: input.automationId ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
tickets.unshift(ticket);
|
||||
tickets.unshift(stored);
|
||||
traces.set(stored.id, flattenTrace(input.trace ?? [], null, []));
|
||||
|
||||
publish('ticket.created', `Nový ticket ${ticket.id}: ${ticket.subject}`, {
|
||||
ticketId: ticket.id,
|
||||
priority: ticket.priority,
|
||||
publish('ticket.created', `Nový ticket ${stored.id}: ${stored.subject}`, {
|
||||
ticketId: stored.id,
|
||||
channel: stored.channel,
|
||||
priority: stored.priority,
|
||||
knownCustomer: stored.customer.id !== null,
|
||||
});
|
||||
return ticket;
|
||||
return toTicket(stored);
|
||||
}
|
||||
|
||||
export function updateTicketStatus(id: string, status: TicketStatus): Ticket | undefined {
|
||||
@@ -126,9 +716,18 @@ export function updateTicketStatus(id: string, status: TicketStatus): Ticket | u
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const previous = ticket.status;
|
||||
ticket.status = status;
|
||||
ticket.updatedAt = new Date().toISOString();
|
||||
|
||||
appendTrace(id, [
|
||||
{
|
||||
kind: 'note',
|
||||
label: `Stav změněn z „${previous}" na „${status}".`,
|
||||
status: 'info',
|
||||
},
|
||||
]);
|
||||
|
||||
if (status === 'resolved') {
|
||||
publish('ticket.resolved', `Ticket ${ticket.id} vyřešen: ${ticket.subject}`, {
|
||||
ticketId: ticket.id,
|
||||
@@ -139,10 +738,55 @@ export function updateTicketStatus(id: string, status: TicketStatus): Ticket | u
|
||||
status,
|
||||
});
|
||||
}
|
||||
return ticket;
|
||||
return toTicket(ticket);
|
||||
}
|
||||
|
||||
/** Prvni nevyrizeny ticket - pouziva simulace, kdyz uzivatel neurci ktery. */
|
||||
export function firstOpenTicket(): Ticket | undefined {
|
||||
return tickets.find((t) => t.status !== 'resolved');
|
||||
/** Prirazeni resitele. `null` ticket vrati zpatky do fronty. */
|
||||
export function assignTicket(id: string, assigneeId: string | null): Ticket | undefined {
|
||||
const ticket = tickets.find((t) => t.id === id);
|
||||
if (!ticket) {
|
||||
console.warn(`[tickets] prirazeni neexistujiciho ticketu: ${id}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const person = assigneeId ? findPerson(assigneeId) : null;
|
||||
if (assigneeId && !person) {
|
||||
console.warn(`[tickets] ${id}: prirazeni na neznameho resitele ${assigneeId}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
ticket.assigneeId = person?.id ?? null;
|
||||
ticket.updatedAt = new Date().toISOString();
|
||||
|
||||
appendTrace(id, [
|
||||
{
|
||||
kind: 'note',
|
||||
label: person ? `Ticket přiřazen: ${person.name}.` : 'Ticket vrácen do fronty.',
|
||||
status: 'info',
|
||||
},
|
||||
]);
|
||||
|
||||
publish(
|
||||
'ticket.assigned',
|
||||
person
|
||||
? `Ticket ${ticket.id} přiřazen: ${person.name}`
|
||||
: `Ticket ${ticket.id} vrácen do fronty`,
|
||||
{ ticketId: ticket.id, assigneeId: ticket.assigneeId },
|
||||
);
|
||||
return toTicket(ticket);
|
||||
}
|
||||
|
||||
/** Komentar je jen dalsi radek logu - at je vsechno na jedne casove ose. */
|
||||
export function addComment(id: string, author: string, text: string): Ticket | undefined {
|
||||
const ticket = tickets.find((t) => t.id === id);
|
||||
if (!ticket) {
|
||||
console.warn(`[tickets] komentar k neexistujicimu ticketu: ${id}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
ticket.updatedAt = new Date().toISOString();
|
||||
appendTrace(id, [{ kind: 'note', label: `${author}: ${text}`, status: 'info' }]);
|
||||
|
||||
publish('ticket.updated', `Nový komentář u ticketu ${ticket.id}`, { ticketId: ticket.id });
|
||||
return toTicket(ticket);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { randomUUID } from 'node:crypto';
|
||||
export type DashboardEventType =
|
||||
| 'ticket.created'
|
||||
| 'ticket.updated'
|
||||
| 'ticket.assigned'
|
||||
| 'ticket.resolved'
|
||||
| 'incident.started'
|
||||
| 'incident.updated'
|
||||
|
||||
+312
-4
@@ -24,6 +24,7 @@ export function buildOpenApiDocument() {
|
||||
{ name: 'Provoz', description: 'Health a zakladni informace' },
|
||||
{ name: 'Autentizace', description: 'Prihlaseni do portalu' },
|
||||
{ name: 'Dashboard', description: 'Data klientskeho portalu' },
|
||||
{ name: 'Tickety', description: 'Pozadavky, jejich resitele a log prubehu' },
|
||||
{ name: 'Automatizace', description: 'Sprava automatizaci a stromu akci' },
|
||||
{ name: 'Simulace', description: 'Vyvolani provoznich udalosti pro nahled' },
|
||||
{ name: 'Webhook', description: 'Verejny prijem dat do automatizace' },
|
||||
@@ -71,19 +72,130 @@ export function buildOpenApiDocument() {
|
||||
user: { $ref: '#/components/schemas/User' },
|
||||
},
|
||||
},
|
||||
Person: {
|
||||
type: 'object',
|
||||
description: 'Resitel ticketu. Nemusi mit ucet v portalu, spojka je e-mail.',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'ppl_vomacka' },
|
||||
name: { type: 'string', example: 'Karel Vomacka' },
|
||||
email: { type: 'string', format: 'email' },
|
||||
role: { type: 'string', example: 'Servicedesk' },
|
||||
capacity: {
|
||||
type: 'integer',
|
||||
description: 'Kolik nevyrizenych ticketu je pro nej jeste zdrava zatez.',
|
||||
},
|
||||
},
|
||||
},
|
||||
TicketCustomer: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
description: 'ID firmy v CRM. null = zakaznika se nepodarilo dohledat.',
|
||||
example: 'crm_1042',
|
||||
},
|
||||
company: { type: 'string', example: 'Firma s.r.o.' },
|
||||
contact: { type: 'string', example: 'Petra Klientova' },
|
||||
reply: {
|
||||
type: 'string',
|
||||
description: 'Adresa nebo cislo, odkud pozadavek prisel a kam se odpovida.',
|
||||
},
|
||||
},
|
||||
},
|
||||
Ticket: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'TK-4821' },
|
||||
subject: { type: 'string' },
|
||||
requester: { type: 'string' },
|
||||
channel: {
|
||||
type: 'string',
|
||||
enum: ['whatsapp', 'email', 'voice', 'form', 'portal'],
|
||||
description: 'Odkud pozadavek prisel.',
|
||||
},
|
||||
customer: { $ref: '#/components/schemas/TicketCustomer' },
|
||||
status: { type: 'string', enum: ['new', 'open', 'waiting', 'resolved'] },
|
||||
priority: { type: 'string', enum: ['low', 'normal', 'high', 'critical'] },
|
||||
assignee: { type: 'string', nullable: true },
|
||||
assignee: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
description: 'Kdo ma ticket u sebe. null = ceka ve fronte.',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'ppl_vomacka' },
|
||||
name: { type: 'string', example: 'Karel Vomacka' },
|
||||
},
|
||||
},
|
||||
automationId: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
description: 'Automatizace, ktera ticket zalozila. null = zalozeno rucne.',
|
||||
},
|
||||
createdAt: { type: 'string', format: 'date-time' },
|
||||
updatedAt: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
},
|
||||
TicketTraceEntry: {
|
||||
type: 'object',
|
||||
description:
|
||||
'Jeden radek logu ticketu. Strom se sklada pres parentId - vetev podminky ' +
|
||||
'visi na zaznamu te podminky.',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'tr_12' },
|
||||
parentId: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
description: 'null = zaznam v hlavni sekvenci.',
|
||||
},
|
||||
kind: { type: 'string', enum: ['trigger', 'action', 'condition', 'note'] },
|
||||
connectorId: { type: 'string', nullable: true, example: 'raynet' },
|
||||
operationId: { type: 'string', nullable: true, example: 'upsert-contact' },
|
||||
label: { type: 'string' },
|
||||
status: { type: 'string', enum: ['ok', 'error', 'skipped', 'info'] },
|
||||
response: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
description: 'Co sluzba vratila. Kvuli tomuhle log existuje.',
|
||||
},
|
||||
durationMs: { type: 'integer', nullable: true },
|
||||
at: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
},
|
||||
TicketDetail: {
|
||||
allOf: [
|
||||
{ $ref: '#/components/schemas/Ticket' },
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
trace: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/TicketTraceEntry' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
Workload: {
|
||||
type: 'object',
|
||||
description: 'Prehled nad firmou - kdo ma kolik ticketu u sebe.',
|
||||
properties: {
|
||||
rows: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
person: { $ref: '#/components/schemas/Person' },
|
||||
open: { type: 'integer', description: 'Nevyresene tickety.' },
|
||||
total: { type: 'integer' },
|
||||
critical: { type: 'integer' },
|
||||
oldestOpenAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
overloaded: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
},
|
||||
unassigned: { type: 'integer', description: 'Nevyresene tickety bez resitele.' },
|
||||
openTotal: { type: 'integer' },
|
||||
},
|
||||
},
|
||||
Incident: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -288,11 +400,58 @@ export function buildOpenApiDocument() {
|
||||
responses: { '200': { description: 'Souhrnne metriky a casova rada' } },
|
||||
},
|
||||
},
|
||||
'/api/dashboard/people': {
|
||||
get: {
|
||||
tags: ['Tickety'],
|
||||
summary: 'Seznam resitelu',
|
||||
description:
|
||||
'Lide, na ktere jde ticket priradit. `meId` je resitel odpovidajici ' +
|
||||
'prihlasenemu uzivateli, nebo null, pokud zadny neni.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Resitele',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
items: { type: 'array', items: { $ref: '#/components/schemas/Person' } },
|
||||
meId: { type: 'string', nullable: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/dashboard/tickets': {
|
||||
get: {
|
||||
tags: ['Dashboard'],
|
||||
tags: ['Tickety'],
|
||||
summary: 'Seznam ticketu',
|
||||
description: 'Neznama hodnota filtru se ignoruje a zaloguje, seznam se nezuzi.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
parameters: [
|
||||
{
|
||||
name: 'assignee',
|
||||
in: 'query',
|
||||
schema: { type: 'string' },
|
||||
description:
|
||||
'ID resitele, `me` pro prihlaseneho uzivatele, nebo `unassigned` pro frontu.',
|
||||
example: 'me',
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
in: 'query',
|
||||
schema: { type: 'string', enum: ['new', 'open', 'waiting', 'resolved'] },
|
||||
},
|
||||
{
|
||||
name: 'channel',
|
||||
in: 'query',
|
||||
schema: { type: 'string', enum: ['whatsapp', 'email', 'voice', 'form', 'portal'] },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Tickety',
|
||||
@@ -302,6 +461,7 @@ export function buildOpenApiDocument() {
|
||||
type: 'object',
|
||||
properties: {
|
||||
items: { type: 'array', items: { $ref: '#/components/schemas/Ticket' } },
|
||||
meId: { type: 'string', nullable: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -310,6 +470,144 @@ export function buildOpenApiDocument() {
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/dashboard/tickets/workload': {
|
||||
get: {
|
||||
tags: ['Tickety'],
|
||||
summary: 'Kdo co ma u sebe',
|
||||
description: 'Prehled zateze pres cely tym vcetne poctu ticketu ve fronte.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Vytizeni resitelu',
|
||||
content: {
|
||||
'application/json': { schema: { $ref: '#/components/schemas/Workload' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/dashboard/tickets/{id}': {
|
||||
get: {
|
||||
tags: ['Tickety'],
|
||||
summary: 'Detail ticketu vcetne logu',
|
||||
description: 'Log obsahuje i to, co ktera volana sluzba vratila.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
parameters: [
|
||||
{
|
||||
name: 'id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
schema: { type: 'string' },
|
||||
example: 'TK-4821',
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Detail',
|
||||
content: {
|
||||
'application/json': { schema: { $ref: '#/components/schemas/TicketDetail' } },
|
||||
},
|
||||
},
|
||||
'404': { description: 'Neexistuje' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/dashboard/tickets/{id}/assign': {
|
||||
post: {
|
||||
tags: ['Tickety'],
|
||||
summary: 'Priradit resitele',
|
||||
description: 'Poslete null pro vraceni ticketu do fronty.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['assigneeId'],
|
||||
properties: {
|
||||
assigneeId: { type: 'string', nullable: true, example: 'ppl_vomacka' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Prirazeno',
|
||||
content: {
|
||||
'application/json': { schema: { $ref: '#/components/schemas/Ticket' } },
|
||||
},
|
||||
},
|
||||
'400': { description: 'Chybi assigneeId' },
|
||||
'404': { description: 'Ticket nebo resitel neexistuje' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/dashboard/tickets/{id}/status': {
|
||||
post: {
|
||||
tags: ['Tickety'],
|
||||
summary: 'Zmenit stav ticketu',
|
||||
security: [{ bearerAuth: [] }],
|
||||
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['status'],
|
||||
properties: {
|
||||
status: { type: 'string', enum: ['new', 'open', 'waiting', 'resolved'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Zmeneno',
|
||||
content: {
|
||||
'application/json': { schema: { $ref: '#/components/schemas/Ticket' } },
|
||||
},
|
||||
},
|
||||
'400': { description: 'Neplatny stav' },
|
||||
'404': { description: 'Neexistuje' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/dashboard/tickets/{id}/comment': {
|
||||
post: {
|
||||
tags: ['Tickety'],
|
||||
summary: 'Pridat komentar',
|
||||
description: 'Komentar je dalsi radek logu, aby bylo vse na jedne casove ose.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['text'],
|
||||
properties: { text: { type: 'string', minLength: 2 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Zapsano',
|
||||
content: {
|
||||
'application/json': { schema: { $ref: '#/components/schemas/Ticket' } },
|
||||
},
|
||||
},
|
||||
'400': { description: 'Prazdny komentar' },
|
||||
'404': { description: 'Neexistuje' },
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/dashboard/incidents': {
|
||||
get: {
|
||||
tags: ['Dashboard'],
|
||||
@@ -502,7 +800,17 @@ export function buildOpenApiDocument() {
|
||||
],
|
||||
},
|
||||
subject: { type: 'string' },
|
||||
requester: { type: 'string' },
|
||||
channel: {
|
||||
type: 'string',
|
||||
enum: ['whatsapp', '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' },
|
||||
|
||||
+159
-9
@@ -11,10 +11,26 @@ import {
|
||||
type FlowStep,
|
||||
} from '../data/automationStore.js';
|
||||
import { operatorAllowedForType, operatorsByType } from '../data/conditions.js';
|
||||
import { connectorCategories, connectors, findOperation } from '../data/connectors.js';
|
||||
import {
|
||||
connectorCategories,
|
||||
connectors,
|
||||
findOperation,
|
||||
providedFieldsFor,
|
||||
} from '../data/connectors.js';
|
||||
import { listIncidents } from '../data/incidentStore.js';
|
||||
import { getSummary } from '../data/mock.js';
|
||||
import { listTickets } from '../data/ticketStore.js';
|
||||
import { findPersonByEmail, listPeople } from '../data/people.js';
|
||||
import {
|
||||
addComment,
|
||||
assignTicket,
|
||||
getTicket,
|
||||
getWorkload,
|
||||
listTickets,
|
||||
updateTicketStatus,
|
||||
type TicketChannel,
|
||||
type TicketFilter,
|
||||
type TicketStatus,
|
||||
} from '../data/ticketStore.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { streamRouter } from './stream.js';
|
||||
|
||||
@@ -27,14 +43,132 @@ dashboardRouter.get('/summary', (_req, res) => {
|
||||
res.json(getSummary());
|
||||
});
|
||||
|
||||
dashboardRouter.get('/tickets', (_req, res) => {
|
||||
res.json({ items: listTickets() });
|
||||
});
|
||||
|
||||
dashboardRouter.get('/incidents', (_req, res) => {
|
||||
res.json({ items: listIncidents() });
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------- tickety
|
||||
|
||||
/** Resitele. Klient je potrebuje do nabidky prirazeni i do prehledu firmy. */
|
||||
dashboardRouter.get('/people', (req, res) => {
|
||||
const me = req.user ? findPersonByEmail(req.user.email) : undefined;
|
||||
res.json({ items: listPeople(), meId: me?.id ?? null });
|
||||
});
|
||||
|
||||
const ticketStatuses: TicketStatus[] = ['new', 'open', 'waiting', 'resolved'];
|
||||
const ticketChannels: TicketChannel[] = ['whatsapp', 'email', 'voice', 'form', 'portal'];
|
||||
|
||||
/**
|
||||
* Filtr ze query parametru. Nesmyslnou hodnotu zahodime a zalogujeme -
|
||||
* je lepsi ukazat vic ticketu nez prazdny seznam bez vysvetleni.
|
||||
*/
|
||||
function ticketFilterFrom(query: Record<string, unknown>, myPersonId: string | null): TicketFilter {
|
||||
const filter: TicketFilter = {};
|
||||
|
||||
const assignee = typeof query.assignee === 'string' ? query.assignee : undefined;
|
||||
if (assignee === 'me') {
|
||||
// Prihlaseny uzivatel nemusi byt resitel - pak nema smysl nic vracet.
|
||||
filter.assignee = myPersonId ?? '__nikdo__';
|
||||
} else if (assignee) {
|
||||
filter.assignee = assignee;
|
||||
}
|
||||
|
||||
const status = typeof query.status === 'string' ? query.status : undefined;
|
||||
if (status) {
|
||||
if (ticketStatuses.includes(status as TicketStatus)) filter.status = status as TicketStatus;
|
||||
else console.warn(`[tickets] neznamy stav ve filtru: ${status}`);
|
||||
}
|
||||
|
||||
const channel = typeof query.channel === 'string' ? query.channel : undefined;
|
||||
if (channel) {
|
||||
if (ticketChannels.includes(channel as TicketChannel)) filter.channel = channel as TicketChannel;
|
||||
else console.warn(`[tickets] neznamy kanal ve filtru: ${channel}`);
|
||||
}
|
||||
|
||||
return filter;
|
||||
}
|
||||
|
||||
dashboardRouter.get('/tickets', (req, res) => {
|
||||
const me = req.user ? findPersonByEmail(req.user.email) : undefined;
|
||||
const filter = ticketFilterFrom(req.query as Record<string, unknown>, me?.id ?? null);
|
||||
res.json({ items: listTickets(filter), meId: me?.id ?? null });
|
||||
});
|
||||
|
||||
/** Kdo co ma u sebe. MUSI byt pred /tickets/:id, jinak by to spadlo na detail. */
|
||||
dashboardRouter.get('/tickets/workload', (_req, res) => {
|
||||
res.json(getWorkload(listPeople()));
|
||||
});
|
||||
|
||||
dashboardRouter.get('/tickets/:id', (req, res) => {
|
||||
const ticket = getTicket(req.params.id);
|
||||
if (!ticket) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
|
||||
}
|
||||
return res.json(ticket);
|
||||
});
|
||||
|
||||
const assignSchema = z.object({
|
||||
/** null = vratit do fronty. */
|
||||
assigneeId: z.string().min(1).nullable(),
|
||||
});
|
||||
|
||||
dashboardRouter.post('/tickets/:id/assign', (req, res) => {
|
||||
const parsed = assignSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: 'Chybí ID řešitele, nebo poslete null pro vrácení do fronty.',
|
||||
});
|
||||
}
|
||||
|
||||
const ticket = assignTicket(req.params.id, parsed.data.assigneeId);
|
||||
if (!ticket) {
|
||||
return res.status(404).json({
|
||||
error: 'not_found',
|
||||
message: 'Ticket nebo řešitel neexistuje.',
|
||||
});
|
||||
}
|
||||
return res.json(ticket);
|
||||
});
|
||||
|
||||
const statusSchema = z.object({
|
||||
status: z.enum(['new', 'open', 'waiting', 'resolved']),
|
||||
});
|
||||
|
||||
dashboardRouter.post('/tickets/:id/status', (req, res) => {
|
||||
const parsed = statusSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: 'validation_error', message: 'Neplatný stav ticketu.' });
|
||||
}
|
||||
|
||||
const ticket = updateTicketStatus(req.params.id, parsed.data.status);
|
||||
if (!ticket) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
|
||||
}
|
||||
return res.json(ticket);
|
||||
});
|
||||
|
||||
const commentSchema = z.object({
|
||||
text: z.string().trim().min(2, 'Komentář musí mít alespoň 2 znaky.'),
|
||||
});
|
||||
|
||||
dashboardRouter.post('/tickets/:id/comment', (req, res) => {
|
||||
const parsed = commentSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: 'validation_error',
|
||||
message: parsed.error.issues[0]?.message ?? 'Neplatný komentář.',
|
||||
});
|
||||
}
|
||||
|
||||
const author = req.user?.name ?? 'Portál';
|
||||
const ticket = addComment(req.params.id, author, parsed.data.text);
|
||||
if (!ticket) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
|
||||
}
|
||||
return res.json(ticket);
|
||||
});
|
||||
|
||||
// Zivy stream zmen. Musi byt pred obecnymi cestami, aby ho nic neprebilo.
|
||||
dashboardRouter.use('/stream', streamRouter);
|
||||
|
||||
@@ -125,6 +259,20 @@ const updateSchema = z.object({
|
||||
flow: flowSchema.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Spoustec, ktery si data urcuje sam (e-mail, WhatsApp, ticket), nesmi mit
|
||||
* parametry od klienta. Dosadime katalogovou verzi, a to jeste PRED validaci -
|
||||
* jinak by podminky odkazujici na katalogova ID vypadaly jako rozbite.
|
||||
*/
|
||||
function normalizeTriggerFields(flow: z.infer<typeof flowSchema>): z.infer<typeof flowSchema> {
|
||||
if (!flow.trigger) return flow;
|
||||
|
||||
const provided = providedFieldsFor(flow.trigger.connectorId, flow.trigger.operationId);
|
||||
if (!provided) return flow;
|
||||
|
||||
return { ...flow, trigger: { ...flow.trigger, fields: provided.map((field) => ({ ...field })) } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Overi, ze kazdy krok odkazuje na existujici konektor a operaci.
|
||||
* Vraci seznam problemu - prazdny znamena, ze je strom v poradku.
|
||||
@@ -212,8 +360,10 @@ dashboardRouter.put('/automations/:id', (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (parsed.data.flow) {
|
||||
const problems = validateFlowReferences(parsed.data.flow);
|
||||
const flow = parsed.data.flow ? normalizeTriggerFields(parsed.data.flow) : undefined;
|
||||
|
||||
if (flow) {
|
||||
const problems = validateFlowReferences(flow);
|
||||
if (problems.length > 0) {
|
||||
console.warn(`[automations] ${req.params.id}: neplatny strom - ${problems.join(' ')}`);
|
||||
return res.status(400).json({
|
||||
@@ -224,7 +374,7 @@ dashboardRouter.put('/automations/:id', (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
const updated = updateAutomation(req.params.id, parsed.data);
|
||||
const updated = updateAutomation(req.params.id, { ...parsed.data, flow });
|
||||
if (!updated) {
|
||||
return res.status(404).json({ error: 'not_found', message: 'Automatizace neexistuje.' });
|
||||
}
|
||||
|
||||
+252
-11
@@ -6,7 +6,14 @@ import {
|
||||
firstActiveIncident,
|
||||
updateIncidentStatus,
|
||||
} from '../data/incidentStore.js';
|
||||
import { createTicket, firstOpenTicket, updateTicketStatus } from '../data/ticketStore.js';
|
||||
import {
|
||||
createTicket,
|
||||
firstOpenTicket,
|
||||
updateTicketStatus,
|
||||
type TicketChannel,
|
||||
type TicketPriority,
|
||||
type TraceInput,
|
||||
} from '../data/ticketStore.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
export const simulateRouter = Router();
|
||||
@@ -22,9 +29,13 @@ simulateRouter.use(requireAuth);
|
||||
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', 'email', 'voice', 'form', 'portal']).optional(),
|
||||
subject: z.string().trim().min(3).optional(),
|
||||
requester: z.string().trim().min(2).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'),
|
||||
@@ -47,14 +58,227 @@ const schema = z.discriminatedUnion('action', [
|
||||
}),
|
||||
]);
|
||||
|
||||
const defaultSubjects = [
|
||||
'Nefunguje export objednávek do skladu',
|
||||
'Voicebot nerozumí názvu ulice',
|
||||
'Faktura se nespárovala s platbou',
|
||||
'Chybí notifikace o nové poptávce',
|
||||
'Zákazník žádá změnu fakturačních údajů',
|
||||
/**
|
||||
* Ukazkovy prichod pozadavku podle kanalu. Kanal urcuje nejen text,
|
||||
* ale i to, jak vypada zaznam spoustece v logu ticketu.
|
||||
*/
|
||||
const channelPresets: Record<
|
||||
TicketChannel,
|
||||
{
|
||||
connectorId: string;
|
||||
operationId: string;
|
||||
triggerLabel: string;
|
||||
subjects: string[];
|
||||
contact: string;
|
||||
reply: string;
|
||||
/** Co spoustec vratil. `%s` se nahradi predmetem. */
|
||||
payload: (contact: string, reply: string, subject: string) => string;
|
||||
}
|
||||
> = {
|
||||
whatsapp: {
|
||||
connectorId: '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',
|
||||
payload: (contact, reply, subject) =>
|
||||
`{ "from": "${reply.replace(/\s/g, '')}", "profileName": "${contact}", "text": "${subject}" }`,
|
||||
},
|
||||
email: {
|
||||
connectorId: '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',
|
||||
payload: (_contact, reply, subject) =>
|
||||
`{ "from": "${reply}", "subject": "${subject}", "attachments": 1 }`,
|
||||
},
|
||||
voice: {
|
||||
connectorId: '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',
|
||||
payload: (_contact, reply, _subject) =>
|
||||
`{ "callId": "cl_${Math.floor(Math.random() * 90_000) + 10_000}", "from": "${reply.replace(/\s/g, '')}", "durationSec": 74 }`,
|
||||
},
|
||||
form: {
|
||||
connectorId: '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',
|
||||
payload: (contact, _reply, subject) =>
|
||||
`{ "company": "LogiTrans", "contact": "${contact}", "topic": "${subject}" }`,
|
||||
},
|
||||
portal: {
|
||||
connectorId: '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',
|
||||
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' },
|
||||
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',
|
||||
connectorId: preset.connectorId,
|
||||
operationId: preset.operationId,
|
||||
label: preset.triggerLabel,
|
||||
status: 'ok',
|
||||
response: preset.payload(input.contact, input.reply, input.subject),
|
||||
durationMs: 60,
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: 'ai-text',
|
||||
operationId: 'classify',
|
||||
label: 'Zařazení do kategorie',
|
||||
status: 'ok',
|
||||
response: `{ "category": "pozadavek", "priority": "${input.priority}", "confidence": 0.83 }`,
|
||||
durationMs: 880,
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: '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',
|
||||
connectorId: 'ticket',
|
||||
operationId: 'create',
|
||||
label: 'Založení ticketu',
|
||||
status: 'ok',
|
||||
response: `{ "customerId": "${input.known.id}", "priority": "${input.priority}" }`,
|
||||
durationMs: 70,
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: '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',
|
||||
connectorId: 'ticket',
|
||||
operationId: 'create',
|
||||
label: 'Založení ticketu bez napojení na firmu',
|
||||
status: 'ok',
|
||||
response: `{ "customerId": null, "priority": "${input.priority}" }`,
|
||||
durationMs: 68,
|
||||
},
|
||||
{
|
||||
kind: 'action',
|
||||
connectorId: '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',
|
||||
connectorId: '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' },
|
||||
@@ -80,10 +304,27 @@ simulateRouter.post('/', (req, res) => {
|
||||
|
||||
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({
|
||||
subject: input.subject ?? pick(defaultSubjects),
|
||||
requester: input.requester ?? 'Firma s.r.o.',
|
||||
priority: input.priority ?? 'normal',
|
||||
subject,
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ const Automations = lazy(() => import('@/pages/dashboard/Automations'));
|
||||
const AutomationDetail = lazy(() => import('@/pages/dashboard/AutomationDetail'));
|
||||
const Connectors = lazy(() => import('@/pages/dashboard/Connectors'));
|
||||
const Tickets = lazy(() => import('@/pages/dashboard/Tickets'));
|
||||
const TicketDetail = lazy(() => import('@/pages/dashboard/TicketDetail'));
|
||||
const Incidents = lazy(() => import('@/pages/dashboard/Incidents'));
|
||||
const Settings = lazy(() => import('@/pages/dashboard/Settings'));
|
||||
|
||||
@@ -61,6 +62,7 @@ export default function App() {
|
||||
<Route path="automatizace/:id" element={<AutomationDetail />} />
|
||||
<Route path="konektory" element={<Connectors />} />
|
||||
<Route path="tickety" element={<Tickets />} />
|
||||
<Route path="tickety/:id" element={<TicketDetail />} />
|
||||
<Route path="incidenty" element={<Incidents />} />
|
||||
<Route path="nastaveni" element={<Settings />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AlarmClock, CheckCircle2, LifeBuoy, Webhook, Workflow, X } from 'lucide-react';
|
||||
import { AlarmClock, CheckCircle2, LifeBuoy, UserCheck, Webhook, Workflow, X } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { useEventStream } from '@/components/dashboard/EventStreamProvider';
|
||||
@@ -12,6 +12,7 @@ const MAX_VISIBLE = 4;
|
||||
const style: Record<DashboardEventType, { icon: LucideIcon; tone: string }> = {
|
||||
'ticket.created': { icon: LifeBuoy, tone: 'text-brand-300 bg-brand-500/12' },
|
||||
'ticket.updated': { icon: LifeBuoy, tone: 'text-brand-300 bg-brand-500/12' },
|
||||
'ticket.assigned': { icon: UserCheck, tone: 'text-accent-300 bg-accent-500/12' },
|
||||
'ticket.resolved': { icon: CheckCircle2, tone: 'text-ok-400 bg-ok-500/12' },
|
||||
'incident.started': { icon: AlarmClock, tone: 'text-danger-400 bg-danger-500/12' },
|
||||
'incident.updated': { icon: AlarmClock, tone: 'text-warn-400 bg-warn-500/12' },
|
||||
|
||||
@@ -25,6 +25,15 @@ const priorities = [
|
||||
{ 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: '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ý' },
|
||||
@@ -46,8 +55,10 @@ export function SimulationModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
const [result, setResult] = useState<Result | null>(null);
|
||||
|
||||
const [subject, setSubject] = useState('');
|
||||
const [requester, setRequester] = 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('');
|
||||
@@ -76,8 +87,10 @@ export function SimulationModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
void run('ticket.created', {
|
||||
// Prazdna pole neposilame, server pak doplni ukazkovou hodnotu.
|
||||
...(subject.trim() ? { subject: subject.trim() } : {}),
|
||||
...(requester.trim() ? { requester: requester.trim() } : {}),
|
||||
...(contact.trim() ? { contact: contact.trim() } : {}),
|
||||
channel,
|
||||
priority,
|
||||
knownCustomer,
|
||||
});
|
||||
setSubject('');
|
||||
}
|
||||
@@ -100,28 +113,30 @@ export function SimulationModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
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">
|
||||
<Panel icon={LifeBuoy} title="Nový ticket z kanálu">
|
||||
<form onSubmit={submitTicket} className="space-y-3">
|
||||
<input
|
||||
value={subject}
|
||||
onChange={(event) => setSubject(event.target.value)}
|
||||
placeholder="Předmět (nepovinné, jinak se doplní ukázkový)"
|
||||
className={inputClass}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<input
|
||||
value={requester}
|
||||
onChange={(event) => setRequester(event.target.value)}
|
||||
placeholder="Zadavatel"
|
||||
className={cn(inputClass, 'min-w-0 flex-1')}
|
||||
/>
|
||||
<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, 'w-40')}
|
||||
className={cn(inputClass, 'min-w-0 flex-1')}
|
||||
>
|
||||
{priorities.map((item) => (
|
||||
<option key={item.value} value={item.value} className="bg-ink-850">
|
||||
@@ -130,6 +145,33 @@ export function SimulationModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
))}
|
||||
</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'}
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
import { AlertTriangle, CheckCircle2, CircleDot, Clock, Search, ShieldAlert } from 'lucide-react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
CircleDot,
|
||||
Clock,
|
||||
FileInput,
|
||||
LayoutDashboard,
|
||||
Mail,
|
||||
MessageCircle,
|
||||
PhoneCall,
|
||||
Search,
|
||||
ShieldAlert,
|
||||
} from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Badge, type BadgeTone } from '@/components/ui/Badge';
|
||||
import type {
|
||||
IncidentSeverity,
|
||||
IncidentStatus,
|
||||
TicketChannel,
|
||||
TicketPriority,
|
||||
TicketStatus,
|
||||
} from '@/types/dashboard';
|
||||
@@ -26,6 +40,15 @@ const ticketPriorityMap: Record<TicketPriority, { label: string; tone: BadgeTone
|
||||
critical: { label: 'Kritická', tone: 'danger' },
|
||||
};
|
||||
|
||||
/** Kanal nese ikonu, ne barvu - neni to stav, nema co kricet. */
|
||||
const ticketChannelMap: Record<TicketChannel, { label: string; icon: LucideIcon }> = {
|
||||
whatsapp: { label: 'WhatsApp', icon: MessageCircle },
|
||||
email: { label: 'E-mail', icon: Mail },
|
||||
voice: { label: 'Hlasová linka', icon: PhoneCall },
|
||||
form: { label: 'Formulář', icon: FileInput },
|
||||
portal: { label: 'Portál', icon: LayoutDashboard },
|
||||
};
|
||||
|
||||
const incidentSeverityMap: Record<IncidentSeverity, { label: string; tone: BadgeTone }> = {
|
||||
sev1: { label: 'SEV1 — kritický', tone: 'danger' },
|
||||
sev2: { label: 'SEV2 — vážný', tone: 'warn' },
|
||||
@@ -60,6 +83,16 @@ export function TicketPriorityBadge({ priority }: { priority: TicketPriority })
|
||||
);
|
||||
}
|
||||
|
||||
export function TicketChannelBadge({ channel }: { channel: TicketChannel }) {
|
||||
const { label, icon: Icon } = ticketChannelMap[channel];
|
||||
return (
|
||||
<Badge tone="neutral">
|
||||
<Icon className="size-3.5" />
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function IncidentSeverityBadge({ severity }: { severity: IncidentSeverity }) {
|
||||
const { label, tone } = incidentSeverityMap[severity];
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { AlertCircle, CheckCircle2, GitBranch, Info, MinusCircle, Zap } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { connectorIcon } from '@/lib/connectorIcons';
|
||||
import { formatDateTime, formatDuration } from '@/lib/format';
|
||||
import type { Connector, TicketTraceEntry, TraceStatus } from '@/types/dashboard';
|
||||
|
||||
/**
|
||||
* Log ticketu jako strom. Vetev podminky je zanorena pod ni,
|
||||
* takze je videt, ktera cast behu se vubec nespustila.
|
||||
*
|
||||
* Odpoved sluzby se zobrazuje rovnou, ne po rozkliknuti - kvuli ni se
|
||||
* do logu chodi a schovavat ji za dalsi klik nema smysl.
|
||||
*/
|
||||
|
||||
const statusMeta: Record<TraceStatus, { icon: LucideIcon; tone: string; line: string }> = {
|
||||
ok: { icon: CheckCircle2, tone: 'text-ok-400 bg-ok-500/12', line: 'border-ok-400/25' },
|
||||
error: { icon: AlertCircle, tone: 'text-danger-400 bg-danger-500/12', line: 'border-danger-400/30' },
|
||||
skipped: { icon: MinusCircle, tone: 'text-white/40 bg-white/5', line: 'border-white/10' },
|
||||
info: { icon: Info, tone: 'text-brand-300 bg-brand-500/12', line: 'border-brand-400/20' },
|
||||
};
|
||||
|
||||
interface TraceNode extends TicketTraceEntry {
|
||||
children: TraceNode[];
|
||||
}
|
||||
|
||||
/** Ze seznamu s `parentId` udela strom. Zaznam s neznamym rodicem se nesmi ztratit. */
|
||||
function buildTree(entries: TicketTraceEntry[]): TraceNode[] {
|
||||
const byId = new Map<string, TraceNode>();
|
||||
for (const entry of entries) {
|
||||
byId.set(entry.id, { ...entry, children: [] });
|
||||
}
|
||||
|
||||
const roots: TraceNode[] = [];
|
||||
for (const entry of entries) {
|
||||
const node = byId.get(entry.id);
|
||||
if (!node) continue;
|
||||
|
||||
if (entry.parentId === null) {
|
||||
roots.push(node);
|
||||
continue;
|
||||
}
|
||||
|
||||
const parent = byId.get(entry.parentId);
|
||||
if (!parent) {
|
||||
console.warn(`[trace] zaznam ${entry.id} ma neznameho rodice ${entry.parentId}`);
|
||||
roots.push(node);
|
||||
continue;
|
||||
}
|
||||
parent.children.push(node);
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
export function TicketTrace({
|
||||
entries,
|
||||
connectors,
|
||||
}: {
|
||||
entries: TicketTraceEntry[];
|
||||
connectors: Connector[];
|
||||
}) {
|
||||
const tree = useMemo(() => buildTree(entries), [entries]);
|
||||
|
||||
if (entries.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">
|
||||
K tomuto ticketu zatím není žádný záznam.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ol className="space-y-2.5">
|
||||
{tree.map((node) => (
|
||||
<TraceRow key={node.id} node={node} connectors={connectors} />
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
function TraceRow({ node, connectors }: { node: TraceNode; connectors: Connector[] }) {
|
||||
const meta = statusMeta[node.status];
|
||||
const Icon = node.kind === 'condition' ? GitBranch : node.kind === 'trigger' ? Zap : meta.icon;
|
||||
const service = describeService(node, connectors);
|
||||
|
||||
return (
|
||||
<li>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border bg-ink-850/40 p-3.5',
|
||||
node.status === 'error' ? 'border-danger-400/30' : 'border-ink-600/50',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={cn('mt-0.5 grid size-7 shrink-0 place-items-center rounded-lg', meta.tone)}>
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<p className="text-sm font-medium text-white/85">{node.label}</p>
|
||||
{service && (
|
||||
<span className="font-mono text-xs text-brand-300/80">{service}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-white/35">
|
||||
<span>{formatDateTime(node.at)}</span>
|
||||
{node.durationMs !== null && <span>{formatDuration(node.durationMs)}</span>}
|
||||
{node.status === 'error' && <span className="text-danger-400">selhalo</span>}
|
||||
{node.status === 'skipped' && <span>neproběhlo</span>}
|
||||
</div>
|
||||
|
||||
{node.response && (
|
||||
<pre className="mt-2.5 overflow-x-auto rounded-lg border border-ink-600/60 bg-ink-900/80 px-3 py-2 font-mono text-xs whitespace-pre-wrap text-white/60">
|
||||
{node.response}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{node.children.length > 0 && (
|
||||
<ol className={cn('mt-2.5 space-y-2.5 border-l-2 pl-4 sm:pl-6', meta.line)}>
|
||||
{node.children.map((child) => (
|
||||
<TraceRow key={child.id} node={child} connectors={connectors} />
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** "RAYNET CRM / Dohledani kontaktu" - kdyz konektor v katalogu neni, aspon jeho ID. */
|
||||
function describeService(node: TraceNode, connectors: Connector[]): string | null {
|
||||
if (!node.connectorId) return null;
|
||||
|
||||
const connector = connectors.find((c) => c.id === node.connectorId);
|
||||
if (!connector) return `${node.connectorId}${node.operationId ? ` / ${node.operationId}` : ''}`;
|
||||
|
||||
if (!node.operationId) return connector.name;
|
||||
|
||||
const pool = node.kind === 'trigger' ? connector.triggers : connector.actions;
|
||||
const operation = pool.find((op) => op.id === node.operationId);
|
||||
return `${connector.name} / ${operation?.name ?? node.operationId}`;
|
||||
}
|
||||
|
||||
/** Male shrnuti nad logem - kolik kroku, kolik chyb. */
|
||||
export function TraceSummary({ entries }: { entries: TicketTraceEntry[] }) {
|
||||
const failed = entries.filter((e) => e.status === 'error').length;
|
||||
const skipped = entries.filter((e) => e.status === 'skipped').length;
|
||||
const services = new Set(entries.map((e) => e.connectorId).filter(Boolean)).size;
|
||||
|
||||
return (
|
||||
<p className="text-xs text-white/40">
|
||||
{entries.length} {entries.length === 1 ? 'záznam' : entries.length <= 4 ? 'záznamy' : 'záznamů'}
|
||||
{services > 0 && ` · ${services} ${services === 1 ? 'služba' : services <= 4 ? 'služby' : 'služeb'}`}
|
||||
{failed > 0 && <span className="text-danger-400"> · {failed} selhalo</span>}
|
||||
{skipped > 0 && ` · ${skipped} neproběhlo`}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/** Ikona konektoru pro pouziti nad logem (napr. u kanalu ticketu). */
|
||||
export function serviceIcon(connectorId: string, connectors: Connector[]): LucideIcon | null {
|
||||
const connector = connectors.find((c) => c.id === connectorId);
|
||||
return connector ? connectorIcon(connector.icon) : null;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { AlertTriangle, Inbox, Users } from 'lucide-react';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { formatRelative } from '@/lib/format';
|
||||
import type { Workload } from '@/types/dashboard';
|
||||
|
||||
/**
|
||||
* Prehled nad firmou: kdo ma kolik ticketu u sebe a kolik jich ceka ve fronte.
|
||||
*
|
||||
* Radek je zaroven filtr seznamu - jinak by to byl jen obrazek.
|
||||
* Bez tohohle pohledu nejde poznat, ze jeden clovek utahuje pulku servicedesku.
|
||||
*/
|
||||
export function TicketWorkload({
|
||||
workload,
|
||||
active,
|
||||
onSelect,
|
||||
}: {
|
||||
workload: Workload;
|
||||
/** ID resitele, 'unassigned', nebo null pro "nefiltrovano". */
|
||||
active: string | null;
|
||||
onSelect: (value: string | null) => void;
|
||||
}) {
|
||||
const busiest = workload.rows[0]?.open ?? 0;
|
||||
|
||||
return (
|
||||
<section className="glass rounded-card p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="flex items-center gap-2 font-semibold text-white">
|
||||
<Users className="size-4 text-brand-300" />
|
||||
Kdo to má u sebe
|
||||
</h2>
|
||||
<span className="text-xs text-white/40">{workload.openTotal} nevyřešených</span>
|
||||
</div>
|
||||
|
||||
<ul className="mt-4 space-y-1.5">
|
||||
{workload.rows.map((row) => {
|
||||
const isActive = active === row.person.id;
|
||||
// Sirka pruhu je vzdy proti nejvytizenejsimu, ne proti kapacite -
|
||||
// jde o porovnani lidi mezi sebou.
|
||||
const width = busiest === 0 ? 0 : Math.round((row.open / busiest) * 100);
|
||||
|
||||
return (
|
||||
<li key={row.person.id}>
|
||||
<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]',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="truncate text-sm font-medium text-white/85">
|
||||
{row.person.name}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 font-mono text-sm tabular-nums',
|
||||
row.overloaded ? 'text-warn-400' : 'text-white/60',
|
||||
)}
|
||||
>
|
||||
{row.open}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-1.5 h-1 overflow-hidden rounded-full bg-white/8">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full',
|
||||
row.overloaded ? 'bg-warn-400/70' : 'bg-brand-400/70',
|
||||
)}
|
||||
style={{ width: `${width}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-x-2.5 gap-y-1 text-xs text-white/35">
|
||||
<span>{row.person.role}</span>
|
||||
{row.critical > 0 && (
|
||||
<span className="flex items-center gap-1 text-danger-400">
|
||||
<AlertTriangle className="size-3" />
|
||||
{row.critical} kritick{row.critical === 1 ? 'ý' : 'é'}
|
||||
</span>
|
||||
)}
|
||||
{row.oldestOpenAt && <span>nejstarší {formatRelative(row.oldestOpenAt)}</span>}
|
||||
{row.overloaded && (
|
||||
<span className="text-warn-400">nad kapacitu {row.person.capacity}</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(active === 'unassigned' ? null : 'unassigned')}
|
||||
aria-pressed={active === 'unassigned'}
|
||||
className={cn(
|
||||
'mt-3 flex w-full items-center justify-between gap-3 rounded-xl border border-dashed px-3.5 py-2.5 text-sm transition-colors',
|
||||
active === 'unassigned'
|
||||
? 'border-warn-400/50 bg-warn-500/8 text-white'
|
||||
: 'border-ink-600/70 text-white/55 hover:border-warn-400/50 hover:text-white',
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Inbox className="size-4" />
|
||||
Ve frontě, bez řešitele
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono tabular-nums',
|
||||
workload.unassigned > 0 ? 'text-warn-400' : 'text-white/40',
|
||||
)}
|
||||
>
|
||||
{workload.unassigned}
|
||||
</span>
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -29,27 +29,12 @@ export function TriggerConfig({
|
||||
const isWebhook = trigger.connectorId === 'webhook';
|
||||
const operation = connector?.triggers.find((t) => t.id === trigger.operationId);
|
||||
const editable = operation?.customPayload === true;
|
||||
|
||||
function addField() {
|
||||
onChangeFields([
|
||||
...trigger.fields,
|
||||
{ id: newFieldId(), name: '', type: 'string', required: true },
|
||||
]);
|
||||
}
|
||||
|
||||
function updateField(id: string, patch: Partial<TriggerField>) {
|
||||
onChangeFields(trigger.fields.map((f) => (f.id === id ? { ...f, ...patch } : f)));
|
||||
}
|
||||
|
||||
function removeField(id: string) {
|
||||
onChangeFields(trigger.fields.filter((f) => f.id !== id));
|
||||
}
|
||||
|
||||
const duplicates = new Set(
|
||||
trigger.fields
|
||||
.map((f) => f.name.trim())
|
||||
.filter((name, index, all) => name.length > 0 && all.indexOf(name) !== index),
|
||||
);
|
||||
/**
|
||||
* Sluzba, ktera si data urcuje sama (e-mail, WhatsApp, ticket). Parametry
|
||||
* pak nejsou na uzivateli - server je pri ulozeni stejne prepise katalogem,
|
||||
* takze by editovatelne pole jen lhalo.
|
||||
*/
|
||||
const provided = operation?.providedFields;
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-4 border-t border-ink-600/50 pt-4">
|
||||
@@ -62,34 +47,75 @@ export function TriggerConfig({
|
||||
/>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white">Vstupní parametry</h3>
|
||||
<p className="mt-0.5 text-xs text-white/45">
|
||||
{editable
|
||||
? 'Co bude na spouštěč přicházet. Podle těchto hodnot pak stavíte podmínky.'
|
||||
: 'Tato služba předává vlastní data. Parametry pro podmínky si můžete doplnit ručně.'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addField}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-ink-600/70 px-3 py-1.5 text-xs font-medium text-white/70 transition-colors hover:border-brand-400/60 hover:text-white"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
Přidat parametr
|
||||
</button>
|
||||
</div>
|
||||
{provided ? (
|
||||
<ProvidedFields fields={provided} serviceName={connector?.name ?? 'Tato služba'} />
|
||||
) : (
|
||||
<CustomFields fields={trigger.fields} editable={editable} onChange={onChangeFields} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{trigger.fields.length === 0 ? (
|
||||
<p className="mt-3 rounded-xl border border-dashed border-ink-600/70 px-4 py-3 text-xs text-white/40">
|
||||
Zatím žádné parametry. Bez nich nelze přidat podmínku — nebylo by podle čeho
|
||||
se rozhodovat.
|
||||
/**
|
||||
* Parametry, ktere si deklaruje uzivatel (webhook, formular).
|
||||
* Podminky se odkazuji na `field.id`, prejmenovani je tedy nerozbije.
|
||||
*/
|
||||
function CustomFields({
|
||||
fields,
|
||||
editable,
|
||||
onChange,
|
||||
}: {
|
||||
fields: TriggerField[];
|
||||
editable: boolean;
|
||||
onChange: (fields: TriggerField[]) => void;
|
||||
}) {
|
||||
function addField() {
|
||||
onChange([...fields, { id: newFieldId(), name: '', type: 'string', required: true }]);
|
||||
}
|
||||
|
||||
function updateField(id: string, patch: Partial<TriggerField>) {
|
||||
onChange(fields.map((f) => (f.id === id ? { ...f, ...patch } : f)));
|
||||
}
|
||||
|
||||
function removeField(id: string) {
|
||||
onChange(fields.filter((f) => f.id !== id));
|
||||
}
|
||||
|
||||
const duplicates = new Set(
|
||||
fields
|
||||
.map((f) => f.name.trim())
|
||||
.filter((name, index, all) => name.length > 0 && all.indexOf(name) !== index),
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white">Vstupní parametry</h3>
|
||||
<p className="mt-0.5 text-xs text-white/45">
|
||||
{editable
|
||||
? 'Co bude na spouštěč přicházet. Podle těchto hodnot pak stavíte podmínky.'
|
||||
: 'Tato služba předává vlastní data. Parametry pro podmínky si můžete doplnit ručně.'}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-3 space-y-2">
|
||||
{trigger.fields.map((field) => {
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addField}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-ink-600/70 px-3 py-1.5 text-xs font-medium text-white/70 transition-colors hover:border-brand-400/60 hover:text-white"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
Přidat parametr
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{fields.length === 0 ? (
|
||||
<p className="mt-3 rounded-xl border border-dashed border-ink-600/70 px-4 py-3 text-xs text-white/40">
|
||||
Zatím žádné parametry. Bez nich nelze přidat podmínku — nebylo by podle čeho
|
||||
se rozhodovat.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-3 space-y-2">
|
||||
{fields.map((field) => {
|
||||
const duplicate = field.name.trim().length > 0 && duplicates.has(field.name.trim());
|
||||
|
||||
return (
|
||||
@@ -154,10 +180,47 @@ export function TriggerConfig({
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parametry, ktere spoustec predava sam. Jen ke cteni - jsou dane katalogem
|
||||
* a menit je z builderu by bylo mateni. Podminky se na ne odkazuji stejne
|
||||
* jako na rucne deklarovane.
|
||||
*/
|
||||
function ProvidedFields({
|
||||
fields,
|
||||
serviceName,
|
||||
}: {
|
||||
fields: TriggerField[];
|
||||
serviceName: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white">Co spouštěč předá dál</h3>
|
||||
<p className="mt-0.5 text-xs text-white/45">
|
||||
{serviceName} posílá tyto hodnoty sama. Nastavovat se nedají, ale můžete nad nimi
|
||||
stavět podmínky.
|
||||
</p>
|
||||
|
||||
<ul className="mt-3 grid gap-2 sm:grid-cols-2">
|
||||
{fields.map((field) => (
|
||||
<li
|
||||
key={field.id}
|
||||
className="flex items-center justify-between gap-3 rounded-xl border border-ink-600/50 bg-ink-850/50 px-3 py-2"
|
||||
>
|
||||
<span className="min-w-0 truncate font-mono text-sm text-brand-200">{field.name}</span>
|
||||
<span className="flex shrink-0 items-center gap-2 text-xs text-white/40">
|
||||
{fieldTypeLabels[field.type]}
|
||||
{field.required && <span className="text-white/25">povinný</span>}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
LifeBuoy,
|
||||
Mail,
|
||||
Megaphone,
|
||||
MessageCircle,
|
||||
MessageSquare,
|
||||
MousePointer,
|
||||
MousePointerClick,
|
||||
@@ -45,6 +46,7 @@ const icons: Record<string, LucideIcon> = {
|
||||
LifeBuoy,
|
||||
Mail,
|
||||
Megaphone,
|
||||
MessageCircle,
|
||||
MessageSquare,
|
||||
MousePointer,
|
||||
MousePointerClick,
|
||||
|
||||
@@ -77,6 +77,29 @@ export default function AutomationDetail() {
|
||||
setSavedAt(null);
|
||||
}, []);
|
||||
|
||||
/** Parametry, ktere ma mit spoustec po jeho zmene. */
|
||||
function fieldsForTrigger(connectorId: string, operationId: string): TriggerField[] {
|
||||
const provided = findTrigger(connectorId, operationId)?.providedFields;
|
||||
// Sluzba si data urcuje sama. Server je pri ulozeni stejne dosadi z katalogu,
|
||||
// tady je nastavime hned, aby slo rovnou stavet podminky.
|
||||
if (provided) return provided;
|
||||
|
||||
const previous = flow.trigger
|
||||
? findTrigger(flow.trigger.connectorId, flow.trigger.operationId)
|
||||
: undefined;
|
||||
// Prechod z katalogoveho spoustece na vlastni - cizi parametry by tu neplatily.
|
||||
if (previous?.providedFields) return [];
|
||||
|
||||
// Jinak drzime uz nadeklarovane parametry, aby se nezahodila prace.
|
||||
return flow.trigger?.fields ?? [];
|
||||
}
|
||||
|
||||
function findTrigger(connectorId: string, operationId: string) {
|
||||
return connectors
|
||||
.find((connector) => connector.id === connectorId)
|
||||
?.triggers.find((operation) => operation.id === operationId);
|
||||
}
|
||||
|
||||
function handlePick(connectorId: string, operationId: string) {
|
||||
if (!picker) {
|
||||
console.warn('[builder] vyber potvrzen bez otevreneho cile');
|
||||
@@ -84,13 +107,12 @@ export default function AutomationDetail() {
|
||||
}
|
||||
|
||||
if (picker.mode === 'trigger') {
|
||||
// Pri zmene spoustece drzime uz nadeklarovane parametry, aby se nezahodila prace.
|
||||
changeFlow({
|
||||
...flow,
|
||||
trigger: {
|
||||
connectorId,
|
||||
operationId,
|
||||
fields: flow.trigger?.fields ?? [],
|
||||
fields: fieldsForTrigger(connectorId, operationId),
|
||||
webhookToken: flow.trigger?.webhookToken,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -9,7 +9,12 @@ import { Button } from '@/components/ui/Button';
|
||||
import { formatNumber, formatPercent, formatRelative } from '@/lib/format';
|
||||
import { useApiQuery } from '@/lib/useApiQuery';
|
||||
import { usePageMeta } from '@/lib/usePageMeta';
|
||||
import type { DashboardSummary, Incident, ListResponse, Ticket } from '@/types/dashboard';
|
||||
import type {
|
||||
DashboardSummary,
|
||||
Incident,
|
||||
ListResponse,
|
||||
TicketListResponse,
|
||||
} from '@/types/dashboard';
|
||||
|
||||
export default function Overview() {
|
||||
usePageMeta({ title: 'Přehled — portál Automia' });
|
||||
@@ -30,8 +35,8 @@ export default function Overview() {
|
||||
'webhook.received',
|
||||
],
|
||||
});
|
||||
const tickets = useApiQuery<ListResponse<Ticket>>('/api/dashboard/tickets', {
|
||||
refetchOn: ['ticket.created', 'ticket.updated', 'ticket.resolved'],
|
||||
const tickets = useApiQuery<TicketListResponse>('/api/dashboard/tickets', {
|
||||
refetchOn: ['ticket.created', 'ticket.updated', 'ticket.assigned', 'ticket.resolved'],
|
||||
});
|
||||
const incidents = useApiQuery<ListResponse<Incident>>('/api/dashboard/incidents', {
|
||||
refetchOn: ['incident.started', 'incident.updated', 'incident.resolved'],
|
||||
@@ -126,9 +131,15 @@ export default function Overview() {
|
||||
<li key={ticket.id} className="flex items-start gap-3 py-3.5 first:pt-0 last:pb-0">
|
||||
<span className="mt-0.5 font-mono text-xs text-white/35">{ticket.id}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-white/80">{ticket.subject}</p>
|
||||
<Link
|
||||
to={`/dashboard/tickety/${ticket.id}`}
|
||||
className="block truncate text-sm text-white/80 transition-colors hover:text-brand-200"
|
||||
>
|
||||
{ticket.subject}
|
||||
</Link>
|
||||
<p className="mt-0.5 text-xs text-white/40">
|
||||
{ticket.requester} · {formatRelative(ticket.updatedAt)}
|
||||
{ticket.customer.company} · {ticket.assignee?.name ?? 've frontě'} ·{' '}
|
||||
{formatRelative(ticket.updatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<TicketStatusBadge status={ticket.status} />
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import { AlertCircle, ArrowLeft, Building2, MessageSquarePlus, ScrollText, UserCheck } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { DataState } from '@/components/dashboard/DataState';
|
||||
import {
|
||||
TicketChannelBadge,
|
||||
TicketPriorityBadge,
|
||||
TicketStatusBadge,
|
||||
} from '@/components/dashboard/StatusBadge';
|
||||
import { TicketTrace, TraceSummary } from '@/components/dashboard/TicketTrace';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { formatDateTime } from '@/lib/format';
|
||||
import { useApiQuery } from '@/lib/useApiQuery';
|
||||
import { usePageMeta } from '@/lib/usePageMeta';
|
||||
import type {
|
||||
ConnectorCatalog,
|
||||
PeopleResponse,
|
||||
TicketDetail as Detail,
|
||||
TicketStatus,
|
||||
} from '@/types/dashboard';
|
||||
|
||||
const statusOptions: Array<{ value: TicketStatus; label: string }> = [
|
||||
{ value: 'new', label: 'Nový' },
|
||||
{ value: 'open', label: 'V řešení' },
|
||||
{ value: 'waiting', label: 'Čeká na klienta' },
|
||||
{ value: 'resolved', label: 'Vyřešeno' },
|
||||
];
|
||||
|
||||
const selectClass =
|
||||
'rounded-xl border border-ink-600/70 bg-ink-850/70 px-3.5 py-2.5 text-sm text-white focus:border-brand-400/70 focus:outline-none';
|
||||
|
||||
export default function TicketDetail() {
|
||||
const { id = '' } = useParams();
|
||||
|
||||
const ticket = useApiQuery<Detail>(`/api/dashboard/tickets/${id}`, {
|
||||
refetchOn: ['ticket.updated', 'ticket.assigned', 'ticket.resolved'],
|
||||
});
|
||||
const people = useApiQuery<PeopleResponse>('/api/dashboard/people');
|
||||
const catalog = useApiQuery<ConnectorCatalog>('/api/dashboard/connectors');
|
||||
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [comment, setComment] = useState('');
|
||||
|
||||
usePageMeta({ title: `${ticket.data ? ticket.data.id : 'Ticket'} — portál Automia` });
|
||||
|
||||
// Po zmene ticketu zmizi stara chyba, at nevisi u uz opraveneho stavu.
|
||||
useEffect(() => {
|
||||
setActionError(null);
|
||||
}, [ticket.data?.updatedAt]);
|
||||
|
||||
/** Kazda zmena jde pres server a nasledne se ticket nacte znovu. Vraci uspech. */
|
||||
async function mutate(path: string, body: unknown, fallback: string): Promise<boolean> {
|
||||
setBusy(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
await apiFetch<unknown>(`/api/dashboard/tickets/${id}${path}`, { method: 'POST', body });
|
||||
ticket.reload();
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : fallback;
|
||||
console.error(`[ticket] ${path} selhalo:`, err);
|
||||
setActionError(message);
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitComment(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const text = comment.trim();
|
||||
if (text.length < 2) return;
|
||||
// Pri chybe text necháme v poli, aby ho uzivatel nemusel psat znovu.
|
||||
if (await mutate('/comment', { text }, 'Komentář se nepodařilo uložit.')) {
|
||||
setComment('');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Link
|
||||
to="/dashboard/tickety"
|
||||
className="inline-flex items-center gap-2 text-sm text-white/45 transition-colors hover:text-white"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Zpět na tickety
|
||||
</Link>
|
||||
|
||||
<DataState
|
||||
loading={ticket.loading || catalog.loading || people.loading}
|
||||
error={ticket.error ?? catalog.error ?? people.error}
|
||||
onRetry={() => {
|
||||
ticket.reload();
|
||||
catalog.reload();
|
||||
people.reload();
|
||||
}}
|
||||
>
|
||||
{ticket.data && (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-xs text-white/35">{ticket.data.id}</span>
|
||||
<TicketChannelBadge channel={ticket.data.channel} />
|
||||
<TicketStatusBadge status={ticket.data.status} />
|
||||
<TicketPriorityBadge priority={ticket.data.priority} />
|
||||
{ticket.data.customer.id === null && (
|
||||
<Badge tone="warn">
|
||||
<AlertCircle className="size-3.5" />
|
||||
Zákazník nedohledán
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="mt-2 text-2xl font-bold text-white">{ticket.data.subject}</h1>
|
||||
</header>
|
||||
|
||||
{actionError && (
|
||||
<p className="flex items-center gap-2 rounded-xl border border-danger-400/30 bg-danger-500/10 px-4 py-3 text-sm text-danger-400">
|
||||
<AlertCircle className="size-4 shrink-0" />
|
||||
{actionError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1fr_20rem]">
|
||||
<section className="glass rounded-card p-5 sm:p-6">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="flex items-center gap-2 font-semibold text-white">
|
||||
<ScrollText className="size-4 text-brand-300" />
|
||||
Průběh a log
|
||||
</h2>
|
||||
<TraceSummary entries={ticket.data.trace} />
|
||||
</div>
|
||||
|
||||
<TicketTrace
|
||||
entries={ticket.data.trace}
|
||||
connectors={catalog.data?.items ?? []}
|
||||
/>
|
||||
|
||||
<form
|
||||
onSubmit={(event) => void submitComment(event)}
|
||||
className="mt-5 border-t border-ink-600/50 pt-5"
|
||||
>
|
||||
<label
|
||||
htmlFor="ticket-comment"
|
||||
className="text-xs font-semibold tracking-wide text-white/45 uppercase"
|
||||
>
|
||||
Přidat komentář
|
||||
</label>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<input
|
||||
id="ticket-comment"
|
||||
value={comment}
|
||||
onChange={(event) => setComment(event.target.value)}
|
||||
placeholder="Co jste zjistili nebo udělali…"
|
||||
className="min-w-0 flex-1 rounded-xl border border-ink-600/70 bg-ink-850/70 px-3.5 py-2.5 text-sm text-white placeholder:text-white/30 focus:border-brand-400/70 focus:outline-none"
|
||||
/>
|
||||
<Button type="submit" size="sm" disabled={busy || comment.trim().length < 2}>
|
||||
<MessageSquarePlus className="size-4" />
|
||||
Zapsat
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-white/35">
|
||||
Komentář se zapíše do stejné časové osy jako běh automatizace.
|
||||
</p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<aside className="space-y-4">
|
||||
<div className="glass rounded-card p-5">
|
||||
<h2 className="flex items-center gap-2 font-semibold text-white">
|
||||
<UserCheck className="size-4 text-brand-300" />
|
||||
Řešitel
|
||||
</h2>
|
||||
|
||||
<p
|
||||
className={cn(
|
||||
'mt-3 text-sm',
|
||||
ticket.data.assignee ? 'text-white/85' : 'text-warn-400',
|
||||
)}
|
||||
>
|
||||
{ticket.data.assignee ? ticket.data.assignee.name : 'Ve frontě, bez řešitele'}
|
||||
</p>
|
||||
|
||||
<label htmlFor="ticket-assignee" className="sr-only">
|
||||
Přiřadit řešiteli
|
||||
</label>
|
||||
<select
|
||||
id="ticket-assignee"
|
||||
value={ticket.data.assignee?.id ?? ''}
|
||||
disabled={busy}
|
||||
onChange={(event) =>
|
||||
void mutate(
|
||||
'/assign',
|
||||
{ assigneeId: event.target.value === '' ? null : event.target.value },
|
||||
'Přiřazení se nepodařilo.',
|
||||
)
|
||||
}
|
||||
className={cn(selectClass, 'mt-3 w-full')}
|
||||
>
|
||||
<option value="" className="bg-ink-850">
|
||||
Vrátit do fronty
|
||||
</option>
|
||||
{(people.data?.items ?? []).map((person) => (
|
||||
<option key={person.id} value={person.id} className="bg-ink-850">
|
||||
{person.name}, {person.role}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<label
|
||||
htmlFor="ticket-status"
|
||||
className="mt-4 block text-xs font-semibold tracking-wide text-white/45 uppercase"
|
||||
>
|
||||
Stav
|
||||
</label>
|
||||
<select
|
||||
id="ticket-status"
|
||||
value={ticket.data.status}
|
||||
disabled={busy}
|
||||
onChange={(event) =>
|
||||
void mutate(
|
||||
'/status',
|
||||
{ status: event.target.value },
|
||||
'Změna stavu se nepodařila.',
|
||||
)
|
||||
}
|
||||
className={cn(selectClass, 'mt-2 w-full')}
|
||||
>
|
||||
{statusOptions.map((option) => (
|
||||
<option key={option.value} value={option.value} className="bg-ink-850">
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="glass rounded-card p-5">
|
||||
<h2 className="flex items-center gap-2 font-semibold text-white">
|
||||
<Building2 className="size-4 text-brand-300" />
|
||||
Zákazník
|
||||
</h2>
|
||||
<dl className="mt-3 space-y-2.5 text-sm">
|
||||
<Row
|
||||
label="Firma"
|
||||
value={ticket.data.customer.company}
|
||||
warn={ticket.data.customer.id === null}
|
||||
/>
|
||||
<Row label="Kontakt" value={ticket.data.customer.contact} />
|
||||
<Row label="Odpověď na" value={ticket.data.customer.reply} />
|
||||
<Row
|
||||
label="ID v CRM"
|
||||
value={ticket.data.customer.id ?? 'nedohledáno'}
|
||||
warn={ticket.data.customer.id === null}
|
||||
/>
|
||||
</dl>
|
||||
|
||||
{ticket.data.customer.id === null && (
|
||||
<p className="mt-3 rounded-lg border border-warn-400/30 bg-warn-500/8 px-3 py-2.5 text-xs leading-relaxed text-white/60">
|
||||
Firmu se nepodařilo dohledat v CRM. Ticket proto zůstal bez řešitele,
|
||||
není podle čeho určit garanta.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="glass rounded-card p-5">
|
||||
<h2 className="font-semibold text-white">Původ</h2>
|
||||
<dl className="mt-3 space-y-2.5 text-sm">
|
||||
<Row label="Založeno" value={formatDateTime(ticket.data.createdAt)} />
|
||||
<Row label="Poslední změna" value={formatDateTime(ticket.data.updatedAt)} />
|
||||
<Row
|
||||
label="Automatizace"
|
||||
value={ticket.data.automationId ?? 'ručně'}
|
||||
to={
|
||||
ticket.data.automationId
|
||||
? `/dashboard/automatizace/${ticket.data.automationId}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</dl>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DataState>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
label,
|
||||
value,
|
||||
warn,
|
||||
to,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
warn?: boolean;
|
||||
to?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex justify-between gap-3">
|
||||
<dt className="shrink-0 text-white/40">{label}</dt>
|
||||
<dd className={cn('text-right break-all', warn ? 'text-warn-400' : 'text-white/70')}>
|
||||
{to ? (
|
||||
<Link to={to} className="text-brand-300 transition-colors hover:text-brand-200">
|
||||
{value}
|
||||
</Link>
|
||||
) : (
|
||||
value
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,75 +1,303 @@
|
||||
import { ChevronRight, Search } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { DataState } from '@/components/dashboard/DataState';
|
||||
import { TicketPriorityBadge, TicketStatusBadge } from '@/components/dashboard/StatusBadge';
|
||||
import {
|
||||
TicketChannelBadge,
|
||||
TicketPriorityBadge,
|
||||
TicketStatusBadge,
|
||||
} from '@/components/dashboard/StatusBadge';
|
||||
import { TicketWorkload } from '@/components/dashboard/TicketWorkload';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { formatDateTime, formatRelative } from '@/lib/format';
|
||||
import { useApiQuery } from '@/lib/useApiQuery';
|
||||
import { usePageMeta } from '@/lib/usePageMeta';
|
||||
import type { ListResponse, Ticket } from '@/types/dashboard';
|
||||
import type {
|
||||
TicketChannel,
|
||||
TicketListResponse,
|
||||
TicketStatus,
|
||||
Workload,
|
||||
} from '@/types/dashboard';
|
||||
|
||||
/** Vsechny zmeny ticketu, po kterych ma smysl nacist data znovu. */
|
||||
const ticketEvents = ['ticket.created', 'ticket.updated', 'ticket.assigned', 'ticket.resolved'] as const;
|
||||
|
||||
const statusFilters: Array<{ value: TicketStatus; label: string }> = [
|
||||
{ value: 'new', label: 'Nové' },
|
||||
{ value: 'open', label: 'V řešení' },
|
||||
{ value: 'waiting', label: 'Čeká na klienta' },
|
||||
{ value: 'resolved', label: 'Vyřešené' },
|
||||
];
|
||||
|
||||
const channelFilters: Array<{ value: TicketChannel; label: string }> = [
|
||||
{ value: 'whatsapp', label: 'WhatsApp' },
|
||||
{ value: 'email', label: 'E-mail' },
|
||||
{ value: 'voice', label: 'Hlasová linka' },
|
||||
{ value: 'form', label: 'Formulář' },
|
||||
{ value: 'portal', label: 'Portál' },
|
||||
];
|
||||
|
||||
export default function Tickets() {
|
||||
usePageMeta({ title: 'Tickety — portál Automia' });
|
||||
const { data, loading, error, reload } = useApiQuery<ListResponse<Ticket>>(
|
||||
'/api/dashboard/tickets',
|
||||
{ refetchOn: ['ticket.created', 'ticket.updated', 'ticket.resolved'] },
|
||||
);
|
||||
|
||||
/** null = vsichni, 'me' = prihlaseny, '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 [query, setQuery] = useState('');
|
||||
|
||||
// Filtrovani resi server, aby seznam a prehled nikdy neukazovaly jina cisla.
|
||||
const path = useMemo(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (assignee) params.set('assignee', assignee);
|
||||
if (status) params.set('status', status);
|
||||
if (channel) params.set('channel', channel);
|
||||
const search = params.toString();
|
||||
return `/api/dashboard/tickets${search ? `?${search}` : ''}`;
|
||||
}, [assignee, status, channel]);
|
||||
|
||||
const tickets = useApiQuery<TicketListResponse>(path, { refetchOn: [...ticketEvents] });
|
||||
const workload = useApiQuery<Workload>('/api/dashboard/tickets/workload', {
|
||||
refetchOn: [...ticketEvents],
|
||||
});
|
||||
|
||||
// Hledani je jen dohledani v uz nactenem seznamu, proto na klientovi.
|
||||
const items = useMemo(() => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
const all = tickets.data?.items ?? [];
|
||||
if (needle.length === 0) return all;
|
||||
|
||||
return all.filter((ticket) =>
|
||||
[ticket.id, ticket.subject, ticket.customer.company, ticket.customer.contact]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(needle),
|
||||
);
|
||||
}, [tickets.data, query]);
|
||||
|
||||
const meId = tickets.data?.meId ?? null;
|
||||
const filtered = assignee !== null || status !== null || channel !== null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="text-2xl font-bold text-white">Tickety</h1>
|
||||
<p className="mt-1 text-sm text-white/50">
|
||||
Požadavky napříč kanály. Filtrování, komentáře a zakládání ticketů přidáme v další
|
||||
iteraci.
|
||||
Požadavky ze všech kanálů. Každý má svého řešitele a dohledatelný průběh.
|
||||
V detailu je vidět, co která služba vrátila.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="glass overflow-hidden rounded-card">
|
||||
<DataState
|
||||
loading={loading}
|
||||
error={error}
|
||||
empty={data?.items.length === 0}
|
||||
onRetry={reload}
|
||||
emptyLabel="Žádné tickety."
|
||||
>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[52rem] 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">Zadavatel</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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-600/40">
|
||||
{data?.items.map((ticket) => (
|
||||
<tr key={ticket.id} className="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 text-white/85">{ticket.subject}</td>
|
||||
<td className="px-5 py-4 text-white/55">{ticket.requester}</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 text-white/55">
|
||||
{ticket.assignee ?? <span className="text-white/30">nepřiřazeno</span>}
|
||||
</td>
|
||||
<td className="px-5 py-4 text-white/45">
|
||||
<span title={formatDateTime(ticket.updatedAt)}>
|
||||
{formatRelative(ticket.updatedAt)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="grid gap-6 xl:grid-cols-[1fr_20rem]">
|
||||
<div className="space-y-4">
|
||||
<div className="glass rounded-card p-4 sm:p-5">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-3.5 size-4 -translate-y-1/2 text-white/35" />
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Hledat podle čísla, předmětu nebo firmy…"
|
||||
className="w-full rounded-xl border border-ink-600/70 bg-ink-850/70 py-2.5 pr-4 pl-10 text-sm text-white placeholder:text-white/30 focus:border-brand-400/70 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Chip active={assignee === null} onClick={() => setAssignee(null)}>
|
||||
Všechny
|
||||
</Chip>
|
||||
<Chip
|
||||
active={assignee === 'me'}
|
||||
onClick={() => setAssignee(assignee === 'me' ? null : 'me')}
|
||||
disabled={meId === null}
|
||||
title={
|
||||
meId === null
|
||||
? 'Přihlášený účet není v seznamu řešitelů, nemá tedy vlastní tickety.'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Moje
|
||||
</Chip>
|
||||
<Chip
|
||||
active={assignee === 'unassigned'}
|
||||
onClick={() => setAssignee(assignee === 'unassigned' ? null : 'unassigned')}
|
||||
>
|
||||
Ve frontě
|
||||
</Chip>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{statusFilters.map((item) => (
|
||||
<Chip
|
||||
key={item.value}
|
||||
active={status === item.value}
|
||||
onClick={() => setStatus(status === item.value ? null : item.value)}
|
||||
>
|
||||
{item.label}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{channelFilters.map((item) => (
|
||||
<Chip
|
||||
key={item.value}
|
||||
active={channel === item.value}
|
||||
onClick={() => setChannel(channel === item.value ? null : item.value)}
|
||||
>
|
||||
{item.label}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</DataState>
|
||||
|
||||
<div className="glass overflow-hidden rounded-card">
|
||||
<DataState
|
||||
loading={tickets.loading}
|
||||
error={tickets.error}
|
||||
empty={items.length === 0}
|
||||
onRetry={tickets.reload}
|
||||
emptyLabel={
|
||||
filtered || query.trim().length > 0
|
||||
? 'Nic neodpovídá filtru.'
|
||||
: 'Žádné tickety.'
|
||||
}
|
||||
>
|
||||
<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>
|
||||
</DataState>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside>
|
||||
<DataState
|
||||
loading={workload.loading}
|
||||
error={workload.error}
|
||||
onRetry={workload.reload}
|
||||
>
|
||||
{workload.data && (
|
||||
<TicketWorkload
|
||||
workload={workload.data}
|
||||
active={assignee}
|
||||
onSelect={setAssignee}
|
||||
/>
|
||||
)}
|
||||
</DataState>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Chip({
|
||||
active,
|
||||
onClick,
|
||||
disabled,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
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',
|
||||
disabled && 'cursor-not-allowed opacity-40 hover:border-ink-600/70 hover:text-white/50',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,20 +5,93 @@
|
||||
|
||||
export type TicketStatus = 'new' | 'open' | 'waiting' | 'resolved';
|
||||
export type TicketPriority = 'low' | 'normal' | 'high' | 'critical';
|
||||
/** Odkud pozadavek prisel. */
|
||||
export type TicketChannel = 'whatsapp' | 'email' | 'voice' | 'form' | 'portal';
|
||||
export type IncidentSeverity = 'sev1' | 'sev2' | 'sev3';
|
||||
export type IncidentStatus = 'investigating' | 'identified' | 'monitoring' | 'resolved';
|
||||
|
||||
/** Resitel ticketu. Nemusi mit ucet v portalu, spojka je e-mail. */
|
||||
export interface Person {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
capacity: number;
|
||||
}
|
||||
|
||||
export interface TicketCustomer {
|
||||
/** null = zakaznika se nepodarilo dohledat v CRM. */
|
||||
id: string | null;
|
||||
company: string;
|
||||
contact: string;
|
||||
/** Adresa nebo cislo, odkud to prislo a kam se odpovida. */
|
||||
reply: string;
|
||||
}
|
||||
|
||||
export interface Ticket {
|
||||
id: string;
|
||||
subject: string;
|
||||
requester: string;
|
||||
channel: TicketChannel;
|
||||
customer: TicketCustomer;
|
||||
status: TicketStatus;
|
||||
priority: TicketPriority;
|
||||
assignee: string | null;
|
||||
/** Kdo ma ticket u sebe. null = ceka ve fronte. */
|
||||
assignee: { id: string; name: string } | null;
|
||||
/** Automatizace, ktera ticket zalozila. null = zalozeno rucne. */
|
||||
automationId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type TraceStatus = 'ok' | 'error' | 'skipped' | 'info';
|
||||
export type TraceKind = 'trigger' | 'action' | 'condition' | 'note';
|
||||
|
||||
/** Radek logu ticketu. Strom se sklada pres `parentId`. */
|
||||
export interface TicketTraceEntry {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
kind: TraceKind;
|
||||
connectorId: string | null;
|
||||
operationId: string | null;
|
||||
label: string;
|
||||
status: TraceStatus;
|
||||
/** Co sluzba vratila. */
|
||||
response: string | null;
|
||||
durationMs: number | null;
|
||||
at: string;
|
||||
}
|
||||
|
||||
export interface TicketDetail extends Ticket {
|
||||
trace: TicketTraceEntry[];
|
||||
}
|
||||
|
||||
/** Odpoved seznamu ticketu - `meId` rika, ktery resitel je prihlaseny uzivatel. */
|
||||
export interface TicketListResponse {
|
||||
items: Ticket[];
|
||||
meId: string | null;
|
||||
}
|
||||
|
||||
export interface PeopleResponse {
|
||||
items: Person[];
|
||||
meId: string | null;
|
||||
}
|
||||
|
||||
export interface WorkloadRow {
|
||||
person: Person;
|
||||
open: number;
|
||||
total: number;
|
||||
critical: number;
|
||||
oldestOpenAt: string | null;
|
||||
overloaded: boolean;
|
||||
}
|
||||
|
||||
/** Prehled nad firmou - kdo co ma u sebe. */
|
||||
export interface Workload {
|
||||
rows: WorkloadRow[];
|
||||
unassigned: number;
|
||||
openTotal: number;
|
||||
}
|
||||
|
||||
export interface Incident {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -52,6 +125,7 @@ export interface Automation {
|
||||
|
||||
export type ConnectorCategory =
|
||||
| 'spoustece'
|
||||
| 'servicedesk'
|
||||
| 'crm'
|
||||
| 'ekonomika'
|
||||
| 'logistika'
|
||||
@@ -72,6 +146,11 @@ export interface ConnectorOperation {
|
||||
* (webhook, formular). false/chybi = data urcuje sluzba.
|
||||
*/
|
||||
customPayload?: boolean;
|
||||
/**
|
||||
* Jen u triggeru: parametry, ktere sluzba predava sama. Uzivatel je nemeni,
|
||||
* server je pri ukladani stromu vzdy dosadi z katalogu.
|
||||
*/
|
||||
providedFields?: TriggerField[];
|
||||
}
|
||||
|
||||
export interface Connector {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
export type DashboardEventType =
|
||||
| 'ticket.created'
|
||||
| 'ticket.updated'
|
||||
| 'ticket.assigned'
|
||||
| 'ticket.resolved'
|
||||
| 'incident.started'
|
||||
| 'incident.updated'
|
||||
|
||||
Reference in New Issue
Block a user