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:
JiriUhlir
2026-08-03 12:29:26 +02:00
parent 52b190bfbc
commit dd021b5f69
24 changed files with 1651 additions and 80 deletions
+12 -6
View File
@@ -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).
+12 -1
View File
@@ -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.
+2 -2
View File
@@ -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
+33 -5
View File
@@ -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
View File
@@ -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 |
+53
View File
@@ -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
View File
@@ -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
View File
@@ -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 ?? [];
}
+91
View File
@@ -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];
}
+42
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 && (
<> 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 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>
);
}
+4
View File
@@ -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,
+92
View File
@@ -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) })
}
+35 -3
View File
@@ -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">
+2
View File
@@ -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ář' },
+31 -1
View File
@@ -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;