From dd021b5f69640f7879ca5cc6ad05058a27c4ffc0 Mon Sep 17 00:00:00 2001 From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:29:26 +0200 Subject: [PATCH] Obsah ticketu, nastaveni kroku a vystupy kroku Ticket dostal telo (body) a odkaz na zdrojovou zpravu. Predmet je shrnuti, telo je cely text pozadavku. Akce maji nastavitelna pole (inputs) se sablonami {{parametr}}. Zatim ticket, kanaly, CRM a AI, ostatni maji jen napovedu. Krok vidi parametry spoustece plus vystupy kroku pred nim, takze jde vlozit predvalidaci a vetvit se podle jejiho vysledku. Vetev podminky nepridava nic do sekvence za podminkou. Nove konektory Facebook Messenger a Instagram, nova akce RAYNET Dohledat firmu. Ctyri vzorove automatizace v rozdeleni jedna na kanal pro prijem a jedna spolecna pro smerovani na resitele. Co-Authored-By: Claude Opus 5 (1M context) --- documentation/01-prehled-a-stav.md | 18 +- documentation/03-architektura-a-mapa-kodu.md | 13 +- documentation/04-api.md | 4 +- documentation/05-dashboard-a-builder.md | 38 +- documentation/06-tickety.md | 159 +++++++- documentation/99-zmeny.md | 53 +++ src/data/automationStore.ts | 375 +++++++++++++++++- src/data/connectors.ts | 297 +++++++++++++- src/data/flowScope.ts | 91 +++++ src/data/templates.ts | 42 ++ src/data/ticketStore.ts | 41 +- src/openapi.ts | 24 +- src/routes/dashboard.ts | 34 +- src/routes/simulate.ts | 48 ++- .../components/dashboard/SimulationModal.tsx | 2 + web/src/components/dashboard/StatusBadge.tsx | 4 + .../components/dashboard/flow/FlowCanvas.tsx | 98 +++-- .../components/dashboard/flow/StepInputs.tsx | 218 ++++++++++ web/src/lib/connectorIcons.ts | 4 + web/src/lib/flow.ts | 92 +++++ web/src/pages/dashboard/AutomationDetail.tsx | 4 + web/src/pages/dashboard/TicketDetail.tsx | 38 +- web/src/pages/dashboard/Tickets.tsx | 2 + web/src/types/dashboard.ts | 32 +- 24 files changed, 1651 insertions(+), 80 deletions(-) create mode 100644 src/data/flowScope.ts create mode 100644 src/data/templates.ts create mode 100644 web/src/components/dashboard/flow/StepInputs.tsx diff --git a/documentation/01-prehled-a-stav.md b/documentation/01-prehled-a-stav.md index 2c451ef..3930ad9 100644 --- a/documentation/01-prehled-a-stav.md +++ b/documentation/01-prehled-a-stav.md @@ -17,15 +17,18 @@ 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 | 26 sluzeb, 9 kategorii | +| Katalog konektoru | hotovo | 29 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 | +| Nastaveni poli akci | castecne | ticket, e-mail a WhatsApp ano, ostatni jen napoveda | +| Obsah ticketu a sablony | hotovo | `{{parametr}}` ze spoustece do poli akce | +| Vystupy kroku a predvalidace | hotovo | podminka se umi zeptat, co vratil predchozi krok | +| Kanaly WhatsApp, FB, Instagram | hotovo | vcetne vzorovych automatizaci na prijem | | 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 | | Odesilani e-mailu z formulare | chybi | poptavka se zatim jen loguje | @@ -47,10 +50,13 @@ 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". Parametry spoustece -uz existuji na obou stranach, chybi jen jejich pouziti v akcich. Podrobnosti +Nejuzitecnejsi pristavek je runtime. Strom uz nese vsechno potrebne: spoustec +s parametry, podminky a u ticketu i kanalu nastavena pole se sablonami. Chybi +jen to, co ho vykona. Do te doby je ulozena automatizace popis zameru, ne provoz. + +Vedle toho zbyva prevest na `inputs` i ostatni konektory a doplnit odkazy +na vystup predchoziho kroku, ne jen na spoustec. 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, +Za rozmysleni stoji evidence bugs a wishes. Zamerne to nejsou tickety, duvod je v [06-tickety.md](06-tickety.md). diff --git a/documentation/03-architektura-a-mapa-kodu.md b/documentation/03-architektura-a-mapa-kodu.md index 199c36a..5460392 100644 --- a/documentation/03-architektura-a-mapa-kodu.md +++ b/documentation/03-architektura-a-mapa-kodu.md @@ -45,6 +45,8 @@ image jen `dist`, takze staci jedna slozka. | `src/data/automationStore.ts` | automatizace, strom akci, tokeny webhooku | | `src/data/connectors.ts` | katalog konektoru, jejich spousteču a akci | | `src/data/conditions.ts` | typy parametru a operatory podminek | +| `src/data/templates.ts` | sablony `{{parametr}}` v nastaveni kroku | +| `src/data/flowScope.ts` | co je videt v kterem miste stromu | | `src/data/users.ts` | demo uzivatele | | `src/data/mock.ts` | souhrn pro prehled a casova rada grafu | @@ -61,7 +63,7 @@ image jen `dist`, takze staci jedna slozka. | `web/src/lib/useApiQuery.ts` | nacitani dat vcetne obnoveni pri udalosti | | `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/flow/` | strom akci, vyber kroku, nastaveni poli akce | | `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 | @@ -92,4 +94,13 @@ 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. +**Krok vidi jen to, co je pred nim.** Parametry spoustece plus vystupy +predchozich kroku. Vetev podminky nepridava nic do sekvence za podminkou, protoze +nemusela probehnout. Vypocet je v `flowScope.ts`, priklady +v [06-tickety.md](06-tickety.md). + +**Sablony odkazuji jmenem, ne ID.** Opak podminek, a je to zamer: `{{subject}}` +uzivatel napise a precte, `{{f_42}}` ne. Rozbite odkazy po prejmenovani se hlasi +jako nedodelek. + **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 cefa931..1aa7caa 100644 --- a/documentation/04-api.md +++ b/documentation/04-api.md @@ -144,8 +144,8 @@ 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 - +U `ticket.created` urcuje `channel` (whatsapp, facebook, instagram, email, voice, +form, portal), odkud pozadavek prisel, a podle toho se poskladá i log ticketu. `knownCustomer: false` znamena, ze CRM firmu nedohleda - ticket zustane bez zakaznika i bez resitele a v logu je videt proc. Nevyplnena pole server doplni ukazkovou hodnotou. U akci s "resolved" se bez diff --git a/documentation/05-dashboard-a-builder.md b/documentation/05-dashboard-a-builder.md index 8ed2fae..93f5e03 100644 --- a/documentation/05-dashboard-a-builder.md +++ b/documentation/05-dashboard-a-builder.md @@ -96,17 +96,46 @@ autorita, kopie na klientovi existuje jen proto, aby UI nenabidlo nesmysl. Dokud spoustec nema zadny parametr, nejde pridat podminka - nebylo by podle ceho se rozhodovat. Dialog to vysvetli. +## Co je v kterem kroku videt + +Krok vidi parametry spoustece **plus vystupy vsech kroku pred nim**. Akce muze +v katalogu deklarovat `outputFields`, napriklad "Dohledat firmu" vraci +`customerKnown` a `companyName`. Podminka i sablona se na ne muzou odkazat. + +Vetev podminky nepridava nic do sekvence za podminkou, protoze nemusela +probehnout. Vypocet je v `src/data/flowScope.ts`, kopie pro UI ve `flow.ts`. + +Odkaz na parametr, ktery ve strome vubec neni, je **chyba 400**. Odkaz na +parametr, ktery vznika az v pozdejsim kroku (typicky po presunuti kroku), je +**nedodelek** - ulozi se a rekne se, ze podminku staci posunout niz. + +## Nastaveni kroku + +Akce muze mit nastavitelna pole (`inputs` v katalogu). Vyplnuji se primo na karte +kroku ve strome. Hodnota je sablona, `{{nazev}}` se nahradi parametrem spoustece, +takze jde rict "do obsahu ticketu dej text zpravy z WhatsApp". + +Pod poli je nabidka parametru, kliknuti vlozi odkaz na pozici kurzoru. + +Zatim to maji ticket, e-mail a WhatsApp. Ostatni akce maji jen `fields`, coz je +pouha napoveda - builder u nich napise, ze je zatim nejde nastavit. Cilovy stav +je prevest vsechny. + +Podrobnosti vcetne toho, proc se odkazuje jmenem a ne ID, jsou +v [06-tickety.md](06-tickety.md), sekce "Sablony". + ## Validace Rozlisuji se dve veci: **Chyby** vraci 400 a neulozi se: neexistujici konektor nebo operace, operace spatneho druhu, podminka na neexistujici parametr, operator nesedici na typ, -duplicitni nebo nevalidni nazev parametru. +duplicitni nebo nevalidni nazev parametru, nastaveni pole, ktere akce nema. **Nedodelky** se ulozi, jen brani zapnuti: chybi spoustec, zadny krok, webhook -bez adresy, podminka bez hodnoty. Vraci se v poli `issues` a builder je vypise. -Rozdelana prace se nikdy nezahazuje. +bez adresy, podminka bez hodnoty, nevyplnene povinne pole akce, sablona +odkazujici na parametr, ktery uz neexistuje. Vraci se v poli `issues` a builder +je vypise. Rozdelana prace se nikdy nezahazuje. ## Pridani konektoru @@ -125,9 +154,8 @@ Builder i katalog ji vezmou automaticky. | Chybi | Poznamka | | --------------------------- | -------------------------------------------------------- | -| Nastaveni poli akci | `fields` u akci se zobrazuji jen jako napoveda | +| `inputs` u zbylych konektoru| zatim ticket, kanaly, CRM a AI, ostatni maji jen `fields` | | 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 | | Historie behu a logy | prazdne, chybi runtime | diff --git a/documentation/06-tickety.md b/documentation/06-tickety.md index 45c8fa0..71ab628 100644 --- a/documentation/06-tickety.md +++ b/documentation/06-tickety.md @@ -2,9 +2,9 @@ ## 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 je **prichozi pozadavek odkudkoliv**. Prisla zprava na WhatsApp, Messenger +nebo Instagram, 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, @@ -31,6 +31,8 @@ a **co ktera sluzba vratila**. Kdyz neco nesedi, neni potreba hadat. interface Ticket { id: string; subject: string; + body: string; // cely text pozadavku, prazdny = krok ho nenaplnil + sourceRef: string | null; // odkaz na zdrojovou zpravu u poskytovatele channel: 'whatsapp' | 'email' | 'voice' | 'form' | 'portal'; customer: TicketCustomer; status: 'new' | 'open' | 'waiting' | 'resolved'; @@ -53,6 +55,11 @@ interface TicketCustomer { ve strome automatizace. Bez toho by nesla postavit vetev "firmu neznam, zaloz obchodni pripad a nekomu to dej". +`subject` je kratke shrnuti do seznamu, `body` je cely text pozadavku. Do `body` +patri telo e-mailu, zprava z WhatsApp nebo prepis hovoru. Kdyz je prazdne, +znamena to, ze krok "Zalozit ticket" nemel nastavene pole Obsah - detail ticketu +to napise nahlas misto toho, aby ukazal prazdne misto. + 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. @@ -146,6 +153,125 @@ Prijata zprava z WhatsApp NE Zalozit ticket, Zalozit obchodni pripad, Upozornit servicedesk ``` +## Co se ticketu preda + +Akce "Zalozit ticket" ma nastavitelna pole. Klikni na krok ve strome a vyplnis +je primo tam. Hodnota je **sablona**: `{{nazev}}` se nahradi parametrem +spoustece. + +| Pole | Typicka hodnota u WhatsApp | +| ------------ | --------------------------------- | +| Predmet | `Zprava od {{profileName}}` | +| Obsah | `{{text}}` | +| Firma | necha se prazdne, doplni CRM krok | +| Kontakt | `{{profileName}}` | +| Odpoved na | `{{phone}}` | +| Priorita | vyber ze seznamu | +| Resitel | vyber ze seznamu lidi | + +Nabidka parametru je pod poli. Kliknuti vlozi `{{nazev}}` na pozici kurzoru, +takze se nemusi psat rucne a neudela se preklep. + +Vyber resitele se plni ze seznamu v `people.ts`, ne z rucne psaneho ID. Novy +clovek v tymu se v nabidce objevi sam. + +## Co je kde videt + +Krok vidi **parametry spoustece plus vystupy vsech kroku, ktere jsou pred nim**. +Diky tomu jde do stromu vlozit predvalidaci a vetvit se podle jeji odpovedi. + +Dve pravidla: + +- krok vidi to, co je pred nim ve stejne sekvenci, a to, co videl jeho rodic, +- **vetev podminky nepridava nic do sekvence za podminkou**. Vetev nemusela + probehnout, spolehat se na jeji vystup by byla past. + +Vystupy akci deklaruje katalog v `outputFields`. Ma je napriklad "Dohledat firmu" +(`customerKnown`, `companyId`, `companyName`), "Zaradit do kategorie" +(`category`, `confidence`) nebo "Zalozit ticket" (`newTicketId`). + +Vypocet je v `src/data/flowScope.ts`, kopie pro UI v `web/src/lib/flow.ts`. + +## Jak udelat podminku + +Tri ruzne pripady, kazdy se resi jinak. + +**Na prichozi zpravu.** `text` je parametr spoustece, funguje rovnou: + +``` +Prijata zprava z WhatsApp + text obsahuje "faktura" + ANO ... +``` + +**Na vysledek mezikroku.** Tohle je ta predvalidace. Krok "Dohledat firmu" vrati +`customerKnown` a podminka se na nej zepta: + +``` +Prijata zprava z WhatsApp + Dohledat firmu Telefon {{phone}} + customerKnown je splneno + ANO Zalozit ticket Firma {{companyName}}, Obsah {{text}} + NE Zalozit ticket bez firmy + Zalozit obchodni pripad +``` + +`{{companyName}}` je uvnitr vetve dostupne, protoze krok "Dohledat firmu" je +pred podminkou. Presne tenhle strom je ve vzorovych automatizacich. + +**Na obsah uz zalozeneho ticketu.** Ve stejnem strome nejde, ticket v tu chvili +teprve vznika a "Zalozit ticket" vraci jen `newTicketId`, ne cely ticket. Dela se +**druhou automatizaci** se spoustecem "Zalozen ticket": + +``` +Zalozen ticket + assigned neni splneno + ANO body obsahuje "faktur" + ANO Priradit resiteli ID {{ticketId}}, Martin Kriz + NE body obsahuje "voicebot" + ANO Priradit Eva Novakova + NE Priradit Karel Vomacka +``` + +Rozdeleni neni obchazeni omezeni, ale zamer. Prijem z kanalu je jedna +automatizace na kanal, smerovani je jedna spolecna nad vsemi tickety. + +## Vzorove automatizace + +V `automationStore.ts` jsou nasazene presne v tomhle rozdeleni: + +| Automatizace | Co ukazuje | +| -------------------------------- | --------------------------------------------- | +| WhatsApp: zprava do ticketu | predvalidace v CRM a vetveni podle vysledku | +| Facebook: zprava do ticketu | prijem bez predvalidace, nemame podle ceho hledat | +| E-mail: pozadavky do ticketu | dohledani podle `{{from}}`, plus odpoved zadavateli | +| Smerovani ticketu na resitele | jedna spolecna logika nad vsemi tickety | + +Prijmove automatizace zamerne **neprirazuji resitele**. Nechavaji ticket ve +fronte a smerovani si ho prevezme. Podminka `assigned neni splneno` na zacatku +smerovani zaridi, ze rucni prirazeni se neprepise. + +## Sablony + +Odkazuje se **jmenem** parametru (`{{subject}}`), ne jeho ID. Jmeno uzivatel +vidi a pise, `{{f_42}}` by nikdo neprecetl. + +Cenou je, ze prejmenovani parametru sablonu rozbije. Proto se to hlida: + +- builder podtrhne pole a napise, ktery parametr chybi, +- server to vraci mezi `issues`, tedy jako **nedodelek**, ne jako chybu. + Rozdelana prace se ulozi, jen automatizace nepujde zapnout. + +Naopak **chyba (400)** je nastaveni pole, ktere akce v katalogu nema. To uz neni +nedodelek, ale rozbity strom - ulozit ho by znamenalo drzet data, ktera nikdo +neprecte. + +Chybejici parametr se pri behu dosadi jako prazdny retezec a zaloguje. Nechat +v textu `{{neco}}` by znamenalo poslat to zakaznikovi. + +Akce, ktere `inputs` zatim nemaji, to v builderu napisou primo na karte kroku. +Lepsi nez nechat cloveka hledat nastaveni, ktere neexistuje. + ## Parametry od sluzby Nektere spoustece si data urcuji samy. E-mail posila `from`, `subject`, `body`. @@ -173,12 +299,37 @@ parametry si deklaruje uzivatel. /dashboard/tickety/:id detail: prubeh a log, resitel, zakaznik, puvod ``` +## Otevrene rozhodnuti pro runtime + +Model nema zadne "prvni shoda vyhrava". **Vsechny automatizace se stejnym +spoustecem se spusti, vsechny.** Podminka je uvnitr stromu, ne na spousteci, +takze automatizaci nezastavi pred prvnim krokem. + +Doporucene rozdeleni (jedna automatizace na kanal, jedna spolecna na smerovani) +na to nenarazi, protoze kazdy prijem ma jiny spoustec a smerovani je jen jedno. +Problem vznikne, jakmile nekdo udela vic automatizaci nad stejnym spoustecem: + +- tri automatizace na "Prijat e-mail", kazda s jinym `obsahuje`, udelaji + z jednoho e-mailu tri tickety, +- dve smerovaci automatizace provedou dve prirazeni a poradi neni dane. + +Dnes se to neprojevi, protoze runtime neexistuje. Az se bude psat, je potreba +to rozhodnout vedome, ne omylem. Varianty od nejlevnejsi: + +1. **Nechat jak je** a drzet se pravidla jedna automatizace na spoustec. + Funguje dnes, nic se nemeni, ale nikdo to nevynucuje. +2. **Filtr na spousteci.** Automatizace by umela rict "tenhle e-mail neni muj" + jeste pred prvnim krokem. Umozni jednu automatizaci na pravidlo. +3. **Poradi a prvni shoda vyhrava.** Nejmocnejsi, ale chovani zavisle + na neviditelnem poradi se spatne ladi. Nedoporucuje se. + ## Co chybi | Chybi | Poznamka | | ---------------------------- | ------------------------------------------------------- | | Bugs a wishes | vyvojarska agenda, samostatna evidence | -| Skutecny beh automatizaci | log ticketu zatim plni simulace, ne runtime | +| Skutecny beh automatizaci | sablony se ukladaji, ale nikdo je nevyhodnocuje | +| `inputs` u zbylych konektoru | zatim ticket, kanaly, CRM a AI, ostatni maji jen napovedu | | 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 | diff --git a/documentation/99-zmeny.md b/documentation/99-zmeny.md index b69503b..d1ba763 100644 --- a/documentation/99-zmeny.md +++ b/documentation/99-zmeny.md @@ -44,6 +44,59 @@ Popis modelu je v [06-tickety.md](06-tickety.md). Bugs a wishes zustavaji mimo. Vyvojarska agenda ma jiny zivotni cyklus a slucovat ji s tickety by znamenalo, ze ani jedna evidence nefunguje poradne. +### Doplneno pote + +Puvodni verze mela diru: ticket nemel zadny obsah a krok "Zalozit ticket" nesel +nastavit. Slo tedy rict "z WhatsApp udelej ticket", ale ne uz co se ma kam ulozit. + +- `Ticket.body` a `Ticket.sourceRef`. Predmet je shrnuti, telo je cely text + pozadavku. `body` vystaveno i ve spoustecich `created` a `unknown-customer`, + takze na obsah ticketu jde udelat podminka v navazne automatizaci. +- Nastavitelna pole akci (`OperationField` a `FlowStep.inputs`). Ticket, e-mail + a WhatsApp maji skutecna pole misto pouhe napovedy. +- Sablony `{{parametr}}` v hodnotach poli (`src/data/templates.ts`) vcetne + nabidky parametru, ktera je vklada na pozici kurzoru. +- Vyber resitele u akci se plni ze seznamu lidi, ne z rucne psaneho ID. +- Nevyplnene povinne pole a odkaz na neexistujici parametr se hlasi jako + nedodelek. Nastaveni pole, ktere akce nema, je chyba 400. +- Akce bez `inputs` to v builderu napisou primo na karte kroku. + +### Vystupy kroku a predvalidace + +Druha dira: kroky slo vkladat kamkoliv, ale podminka videla jen parametry +spoustece. Slo tedy pridat krok "zeptej se CRM", ale ne se vetvit podle toho, +co vratil. Bez toho byla predvalidace k nicemu. + +- `outputFields` v katalogu: co akce vrati dalsim krokum. Ma je "Dohledat firmu" + (`customerKnown`, `companyId`, `companyName`), "Zaradit do kategorie", + "Zalozit obchodni pripad" i "Zalozit ticket". +- Nova akce RAYNET "Dohledat firmu". Nic nezaklada, jen odpovi, jestli + odesilatele zname. Presne pro predvalidaci. +- `src/data/flowScope.ts` pocita, co je videt v kterem miste stromu. Krok vidi + spoustec plus vystupy kroku pred nim. Vetev nepridava nic do sekvence + za podminkou, protoze nemusela probehnout. +- Builder nabizi v podmince i v polich akce presne ty parametry, ktere v danem + miste doopravdy jsou. +- Odkaz na parametr, ktery ve strome neni, je chyba 400. Odkaz na parametr, + ktery vznika az pozdeji, je nedodelek s radou posunout podminku niz. +- Duplicitni jmeno parametru ve scope je nedodelek. V sablone by nesl poznat, + ktery se dosadi. + +### Kanaly a vzorove automatizace + +- Konektory Facebook Messenger a Instagram, kanaly `facebook` a `instagram` + u ticketu. +- Ctyri nove vzorove automatizace v rozdeleni, ktere odpovida zameru: jedna + na kanal pro prijem, jedna spolecna pro smerovani na resitele. + Prijmove zamerne neprirazuji, smerovani si ticket prevezme a podminkou + `assigned neni splneno` neprepise rucni rozhodnuti. + +### Zapsano jako otevrene rozhodnuti + +Vsechny automatizace se stejnym spoustecem se spusti. Doporucene rozdeleni na to +nenarazi, ale az se bude psat runtime, musi se to rozhodnout vedome. Varianty +a doporuceni v [06-tickety.md](06-tickety.md). + ## 2026-07-31 Prvni nasazeni aplikace do repozitare csbot-prototype. diff --git a/src/data/automationStore.ts b/src/data/automationStore.ts index 6a98115..2567713 100644 --- a/src/data/automationStore.ts +++ b/src/data/automationStore.ts @@ -9,7 +9,9 @@ import { randomBytes } from 'node:crypto'; import { publish } from '../events/bus.js'; import { isUnary, type ConditionOperator, type FieldType } from './conditions.js'; -import { findConnector } from './connectors.js'; +import { actionInputsFor, findConnector } from './connectors.js'; +import { collectScopes, duplicateNames, scopeFor } from './flowScope.js'; +import { referencedFields } from './templates.js'; export type AutomationKind = 'workflow' | 'voicebot' | 'integrace' | 'report'; @@ -44,6 +46,12 @@ export type FlowStep = kind: 'action'; connectorId: string; operationId: string; + /** + * Nastaveni akce: klic je `OperationField.id` z katalogu, hodnota je + * sablona s odkazy na parametry spoustece (`{{subject}}`). + * Chybi u akci, ktere zatim nemaji `inputs`. + */ + inputs?: Record; } | { id: string; @@ -122,6 +130,44 @@ export function countSteps(steps: FlowStep[]): number { }, 0); } +/** + * Nedodelky v nastaveni jednoho kroku: nevyplnene povinne pole a odkaz + * na parametr, ktery u spoustece neexistuje. + * + * Druhy pripad nastane hlavne po prejmenovani parametru. Sablonu proto + * nezahazujeme ani tise neopravujeme, jen rekneme, kde se ma doplnit. + */ +function actionInputIssues( + step: Extract, + available: TriggerField[], +): string[] { + const knownNames = new Set(available.map((field) => field.name)); + const catalog = actionInputsFor(step.connectorId, step.operationId); + if (catalog.length === 0) return []; + + const issues: string[] = []; + const operationName = findConnector(step.connectorId)?.name ?? step.connectorId; + + for (const field of catalog) { + const value = step.inputs?.[field.id] ?? ''; + + if (field.required && value.trim().length === 0) { + issues.push(`Krok „${operationName}": chybí ${field.label.toLowerCase()}.`); + continue; + } + + for (const name of referencedFields(value)) { + if (!knownNames.has(name)) { + issues.push( + `Krok „${operationName}", pole ${field.label.toLowerCase()}: parametr „${name}" u spouštěče neexistuje.`, + ); + } + } + } + + return issues; +} + /** * Co brani zapnuti automatizace. Zamerne to NENI chyba pri ukladani - * rozdelanou praci chceme ulozit, jen ji nesmime pustit do provozu. @@ -144,15 +190,32 @@ export function collectFlowIssues(flow: AutomationFlow): string[] { issues.push('Webhook nemá vygenerovanou adresu.'); } - const fieldById = new Map(flow.trigger.fields.map((field) => [field.id, field])); + const scopes = collectScopes(flow); const walk = (steps: FlowStep[]) => { for (const step of steps) { - if (step.kind !== 'condition') continue; + const available = scopeFor(scopes, step.id); - const field = fieldById.get(step.fieldId); + for (const name of duplicateNames(available)) { + issues.push( + `Parametr „${name}" je v tomto místě stromu dvakrát, v šabloně by nešlo poznat který.`, + ); + } + + if (step.kind === 'action') { + issues.push(...actionInputIssues(step, available)); + continue; + } + + const field = available.find((candidate) => candidate.id === step.fieldId); if (!field) { - issues.push('Podmínka se odkazuje na parametr, který už neexistuje.'); + // Rozlisujeme "neexistuje" od "vznikne az pozdeji". Druhy pripad nastane + // po presunuti kroku a chce jinou radu nez smazat podminku. + issues.push( + scopes.all.has(step.fieldId) + ? 'Podmínka se ptá na parametr, který vzniká až v pozdějším kroku. Posuňte ji níž.' + : 'Podmínka se odkazuje na parametr, který už neexistuje.', + ); } else if (!isUnary(step.operator) && (step.value ?? '').trim().length === 0) { issues.push(`Podmínka nad parametrem „${field.name}" nemá vyplněnou hodnotu.`); } else if (field.type === 'number' && !isUnary(step.operator)) { @@ -167,7 +230,9 @@ export function collectFlowIssues(flow: AutomationFlow): string[] { }; walk(flow.steps); - return issues; + // Stejny nedodelek muze vyjit z vic kroku (typicky duplicitni jmeno parametru). + // Uzivateli staci rict jednou. + return [...new Set(issues)]; } /** Pouziva strom nekde konektor z dane kategorie? */ @@ -370,7 +435,19 @@ seed({ value: '15', yes: [{ id: 'st_42', kind: 'action', connectorId: 'raynet', operationId: 'add-activity' }], no: [ - { id: 'st_43', kind: 'action', connectorId: 'ticket', operationId: 'create' }, + { + id: 'st_43', + kind: 'action', + connectorId: 'ticket', + operationId: 'create', + inputs: { + subject: 'Nízké hodnocení od {{customer}}', + body: 'Hodnocení {{score}} z dotazníku. Komentář: {{comment}}', + company: '{{customer}}', + priority: 'high', + assigneeId: 'ppl_vomacka', + }, + }, { id: 'st_44', kind: 'action', connectorId: 'microsoft365', operationId: 'post-teams' }, ], }, @@ -378,6 +455,290 @@ seed({ }, }); +/* + * Prijem z kanalu: jedna automatizace na kanal, zadne rozhodovani o resiteli. + * Smerovani resi jedna spolecna automatizace nize - viz documentation/06-tickety.md. + */ + +seed({ + name: 'WhatsApp: zpráva do ticketu', + enabled: true, + runsToday: 34, + successRate: 100, + avgDurationMs: 1_950, + lastRunAt: minutesAgo(7), + flow: { + trigger: { + connectorId: 'whatsapp', + operationId: 'message-received', + fields: [ + { 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 }, + ], + }, + steps: [ + // Predvalidace: nejdriv se zeptame CRM, teprve podle odpovedi zakladame. + { + id: 'st_51', + kind: 'action', + connectorId: 'raynet', + operationId: 'find-company', + inputs: { phone: '{{phone}}' }, + }, + { + id: 'st_52', + kind: 'condition', + // Odkaz na vystup kroku st_51, ne na parametr spoustece. + fieldId: 'st_51.raynet.customerKnown', + operator: 'isTrue', + yes: [ + { + id: 'st_53', + kind: 'action', + connectorId: 'ticket', + operationId: 'create', + inputs: { + subject: 'WhatsApp od {{profileName}}', + body: '{{text}}', + company: '{{companyName}}', + contact: '{{profileName}}', + reply: '{{phone}}', + priority: 'normal', + assigneeId: '', + }, + }, + ], + no: [ + { + id: 'st_54', + kind: 'action', + connectorId: 'ticket', + operationId: 'create', + inputs: { + subject: 'WhatsApp od neznámého čísla', + body: '{{text}}', + contact: '{{profileName}}', + reply: '{{phone}}', + priority: 'normal', + assigneeId: '', + }, + }, + { id: 'st_55', kind: 'action', connectorId: 'raynet', operationId: 'create-lead' }, + ], + }, + ], + }, +}); + +seed({ + name: 'Facebook: zpráva do ticketu', + enabled: true, + runsToday: 11, + successRate: 100, + avgDurationMs: 720, + lastRunAt: minutesAgo(52), + flow: { + trigger: { + connectorId: 'facebook', + operationId: 'message-received', + fields: [ + { id: 'facebook.senderId', name: 'senderId', type: 'string', required: true }, + { id: 'facebook.senderName', name: 'senderName', type: 'string', required: false }, + { id: 'facebook.text', name: 'text', type: 'string', required: true }, + { id: 'facebook.pageName', name: 'pageName', type: 'string', required: true }, + { id: 'facebook.receivedAt', name: 'receivedAt', type: 'date', required: true }, + ], + }, + // Bez predvalidace. Z Messengeru nemame e-mail ani telefon, podle ceho + // by se firma dohledala, takze zakladame rovnou a dohledani nechavame na cloveku. + steps: [ + { + id: 'st_61', + kind: 'action', + connectorId: 'ticket', + operationId: 'create', + inputs: { + subject: 'Facebook od {{senderName}}', + body: '{{text}}', + contact: '{{senderName}}', + reply: '{{senderId}}', + priority: 'normal', + assigneeId: '', + }, + }, + ], + }, +}); + +seed({ + name: 'E-mail: požadavky do ticketu', + enabled: true, + runsToday: 58, + successRate: 99.1, + avgDurationMs: 2_310, + lastRunAt: minutesAgo(14), + flow: { + trigger: { + connectorId: 'email', + operationId: 'received', + fields: [ + { 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 }, + ], + }, + steps: [ + { + id: 'st_65', + kind: 'action', + connectorId: 'raynet', + operationId: 'find-company', + inputs: { email: '{{from}}' }, + }, + { + id: 'st_66', + kind: 'condition', + fieldId: 'st_65.raynet.customerKnown', + operator: 'isTrue', + yes: [ + { + id: 'st_67', + kind: 'action', + connectorId: 'ticket', + operationId: 'create', + inputs: { + subject: '{{subject}}', + body: '{{body}}', + company: '{{companyName}}', + contact: '{{from}}', + reply: '{{from}}', + priority: 'normal', + assigneeId: '', + }, + }, + ], + no: [ + { + id: 'st_68', + kind: 'action', + connectorId: 'ticket', + operationId: 'create', + inputs: { + subject: '{{subject}}', + body: '{{body}}', + contact: '{{from}}', + reply: '{{from}}', + priority: 'low', + assigneeId: '', + }, + }, + { + id: 'st_69', + kind: 'action', + connectorId: 'email', + operationId: 'send', + inputs: { + to: '{{from}}', + subject: 'Přijali jsme váš požadavek', + body: 'Dobrý den, požadavek jsme zaevidovali a ozveme se. Tým podpory.', + }, + }, + ], + }, + ], + }, +}); + +/* + * Jedna spolecna automatizace nad vsemi tickety, at vznikly odkudkoliv. + * Tohle je to misto, kde se dela logika zpracovani na miru zakaznikovi. + */ +seed({ + name: 'Směrování ticketů na řešitele', + enabled: true, + runsToday: 103, + successRate: 100, + avgDurationMs: 310, + lastRunAt: minutesAgo(4), + flow: { + trigger: { + connectorId: 'ticket', + operationId: 'created', + fields: [ + { id: 'ticket.id', name: 'ticketId', type: 'string', required: true }, + { id: 'ticket.subject', name: 'subject', type: 'string', required: true }, + { id: 'ticket.body', name: 'body', type: 'string', required: false }, + { 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 }, + ], + }, + steps: [ + { + id: 'st_71', + kind: 'condition', + // Uz prirazeny ticket nepreberame, jinak bychom prepsali rucni rozhodnuti. + fieldId: 'ticket.assigned', + operator: 'isFalse', + yes: [ + { + id: 'st_72', + kind: 'condition', + fieldId: 'ticket.body', + operator: 'contains', + value: 'faktur', + yes: [ + { + id: 'st_73', + kind: 'action', + connectorId: 'ticket', + operationId: 'assign', + inputs: { ticketId: '{{ticketId}}', assigneeId: 'ppl_kriz' }, + }, + ], + no: [ + { + id: 'st_74', + kind: 'condition', + fieldId: 'ticket.body', + operator: 'contains', + value: 'voicebot', + yes: [ + { + id: 'st_75', + kind: 'action', + connectorId: 'ticket', + operationId: 'assign', + inputs: { ticketId: '{{ticketId}}', assigneeId: 'ppl_novakova' }, + }, + ], + no: [ + { + id: 'st_76', + kind: 'action', + connectorId: 'ticket', + operationId: 'assign', + inputs: { ticketId: '{{ticketId}}', assigneeId: 'ppl_vomacka' }, + }, + ], + }, + ], + }, + ], + no: [], + }, + ], + }, +}); + function toSummary(stored: StoredAutomation): Automation { const { flow: _flow, createdAt: _createdAt, updatedAt: _updatedAt, ...rest } = stored; return { diff --git a/src/data/connectors.ts b/src/data/connectors.ts index a40a7f8..c530def 100644 --- a/src/data/connectors.ts +++ b/src/data/connectors.ts @@ -10,6 +10,7 @@ */ import type { FieldType } from './conditions.js'; +import { people } from './people.js'; export type ConnectorCategory = | 'spoustece' @@ -40,12 +41,43 @@ export interface ProvidedField { required: boolean; } +/** + * Nastavitelne pole akce. Na rozdil od `fields` to neni napoveda, ale skutecna + * konfigurace - hodnota se uklada do kroku stromu (`FlowStep.inputs`). + * + * Hodnota je sablona. `{{nazev}}` se nahradi parametrem spoustece, + * viz `src/data/templates.ts`. + */ +export interface OperationField { + id: string; + label: string; + /** text = jeden radek, longtext = vice radku, choice = vyber z `options` */ + kind: 'text' | 'longtext' | 'choice'; + required: boolean; + options?: Array<{ value: string; label: string }>; + hint?: string; +} + export interface ConnectorOperation { id: string; name: string; description: string; - /** Popis toho, co bude potreba nastavit. Zatim jen informativni. */ + /** + * Popis toho, co bude potreba nastavit - jen napoveda, nikam se neuklada. + * Cilovy stav je prevest vsechny akce na `inputs`, zatim je to udelane + * u ticketu a kanalu, ktere do nej ustuji. + */ fields?: string[]; + /** Skutecne nastavitelna pole akce. Kdyz chybi, akci nejde konfigurovat. */ + inputs?: OperationField[]; + /** + * Jen u akci: co krok vrati dalsim krokum. Diky tomu jde po kroku + * "dohledej firmu v CRM" postavit podminku na to, jestli se nasla. + * + * `required: true` znamena, ze sluzba hodnotu vraci vzdy. + * ID musi zustat stabilni, odkazuji se na nej podminky v ulozenych stromech. + */ + outputFields?: ProvidedField[]; /** * Jen u triggeru: true = vstupni parametry si definuje uzivatel * (webhook, formular). false/chybi = data urcuje sluzba. @@ -71,6 +103,26 @@ export interface Connector { actions: ConnectorOperation[]; } +/** Nabidka resitelu do vyberu u akci. Bere se ze seznamu lidi, aby se nerozesla. */ +const assigneeOptions = [ + { value: '', label: 'Nechat ve frontě' }, + ...people.map((person) => ({ value: person.id, label: person.name })), +]; + +const priorityOptions = [ + { value: 'low', label: 'Nízká' }, + { value: 'normal', label: 'Běžná' }, + { value: 'high', label: 'Vysoká' }, + { value: 'critical', label: 'Kritická' }, +]; + +const statusOptions = [ + { value: 'new', label: 'Nový' }, + { value: 'open', label: 'V řešení' }, + { value: 'waiting', label: 'Čeká na klienta' }, + { value: 'resolved', label: 'Vyřešeno' }, +]; + export const connectorCategories: Array<{ id: ConnectorCategory; label: string }> = [ { id: 'spoustece', label: 'Spouštěče' }, { id: 'servicedesk', label: 'Tickety a servicedesk' }, @@ -178,6 +230,7 @@ export const connectors: Connector[] = [ providedFields: [ { id: 'ticket.id', name: 'ticketId', type: 'string', required: true }, { id: 'ticket.subject', name: 'subject', type: 'string', required: true }, + { id: 'ticket.body', name: 'body', type: 'string', required: false }, { 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 }, @@ -195,6 +248,7 @@ export const connectors: Connector[] = [ providedFields: [ { id: 'ticket.id', name: 'ticketId', type: 'string', required: true }, { id: 'ticket.subject', name: 'subject', type: 'string', required: true }, + { id: 'ticket.body', name: 'body', type: 'string', required: false }, { 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 }, @@ -229,32 +283,102 @@ export const connectors: Connector[] = [ { 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'], + description: 'Vytvoří požadavek. Co se kam uloží, určíte v nastavení kroku.', + inputs: [ + { + id: 'subject', + label: 'Předmět', + kind: 'text', + required: true, + hint: 'Krátké shrnutí. Typicky předmět e-mailu nebo začátek zprávy.', + }, + { + id: 'body', + label: 'Obsah', + kind: 'longtext', + required: false, + hint: 'Celý text požadavku. Sem patří tělo e-mailu nebo zpráva z WhatsApp.', + }, + { id: 'company', label: 'Firma', kind: 'text', required: false }, + { id: 'contact', label: 'Kontakt', kind: 'text', required: false }, + { + id: 'reply', + label: 'Adresa pro odpověď', + kind: 'text', + required: false, + hint: 'E-mail nebo telefon, odkud to přišlo.', + }, + { + id: 'priority', + label: 'Priorita', + kind: 'choice', + required: true, + options: priorityOptions, + }, + { + id: 'assigneeId', + label: 'Řešitel', + kind: 'choice', + required: false, + options: assigneeOptions, + }, + ], + outputFields: [{ id: 'ticket.newId', name: 'newTicketId', type: 'string', required: true }], }, { 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'], + inputs: [ + { + id: 'ticketId', + label: 'ID ticketu', + kind: 'text', + required: true, + hint: 'Obvykle {{ticketId}} ze spouštěče.', + }, + { + id: 'assigneeId', + label: 'Řešitel', + kind: 'choice', + required: true, + options: assigneeOptions, + }, + ], }, { 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'], + inputs: [ + { id: 'ticketId', label: 'ID ticketu', kind: 'text', required: true }, + { + id: 'status', + label: 'Nový stav', + kind: 'choice', + required: true, + options: statusOptions, + }, + ], }, { 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'], + inputs: [ + { id: 'ticketId', label: 'ID ticketu', kind: 'text', required: true }, + { id: 'companyId', label: 'ID firmy v CRM', kind: 'text', required: true }, + ], }, { 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'], + inputs: [ + { id: 'ticketId', label: 'ID ticketu', kind: 'text', required: true }, + { id: 'author', label: 'Autor', kind: 'text', required: false }, + { id: 'text', label: 'Text', kind: 'longtext', required: true }, + ], }, ], }, @@ -285,12 +409,44 @@ export const connectors: Connector[] = [ name: 'Založit obchodní případ', description: 'Vytvoří nový obchodní případ včetně napojení na firmu.', fields: ['Název', 'Firma', 'Vlastník', 'Fáze'], + outputFields: [{ id: 'raynet.leadId', name: 'leadId', type: 'string', required: true }], + }, + { + id: 'find-company', + name: 'Dohledat firmu', + description: + 'Zjistí, jestli odesílatele známe. Nic nezakládá. Podle výsledku se pak strom větví.', + inputs: [ + { + id: 'email', + label: 'E-mail', + kind: 'text', + required: false, + hint: 'Například {{from}} u e-mailu.', + }, + { + id: 'phone', + label: 'Telefon', + kind: 'text', + required: false, + hint: 'Například {{phone}} u WhatsApp.', + }, + ], + outputFields: [ + { id: 'raynet.customerKnown', name: 'customerKnown', type: 'boolean', required: true }, + { id: 'raynet.companyId', name: 'companyId', type: 'string', required: false }, + { id: 'raynet.companyName', name: 'companyName', type: 'string', required: false }, + { id: 'raynet.ownerName', name: 'ownerName', type: 'string', required: false }, + ], }, { id: 'upsert-contact', name: 'Založit nebo aktualizovat kontakt', description: 'Podle e-mailu kontakt najde a doplní, jinak vytvoří nový.', fields: ['E-mail', 'Jméno', 'Telefon', 'Firma'], + outputFields: [ + { id: 'raynet.contactId', name: 'contactId', type: 'string', required: true }, + ], }, { id: 'add-activity', @@ -470,8 +626,18 @@ export const connectors: Connector[] = [ { id: 'send', name: 'Odeslat e-mail', - description: 'Odešle zprávu podle šablony s daty z předchozích kroků.', - fields: ['Příjemce', 'Předmět', 'Šablona', 'Přílohy'], + description: 'Odešle zprávu. Text i příjemce sestavíte z parametrů spouštěče.', + inputs: [ + { + id: 'to', + label: 'Příjemce', + kind: 'text', + required: true, + hint: 'Například {{from}}, když odpovídáte na příchozí e-mail.', + }, + { id: 'subject', label: 'Předmět', kind: 'text', required: true }, + { id: 'body', label: 'Text zprávy', kind: 'longtext', required: true }, + ], }, ], }, @@ -501,7 +667,16 @@ export const connectors: Connector[] = [ 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'], + inputs: [ + { + id: 'phone', + label: 'Telefon', + kind: 'text', + required: true, + hint: 'Například {{phone}} pro odpověď odesílateli.', + }, + { id: 'text', label: 'Text', kind: 'longtext', required: true }, + ], }, { id: 'send-template', @@ -511,6 +686,83 @@ export const connectors: Connector[] = [ }, ], }, + { + id: 'facebook', + name: 'Facebook Messenger', + category: 'komunikace', + description: 'Zprávy z firemní stránky na Facebooku.', + icon: 'Facebook', + status: 'connected', + triggers: [ + { + id: 'message-received', + name: 'Přijata zpráva', + description: 'Spustí se při doručení zprávy do schránky stránky.', + providedFields: [ + { id: 'facebook.senderId', name: 'senderId', type: 'string', required: true }, + { id: 'facebook.senderName', name: 'senderName', type: 'string', required: false }, + { id: 'facebook.text', name: 'text', type: 'string', required: true }, + { id: 'facebook.pageName', name: 'pageName', type: 'string', required: true }, + { id: 'facebook.receivedAt', name: 'receivedAt', type: 'date', required: true }, + ], + }, + ], + actions: [ + { + id: 'send', + name: 'Odeslat zprávu', + description: 'Odpoví do konverzace, ze které zpráva přišla.', + inputs: [ + { + id: 'recipientId', + label: 'Příjemce', + kind: 'text', + required: true, + hint: 'Například {{senderId}} pro odpověď odesílateli.', + }, + { id: 'text', label: 'Text', kind: 'longtext', required: true }, + ], + }, + ], + }, + { + id: 'instagram', + name: 'Instagram', + category: 'komunikace', + description: 'Přímé zprávy na firemním účtu Instagramu.', + icon: 'Instagram', + status: 'available', + triggers: [ + { + id: 'message-received', + name: 'Přijata zpráva', + description: 'Spustí se při doručení přímé zprávy.', + providedFields: [ + { id: 'instagram.senderId', name: 'senderId', type: 'string', required: true }, + { id: 'instagram.username', name: 'username', type: 'string', required: false }, + { id: 'instagram.text', name: 'text', type: 'string', required: true }, + { id: 'instagram.receivedAt', name: 'receivedAt', type: 'date', required: true }, + ], + }, + ], + actions: [ + { + id: 'send', + name: 'Odeslat zprávu', + description: 'Odpoví do konverzace, ze které zpráva přišla.', + inputs: [ + { + id: 'recipientId', + label: 'Příjemce', + kind: 'text', + required: true, + hint: 'Například {{senderId}} pro odpověď odesílateli.', + }, + { id: 'text', label: 'Text', kind: 'longtext', required: true }, + ], + }, + ], + }, { id: 'microsoft365', name: 'Microsoft 365', @@ -734,7 +986,20 @@ export const connectors: Connector[] = [ id: 'classify', name: 'Zařadit do kategorie', description: 'Rozhodne, do které kategorie text patří (např. téma ticketu).', - fields: ['Seznam kategorií', 'Text'], + inputs: [ + { + id: 'categories', + label: 'Seznam kategorií', + kind: 'text', + required: true, + hint: 'Oddělte čárkou, například: porucha, dotaz, reklamace', + }, + { id: 'text', label: 'Text', kind: 'longtext', required: true }, + ], + outputFields: [ + { id: 'ai.category', name: 'category', type: 'string', required: true }, + { id: 'ai.confidence', name: 'confidence', type: 'number', required: true }, + ], }, { id: 'extract', @@ -857,3 +1122,13 @@ export function providedFieldsFor( ): ProvidedField[] | undefined { return findOperation(connectorId, operationId, 'trigger')?.providedFields; } + +/** Nastavitelna pole akce. Prazdne pole = akci zatim nejde konfigurovat. */ +export function actionInputsFor(connectorId: string, operationId: string): OperationField[] { + return findOperation(connectorId, operationId, 'action')?.inputs ?? []; +} + +/** Co akce vrati dalsim krokum. Prazdne pole = nic, na co by se dalo ptat. */ +export function actionOutputsFor(connectorId: string, operationId: string): ProvidedField[] { + return findOperation(connectorId, operationId, 'action')?.outputFields ?? []; +} diff --git a/src/data/flowScope.ts b/src/data/flowScope.ts new file mode 100644 index 0000000..ee1df79 --- /dev/null +++ b/src/data/flowScope.ts @@ -0,0 +1,91 @@ +/** + * Co je videt v kterem miste stromu. + * + * Spoustec da nejake parametry. Kazda akce muze pridat dalsi - "dohledat firmu" + * vrati `customerKnown`, "zalozit ticket" vrati `newTicketId`. Podminka i sablona + * se pak muzou odkazat na cokoliv, co uz **pred nimi** vzniklo. + * + * Dve pravidla, na kterych to stoji: + * - krok vidi jen to, co je pred nim ve stejne sekvenci, a to, co videl jeho rodic, + * - vetev podminky NEPRIDAVA nic do sekvence za podminkou. Vetev totiz nemusela + * probehnout, takze spolehat se na jeji vystup by byla past. + * + * POZOR: stejna logika je na klientovi v web/src/lib/flow.ts. + * Server je autorita, kopie na klientovi existuje jen kvuli UI. + */ + +import { actionOutputsFor } from './connectors.js'; +import type { AutomationFlow, FlowStep, TriggerField } from './automationStore.js'; + +export interface FlowScopes { + /** Pole dostupna pri vstupu do kroku. Klic je ID kroku. */ + byStep: Map; + /** + * Vsechna pole, ktera ve strome nekdy vzniknou, vcetne vystupu z vetvi. + * Slouzi k rozliseni "parametr neexistuje" (chyba) od + * "parametr vznika az pozdeji" (nedodelek). + */ + all: Map; +} + +/** Vystupy akce jako pole se stabilnim ID v ramci celeho stromu. */ +function outputsOf(step: Extract): TriggerField[] { + return actionOutputsFor(step.connectorId, step.operationId).map((output) => ({ + // Prefix ID krokem - dva stejne kroky ve strome se nesmi prekryt. + id: `${step.id}.${output.id}`, + name: output.name, + type: output.type, + required: output.required, + })); +} + +export function collectScopes(flow: AutomationFlow): FlowScopes { + const byStep = new Map(); + const all = new Map(); + + const triggerFields = flow.trigger?.fields ?? []; + for (const field of triggerFields) all.set(field.id, field); + + const walk = (steps: FlowStep[], inherited: TriggerField[]) => { + let available = inherited; + + for (const step of steps) { + byStep.set(step.id, available); + + if (step.kind === 'condition') { + walk(step.yes, available); + walk(step.no, available); + continue; + } + + const outputs = outputsOf(step); + if (outputs.length === 0) continue; + + for (const output of outputs) all.set(output.id, output); + available = [...available, ...outputs]; + } + }; + + walk(flow.steps, triggerFields); + + return { byStep, all }; +} + +/** Pole dostupna v danem kroku. Prazdne, kdyz krok ve strome neni. */ +export function scopeFor(scopes: FlowScopes, stepId: string): TriggerField[] { + return scopes.byStep.get(stepId) ?? []; +} + +/** + * Jmena, ktera se ve scope opakuji. Sablona se odkazuje jmenem, takze + * dve stejna jmena znamenaji, ze nikdo nepozna, ktere se dosadi. + */ +export function duplicateNames(fields: TriggerField[]): string[] { + const seen = new Set(); + const duplicates = new Set(); + for (const field of fields) { + if (seen.has(field.name)) duplicates.add(field.name); + seen.add(field.name); + } + return [...duplicates]; +} diff --git a/src/data/templates.ts b/src/data/templates.ts new file mode 100644 index 0000000..1cdbdfb --- /dev/null +++ b/src/data/templates.ts @@ -0,0 +1,42 @@ +/** + * Sablony hodnot v krocich stromu. + * + * Hodnota nastavitelneho pole akce muze obsahovat odkazy na parametry + * spoustece: `Reklamace od {{profileName}}`. Pri behu se `{{...}}` nahradi + * skutecnou hodnotou. + * + * Odkazuje se **jmenem** parametru, ne jeho ID. Uzivatel jmeno vidi a pise, + * `{{f_42}}` by nikdo neprecetl. Cenou je, ze prejmenovani parametru sablonu + * rozbije - proto se to hlida a hlasi jako nedodelek, ne jako tichá chyba. + * + * POZOR: stejna logika je na klientovi v web/src/lib/flow.ts. + * Server je autorita, kopie na klientovi existuje jen kvuli napovede v UI. + */ + +/** `{{nazev}}`, mezery kolem nazvu jsou povolene. */ +const REFERENCE = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g; + +/** Jmena parametru, na ktere sablona odkazuje. Bez duplicit, v poradi vyskytu. */ +export function referencedFields(template: string): string[] { + const found: string[] = []; + for (const match of template.matchAll(REFERENCE)) { + const name = match[1]; + if (name && !found.includes(name)) found.push(name); + } + return found; +} + +/** + * Dosadi hodnoty za odkazy. Chybejici parametr se nahradi prazdnym retezcem + * a zaloguje - nechat v textu `{{neco}}` by znamenalo poslat to zakaznikovi. + */ +export function renderTemplate(template: string, values: Record): string { + return template.replace(REFERENCE, (_match, name: string) => { + const value = values[name]; + if (value === undefined) { + console.warn(`[template] odkaz na neznamy parametr "${name}", dosazuji prazdno`); + return ''; + } + return value; + }); +} diff --git a/src/data/ticketStore.ts b/src/data/ticketStore.ts index 10b358b..24898cc 100644 --- a/src/data/ticketStore.ts +++ b/src/data/ticketStore.ts @@ -20,7 +20,14 @@ 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 type TicketChannel = + | 'whatsapp' + | 'facebook' + | 'instagram' + | 'email' + | 'voice' + | 'form' + | 'portal'; export interface TicketCustomer { /** @@ -67,6 +74,13 @@ export interface TicketTraceEntry { export interface Ticket { id: string; subject: string; + /** + * Cely text pozadavku - telo e-mailu, zprava z WhatsApp, prepis hovoru. + * Prazdny retezec znamena, ze krok "Zalozit ticket" obsah nenaplnil. + */ + body: string; + /** Odkaz na zdrojovou zpravu u poskytovatele. null u rucne zalozenych. */ + sourceRef: string | null; channel: TicketChannel; customer: TicketCustomer; status: TicketStatus; @@ -88,6 +102,8 @@ interface StoredTicket extends Omit { export const channelLabels: Record = { whatsapp: 'WhatsApp', + facebook: 'Facebook Messenger', + instagram: 'Instagram', email: 'E-mail', voice: 'Hlasová linka', form: 'Webový formulář', @@ -179,6 +195,10 @@ seed( { id: 'TK-4821', subject: 'Voicebot neodpovídá na volání po 18:00', + body: + 'Dobrý den, po šesté hodině to nikdo nebere. Zkoušeli jsme to včera i dnes, ' + + 'linka jen vyzvání a pak to spadne. Přes den to funguje normálně.', + sourceRef: 'cl_88213', channel: 'voice', customer: { id: 'crm_1042', @@ -287,6 +307,11 @@ seed( { id: 'TK-4820', subject: 'Přidat pole IČO do synchronizace CRM a fakturace', + body: + 'Zdravím, potřebovali bychom, aby se při synchronizaci přenášelo i IČO. ' + + 'Teď ho musíme do faktur doplňovat ručně a občas se na to zapomene. ' + + 'Kolik by to bylo práce?', + sourceRef: '<9f21c4@nordis.cz>', channel: 'email', customer: { id: 'crm_2210', @@ -365,6 +390,8 @@ seed( { id: 'TK-4819', subject: 'Chybí denní report objednávek v e-mailu', + body: 'Dobrý den, už třetí den nechodí ranní report. Můžete se na to prosím podívat?', + sourceRef: 'wamid.HBgLNDIwNzc0OTAyMzMx', channel: 'whatsapp', customer: { id: null, @@ -456,6 +483,10 @@ seed( { id: 'TK-4817', subject: 'Rozšíření hlasového scénáře o objednávku svozu', + body: + 'Chtěli bychom, aby si zákazník mohl objednat svoz rovnou po telefonu, ' + + 'bez přepojení na dispečink. Rozpočet do 40 000. Prosím o odhad.', + sourceRef: null, channel: 'form', customer: { id: 'crm_3390', @@ -510,6 +541,10 @@ seed( { id: 'TK-4812', subject: 'Duplicitní zápis kontaktů z webového formuláře', + body: + 'Každé odeslání formuláře zakládá nový kontakt, i když stejný e-mail už v CRM je. ' + + 'Máme tam desítky duplicit.', + sourceRef: null, channel: 'portal', customer: { id: 'crm_1042', @@ -665,6 +700,8 @@ export function getWorkload(everyone: Person[]): Workload { export interface CreateTicketInput { subject: string; + body?: string; + sourceRef?: string | null; channel: TicketChannel; customer: TicketCustomer; priority: TicketPriority; @@ -688,6 +725,8 @@ export function createTicket(input: CreateTicketInput): Ticket { const stored: StoredTicket = { id: `TK-${ticketCounter}`, subject: input.subject, + body: input.body ?? '', + sourceRef: input.sourceRef ?? null, channel: input.channel, customer: input.customer, status: 'new', diff --git a/src/openapi.ts b/src/openapi.ts index 6295a17..4eaea4f 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -108,9 +108,20 @@ export function buildOpenApiDocument() { properties: { id: { type: 'string', example: 'TK-4821' }, subject: { type: 'string' }, + body: { + type: 'string', + description: + 'Cely text pozadavku. Prazdny retezec = krok "Zalozit ticket" obsah nenaplnil.', + }, + sourceRef: { + type: 'string', + nullable: true, + description: 'Odkaz na zdrojovou zpravu u poskytovatele.', + example: 'wamid.HBgLNDIwNzc0OTAyMzMx', + }, channel: { type: 'string', - enum: ['whatsapp', 'email', 'voice', 'form', 'portal'], + enum: ['whatsapp', 'facebook', 'instagram', 'email', 'voice', 'form', 'portal'], description: 'Odkud pozadavek prisel.', }, customer: { $ref: '#/components/schemas/TicketCustomer' }, @@ -234,6 +245,14 @@ export function buildOpenApiDocument() { kind: { type: 'string', enum: ['action', 'condition'] }, connectorId: { type: 'string', example: 'email' }, operationId: { type: 'string', example: 'send' }, + inputs: { + type: 'object', + additionalProperties: { type: 'string' }, + description: + 'Nastaveni akce. Klic je ID pole z katalogu, hodnota je sablona - ' + + '{{nazev}} se nahradi parametrem spoustece. Neznamy klic vraci 400.', + example: { subject: 'Reklamace od {{profileName}}', body: '{{text}}' }, + }, fieldId: { type: 'string', example: 'f_42' }, operator: { type: 'string', @@ -800,9 +819,10 @@ export function buildOpenApiDocument() { ], }, subject: { type: 'string' }, + body: { type: 'string', description: 'Cely text pozadavku.' }, channel: { type: 'string', - enum: ['whatsapp', 'email', 'voice', 'form', 'portal'], + enum: ['whatsapp', 'facebook', 'instagram', 'email', 'voice', 'form', 'portal'], description: 'Odkud pozadavek prisel. Podle toho se poskladá i log ticketu.', }, contact: { type: 'string' }, diff --git a/src/routes/dashboard.ts b/src/routes/dashboard.ts index d933777..29a64cf 100644 --- a/src/routes/dashboard.ts +++ b/src/routes/dashboard.ts @@ -17,6 +17,7 @@ import { findOperation, providedFieldsFor, } from '../data/connectors.js'; +import { collectScopes } from '../data/flowScope.js'; import { listIncidents } from '../data/incidentStore.js'; import { getSummary } from '../data/mock.js'; import { findPersonByEmail, listPeople } from '../data/people.js'; @@ -56,7 +57,15 @@ dashboardRouter.get('/people', (req, res) => { }); const ticketStatuses: TicketStatus[] = ['new', 'open', 'waiting', 'resolved']; -const ticketChannels: TicketChannel[] = ['whatsapp', 'email', 'voice', 'form', 'portal']; +const ticketChannels: TicketChannel[] = [ + 'whatsapp', + 'facebook', + 'instagram', + 'email', + 'voice', + 'form', + 'portal', +]; /** * Filtr ze query parametru. Nesmyslnou hodnotu zahodime a zalogujeme - @@ -198,6 +207,8 @@ const stepSchema: z.ZodType = z.lazy(() => kind: z.literal('action'), connectorId: z.string().min(1), operationId: z.string().min(1), + // Klic je ID pole z katalogu, hodnota sablona. Overuje se nize. + inputs: z.record(z.string()).optional(), }), z.object({ id: z.string().min(1), @@ -298,12 +309,14 @@ function validateFlowReferences(flow: z.infer): string[] { names.add(field.name); } - const fieldById = new Map(flow.trigger.fields.map((field) => [field.id, field])); + // Scope rika, co je v kterem miste stromu videt. Podminka se smi ptat + // jen na parametry, ktere pred ni uz vznikly. + const scopes = collectScopes(flow); const walk = (steps: FlowStep[]) => { for (const step of steps) { if (step.kind === 'condition') { - const field = fieldById.get(step.fieldId); + const field = scopes.all.get(step.fieldId); if (!field) { problems.push(`Podmínka odkazuje na neexistující parametr (${step.fieldId}).`); } else if (!operatorAllowedForType(step.operator, field.type)) { @@ -315,8 +328,21 @@ function validateFlowReferences(flow: z.infer): string[] { walk(step.no); continue; } - if (!findOperation(step.connectorId, step.operationId, 'action')) { + const action = findOperation(step.connectorId, step.operationId, 'action'); + if (!action) { problems.push(`Akce ${step.connectorId}/${step.operationId} neexistuje v katalogu.`); + continue; + } + + // Nastaveni musi sedet na katalog. Neznamy klic je rozbity strom, + // ne nedodelek - ulozit ho by znamenalo drzet data, ktera nikdo neprecte. + const allowed = new Set((action.inputs ?? []).map((input) => input.id)); + for (const key of Object.keys(step.inputs ?? {})) { + if (!allowed.has(key)) { + problems.push( + `Akce ${step.connectorId}/${step.operationId} nemá nastavitelné pole „${key}".`, + ); + } } } }; diff --git a/src/routes/simulate.ts b/src/routes/simulate.ts index be4ef47..15b09d8 100644 --- a/src/routes/simulate.ts +++ b/src/routes/simulate.ts @@ -30,8 +30,12 @@ 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(), + channel: z + .enum(['whatsapp', 'facebook', 'instagram', 'email', 'voice', 'form', 'portal']) + .optional(), subject: z.string().trim().min(3).optional(), + /** Cely text pozadavku. Bez nej se doplni z predmetu a kanalu. */ + body: z.string().trim().min(3).optional(), contact: z.string().trim().min(2).optional(), priority: z.enum(['low', 'normal', 'high', 'critical']).optional(), /** false = CRM firmu nedohleda, ticket zustane bez zakaznika a bez resitele. */ @@ -71,6 +75,8 @@ const channelPresets: Record< subjects: string[]; contact: string; reply: string; + /** Odkaz na zdrojovou zpravu u poskytovatele. null tam, kde zadny neni. */ + sourceRef: string | null; /** Co spoustec vratil. `%s` se nahradi predmetem. */ payload: (contact: string, reply: string, subject: string) => string; } @@ -86,9 +92,40 @@ const channelPresets: Record< ], contact: 'Bistro Kolektiv', reply: '+420 774 902 331', + sourceRef: 'wamid.HBgLNDIwNzc0OTAyMzMx', payload: (contact, reply, subject) => `{ "from": "${reply.replace(/\s/g, '')}", "profileName": "${contact}", "text": "${subject}" }`, }, + facebook: { + connectorId: 'facebook', + operationId: 'message-received', + triggerLabel: 'Přijata zpráva z Facebook Messengeru', + subjects: [ + 'Dotaz na otevírací dobu poboček', + 'Nefunguje objednávkový formulář', + 'Reklamace poškozené zásilky', + ], + contact: 'Martin Dvořák', + reply: 'fb:1029384756', + sourceRef: 'mid.simulace.fb', + payload: (contact, reply, subject) => + `{ "senderId": "${reply.replace('fb:', '')}", "senderName": "${contact}", "text": "${subject}" }`, + }, + instagram: { + connectorId: 'instagram', + operationId: 'message-received', + triggerLabel: 'Přijata přímá zpráva na Instagramu', + subjects: [ + 'Máte to ještě skladem?', + 'Kdy dorazí objednávka', + 'Jde změnit adresa doručení', + ], + contact: 'kavarna.uzastavky', + reply: 'ig:kavarna.uzastavky', + sourceRef: 'ig.simulace', + payload: (contact, _reply, subject) => + `{ "username": "${contact}", "text": "${subject}" }`, + }, email: { connectorId: 'email', operationId: 'received', @@ -100,6 +137,7 @@ const channelPresets: Record< ], contact: 'Tomáš Beran', reply: 'tomas.beran@nordis.cz', + sourceRef: '', payload: (_contact, reply, subject) => `{ "from": "${reply}", "subject": "${subject}", "attachments": 1 }`, }, @@ -114,6 +152,7 @@ const channelPresets: Record< ], contact: 'Petra Klientová', reply: '+420 601 118 224', + sourceRef: 'cl_simulace', payload: (_contact, reply, _subject) => `{ "callId": "cl_${Math.floor(Math.random() * 90_000) + 10_000}", "from": "${reply.replace(/\s/g, '')}", "durationSec": 74 }`, }, @@ -128,6 +167,7 @@ const channelPresets: Record< ], contact: 'Jana Sedláčková', reply: 'jana.sedlackova@logitrans.cz', + sourceRef: null, payload: (contact, _reply, subject) => `{ "company": "LogiTrans", "contact": "${contact}", "topic": "${subject}" }`, }, @@ -142,6 +182,7 @@ const channelPresets: Record< ], contact: 'Interní požadavek', reply: 'portal@automia.cz', + sourceRef: null, payload: (contact, _reply, subject) => `{ "createdBy": "${contact}", "subject": "${subject}" }`, }, }; @@ -161,6 +202,8 @@ const routingRule: Record voiceboti' }, email: { id: 'ppl_kriz', name: 'Martin Kříž', why: 'email -> integrace' }, whatsapp: { id: 'ppl_vomacka', name: 'Karel Vomáčka', why: 'whatsapp -> servicedesk' }, + facebook: { id: 'ppl_vomacka', name: 'Karel Vomáčka', why: 'facebook -> servicedesk' }, + instagram: { id: 'ppl_vomacka', name: 'Karel Vomáčka', why: 'instagram -> servicedesk' }, form: { id: 'ppl_vomacka', name: 'Karel Vomáčka', why: 'form -> servicedesk' }, portal: { id: 'ppl_uhlir', name: 'Jiří Uhlíř', why: 'portal -> vedoucí týmu' }, }; @@ -314,6 +357,9 @@ simulateRouter.post('/', (req, res) => { const ticket = createTicket({ subject, + // Kanal posila cely text, predmet je jen jeho zkraceni. + body: input.body ?? `${subject}. Poslal ${contact} přes ${preset.triggerLabel}.`, + sourceRef: preset.sourceRef, channel, priority, customer: { diff --git a/web/src/components/dashboard/SimulationModal.tsx b/web/src/components/dashboard/SimulationModal.tsx index 60a1983..2c2f036 100644 --- a/web/src/components/dashboard/SimulationModal.tsx +++ b/web/src/components/dashboard/SimulationModal.tsx @@ -28,6 +28,8 @@ const priorities = [ /** Kanal urcuje nejen text, ale i to, jak bude vypadat log ticketu. */ const channels = [ { value: 'whatsapp', label: 'WhatsApp' }, + { value: 'facebook', label: 'Facebook Messenger' }, + { value: 'instagram', label: 'Instagram' }, { value: 'email', label: 'E-mail' }, { value: 'voice', label: 'Hlasová linka' }, { value: 'form', label: 'Webový formulář' }, diff --git a/web/src/components/dashboard/StatusBadge.tsx b/web/src/components/dashboard/StatusBadge.tsx index 76a9d4f..96f4d6f 100644 --- a/web/src/components/dashboard/StatusBadge.tsx +++ b/web/src/components/dashboard/StatusBadge.tsx @@ -3,7 +3,9 @@ import { CheckCircle2, CircleDot, Clock, + Facebook, FileInput, + Instagram, LayoutDashboard, Mail, MessageCircle, @@ -43,6 +45,8 @@ const ticketPriorityMap: Record = { whatsapp: { label: 'WhatsApp', icon: MessageCircle }, + facebook: { label: 'Facebook', icon: Facebook }, + instagram: { label: 'Instagram', icon: Instagram }, email: { label: 'E-mail', icon: Mail }, voice: { label: 'Hlasová linka', icon: PhoneCall }, form: { label: 'Formulář', icon: FileInput }, diff --git a/web/src/components/dashboard/flow/FlowCanvas.tsx b/web/src/components/dashboard/flow/FlowCanvas.tsx index 684e6f9..48eb962 100644 --- a/web/src/components/dashboard/flow/FlowCanvas.tsx +++ b/web/src/components/dashboard/flow/FlowCanvas.tsx @@ -1,10 +1,11 @@ import { ChevronDown, ChevronUp, GitBranch, Plus, Trash2, Zap } from 'lucide-react'; -import type { ReactNode } from 'react'; +import { StepInputs } from '@/components/dashboard/flow/StepInputs'; import { TriggerConfig } from '@/components/dashboard/flow/TriggerConfig'; import { Badge } from '@/components/ui/Badge'; import { cn } from '@/lib/cn'; import { connectorIcon } from '@/lib/connectorIcons'; import { + collectScopes, defaultOperatorFor, describeCondition, isUnaryOperator, @@ -35,6 +36,7 @@ interface CanvasCallbacks { stepId: string, patch: { fieldId?: string; operator?: ConditionOperator; value?: string }, ) => void; + onUpdateInputs: (stepId: string, inputs: Record) => void; onMoveStep: (stepId: string, offset: number) => void; } @@ -73,7 +75,8 @@ export function FlowCanvas({ steps={flow.steps} path={[]} connectors={connectors} - fields={flow.trigger.fields} + scopes={collectScopes(flow, connectors)} + fallbackFields={flow.trigger.fields} {...callbacks} /> )} @@ -181,17 +184,21 @@ function StepSequence({ steps, path, connectors, - fields, + scopes, + fallbackFields, compact = false, ...callbacks }: CanvasCallbacks & { steps: FlowStep[]; path: FlowPath; connectors: Connector[]; - fields: TriggerField[]; + /** Co je videt v kterem kroku. Klic je ID kroku. */ + scopes: Map; + /** Pouzije se, kdyz krok ve scope jeste neni (prave pridany). */ + fallbackFields: TriggerField[]; compact?: boolean; }) { - const { onAddStep, onRemoveStep, onMoveStep } = callbacks; + const { onAddStep, onRemoveStep, onMoveStep, onUpdateInputs } = callbacks; return (
@@ -203,17 +210,21 @@ function StepSequence({ 0} canMoveDown={index < steps.length - 1} onRemove={() => onRemoveStep(step.id)} onMove={(offset) => onMoveStep(step.id, offset)} + onUpdateInputs={(inputs) => onUpdateInputs(step.id, inputs)} /> ) : ( 0} canMoveDown={index < steps.length - 1} {...callbacks} @@ -260,17 +271,22 @@ function AddStepButton({ function ActionCard({ step, connectors, + available, canMoveUp, canMoveDown, onRemove, onMove, + onUpdateInputs, }: { step: Extract; connectors: Connector[]; + /** Parametry viditelne prave v tomto miste stromu. */ + available: TriggerField[]; canMoveUp: boolean; canMoveDown: boolean; onRemove: () => void; onMove: (offset: number) => void; + onUpdateInputs: (inputs: Record) => void; }) { const resolved = resolveOperation(connectors, step.connectorId, step.operationId, 'action'); @@ -285,24 +301,45 @@ function ActionCard({ } const Icon = connectorIcon(resolved.connector.icon); + const inputs = resolved.operation.inputs; return ( - - - - -
-

{resolved.connector.name}

-

{resolved.operation.name}

-

{resolved.operation.description}

+
+
+ + + +
+

{resolved.connector.name}

+

{resolved.operation.name}

+

{resolved.operation.description}

+
+
- - + + {inputs && inputs.length > 0 ? ( + + ) : ( + // Akce, ktere zatim nemaji `inputs`. Radeji to rekneme nahlas, + // nez aby uzivatel hledal nastaveni, ktere neexistuje. +

+ Tuhle akci zatím nejde nastavit. + {resolved.operation.fields && resolved.operation.fields.length > 0 && ( + <> Až půjde, budou to pole: {resolved.operation.fields.join(', ')}. + )} +

+ )} +
); } @@ -310,7 +347,9 @@ function ConditionCard({ step, path, connectors, - fields, + scopes, + fallbackFields, + available: fields, canMoveUp, canMoveDown, ...callbacks @@ -318,7 +357,9 @@ function ConditionCard({ step: Extract; path: FlowPath; connectors: Connector[]; - fields: TriggerField[]; + scopes: Map; + fallbackFields: TriggerField[]; + available: TriggerField[]; canMoveUp: boolean; canMoveDown: boolean; }) { @@ -459,7 +500,8 @@ function ConditionCard({ steps={step[branch]} path={[...path, { stepId: step.id, branch }]} connectors={connectors} - fields={fields} + scopes={scopes} + fallbackFields={fallbackFields} compact {...callbacks} /> @@ -470,14 +512,6 @@ function ConditionCard({ ); } -function StepShell({ children }: { children: ReactNode }) { - return ( -
- {children} -
- ); -} - function StepControls({ canMoveUp, canMoveDown, diff --git a/web/src/components/dashboard/flow/StepInputs.tsx b/web/src/components/dashboard/flow/StepInputs.tsx new file mode 100644 index 0000000..cbec1c4 --- /dev/null +++ b/web/src/components/dashboard/flow/StepInputs.tsx @@ -0,0 +1,218 @@ +import { AlertTriangle, Braces } from 'lucide-react'; +import { useRef, useState } from 'react'; +import { Badge } from '@/components/ui/Badge'; +import { brokenReferences } from '@/lib/flow'; +import { cn } from '@/lib/cn'; +import type { OperationField, TriggerField } from '@/types/dashboard'; + +/** + * Nastaveni akce: co se ma kam ulozit. + * + * Hodnota je sablona. `{{nazev}}` se pri behu nahradi parametrem spoustece, + * takze jde rict "do obsahu ticketu dej text zpravy z WhatsApp". + * + * Odkazuje se jmenem parametru, ne jeho ID - jmeno uzivatel vidi a pise. + * Prejmenovani parametru proto sablonu rozbije, coz se tady rovnou ukaze + * a server to hlasi jako nedodelek. + */ +export function StepInputs({ + fields, + values, + available, + onChange, +}: { + fields: OperationField[]; + values: Record; + /** Parametry viditelne prave tady: spoustec plus vystupy predchozich kroku. */ + available: TriggerField[]; + onChange: (values: Record) => void; +}) { + /** Do ktereho pole vlozi kliknuti na parametr. Vyber nema kam vkladat. */ + const [focused, setFocused] = useState(null); + const elements = useRef(new Map()); + + function set(fieldId: string, value: string) { + onChange({ ...values, [fieldId]: value }); + } + + /** Vlozi `{{nazev}}` na pozici kurzoru v naposledy pouzitem poli. */ + function insert(name: string) { + if (!focused) return; + const element = elements.current.get(focused); + const current = values[focused] ?? ''; + const token = `{{${name}}}`; + + if (!element) { + set(focused, current + token); + return; + } + + const start = element.selectionStart ?? current.length; + const end = element.selectionEnd ?? start; + set(focused, current.slice(0, start) + token + current.slice(end)); + + // Kurzor za vlozeny odkaz, aby slo psat dal bez klikani. + window.requestAnimationFrame(() => { + element.focus(); + const at = start + token.length; + element.setSelectionRange(at, at); + }); + } + + const inputClass = + 'w-full rounded-lg border bg-ink-900/70 px-3 py-1.5 text-sm text-white placeholder:text-white/25 focus:outline-none'; + + return ( +
+

+ Co se má kam uložit +

+ + {fields.map((field) => { + const value = values[field.id] ?? ''; + const missing = field.required && value.trim().length === 0; + const broken = field.kind === 'choice' ? [] : brokenReferences(value, available); + + return ( +
+ + + {field.kind === 'choice' ? ( + + ) : field.kind === 'longtext' ? ( +