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) <noreply@anthropic.com>
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+155
-4
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
+368
-7
@@ -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<string, string>;
|
||||
}
|
||||
| {
|
||||
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<FlowStep, { kind: 'action' }>,
|
||||
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 {
|
||||
|
||||
+286
-11
@@ -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 ?? [];
|
||||
}
|
||||
|
||||
@@ -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<string, TriggerField[]>;
|
||||
/**
|
||||
* 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<string, TriggerField>;
|
||||
}
|
||||
|
||||
/** Vystupy akce jako pole se stabilnim ID v ramci celeho stromu. */
|
||||
function outputsOf(step: Extract<FlowStep, { kind: 'action' }>): 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<string, TriggerField[]>();
|
||||
const all = new Map<string, TriggerField>();
|
||||
|
||||
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<string>();
|
||||
const duplicates = new Set<string>();
|
||||
for (const field of fields) {
|
||||
if (seen.has(field.name)) duplicates.add(field.name);
|
||||
seen.add(field.name);
|
||||
}
|
||||
return [...duplicates];
|
||||
}
|
||||
@@ -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, string>): 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;
|
||||
});
|
||||
}
|
||||
+40
-1
@@ -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<Ticket, 'assignee'> {
|
||||
|
||||
export const channelLabels: Record<TicketChannel, string> = {
|
||||
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',
|
||||
|
||||
+22
-2
@@ -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' },
|
||||
|
||||
+30
-4
@@ -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<FlowStep> = 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<typeof flowSchema>): 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<typeof flowSchema>): 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}".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
+47
-1
@@ -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: '<sim-4f8a@nordis.cz>',
|
||||
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<TicketChannel, { id: string; name: string; why: string
|
||||
voice: { id: 'ppl_novakova', name: 'Eva Nováková', why: 'voice -> voiceboti' },
|
||||
email: { id: 'ppl_kriz', name: 'Martin Kříž', why: 'email -> integrace' },
|
||||
whatsapp: { id: 'ppl_vomacka', name: 'Karel Vomáčka', why: 'whatsapp -> servicedesk' },
|
||||
facebook: { id: 'ppl_vomacka', name: 'Karel Vomáčka', why: 'facebook -> servicedesk' },
|
||||
instagram: { id: 'ppl_vomacka', name: 'Karel Vomáčka', why: 'instagram -> servicedesk' },
|
||||
form: { id: 'ppl_vomacka', name: 'Karel Vomáčka', why: 'form -> servicedesk' },
|
||||
portal: { id: 'ppl_uhlir', name: 'Jiří Uhlíř', why: 'portal -> vedoucí týmu' },
|
||||
};
|
||||
@@ -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: {
|
||||
|
||||
@@ -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ář' },
|
||||
|
||||
@@ -3,7 +3,9 @@ import {
|
||||
CheckCircle2,
|
||||
CircleDot,
|
||||
Clock,
|
||||
Facebook,
|
||||
FileInput,
|
||||
Instagram,
|
||||
LayoutDashboard,
|
||||
Mail,
|
||||
MessageCircle,
|
||||
@@ -43,6 +45,8 @@ const ticketPriorityMap: Record<TicketPriority, { label: string; tone: BadgeTone
|
||||
/** Kanal nese ikonu, ne barvu - neni to stav, nema co kricet. */
|
||||
const ticketChannelMap: Record<TicketChannel, { label: string; icon: LucideIcon }> = {
|
||||
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 },
|
||||
|
||||
@@ -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<string, string>) => 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<string, TriggerField[]>;
|
||||
/** 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 (
|
||||
<div className="flex flex-col items-stretch">
|
||||
@@ -203,17 +210,21 @@ function StepSequence({
|
||||
<ActionCard
|
||||
step={step}
|
||||
connectors={connectors}
|
||||
available={scopes.get(step.id) ?? fallbackFields}
|
||||
canMoveUp={index > 0}
|
||||
canMoveDown={index < steps.length - 1}
|
||||
onRemove={() => onRemoveStep(step.id)}
|
||||
onMove={(offset) => onMoveStep(step.id, offset)}
|
||||
onUpdateInputs={(inputs) => onUpdateInputs(step.id, inputs)}
|
||||
/>
|
||||
) : (
|
||||
<ConditionCard
|
||||
step={step}
|
||||
path={path}
|
||||
connectors={connectors}
|
||||
fields={fields}
|
||||
scopes={scopes}
|
||||
fallbackFields={fallbackFields}
|
||||
available={scopes.get(step.id) ?? fallbackFields}
|
||||
canMoveUp={index > 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<FlowStep, { kind: 'action' }>;
|
||||
connectors: Connector[];
|
||||
/** Parametry viditelne prave v tomto miste stromu. */
|
||||
available: TriggerField[];
|
||||
canMoveUp: boolean;
|
||||
canMoveDown: boolean;
|
||||
onRemove: () => void;
|
||||
onMove: (offset: number) => void;
|
||||
onUpdateInputs: (inputs: Record<string, string>) => 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 (
|
||||
<StepShell>
|
||||
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-ink-700/70 text-brand-300">
|
||||
<Icon className="size-4.5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs text-white/40">{resolved.connector.name}</p>
|
||||
<p className="mt-0.5 font-semibold text-white">{resolved.operation.name}</p>
|
||||
<p className="mt-0.5 text-sm text-white/50">{resolved.operation.description}</p>
|
||||
<div className="rounded-card border border-ink-600/60 bg-ink-800/60 p-4 transition-colors hover:border-ink-600">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-ink-700/70 text-brand-300">
|
||||
<Icon className="size-4.5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs text-white/40">{resolved.connector.name}</p>
|
||||
<p className="mt-0.5 font-semibold text-white">{resolved.operation.name}</p>
|
||||
<p className="mt-0.5 text-sm text-white/50">{resolved.operation.description}</p>
|
||||
</div>
|
||||
<StepControls
|
||||
canMoveUp={canMoveUp}
|
||||
canMoveDown={canMoveDown}
|
||||
onMove={onMove}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
</div>
|
||||
<StepControls
|
||||
canMoveUp={canMoveUp}
|
||||
canMoveDown={canMoveDown}
|
||||
onMove={onMove}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
</StepShell>
|
||||
|
||||
{inputs && inputs.length > 0 ? (
|
||||
<StepInputs
|
||||
fields={inputs}
|
||||
values={step.inputs ?? {}}
|
||||
available={available}
|
||||
onChange={onUpdateInputs}
|
||||
/>
|
||||
) : (
|
||||
// Akce, ktere zatim nemaji `inputs`. Radeji to rekneme nahlas,
|
||||
// nez aby uzivatel hledal nastaveni, ktere neexistuje.
|
||||
<p className="mt-3 border-t border-ink-600/50 pt-3 text-xs text-white/35">
|
||||
Tuhle akci zatím nejde nastavit.
|
||||
{resolved.operation.fields && resolved.operation.fields.length > 0 && (
|
||||
<> Až půjde, budou to pole: {resolved.operation.fields.join(', ')}.</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<FlowStep, { kind: 'condition' }>;
|
||||
path: FlowPath;
|
||||
connectors: Connector[];
|
||||
fields: TriggerField[];
|
||||
scopes: Map<string, TriggerField[]>;
|
||||
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 (
|
||||
<div className="flex items-start gap-3 rounded-card border border-ink-600/60 bg-ink-800/60 p-4 transition-colors hover:border-ink-600">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepControls({
|
||||
canMoveUp,
|
||||
canMoveDown,
|
||||
|
||||
@@ -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<string, string>;
|
||||
/** Parametry viditelne prave tady: spoustec plus vystupy predchozich kroku. */
|
||||
available: TriggerField[];
|
||||
onChange: (values: Record<string, string>) => void;
|
||||
}) {
|
||||
/** Do ktereho pole vlozi kliknuti na parametr. Vyber nema kam vkladat. */
|
||||
const [focused, setFocused] = useState<string | null>(null);
|
||||
const elements = useRef(new Map<string, HTMLInputElement | HTMLTextAreaElement>());
|
||||
|
||||
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 (
|
||||
<div className="mt-4 space-y-3 border-t border-ink-600/50 pt-4">
|
||||
<h4 className="text-xs font-semibold tracking-wide text-white/45 uppercase">
|
||||
Co se má kam uložit
|
||||
</h4>
|
||||
|
||||
{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 (
|
||||
<div key={field.id}>
|
||||
<label
|
||||
htmlFor={`input-${field.id}`}
|
||||
className="flex flex-wrap items-center gap-2 text-xs text-white/55"
|
||||
>
|
||||
{field.label}
|
||||
{field.required && <span className="text-white/30">povinné</span>}
|
||||
{missing && <Badge tone="warn">Chybí</Badge>}
|
||||
</label>
|
||||
|
||||
{field.kind === 'choice' ? (
|
||||
<select
|
||||
id={`input-${field.id}`}
|
||||
value={value}
|
||||
onChange={(event) => set(field.id, event.target.value)}
|
||||
className={cn(
|
||||
inputClass,
|
||||
'mt-1',
|
||||
missing ? 'border-warn-400/60' : 'border-ink-600/70 focus:border-brand-400/70',
|
||||
)}
|
||||
>
|
||||
{/* Prazdna volba jen dokud si uzivatel nevybral. */}
|
||||
{value === '' && !field.options?.some((option) => option.value === '') && (
|
||||
<option value="" className="bg-ink-850">
|
||||
Vyberte
|
||||
</option>
|
||||
)}
|
||||
{field.options?.map((option) => (
|
||||
<option key={option.value} value={option.value} className="bg-ink-850">
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : field.kind === 'longtext' ? (
|
||||
<textarea
|
||||
id={`input-${field.id}`}
|
||||
ref={(element) => {
|
||||
if (element) elements.current.set(field.id, element);
|
||||
else elements.current.delete(field.id);
|
||||
}}
|
||||
value={value}
|
||||
rows={3}
|
||||
onFocus={() => setFocused(field.id)}
|
||||
onChange={(event) => set(field.id, event.target.value)}
|
||||
placeholder={field.hint ?? 'Text, nebo {{parametr}} ze spouštěče'}
|
||||
className={cn(
|
||||
inputClass,
|
||||
'mt-1 resize-y',
|
||||
missing || broken.length > 0
|
||||
? 'border-warn-400/60'
|
||||
: 'border-ink-600/70 focus:border-brand-400/70',
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
id={`input-${field.id}`}
|
||||
ref={(element) => {
|
||||
if (element) elements.current.set(field.id, element);
|
||||
else elements.current.delete(field.id);
|
||||
}}
|
||||
value={value}
|
||||
onFocus={() => setFocused(field.id)}
|
||||
onChange={(event) => set(field.id, event.target.value)}
|
||||
placeholder={field.hint ?? 'Text, nebo {{parametr}} ze spouštěče'}
|
||||
className={cn(
|
||||
inputClass,
|
||||
'mt-1',
|
||||
missing || broken.length > 0
|
||||
? 'border-warn-400/60'
|
||||
: 'border-ink-600/70 focus:border-brand-400/70',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{broken.length > 0 && (
|
||||
<p className="mt-1 flex items-start gap-1.5 text-xs text-warn-400">
|
||||
<AlertTriangle className="mt-0.5 size-3 shrink-0" />
|
||||
Spouštěč nemá parametr {broken.map((name) => `{{${name}}}`).join(', ')}.
|
||||
Nejspíš se přejmenoval.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<ParameterChips
|
||||
available={available}
|
||||
enabled={focused !== null}
|
||||
onInsert={insert}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Nabidka parametru spoustece. Kliknuti je vlozi do rozepsaneho pole. */
|
||||
function ParameterChips({
|
||||
available,
|
||||
enabled,
|
||||
onInsert,
|
||||
}: {
|
||||
available: TriggerField[];
|
||||
enabled: boolean;
|
||||
onInsert: (name: string) => void;
|
||||
}) {
|
||||
if (available.length === 0) {
|
||||
return (
|
||||
<p className="rounded-lg border border-dashed border-ink-600/70 px-3 py-2 text-xs text-white/35">
|
||||
Sem zatím nedoteče žádný parametr, takže do polí jde psát jen pevný text.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-ink-600/50 bg-ink-900/40 px-3 py-2.5">
|
||||
<p className="flex items-center gap-1.5 text-xs text-white/40">
|
||||
<Braces className="size-3" />
|
||||
{enabled
|
||||
? 'Kliknutím vložíte parametr spouštěče na pozici kurzoru.'
|
||||
: 'Klikněte do pole výše a pak sem, parametr se vloží.'}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{available.map((field) => (
|
||||
<button
|
||||
key={field.id}
|
||||
type="button"
|
||||
disabled={!enabled}
|
||||
onClick={() => onInsert(field.name)}
|
||||
title={`Vložit {{${field.name}}}`}
|
||||
className={cn(
|
||||
'rounded-md border px-2 py-1 font-mono text-xs transition-colors',
|
||||
enabled
|
||||
? 'border-brand-400/40 text-brand-200 hover:border-brand-400 hover:bg-brand-500/15'
|
||||
: 'cursor-not-allowed border-ink-600/60 text-white/25',
|
||||
)}
|
||||
>
|
||||
{field.name || '(bez názvu)'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,10 +2,12 @@ import {
|
||||
BarChart3,
|
||||
Building2,
|
||||
Clock,
|
||||
Facebook,
|
||||
FileAudio,
|
||||
FileInput,
|
||||
Globe,
|
||||
Hash,
|
||||
Instagram,
|
||||
Landmark,
|
||||
LifeBuoy,
|
||||
Mail,
|
||||
@@ -38,10 +40,12 @@ const icons: Record<string, LucideIcon> = {
|
||||
BarChart3,
|
||||
Building2,
|
||||
Clock,
|
||||
Facebook,
|
||||
FileAudio,
|
||||
FileInput,
|
||||
Globe,
|
||||
Hash,
|
||||
Instagram,
|
||||
Landmark,
|
||||
LifeBuoy,
|
||||
Mail,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
AutomationFlow,
|
||||
ConditionOperator,
|
||||
Connector,
|
||||
ConnectorOperation,
|
||||
@@ -126,6 +127,97 @@ export function describeCondition(
|
||||
return `${field.name} ${label} ${value?.trim() || '…'}`;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------- co je kde videt
|
||||
|
||||
/**
|
||||
* Co je videt v kterem miste stromu. Spoustec da nejake parametry, kazda akce
|
||||
* muze pridat dalsi - "dohledat firmu" vrati `customerKnown`.
|
||||
*
|
||||
* Vetev podminky NEPRIDAVA nic do sekvence za podminkou, protoze nemusela
|
||||
* probehnout. POZOR: stejna logika je na serveru v src/data/flowScope.ts,
|
||||
* server je autorita.
|
||||
*/
|
||||
export function collectScopes(
|
||||
flow: AutomationFlow,
|
||||
connectors: Connector[],
|
||||
): Map<string, TriggerField[]> {
|
||||
const byStep = new Map<string, TriggerField[]>();
|
||||
const triggerFields = flow.trigger?.fields ?? [];
|
||||
|
||||
const outputsOf = (step: Extract<FlowStep, { kind: 'action' }>): TriggerField[] => {
|
||||
const resolved = resolveOperation(connectors, step.connectorId, step.operationId, 'action');
|
||||
return (resolved?.operation.outputFields ?? []).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,
|
||||
}));
|
||||
};
|
||||
|
||||
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) available = [...available, ...outputs];
|
||||
}
|
||||
};
|
||||
|
||||
walk(flow.steps, triggerFields);
|
||||
return byStep;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- sablony
|
||||
|
||||
/**
|
||||
* `{{nazev}}` v hodnote pole akce se pri behu nahradi parametrem spoustece.
|
||||
* POZOR: stejna logika je na serveru v src/data/templates.ts, server je autorita.
|
||||
*/
|
||||
const REFERENCE = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
|
||||
|
||||
/** Jmena parametru, na ktere sablona odkazuje. Bez duplicit. */
|
||||
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;
|
||||
}
|
||||
|
||||
/** Odkazy na parametry, ktere u spoustece neexistuji. Typicky po prejmenovani. */
|
||||
export function brokenReferences(template: string, fields: TriggerField[]): string[] {
|
||||
const known = new Set(fields.map((field) => field.name));
|
||||
return referencedFields(template).filter((name) => !known.has(name));
|
||||
}
|
||||
|
||||
/** Zmena nastaveni akce kdekoliv ve strome. */
|
||||
export function updateStepInputs(
|
||||
steps: FlowStep[],
|
||||
stepId: string,
|
||||
inputs: Record<string, string>,
|
||||
): FlowStep[] {
|
||||
return steps.map((step) => {
|
||||
if (step.kind === 'condition') {
|
||||
return {
|
||||
...step,
|
||||
yes: updateStepInputs(step.yes, stepId, inputs),
|
||||
no: updateStepInputs(step.no, stepId, inputs),
|
||||
};
|
||||
}
|
||||
return step.id === stepId ? { ...step, inputs } : step;
|
||||
});
|
||||
}
|
||||
|
||||
/** Zmena vlastnosti podminky kdekoliv ve strome. */
|
||||
export function updateCondition(
|
||||
steps: FlowStep[],
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
moveStep,
|
||||
removeStep,
|
||||
updateCondition,
|
||||
updateStepInputs,
|
||||
type FlowPath,
|
||||
} from '@/lib/flow';
|
||||
import { formatDateTime } from '@/lib/format';
|
||||
@@ -361,6 +362,9 @@ export default function AutomationDetail() {
|
||||
onUpdateCondition={(stepId, patch) =>
|
||||
changeFlow({ ...flow, steps: updateCondition(flow.steps, stepId, patch) })
|
||||
}
|
||||
onUpdateInputs={(stepId, inputs) =>
|
||||
changeFlow({ ...flow, steps: updateStepInputs(flow.steps, stepId, inputs) })
|
||||
}
|
||||
onMoveStep={(stepId, offset) =>
|
||||
changeFlow({ ...flow, steps: moveStep(flow.steps, stepId, offset) })
|
||||
}
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { AlertCircle, ArrowLeft, Building2, MessageSquarePlus, ScrollText, UserCheck } from 'lucide-react';
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
Building2,
|
||||
FileText,
|
||||
MessageSquarePlus,
|
||||
ScrollText,
|
||||
UserCheck,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
@@ -126,7 +134,30 @@ export default function TicketDetail() {
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1fr_20rem]">
|
||||
<section className="glass rounded-card p-5 sm:p-6">
|
||||
<div className="space-y-6">
|
||||
<section className="glass rounded-card p-5 sm:p-6">
|
||||
<h2 className="flex items-center gap-2 font-semibold text-white">
|
||||
<FileText className="size-4 text-brand-300" />
|
||||
Obsah požadavku
|
||||
</h2>
|
||||
{ticket.data.body.trim().length > 0 ? (
|
||||
<p className="mt-3 text-sm leading-relaxed whitespace-pre-wrap text-white/75">
|
||||
{ticket.data.body}
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-3 rounded-lg border border-dashed border-ink-600/70 px-3 py-2.5 text-xs text-white/40">
|
||||
Prázdné. Krok „Založit ticket" neměl nastavené pole Obsah, takže se text
|
||||
původního požadavku nikam neuložil.
|
||||
</p>
|
||||
)}
|
||||
{ticket.data.sourceRef && (
|
||||
<p className="mt-3 font-mono text-xs text-white/30">
|
||||
zdroj {ticket.data.sourceRef}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="glass rounded-card p-5 sm:p-6">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="flex items-center gap-2 font-semibold text-white">
|
||||
<ScrollText className="size-4 text-brand-300" />
|
||||
@@ -167,7 +198,8 @@ export default function TicketDetail() {
|
||||
Komentář se zapíše do stejné časové osy jako běh automatizace.
|
||||
</p>
|
||||
</form>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside className="space-y-4">
|
||||
<div className="glass rounded-card p-5">
|
||||
|
||||
@@ -32,6 +32,8 @@ const statusFilters: Array<{ value: TicketStatus; label: string }> = [
|
||||
|
||||
const channelFilters: Array<{ value: TicketChannel; label: string }> = [
|
||||
{ value: 'whatsapp', label: 'WhatsApp' },
|
||||
{ value: 'facebook', label: 'Facebook' },
|
||||
{ value: 'instagram', label: 'Instagram' },
|
||||
{ value: 'email', label: 'E-mail' },
|
||||
{ value: 'voice', label: 'Hlasová linka' },
|
||||
{ value: 'form', label: 'Formulář' },
|
||||
|
||||
@@ -6,7 +6,14 @@
|
||||
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 TicketChannel =
|
||||
| 'whatsapp'
|
||||
| 'facebook'
|
||||
| 'instagram'
|
||||
| 'email'
|
||||
| 'voice'
|
||||
| 'form'
|
||||
| 'portal';
|
||||
export type IncidentSeverity = 'sev1' | 'sev2' | 'sev3';
|
||||
export type IncidentStatus = 'investigating' | 'identified' | 'monitoring' | 'resolved';
|
||||
|
||||
@@ -31,6 +38,10 @@ export interface TicketCustomer {
|
||||
export interface Ticket {
|
||||
id: string;
|
||||
subject: string;
|
||||
/** Cely text pozadavku. Prazdny = krok "Zalozit ticket" obsah nenaplnil. */
|
||||
body: string;
|
||||
/** Odkaz na zdrojovou zpravu u poskytovatele. */
|
||||
sourceRef: string | null;
|
||||
channel: TicketChannel;
|
||||
customer: TicketCustomer;
|
||||
status: TicketStatus;
|
||||
@@ -151,6 +162,23 @@ export interface ConnectorOperation {
|
||||
* server je pri ukladani stromu vzdy dosadi z katalogu.
|
||||
*/
|
||||
providedFields?: TriggerField[];
|
||||
/** Jen u akci: skutecne nastavitelna pole. Kdyz chybi, akci nejde nastavit. */
|
||||
inputs?: OperationField[];
|
||||
/** Jen u akci: co krok vrati dalsim krokum. Podminka se na to muze ptat. */
|
||||
outputFields?: TriggerField[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Nastavitelne pole akce. Hodnota je sablona - `{{nazev}}` se nahradi
|
||||
* parametrem spoustece.
|
||||
*/
|
||||
export interface OperationField {
|
||||
id: string;
|
||||
label: string;
|
||||
kind: 'text' | 'longtext' | 'choice';
|
||||
required: boolean;
|
||||
options?: Array<{ value: string; label: string }>;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export interface Connector {
|
||||
@@ -218,6 +246,8 @@ export type FlowStep =
|
||||
kind: 'action';
|
||||
connectorId: string;
|
||||
operationId: string;
|
||||
/** Klic je `OperationField.id`, hodnota sablona s `{{parametry}}`. */
|
||||
inputs?: Record<string, string>;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user