diff --git a/documentation/01-prehled-a-stav.md b/documentation/01-prehled-a-stav.md index 90b21e2..2c451ef 100644 --- a/documentation/01-prehled-a-stav.md +++ b/documentation/01-prehled-a-stav.md @@ -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). diff --git a/documentation/02-appfactory-proxy.md b/documentation/02-appfactory-proxy.md index f2e8a28..a5c7ff0 100644 --- a/documentation/02-appfactory-proxy.md +++ b/documentation/02-appfactory-proxy.md @@ -48,6 +48,33 @@ Vite build ma `base: './'`, tedy relativni odkazy na soubory. Server pri odeslan Bez `` by prohlizec hledal soubory v `/apps//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// funguje +https://services.csbot.cz/apps/ vraci prazdnou odpoved +``` + +Neni to chyba aplikace. Caddy routuje pres `handle_path /apps//*`, +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/ | grep -i x-powered-by # nic +curl -sI https://services.csbot.cz/apps// | 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 | diff --git a/documentation/03-architektura-a-mapa-kodu.md b/documentation/03-architektura-a-mapa-kodu.md index e2a38f6..199c36a 100644 --- a/documentation/03-architektura-a-mapa-kodu.md +++ b/documentation/03-architektura-a-mapa-kodu.md @@ -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. diff --git a/documentation/04-api.md b/documentation/04-api.md index 7119a56..cefa931 100644 --- a/documentation/04-api.md +++ b/documentation/04-api.md @@ -23,7 +23,13 @@ Vyzaduji `Authorization: Bearer `: | 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//webhook/ \ 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. diff --git a/documentation/05-dashboard-a-builder.md b/documentation/05-dashboard-a-builder.md index 88e83a8..8ed2fae 100644 --- a/documentation/05-dashboard-a-builder.md +++ b/documentation/05-dashboard-a-builder.md @@ -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 /webhook/`. U spoustece se deklaruji vstupni -parametry: nazev, typ (text, cislo, ano-ne, datum) a povinnost. +`POST /webhook/`. 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 | diff --git a/documentation/06-tickety.md b/documentation/06-tickety.md new file mode 100644 index 0000000..45c8fa0 --- /dev/null +++ b/documentation/06-tickety.md @@ -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 | diff --git a/documentation/99-zmeny.md b/documentation/99-zmeny.md index bf6bb04..b69503b 100644 --- a/documentation/99-zmeny.md +++ b/documentation/99-zmeny.md @@ -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. diff --git a/src/data/connectors.ts b/src/data/connectors.ts index 6752d7f..a40a7f8 100644 --- a/src/data/connectors.ts +++ b/src/data/connectors.ts @@ -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; +} diff --git a/src/data/people.ts b/src/data/people.ts new file mode 100644 index 0000000..3249d75 --- /dev/null +++ b/src/data/people.ts @@ -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); +} diff --git a/src/data/ticketStore.ts b/src/data/ticketStore.ts index abedbec..10b358b 100644 --- a/src/data/ticketStore.ts +++ b/src/data/ticketStore.ts @@ -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 { + assigneeId: string | null; +} + +export const channelLabels: Record = { + 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(); + +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": "", "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( + (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); } diff --git a/src/events/bus.ts b/src/events/bus.ts index c4332bc..5bc1b21 100644 --- a/src/events/bus.ts +++ b/src/events/bus.ts @@ -12,6 +12,7 @@ import { randomUUID } from 'node:crypto'; export type DashboardEventType = | 'ticket.created' | 'ticket.updated' + | 'ticket.assigned' | 'ticket.resolved' | 'incident.started' | 'incident.updated' diff --git a/src/openapi.ts b/src/openapi.ts index d60144b..6295a17 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -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' }, diff --git a/src/routes/dashboard.ts b/src/routes/dashboard.ts index acd0afc..d933777 100644 --- a/src/routes/dashboard.ts +++ b/src/routes/dashboard.ts @@ -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, 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, 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): z.infer { + 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.' }); } diff --git a/src/routes/simulate.ts b/src/routes/simulate.ts index 47f8e51..be4ef47 100644 --- a/src/routes/simulate.ts +++ b/src/routes/simulate.ts @@ -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 = { + 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 }); } diff --git a/web/src/App.tsx b/web/src/App.tsx index 70a087a..f59f3b4 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/components/dashboard/EventToasts.tsx b/web/src/components/dashboard/EventToasts.tsx index 69aab26..ec6acd1 100644 --- a/web/src/components/dashboard/EventToasts.tsx +++ b/web/src/components/dashboard/EventToasts.tsx @@ -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 = { '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' }, diff --git a/web/src/components/dashboard/SimulationModal.tsx b/web/src/components/dashboard/SimulationModal.tsx index 23d51f0..60a1983 100644 --- a/web/src/components/dashboard/SimulationModal.tsx +++ b/web/src/components/dashboard/SimulationModal.tsx @@ -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(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ě." >
- +
- setSubject(event.target.value)} - placeholder="Předmět (nepovinné, jinak se doplní ukázkový)" - className={inputClass} - />
- setRequester(event.target.value)} - placeholder="Zadavatel" - className={cn(inputClass, 'min-w-0 flex-1')} - /> +
+ setSubject(event.target.value)} + placeholder="Předmět (nepovinné, jinak se doplní ukázkový)" + className={inputClass} + /> + setContact(event.target.value)} + placeholder="Kdo píše (nepovinné)" + className={inputClass} + /> + + + ); + })} + + + + + ); +} diff --git a/web/src/components/dashboard/flow/TriggerConfig.tsx b/web/src/components/dashboard/flow/TriggerConfig.tsx index 0eee46f..1d6cd34 100644 --- a/web/src/components/dashboard/flow/TriggerConfig.tsx +++ b/web/src/components/dashboard/flow/TriggerConfig.tsx @@ -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) { - 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 (
@@ -62,34 +47,75 @@ export function TriggerConfig({ /> )} -
-
-
-

Vstupní parametry

-

- {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ě.'} -

-
- -
+ {provided ? ( + + ) : ( + + )} +
+ ); +} - {trigger.fields.length === 0 ? ( -

- 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) { + 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 ( +

+
+
+

Vstupní parametry

+

+ {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ě.'}

- ) : ( -
    - {trigger.fields.map((field) => { +
+ +
+ + {fields.length === 0 ? ( +

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

+ ) : ( +
    + {fields.map((field) => { const duplicate = field.name.trim().length > 0 && duplicates.has(field.name.trim()); return ( @@ -154,10 +180,47 @@ export function TriggerConfig({ )} ); - })} -
- )} -
+ })} + + )} +
+ ); +} + +/** + * 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 ( +
+

Co spouštěč předá dál

+

+ {serviceName} posílá tyto hodnoty sama. Nastavovat se nedají, ale můžete nad nimi + stavět podmínky. +

+ +
    + {fields.map((field) => ( +
  • + {field.name} + + {fieldTypeLabels[field.type]} + {field.required && povinný} + +
  • + ))} +
); } diff --git a/web/src/lib/connectorIcons.ts b/web/src/lib/connectorIcons.ts index a87ab97..a8d91f5 100644 --- a/web/src/lib/connectorIcons.ts +++ b/web/src/lib/connectorIcons.ts @@ -10,6 +10,7 @@ import { LifeBuoy, Mail, Megaphone, + MessageCircle, MessageSquare, MousePointer, MousePointerClick, @@ -45,6 +46,7 @@ const icons: Record = { LifeBuoy, Mail, Megaphone, + MessageCircle, MessageSquare, MousePointer, MousePointerClick, diff --git a/web/src/pages/dashboard/AutomationDetail.tsx b/web/src/pages/dashboard/AutomationDetail.tsx index 3d7ed58..2f7e032 100644 --- a/web/src/pages/dashboard/AutomationDetail.tsx +++ b/web/src/pages/dashboard/AutomationDetail.tsx @@ -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, }, }); diff --git a/web/src/pages/dashboard/Overview.tsx b/web/src/pages/dashboard/Overview.tsx index b48d8a4..4a86c1d 100644 --- a/web/src/pages/dashboard/Overview.tsx +++ b/web/src/pages/dashboard/Overview.tsx @@ -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>('/api/dashboard/tickets', { - refetchOn: ['ticket.created', 'ticket.updated', 'ticket.resolved'], + const tickets = useApiQuery('/api/dashboard/tickets', { + refetchOn: ['ticket.created', 'ticket.updated', 'ticket.assigned', 'ticket.resolved'], }); const incidents = useApiQuery>('/api/dashboard/incidents', { refetchOn: ['incident.started', 'incident.updated', 'incident.resolved'], @@ -126,9 +131,15 @@ export default function Overview() {
  • {ticket.id}
    -

    {ticket.subject}

    + + {ticket.subject} +

    - {ticket.requester} · {formatRelative(ticket.updatedAt)} + {ticket.customer.company} · {ticket.assignee?.name ?? 've frontě'} ·{' '} + {formatRelative(ticket.updatedAt)}

    diff --git a/web/src/pages/dashboard/TicketDetail.tsx b/web/src/pages/dashboard/TicketDetail.tsx new file mode 100644 index 0000000..5ddc4b4 --- /dev/null +++ b/web/src/pages/dashboard/TicketDetail.tsx @@ -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(`/api/dashboard/tickets/${id}`, { + refetchOn: ['ticket.updated', 'ticket.assigned', 'ticket.resolved'], + }); + const people = useApiQuery('/api/dashboard/people'); + const catalog = useApiQuery('/api/dashboard/connectors'); + + const [busy, setBusy] = useState(false); + const [actionError, setActionError] = useState(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 { + setBusy(true); + setActionError(null); + try { + await apiFetch(`/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) { + 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 ( +
    + + + Zpět na tickety + + + { + ticket.reload(); + catalog.reload(); + people.reload(); + }} + > + {ticket.data && ( +
    +
    +
    + {ticket.data.id} + + + + {ticket.data.customer.id === null && ( + + + Zákazník nedohledán + + )} +
    +

    {ticket.data.subject}

    +
    + + {actionError && ( +

    + + {actionError} +

    + )} + +
    +
    +
    +

    + + Průběh a log +

    + +
    + + + + void submitComment(event)} + className="mt-5 border-t border-ink-600/50 pt-5" + > + +
    + 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" + /> + +
    +

    + Komentář se zapíše do stejné časové osy jako běh automatizace. +

    + +
    + + +
    +
    + )} +
    +
    + ); +} + +function Row({ + label, + value, + warn, + to, +}: { + label: string; + value: string; + warn?: boolean; + to?: string; +}) { + return ( +
    +
    {label}
    +
    + {to ? ( + + {value} + + ) : ( + value + )} +
    +
    + ); +} diff --git a/web/src/pages/dashboard/Tickets.tsx b/web/src/pages/dashboard/Tickets.tsx index 637a67a..20b7ac8 100644 --- a/web/src/pages/dashboard/Tickets.tsx +++ b/web/src/pages/dashboard/Tickets.tsx @@ -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>( - '/api/dashboard/tickets', - { refetchOn: ['ticket.created', 'ticket.updated', 'ticket.resolved'] }, - ); + + /** null = vsichni, 'me' = prihlaseny, 'unassigned' = fronta, jinak ID resitele. */ + const [assignee, setAssignee] = useState(null); + const [status, setStatus] = useState(null); + const [channel, setChannel] = useState(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(path, { refetchOn: [...ticketEvents] }); + const workload = useApiQuery('/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 (

    Tickety

    - 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.

    -
    - -
    - - - - - - - - - - - - - - {data?.items.map((ticket) => ( - - - - - - - - - - ))} - -
    IDPředmětZadavatelStavPrioritaŘešíAktualizace
    {ticket.id}{ticket.subject}{ticket.requester} - - - - - {ticket.assignee ?? nepřiřazeno} - - - {formatRelative(ticket.updatedAt)} - -
    +
    +
    +
    +
    + + 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" + /> +
    + +
    + setAssignee(null)}> + Všechny + + 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 + + setAssignee(assignee === 'unassigned' ? null : 'unassigned')} + > + Ve frontě + +
    + +
    + {statusFilters.map((item) => ( + setStatus(status === item.value ? null : item.value)} + > + {item.label} + + ))} +
    + +
    + {channelFilters.map((item) => ( + setChannel(channel === item.value ? null : item.value)} + > + {item.label} + + ))} +
    - + +
    + 0 + ? 'Nic neodpovídá filtru.' + : 'Žádné tickety.' + } + > +
    + + + + + + + + + + + + + + + + {items.map((ticket) => ( + + + + + + + + + + + + ))} + +
    IDPředmětZákazníkKanálStavPrioritaŘešíAktualizace + Detail +
    {ticket.id} + + {ticket.subject} + + + {ticket.customer.id ? ( + {ticket.customer.company} + ) : ( + nedohledáno + )} + + {ticket.customer.contact} + + + + + + + + + {ticket.assignee ? ( + + {ticket.assignee.name} + + ) : ( + ve frontě + )} + + + {formatRelative(ticket.updatedAt)} + + + + + +
    +
    +
    +
    +
    + +
    ); } + +function Chip({ + active, + onClick, + disabled, + title, + children, +}: { + active: boolean; + onClick: () => void; + disabled?: boolean; + title?: string; + children: ReactNode; +}) { + return ( + + ); +} diff --git a/web/src/types/dashboard.ts b/web/src/types/dashboard.ts index b9efb81..c5e6417 100644 --- a/web/src/types/dashboard.ts +++ b/web/src/types/dashboard.ts @@ -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 { diff --git a/web/src/types/events.ts b/web/src/types/events.ts index 20ef579..3fc0bd8 100644 --- a/web/src/types/events.ts +++ b/web/src/types/events.ts @@ -6,6 +6,7 @@ export type DashboardEventType = | 'ticket.created' | 'ticket.updated' + | 'ticket.assigned' | 'ticket.resolved' | 'incident.started' | 'incident.updated'