From ef3080d3f62825038c328f74aadb682865d5c441 Mon Sep 17 00:00:00 2001 From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:21:32 +0200 Subject: [PATCH] Nastaveni prezije nasazeni, ukazkova data uz se nevraci MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bez databaze lezi data uvnitr containeru, takze redeploy je smaze a seed je nasype znovu. Dokud nebude Postgres, resi se to trema vecmi: Ukazkova data jen se SEED_DEMO=1. Automatizace, tickety a incidenty se uz po kazdem nasazeni nevraci. Konfigurace se nasypava dal, bez ni je portal nepouzitelny. Token webhooku z WEBHOOK_TOKEN_TEST. Driv se pri kazdem nasazeni vygeneroval novy, takze odesilatel musel prepisovat adresu ve svem kodu. Ted je token v promenne aplikace: neni v gitu a adresa se nemeni. Automatizace, ktera na instanci opravdu bezi, je v seedu. Je to provizorium, ne cil - az data prezijou nasazeni, patri zpatky do dat. Dal: - `ticket/upsert` umi vsechna pole ticketu: zakaznik, kanal, odkaz na zdroj, priorita, stitky, resitel, skupina a vlastni pole typu jako JSON. Zakaznik a kanal se vyplnuji jen pri zalozeni, aby pozdejsi udalost s prazdnym jmenem neprepsala, co uz tam je. - Kroky ve strome jdou sbalit, vychozi je sbaleno. Sbaleny krok ukazuje, co ma vyplneno, ne popis operace. - Seskupovani widgetu podle faze a dva nove widgety: tickety podle stavu a podle faze za tento mesic, obojí s proklikem na vyfiltrovany seznam. - Faze se ukazuje jako stav. Driv byl videt jen nas ctyrprvkovy ciselnik, coz u ticketu z cizi aplikace nedava smysl. Zivotni cyklus zustava vedle jako drobny text, protoze se z nej pocitaji statistiky. Overeno 18 kontrolami proti bezicimu serveru. Co-Authored-By: Claude Opus 5 (1M context) --- documentation/99-zmeny.md | 42 + src/config.ts | 20 + src/data/automationStore.ts | 948 ++++++++++-------- src/data/customWidgets.ts | 37 +- src/data/incidentStore.ts | 11 +- src/data/services.ts | 61 ++ src/data/ticketStore.ts | 576 +++++------ src/routes/dashboard.ts | 2 + src/routes/settings.ts | 4 +- src/routes/widgetData.ts | 5 + src/runtime/builtinSteps.ts | 58 ++ web/src/components/dashboard/StatusBadge.tsx | 32 +- web/src/components/dashboard/TicketTable.tsx | 4 +- .../components/dashboard/flow/FlowCanvas.tsx | 113 ++- web/src/pages/dashboard/PersonDetail.tsx | 2 +- web/src/pages/dashboard/TicketDetail.tsx | 2 +- web/src/pages/dashboard/Tickets.tsx | 10 +- 17 files changed, 1170 insertions(+), 757 deletions(-) diff --git a/documentation/99-zmeny.md b/documentation/99-zmeny.md index 1e469ba..4e72a87 100644 --- a/documentation/99-zmeny.md +++ b/documentation/99-zmeny.md @@ -2,6 +2,48 @@ Nejnovejsi nahore. +## 2026-08-13 - nastaveni prezije nasazeni, ukazkova data uz se nevraci + +Bez databaze lezi data uvnitr containeru, takze **redeploy je smaze** a seed +je nasype znovu. Dokud nebude Postgres, resi se to takhle: + +### Zmeneno + +- **Ukazkova data jen se `SEED_DEMO=1`.** Automatizace, tickety a incidenty + se uz po kazdem nasazeni nevraci. Konfigurace (firmy, uzivatele, role, + resitele, typy, widgety) se nasypava dal - bez ni je portal nepouzitelny. +- **Token webhooku z `WEBHOOK_TOKEN_TEST`.** Driv se pri kazdem nasazeni + vygeneroval novy, takze odesilatel musel prepisovat adresu. Ted je token + v promenne aplikace: neni v gitu a adresa se nemeni. +- **Automatizace, ktera na instanci opravdu bezi, je v seedu.** Je to + provizorium, ne cil - az data prezijou nasazeni, patri zpatky do dat. + +### Pridano + +- `ticket/upsert` umi **vsechna pole ticketu**: zakaznik (firma, kontakt, kam + odpovidat), kanal, odkaz na zdroj, priorita, stitky, resitel, skupina + a vlastni pole typu jako JSON. Zakaznik a kanal se vyplnuji jen pri + zalozeni, aby pozdejsi udalost s prazdnym jmenem neprepsala, co uz tam je. +- **Kroky ve strome jdou sbalit**, vychozi je sbaleno. Sbaleny krok ukazuje, + co ma vyplneno, ne popis operace, ktery uzivatel zna. +- Seskupovani widgetu **podle faze** a dva nove widgety: tickety podle stavu + a podle faze za tento mesic. Obojí se proklikne na vyfiltrovany seznam. +- Filtr na fazi v seznamu ticketu vcetne adresy. + +### Opraveno + +- **Faze se ukazuje jako stav.** Kdyz ticket ma fazi z workflow sveho typu + (napr. stav hovoru od voicebota), ukazuje se ona; nas zivotni cyklus zustava + vedle jako drobny text, protoze se z nej pocitaji statistiky. Driv byl videt + jen nas ctyrprvkovy ciselnik, coz u ticketu z cizi aplikace nedava smysl. + +### Overeno + +18 kontrol proti bezicimu serveru: po startu bez `SEED_DEMO` je tam jedna +automatizace a nula ukazkovych ticketu i incidentu, token webhooku sedi +s promennou, provoz na te same adrese zaklada tickety, krok vyplni vsechna +pole vcetne vlastnich a oba widgety pocitaji a prokliknou se. + ## 2026-08-13 - krok prirazeni a ukazkova data nesahaji na provoz ### Opraveno diff --git a/src/config.ts b/src/config.ts index 7387053..e81bec7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -160,6 +160,26 @@ export const config = { * se nastavi `WORKER=0`. */ workerEnabled: process.env.WORKER !== '0', + /** + * Nasypat ukazkova data? + * + * Vychozi **ne**. Ukazkove automatizace, tickety a incidenty jsou dobre na + * predvedeni, ale na instanci, kde uz nekdo pracuje, jsou to cizi zaznamy, + * ktere se po kazdem redeployi vraceji. Konfigurace (firmy, uzivatele, role, + * resitele, typy) se nasypava vzdycky - bez ni je portal nepouzitelny. + */ + seedDemo: process.env.SEED_DEMO === '1', + /** + * Token webhooku automatizace, ktera je v seedu. + * + * Bez databaze token nikde neprezije redeploy, takze by se po kazdem + * nasazeni menila adresa a odesilatel by ji musel prepisovat. Tohle to drzi: + * token je v promenne aplikace, ne v datech ani v gitu. + * + * Kdyz neni nastaveny, vygeneruje se novy a v logu se rekne, ze se adresa + * zmenila. + */ + seedWebhookToken: (process.env.WEBHOOK_TOKEN_TEST ?? '').trim(), /** * Jak casto plánovač hleda, co je na case (v sekundach). * diff --git a/src/data/automationStore.ts b/src/data/automationStore.ts index db4bca8..1449c63 100644 --- a/src/data/automationStore.ts +++ b/src/data/automationStore.ts @@ -7,6 +7,7 @@ */ import { randomBytes } from 'node:crypto'; +import { config } from '../config.js'; import { publish } from '../events/bus.js'; import { defineStore } from './store/index.js'; import { withMirror } from './store/mirror.js'; @@ -364,456 +365,527 @@ function seed( }); } -seed({ - name: 'Objednávka → sklad → fakturace', - enabled: true, - runsToday: 428, - successRate: 99.3, - avgDurationMs: 1_240, - lastRunAt: minutesAgo(3), - flow: { - trigger: { - serviceId: 'eshop', - operationId: 'order-created', - fields: [ - { id: 'f_1', name: 'orderId', type: 'string', required: true }, - { id: 'f_2', name: 'total', type: 'number', required: true }, - { id: 'f_3', name: 'customerEmail', type: 'string', required: true }, - ], - }, - steps: [ - { id: 'st_1', kind: 'action', serviceId: 'transform', operationId: 'map-fields' }, - { id: 'st_2', kind: 'action', serviceId: 'eshop', operationId: 'update-stock' }, - { - id: 'st_3', - kind: 'condition', - fieldId: 'f_2', - operator: 'gte', - value: '5000', - yes: [ - { id: 'st_4', kind: 'action', serviceId: 'idoklad', operationId: 'create-proforma' }, - { id: 'st_5', kind: 'action', serviceId: 'email', operationId: 'send' }, - ], - no: [{ id: 'st_6', kind: 'action', serviceId: 'idoklad', operationId: 'create-invoice' }], - }, - { id: 'st_7', kind: 'action', serviceId: 'ppl', operationId: 'create-shipment' }, - ], - }, -}); - -seed({ - name: 'Voicebot: příjem poptávek 24/7', - enabled: true, - runsToday: 137, - successRate: 96.1, - avgDurationMs: 74_000, - lastRunAt: minutesAgo(11), - flow: { - trigger: { - serviceId: 'voicebot', - operationId: 'call-received', - fields: [ - { id: 'f_11', name: 'callerNumber', type: 'string', required: true }, - { id: 'f_12', name: 'wantsOperator', type: 'boolean', required: false }, - ], - }, - steps: [ - { id: 'st_11', kind: 'action', serviceId: 'voicebot', operationId: 'play-scenario' }, - { id: 'st_12', kind: 'action', serviceId: 'transcription', operationId: 'transcribe' }, - { id: 'st_13', kind: 'action', serviceId: 'ai-text', operationId: 'classify' }, - { - id: 'st_14', - kind: 'condition', - fieldId: 'f_12', - operator: 'isTrue', - yes: [{ id: 'st_15', kind: 'action', serviceId: 'voicebot', operationId: 'transfer' }], - no: [ - { id: 'st_16', kind: 'action', serviceId: 'raynet', operationId: 'create-lead' }, - { id: 'st_17', kind: 'action', serviceId: 'email', operationId: 'send' }, - ], - }, - ], - }, -}); - -seed({ - name: 'Synchronizace CRM ↔ účetnictví', - enabled: true, - runsToday: 96, - successRate: 98.9, - avgDurationMs: 2_050, - lastRunAt: minutesAgo(26), - flow: { - trigger: { - serviceId: 'raynet', - operationId: 'company-changed', - fields: [{ id: 'f_21', name: 'companyId', type: 'string', required: true }], - }, - steps: [ - { id: 'st_21', kind: 'action', serviceId: 'transform', operationId: 'deduplicate' }, - { id: 'st_22', kind: 'action', serviceId: 'idoklad', operationId: 'create-invoice' }, - { id: 'st_23', kind: 'action', serviceId: 'log', operationId: 'write' }, - ], - }, -}); - -seed({ - name: 'Noční report pro management', - enabled: false, - runsToday: 0, - successRate: 100, - avgDurationMs: 18_400, - lastRunAt: minutesAgo(1_020), - flow: { - trigger: { serviceId: 'scheduler', operationId: 'interval', fields: [] }, - steps: [ - { id: 'st_31', kind: 'action', serviceId: 'ga4', operationId: 'run-report' }, - { id: 'st_32', kind: 'action', serviceId: 'google-ads', operationId: 'campaign-report' }, - { id: 'st_33', kind: 'action', serviceId: 'sklik', operationId: 'campaign-report' }, - { id: 'st_34', kind: 'action', serviceId: 'ai-text', operationId: 'generate' }, - { id: 'st_35', kind: 'action', serviceId: 'email', operationId: 'send' }, - ], - }, -}); - -// Ukazka webhooku s deklarovanymi parametry a podminkou nad cislem. -seed({ - name: 'Webhook: hodnocení z dotazníku', - enabled: true, - runsToday: 61, - successRate: 100, - avgDurationMs: 640, - lastRunAt: minutesAgo(18), - flow: { - trigger: { - serviceId: 'webhook', - operationId: 'received', - webhookToken: generateWebhookToken(), - fields: [ - { id: 'f_41', name: 'customer', type: 'string', required: true }, - { id: 'f_42', name: 'score', type: 'number', required: true }, - { id: 'f_43', name: 'comment', type: 'string', required: false }, - ], - }, - steps: [ - { - id: 'st_41', - kind: 'condition', - fieldId: 'f_42', - operator: 'gte', - value: '15', - yes: [{ id: 'st_42', kind: 'action', serviceId: 'raynet', operationId: 'add-activity' }], - no: [ - { - id: 'st_43', - kind: 'action', - serviceId: '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', serviceId: 'microsoft365', operationId: 'post-teams' }, - ], - }, - ], - }, -}); - -/* - * Prijem z kanalu: jedna automatizace na kanal, zadne rozhodovani o resiteli. - * Smerovani resi jedna spolecna automatizace nize - viz documentation/06-tickety.md. +/** + * Ukazkove automatizace. + * + * Nasypou se **jen se `SEED_DEMO=1`**. Na instanci, kde uz nekdo pracuje, + * jsou to cizi zaznamy, ktere se po kazdem redeployi vraceji - a mazat je + * porad dokola nikoho nebavi. */ - -seed({ - name: 'WhatsApp: zpráva do ticketu', - enabled: true, - runsToday: 34, - successRate: 100, - avgDurationMs: 1_950, - lastRunAt: minutesAgo(7), - flow: { - trigger: { - serviceId: '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', - serviceId: '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', - serviceId: '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', - serviceId: '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', serviceId: 'raynet', operationId: 'create-lead' }, +function seedDemoAutomations(): void { + seed({ + name: 'Objednávka → sklad → fakturace', + enabled: true, + runsToday: 428, + successRate: 99.3, + avgDurationMs: 1_240, + lastRunAt: minutesAgo(3), + flow: { + trigger: { + serviceId: 'eshop', + operationId: 'order-created', + fields: [ + { id: 'f_1', name: 'orderId', type: 'string', required: true }, + { id: 'f_2', name: 'total', type: 'number', required: true }, + { id: 'f_3', name: 'customerEmail', type: 'string', required: true }, ], }, - ], - }, -}); - -seed({ - name: 'Facebook: zpráva do ticketu', - enabled: true, - runsToday: 11, - successRate: 100, - avgDurationMs: 720, - lastRunAt: minutesAgo(52), - flow: { - trigger: { - serviceId: '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', - serviceId: 'ticket', - operationId: 'create', - inputs: { - subject: 'Facebook od {{senderName}}', - body: '{{text}}', - contact: '{{senderName}}', - reply: '{{senderId}}', - priority: 'normal', - assigneeId: '', + steps: [ + { id: 'st_1', kind: 'action', serviceId: 'transform', operationId: 'map-fields' }, + { id: 'st_2', kind: 'action', serviceId: 'eshop', operationId: 'update-stock' }, + { + id: 'st_3', + kind: 'condition', + fieldId: 'f_2', + operator: 'gte', + value: '5000', + yes: [ + { id: 'st_4', kind: 'action', serviceId: 'idoklad', operationId: 'create-proforma' }, + { id: 'st_5', kind: 'action', serviceId: 'email', operationId: 'send' }, + ], + no: [{ id: 'st_6', kind: 'action', serviceId: 'idoklad', operationId: 'create-invoice' }], }, - }, - ], - }, -}); - -seed({ - name: 'E-mail: požadavky do ticketu', - enabled: true, - runsToday: 58, - successRate: 99.1, - avgDurationMs: 2_310, - lastRunAt: minutesAgo(14), - flow: { - trigger: { - serviceId: '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 }, + { id: 'st_7', kind: 'action', serviceId: 'ppl', operationId: 'create-shipment' }, ], }, - steps: [ - { - id: 'st_65', - kind: 'action', + }); + + seed({ + name: 'Voicebot: příjem poptávek 24/7', + enabled: true, + runsToday: 137, + successRate: 96.1, + avgDurationMs: 74_000, + lastRunAt: minutesAgo(11), + flow: { + trigger: { + serviceId: 'voicebot', + operationId: 'call-received', + fields: [ + { id: 'f_11', name: 'callerNumber', type: 'string', required: true }, + { id: 'f_12', name: 'wantsOperator', type: 'boolean', required: false }, + ], + }, + steps: [ + { id: 'st_11', kind: 'action', serviceId: 'voicebot', operationId: 'play-scenario' }, + { id: 'st_12', kind: 'action', serviceId: 'transcription', operationId: 'transcribe' }, + { id: 'st_13', kind: 'action', serviceId: 'ai-text', operationId: 'classify' }, + { + id: 'st_14', + kind: 'condition', + fieldId: 'f_12', + operator: 'isTrue', + yes: [{ id: 'st_15', kind: 'action', serviceId: 'voicebot', operationId: 'transfer' }], + no: [ + { id: 'st_16', kind: 'action', serviceId: 'raynet', operationId: 'create-lead' }, + { id: 'st_17', kind: 'action', serviceId: 'email', operationId: 'send' }, + ], + }, + ], + }, + }); + + seed({ + name: 'Synchronizace CRM ↔ účetnictví', + enabled: true, + runsToday: 96, + successRate: 98.9, + avgDurationMs: 2_050, + lastRunAt: minutesAgo(26), + flow: { + trigger: { serviceId: 'raynet', - operationId: 'find-company', - inputs: { email: '{{from}}' }, + operationId: 'company-changed', + fields: [{ id: 'f_21', name: 'companyId', type: 'string', required: true }], }, - { - id: 'st_66', - kind: 'condition', - fieldId: 'st_65.raynet.customerKnown', - operator: 'isTrue', - yes: [ - { - id: 'st_67', - kind: 'action', - serviceId: 'ticket', - operationId: 'create', - inputs: { - subject: '{{subject}}', - body: '{{body}}', - company: '{{companyName}}', - contact: '{{from}}', - reply: '{{from}}', - priority: 'normal', - assigneeId: '', - }, - }, - ], - no: [ - { - id: 'st_68', - kind: 'action', - serviceId: 'ticket', - operationId: 'create', - inputs: { - subject: '{{subject}}', - body: '{{body}}', - contact: '{{from}}', - reply: '{{from}}', - priority: 'low', - assigneeId: '', - }, - }, - { - id: 'st_69', - kind: 'action', - serviceId: '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', - /* - * Ukazkova automatizace, ktera **meni data**, je vypnuta. - * - * Zapnuta by prebirala tickety, ktere uz nekomu patri podle skutecne - * automatizace zakaznika - ukazkova data nemaji sahat na zivy provoz. - * Kdo si ji chce vyzkouset, zapne si ji. - */ - enabled: false, - runsToday: 103, - successRate: 100, - avgDurationMs: 310, - lastRunAt: minutesAgo(4), - flow: { - trigger: { - serviceId: '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_21', kind: 'action', serviceId: 'transform', operationId: 'deduplicate' }, + { id: 'st_22', kind: 'action', serviceId: 'idoklad', operationId: 'create-invoice' }, + { id: 'st_23', kind: 'action', serviceId: 'log', operationId: 'write' }, ], }, - 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', - serviceId: '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', - serviceId: 'ticket', - operationId: 'assign', - inputs: { ticketId: '{{ticketId}}', assigneeId: 'ppl_novakova' }, - }, - ], - no: [ - { - id: 'st_76', - kind: 'action', - serviceId: 'ticket', - operationId: 'assign', - inputs: { ticketId: '{{ticketId}}', assigneeId: 'ppl_vomacka' }, - }, - ], - }, - ], - }, + }); + + seed({ + name: 'Noční report pro management', + enabled: false, + runsToday: 0, + successRate: 100, + avgDurationMs: 18_400, + lastRunAt: minutesAgo(1_020), + flow: { + trigger: { serviceId: 'scheduler', operationId: 'interval', fields: [] }, + steps: [ + { id: 'st_31', kind: 'action', serviceId: 'ga4', operationId: 'run-report' }, + { id: 'st_32', kind: 'action', serviceId: 'google-ads', operationId: 'campaign-report' }, + { id: 'st_33', kind: 'action', serviceId: 'sklik', operationId: 'campaign-report' }, + { id: 'st_34', kind: 'action', serviceId: 'ai-text', operationId: 'generate' }, + { id: 'st_35', kind: 'action', serviceId: 'email', operationId: 'send' }, + ], + }, + }); + + // Ukazka webhooku s deklarovanymi parametry a podminkou nad cislem. + seed({ + name: 'Webhook: hodnocení z dotazníku', + enabled: true, + runsToday: 61, + successRate: 100, + avgDurationMs: 640, + lastRunAt: minutesAgo(18), + flow: { + trigger: { + serviceId: 'webhook', + operationId: 'received', + webhookToken: generateWebhookToken(), + fields: [ + { id: 'f_41', name: 'customer', type: 'string', required: true }, + { id: 'f_42', name: 'score', type: 'number', required: true }, + { id: 'f_43', name: 'comment', type: 'string', required: false }, ], - no: [], }, - ], - }, -}); + steps: [ + { + id: 'st_41', + kind: 'condition', + fieldId: 'f_42', + operator: 'gte', + value: '15', + yes: [{ id: 'st_42', kind: 'action', serviceId: 'raynet', operationId: 'add-activity' }], + no: [ + { + id: 'st_43', + kind: 'action', + serviceId: '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', serviceId: 'microsoft365', operationId: 'post-teams' }, + ], + }, + ], + }, + }); + + /* + * 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: { + serviceId: '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', + serviceId: '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', + serviceId: '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', + serviceId: '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', serviceId: 'raynet', operationId: 'create-lead' }, + ], + }, + ], + }, + }); + + seed({ + name: 'Facebook: zpráva do ticketu', + enabled: true, + runsToday: 11, + successRate: 100, + avgDurationMs: 720, + lastRunAt: minutesAgo(52), + flow: { + trigger: { + serviceId: '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', + serviceId: '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: { + serviceId: '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', + serviceId: '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', + serviceId: 'ticket', + operationId: 'create', + inputs: { + subject: '{{subject}}', + body: '{{body}}', + company: '{{companyName}}', + contact: '{{from}}', + reply: '{{from}}', + priority: 'normal', + assigneeId: '', + }, + }, + ], + no: [ + { + id: 'st_68', + kind: 'action', + serviceId: 'ticket', + operationId: 'create', + inputs: { + subject: '{{subject}}', + body: '{{body}}', + contact: '{{from}}', + reply: '{{from}}', + priority: 'low', + assigneeId: '', + }, + }, + { + id: 'st_69', + kind: 'action', + serviceId: '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', + /* + * Ukazkova automatizace, ktera **meni data**, je vypnuta. + * + * Zapnuta by prebirala tickety, ktere uz nekomu patri podle skutecne + * automatizace zakaznika - ukazkova data nemaji sahat na zivy provoz. + * Kdo si ji chce vyzkouset, zapne si ji. + */ + enabled: false, + runsToday: 103, + successRate: 100, + avgDurationMs: 310, + lastRunAt: minutesAgo(4), + flow: { + trigger: { + serviceId: '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', + serviceId: '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', + serviceId: 'ticket', + operationId: 'assign', + inputs: { ticketId: '{{ticketId}}', assigneeId: 'ppl_novakova' }, + }, + ], + no: [ + { + id: 'st_76', + kind: 'action', + serviceId: 'ticket', + operationId: 'assign', + inputs: { ticketId: '{{ticketId}}', assigneeId: 'ppl_vomacka' }, + }, + ], + }, + ], + }, + ], + no: [], + }, + ], + }, + }); +} + +/** + * Automatizace, ktere na instanci opravdu bezi. + * + * Dokud data neprezivaji redeploy, je tohle jedine misto, kde nastaveni + * prezije nasazeni. Az bude Postgres, patri to zpatky do dat a odsud pryc. + * + * Token webhooku se bere z `WEBHOOK_TOKEN_TEST`, aby se adresa po nasazeni + * nemenila a odesilatel ji nemusel prepisovat. + */ +function seedRealAutomations(): void { + seed({ + name: 'TEST', + enabled: true, + runsToday: 0, + successRate: 100, + avgDurationMs: 0, + lastRunAt: minutesAgo(0), + flow: { + trigger: { + serviceId: 'webhook', + operationId: 'received', + fields: [ + { id: 'f_callsid', name: 'callSid', type: 'string', required: true }, + { id: 'f_status', name: 'status', type: 'string', required: true }, + { id: 'f_voicebot', name: 'voicebotId', type: 'string', required: true }, + ], + webhookToken: config.seedWebhookToken || generateWebhookToken(), + }, + steps: [ + { + id: 'st_upsert', + kind: 'action', + serviceId: 'ticket', + operationId: 'upsert', + inputs: { + externalId: '{{callSid}}', + body: '{{voicebotId}}', + status: '{{status}}', + tags: '{{voicebotId}}', + priority: 'low', + }, + }, + ], + }, + }); +} + +seedRealAutomations(); + +if (config.seedDemo) { + seedDemoAutomations(); +} else { + console.info('[automatizace] ukazkova data vypnuta (SEED_DEMO neni 1)'); +} + +if (!config.seedWebhookToken) { + console.warn( + '[automatizace] WEBHOOK_TOKEN_TEST neni nastaveny, adresa webhooku se pri kazdem ' + + 'nasazeni zmeni. Nastavte ji jako promennou aplikace.', + ); +} function toSummary(stored: StoredAutomation): Automation { const { flow: _flow, createdAt: _createdAt, updatedAt: _updatedAt, ...rest } = stored; diff --git a/src/data/customWidgets.ts b/src/data/customWidgets.ts index 491d3f9..6d6e9bf 100644 --- a/src/data/customWidgets.ts +++ b/src/data/customWidgets.ts @@ -22,7 +22,16 @@ import type { TicketChannel, TicketStatus } from './ticketStore.js'; export type WidgetRender = 'stat' | 'chart' | 'list' | 'table' | 'gauge'; /** Podle ceho se da seskupovat. Bez toho by byl widget na kazdeho cloveka. */ -export type WidgetGroupBy = 'assignee' | 'group' | 'status' | 'type' | 'tag' | 'channel'; +export type WidgetGroupBy = + | 'assignee' + | 'group' + /** Zivotni cyklus: novy, v reseni, ceka, vyreseno. */ + | 'status' + /** Faze z workflow typu, napr. stavy hovoru od voicebota. */ + | 'stage' + | 'type' + | 'tag' + | 'channel'; export type WidgetPeriod = 'today' | '7d' | '30d' | 'month' | 'all'; @@ -98,6 +107,32 @@ export function seedCustomWidgets(): CustomWidget[] { const base = { tenantId: 'tnt_automia', ownerId: null, createdAt: timestamp, updatedAt: timestamp }; return [ + { + ...base, + id: 'cw_status_month', + name: 'Tickety podle stavu', + description: 'Kolik ticketů je v kterém stavu za tento měsíc.', + render: 'table', + source: { + kind: 'ticketCount', + filter: { period: 'month' }, + groupBy: 'status', + }, + size: 'half', + }, + { + ...base, + id: 'cw_stage_month', + name: 'Tickety podle fáze', + description: 'Fáze z workflow typu, například stavy hovorů. Za tento měsíc.', + render: 'table', + source: { + kind: 'ticketCount', + filter: { period: 'month' }, + groupBy: 'stage', + }, + size: 'half', + }, { ...base, id: 'cw_idoklad_invoices', diff --git a/src/data/incidentStore.ts b/src/data/incidentStore.ts index 8610acd..f5a43c9 100644 --- a/src/data/incidentStore.ts +++ b/src/data/incidentStore.ts @@ -7,6 +7,7 @@ * kdo ji pouzivaji. Proto `tenantId: null`. */ +import { config } from '../config.js'; import { publish } from '../events/bus.js'; import { defineStore, nowIso, type TenantEntity } from './store/index.js'; import { withMirror } from './store/mirror.js'; @@ -51,7 +52,11 @@ function minutesAgo(minutes: number): string { return new Date(Date.now() - minutes * 60_000).toISOString(); } -const incidents: Incident[] = [ +/** + * Ukazkove incidenty. Se `SEED_DEMO=1` se pouziji, jinak se zacina naprazdno - + * incident je hlaseni o vypadku a vymysleny vypadek nikoho nezajima. + */ +const demoIncidents: Incident[] = [ { id: 'INC-231', tenantId: null, @@ -96,7 +101,9 @@ const incidents: Incident[] = [ }, ]; -let counter = 231; +const incidents: Incident[] = config.seedDemo ? [...demoIncidents] : []; + +let counter = config.seedDemo ? 231 : 0; /** Tvar v ulozisti. Cas vzniku je `startedAt`, `createdAt` je kvuli rozhrani. */ interface StoredIncident extends Incident, TenantEntity {} diff --git a/src/data/services.ts b/src/data/services.ts index 7c61a29..b9f065e 100644 --- a/src/data/services.ts +++ b/src/data/services.ts @@ -532,6 +532,67 @@ export const services: Service[] = [ required: false, hint: 'Musí být z workflow daného typu. Například stav hovoru od voicebota.', }, + { + id: 'fields', + label: 'Vlastní pole typu', + kind: 'json', + required: false, + hint: 'JSON s klíči podle typu ticketu, například {"orderNumber":"{{orderId}}"}.', + }, + { + id: 'company', + label: 'Zákazník: firma', + kind: 'text', + required: false, + hint: 'Vyplní se jen při založení, další událost to nepřepíše.', + }, + { id: 'contact', label: 'Zákazník: kontakt', kind: 'text', required: false }, + { + id: 'reply', + label: 'Zákazník: kam odpovídat', + kind: 'text', + required: false, + hint: 'E-mail nebo číslo, odkud to přišlo.', + }, + { + id: 'channel', + label: 'Kanál', + kind: 'choice', + required: false, + options: [ + { 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ář' }, + { value: 'portal', label: 'Portál' }, + ], + }, + { + id: 'sourceRef', + label: 'Odkaz na zdroj', + kind: 'text', + required: false, + hint: 'ID zprávy u odesílatele, ať je dohledatelná.', + }, + { + id: 'assigneeId', + label: 'Řešitel', + kind: 'choice', + required: false, + options: [], + optionsFrom: 'people', + hint: 'Když víte rovnou, komu to patří. Jinak použijte samostatný krok.', + }, + { + id: 'groupId', + label: 'Skupina', + kind: 'choice', + required: false, + options: [], + optionsFrom: 'groups', + }, { id: 'event', label: 'Typ události', kind: 'text', required: false }, { id: 'label', label: 'Popisek do časové osy', kind: 'text', required: false }, ], diff --git a/src/data/ticketStore.ts b/src/data/ticketStore.ts index 1c73786..70fea80 100644 --- a/src/data/ticketStore.ts +++ b/src/data/ticketStore.ts @@ -16,6 +16,7 @@ * `data/store/index.ts`, tady se to neresi. */ +import { config } from '../config.js'; import { publish } from '../events/bus.js'; import { onTicketChanged } from './ticketHooks.js'; import { notify } from './notifications.js'; @@ -390,296 +391,307 @@ function seed(ticket: StoredTicket, trace: TraceInput[]) { traces.set(ticket.id, flattenTrace(trace, null, [])); } -seed( - { - id: 'TK-4821', - tenantId: 'tnt_automia', - 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', - company: 'Firma s.r.o.', - contact: 'Petra Klientová', - reply: '+420 601 118 224', - }, - status: 'open', - priority: 'high', - assigneeId: 'ppl_novakova', - automationId: 'AUT-02', - createdAt: minutesAgo(310), - updatedAt: minutesAgo(42), - }, - [ +/** + * Ukazkove tickety. + * + * Nasypou se **jen se `SEED_DEMO=1`**. Na instanci, kde uz chodi skutecny + * provoz, jsou to cizi zaznamy mezi opravdovymi a po kazdem redeployi + * se vraceji. + */ +function seedDemoTickets(): void { + seed( { - kind: 'trigger', - serviceId: 'voicebot', - operationId: 'call-received', - label: 'Příchozí hovor na linku 800 100 200', - status: 'ok', - response: '{ "callId": "cl_88213", "from": "+420601118224", "durationSec": 96 }', - durationMs: 120, - agoMinutes: 310, + id: 'TK-4821', + tenantId: 'tnt_automia', + 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', + company: 'Firma s.r.o.', + contact: 'Petra Klientová', + reply: '+420 601 118 224', + }, + status: 'open', + priority: 'high', + assigneeId: 'ppl_novakova', + automationId: 'AUT-02', + createdAt: minutesAgo(310), + updatedAt: minutesAgo(42), }, - { - kind: 'action', - serviceId: 'transcription', - operationId: 'transcribe', - label: 'Přepis nahrávky', - status: 'ok', - response: - '{ "language": "cs", "confidence": 0.94, "text": "Dobrý den, po šesté hodině to nikdo nebere..." }', - durationMs: 4_180, - agoMinutes: 309, - }, - { - kind: 'action', - serviceId: 'ai-text', - operationId: 'classify', - label: 'Zařazení do kategorie', - status: 'ok', - response: '{ "category": "porucha", "priority": "high", "confidence": 0.88 }', - durationMs: 910, - agoMinutes: 309, - }, - { - kind: 'action', - serviceId: 'raynet', - operationId: 'upsert-contact', - label: 'Dohledání firmy podle telefonu', - status: 'ok', - response: '{ "companyId": "crm_1042", "name": "Firma s.r.o.", "matchedBy": "phone" }', - durationMs: 640, - agoMinutes: 309, - }, - { - kind: 'condition', - label: 'knownCustomer je splněno', - status: 'ok', - response: 'true, pokračuje větev ANO', - agoMinutes: 309, - children: [ - { - kind: 'action', - serviceId: 'ticket', - operationId: 'create', - label: 'Založení ticketu', - status: 'ok', - response: '{ "ticketId": "TK-4821", "priority": "high" }', - durationMs: 85, - agoMinutes: 309, - }, - { - kind: 'action', - serviceId: 'ticket', - operationId: 'assign', - label: 'Přiřazení řešitele podle služby', - status: 'ok', - response: '{ "assignee": "Eva Nováková", "rule": "voicebot -> voiceboti" }', - durationMs: 40, - agoMinutes: 309, - }, - ], - }, - { - kind: 'action', - serviceId: 'microsoft365', - operationId: 'post-teams', - label: 'Upozornění do Teams', - status: 'error', - response: 'HTTP 429 Too Many Requests, kanál "Servicedesk" překročil limit, zpráva neodešla', - durationMs: 2_400, - agoMinutes: 308, - }, - { - kind: 'note', - label: 'Eva Nováková: Reprodukováno, chyba je v nočním režimu scénáře.', - status: 'info', - agoMinutes: 42, - }, - ], -); + [ + { + kind: 'trigger', + serviceId: 'voicebot', + operationId: 'call-received', + label: 'Příchozí hovor na linku 800 100 200', + status: 'ok', + response: '{ "callId": "cl_88213", "from": "+420601118224", "durationSec": 96 }', + durationMs: 120, + agoMinutes: 310, + }, + { + kind: 'action', + serviceId: 'transcription', + operationId: 'transcribe', + label: 'Přepis nahrávky', + status: 'ok', + response: + '{ "language": "cs", "confidence": 0.94, "text": "Dobrý den, po šesté hodině to nikdo nebere..." }', + durationMs: 4_180, + agoMinutes: 309, + }, + { + kind: 'action', + serviceId: 'ai-text', + operationId: 'classify', + label: 'Zařazení do kategorie', + status: 'ok', + response: '{ "category": "porucha", "priority": "high", "confidence": 0.88 }', + durationMs: 910, + agoMinutes: 309, + }, + { + kind: 'action', + serviceId: 'raynet', + operationId: 'upsert-contact', + label: 'Dohledání firmy podle telefonu', + status: 'ok', + response: '{ "companyId": "crm_1042", "name": "Firma s.r.o.", "matchedBy": "phone" }', + durationMs: 640, + agoMinutes: 309, + }, + { + kind: 'condition', + label: 'knownCustomer je splněno', + status: 'ok', + response: 'true, pokračuje větev ANO', + agoMinutes: 309, + children: [ + { + kind: 'action', + serviceId: 'ticket', + operationId: 'create', + label: 'Založení ticketu', + status: 'ok', + response: '{ "ticketId": "TK-4821", "priority": "high" }', + durationMs: 85, + agoMinutes: 309, + }, + { + kind: 'action', + serviceId: 'ticket', + operationId: 'assign', + label: 'Přiřazení řešitele podle služby', + status: 'ok', + response: '{ "assignee": "Eva Nováková", "rule": "voicebot -> voiceboti" }', + durationMs: 40, + agoMinutes: 309, + }, + ], + }, + { + kind: 'action', + serviceId: 'microsoft365', + operationId: 'post-teams', + label: 'Upozornění do Teams', + status: 'error', + response: 'HTTP 429 Too Many Requests, kanál "Servicedesk" překročil limit, zpráva neodešla', + durationMs: 2_400, + agoMinutes: 308, + }, + { + kind: 'note', + label: 'Eva Nováková: Reprodukováno, chyba je v nočním režimu scénáře.', + status: 'info', + agoMinutes: 42, + }, + ], + ); -seed( - { - id: 'TK-4820', - tenantId: 'tnt_automia', - 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', - company: 'Nordis a.s.', - contact: 'Tomáš Beran', - reply: 'tomas.beran@nordis.cz', - }, - status: 'waiting', - priority: 'normal', - assigneeId: 'ppl_kriz', - automationId: 'AUT-03', - createdAt: minutesAgo(1_180), - updatedAt: minutesAgo(190), - }, - [ + seed( { - kind: 'trigger', - serviceId: 'email', - operationId: 'received', - label: 'Přijat e-mail do schránky podpora@', - status: 'ok', - response: '{ "from": "tomas.beran@nordis.cz", "subject": "IČO v synchronizaci", "attachments": 0 }', - durationMs: 60, - agoMinutes: 1_180, + id: 'TK-4820', + tenantId: 'tnt_automia', + 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', + company: 'Nordis a.s.', + contact: 'Tomáš Beran', + reply: 'tomas.beran@nordis.cz', + }, + status: 'waiting', + priority: 'normal', + assigneeId: 'ppl_kriz', + automationId: 'AUT-03', + createdAt: minutesAgo(1_180), + updatedAt: minutesAgo(190), }, - { - kind: 'action', - serviceId: 'ai-text', - operationId: 'extract', - label: 'Vytažení údajů z textu', - status: 'ok', - response: '{ "type": "pozadavek na zmenu", "system": "CRM + iDoklad", "urgent": false }', - durationMs: 1_120, - agoMinutes: 1_180, - }, - { - kind: 'action', - serviceId: 'raynet', - operationId: 'upsert-contact', - label: 'Dohledání firmy podle e-mailu', - status: 'ok', - response: '{ "companyId": "crm_2210", "name": "Nordis a.s.", "matchedBy": "emailDomain" }', - durationMs: 520, - agoMinutes: 1_180, - }, - { - kind: 'action', - serviceId: 'ticket', - operationId: 'create', - label: 'Založení ticketu', - status: 'ok', - response: '{ "ticketId": "TK-4820", "priority": "normal" }', - durationMs: 74, - agoMinutes: 1_179, - }, - { - kind: 'action', - serviceId: 'email', - operationId: 'send', - label: 'Potvrzení zadavateli', - status: 'ok', - response: '{ "messageId": "", "to": "tomas.beran@nordis.cz" }', - durationMs: 380, - agoMinutes: 1_179, - }, - { - kind: 'note', - label: 'Martin Kříž: Čekáme na potvrzení rozsahu od zákazníka.', - status: 'info', - agoMinutes: 190, - }, - ], -); + [ + { + kind: 'trigger', + serviceId: 'email', + operationId: 'received', + label: 'Přijat e-mail do schránky podpora@', + status: 'ok', + response: '{ "from": "tomas.beran@nordis.cz", "subject": "IČO v synchronizaci", "attachments": 0 }', + durationMs: 60, + agoMinutes: 1_180, + }, + { + kind: 'action', + serviceId: 'ai-text', + operationId: 'extract', + label: 'Vytažení údajů z textu', + status: 'ok', + response: '{ "type": "pozadavek na zmenu", "system": "CRM + iDoklad", "urgent": false }', + durationMs: 1_120, + agoMinutes: 1_180, + }, + { + kind: 'action', + serviceId: 'raynet', + operationId: 'upsert-contact', + label: 'Dohledání firmy podle e-mailu', + status: 'ok', + response: '{ "companyId": "crm_2210", "name": "Nordis a.s.", "matchedBy": "emailDomain" }', + durationMs: 520, + agoMinutes: 1_180, + }, + { + kind: 'action', + serviceId: 'ticket', + operationId: 'create', + label: 'Založení ticketu', + status: 'ok', + response: '{ "ticketId": "TK-4820", "priority": "normal" }', + durationMs: 74, + agoMinutes: 1_179, + }, + { + kind: 'action', + serviceId: 'email', + operationId: 'send', + label: 'Potvrzení zadavateli', + status: 'ok', + response: '{ "messageId": "", "to": "tomas.beran@nordis.cz" }', + durationMs: 380, + agoMinutes: 1_179, + }, + { + kind: 'note', + label: 'Martin Kříž: Čekáme na potvrzení rozsahu od zákazníka.', + status: 'info', + agoMinutes: 190, + }, + ], + ); -seed( - { - id: 'TK-4819', - tenantId: 'tnt_automia', - 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, - company: 'Neznámá firma', - contact: 'Bistro Kolektiv', - reply: '+420 774 902 331', - }, - status: 'new', - priority: 'normal', - assigneeId: null, - automationId: 'AUT-01', - createdAt: minutesAgo(95), - updatedAt: minutesAgo(95), - }, - [ + seed( { - kind: 'trigger', - serviceId: 'whatsapp', - operationId: 'message-received', - label: 'Přijata zpráva z WhatsApp', - status: 'ok', - response: - '{ "from": "+420774902331", "profileName": "Bistro Kolektiv", "text": "Dobrý den, už třetí den nechodí ranní report." }', - durationMs: 55, - agoMinutes: 95, + id: 'TK-4819', + tenantId: 'tnt_automia', + 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, + company: 'Neznámá firma', + contact: 'Bistro Kolektiv', + reply: '+420 774 902 331', + }, + status: 'new', + priority: 'normal', + assigneeId: null, + automationId: 'AUT-01', + createdAt: minutesAgo(95), + updatedAt: minutesAgo(95), }, - { - kind: 'action', - serviceId: 'ai-text', - operationId: 'classify', - label: 'Zařazení do kategorie', - status: 'ok', - response: '{ "category": "vypadek reportu", "priority": "normal", "confidence": 0.79 }', - durationMs: 870, - agoMinutes: 95, - }, - { - kind: 'action', - serviceId: 'raynet', - operationId: 'upsert-contact', - label: 'Dohledání firmy podle telefonu', - status: 'error', - response: '{ "matches": 0, "searchedBy": "phone", "value": "+420774902331" }', - durationMs: 610, - agoMinutes: 95, - }, - { - kind: 'condition', - label: 'knownCustomer není splněno', - status: 'skipped', - response: 'false, pokračuje větev NE', - agoMinutes: 95, - children: [ - { - kind: 'action', - serviceId: 'ticket', - operationId: 'create', - label: 'Založení ticketu bez napojení na firmu', - status: 'ok', - response: '{ "ticketId": "TK-4819", "customerId": null }', - durationMs: 68, - agoMinutes: 95, - }, - { - kind: 'action', - serviceId: 'raynet', - operationId: 'create-lead', - label: 'Založení obchodního případu k dohledání', - status: 'ok', - response: '{ "leadId": "lead_7781", "stage": "k overeni" }', - durationMs: 940, - agoMinutes: 95, - }, - { - kind: 'action', - serviceId: 'ticket', - operationId: 'assign', - label: 'Přiřazení řešitele', - status: 'skipped', - response: 'Přeskočeno, bez známé firmy nelze určit garanta a ticket zůstal nepřiřazený', - agoMinutes: 95, - }, - ], - }, - ], -); + [ + { + kind: 'trigger', + serviceId: 'whatsapp', + operationId: 'message-received', + label: 'Přijata zpráva z WhatsApp', + status: 'ok', + response: + '{ "from": "+420774902331", "profileName": "Bistro Kolektiv", "text": "Dobrý den, už třetí den nechodí ranní report." }', + durationMs: 55, + agoMinutes: 95, + }, + { + kind: 'action', + serviceId: 'ai-text', + operationId: 'classify', + label: 'Zařazení do kategorie', + status: 'ok', + response: '{ "category": "vypadek reportu", "priority": "normal", "confidence": 0.79 }', + durationMs: 870, + agoMinutes: 95, + }, + { + kind: 'action', + serviceId: 'raynet', + operationId: 'upsert-contact', + label: 'Dohledání firmy podle telefonu', + status: 'error', + response: '{ "matches": 0, "searchedBy": "phone", "value": "+420774902331" }', + durationMs: 610, + agoMinutes: 95, + }, + { + kind: 'condition', + label: 'knownCustomer není splněno', + status: 'skipped', + response: 'false, pokračuje větev NE', + agoMinutes: 95, + children: [ + { + kind: 'action', + serviceId: 'ticket', + operationId: 'create', + label: 'Založení ticketu bez napojení na firmu', + status: 'ok', + response: '{ "ticketId": "TK-4819", "customerId": null }', + durationMs: 68, + agoMinutes: 95, + }, + { + kind: 'action', + serviceId: 'raynet', + operationId: 'create-lead', + label: 'Založení obchodního případu k dohledání', + status: 'ok', + response: '{ "leadId": "lead_7781", "stage": "k overeni" }', + durationMs: 940, + agoMinutes: 95, + }, + { + kind: 'action', + serviceId: 'ticket', + operationId: 'assign', + label: 'Přiřazení řešitele', + status: 'skipped', + response: 'Přeskočeno, bez známé firmy nelze určit garanta a ticket zůstal nepřiřazený', + agoMinutes: 95, + }, + ], + }, + ], + ); +} + +if (config.seedDemo) seedDemoTickets(); seed( { diff --git a/src/routes/dashboard.ts b/src/routes/dashboard.ts index 1e86f22..424df8b 100644 --- a/src/routes/dashboard.ts +++ b/src/routes/dashboard.ts @@ -475,6 +475,8 @@ function ticketFilterFrom(query: Record, scope: ResolvedScope): if (typeof typeId === 'string' && typeId !== '') filter.typeId = typeId; const tag = query.tag; if (typeof tag === 'string' && tag !== '') filter.tag = tag; + const stage = query.stage; + if (typeof stage === 'string' && stage !== '') filter.stage = stage; const groupId = query.groupId; if (typeof groupId === 'string' && groupId !== '') filter.groupId = groupId; diff --git a/src/routes/settings.ts b/src/routes/settings.ts index 402b98d..b12c26f 100644 --- a/src/routes/settings.ts +++ b/src/routes/settings.ts @@ -663,7 +663,9 @@ const sourceSchema = z.discriminatedUnion('kind', [ z.object({ kind: z.literal('ticketCount'), filter: z.record(z.unknown()).default({}), - groupBy: z.enum(['assignee', 'group', 'status', 'type', 'tag', 'channel']).optional(), + groupBy: z + .enum(['assignee', 'group', 'status', 'stage', 'type', 'tag', 'channel']) + .optional(), }), z.object({ kind: z.literal('ticketList'), diff --git a/src/routes/widgetData.ts b/src/routes/widgetData.ts index dfbbc74..5fed3e0 100644 --- a/src/routes/widgetData.ts +++ b/src/routes/widgetData.ts @@ -179,6 +179,8 @@ function groupLabel(key: string, groupBy: WidgetGroupBy): string { return key === '' ? 'Bez typu' : (findTicketType(key)?.name ?? key); case 'status': return statusLabels[key] ?? key; + case 'stage': + return key === '' ? 'Bez fáze' : key; case 'channel': return channelLabels[key as keyof typeof channelLabels] ?? key; case 'tag': @@ -198,6 +200,8 @@ function groupKeys(ticket: Ticket, groupBy: string): string[] { return [ticket.typeId ?? '']; case 'status': return [ticket.status]; + case 'stage': + return [ticket.stage ?? '']; case 'channel': return [ticket.channel]; case 'tag': @@ -377,6 +381,7 @@ function ticketsHref( assignee: 'assignee', group: 'groupId', status: 'status', + stage: 'stage', type: 'typeId', tag: 'tag', channel: 'channel', diff --git a/src/runtime/builtinSteps.ts b/src/runtime/builtinSteps.ts index 7934657..0e6db28 100644 --- a/src/runtime/builtinSteps.ts +++ b/src/runtime/builtinSteps.ts @@ -24,6 +24,7 @@ import { setTicketType, updateTicketStatus, type Ticket, + type TicketChannel, type TicketPriority, type TicketStatus, } from '../data/ticketStore.js'; @@ -55,6 +56,33 @@ function missing(what: string): StepOutcome { const statuses: TicketStatus[] = ['new', 'open', 'waiting', 'resolved']; const priorities: TicketPriority[] = ['low', 'normal', 'high', 'critical']; +const channels: TicketChannel[] = [ + 'whatsapp', + 'facebook', + 'instagram', + 'email', + 'voice', + 'form', + 'portal', +]; + +/** + * Z JSONu nechá jen hodnoty, ktere se do vlastniho pole vejdou. + * + * Vnoreny objekt nebo pole by v poli typu `string` skoncilo jako + * `[object Object]`, coz je horsi nez ho zahodit a rict to v logu. + */ +function scalarFields(value: Record): Record { + const result: Record = {}; + for (const [key, item] of Object.entries(value)) { + if (item === null || ['string', 'number', 'boolean'].includes(typeof item)) { + result[key] = item as string | number | boolean | null; + } else { + console.warn(`[kroky] pole ${key} neni jednoducha hodnota, preskakuji`); + } + } + return result; +} /** * Registr kroku. Klic je `serviceId/operationId` z katalogu. @@ -71,6 +99,20 @@ const handlers: Record = { const subject = inputs.subject?.trim(); if (!subject && !inputs.externalId) return missing('předmět nebo externí ID'); + /* + * Vlastni pole typu ticketu. Zapisuji se jako JSON, protoze jejich klice + * urcuje typ a nejde je mit jako pevny seznam vstupu kroku. Sablony uz + * jsou dosazene, takze `{"orderNumber":"{{orderId}}"}` funguje. + * + * Doplni se i na uz existujici ticket - druha zprava o teze objednavce + * casto nese vic, nez nesla prvni. + */ + const fields = inputs.fields ? scalarFields(safeJson(inputs.fields)) : undefined; + + const channel = channels.includes(inputs.channel as TicketChannel) + ? (inputs.channel as TicketChannel) + : undefined; + const result = intakeEvent({ tenantId: context.tenantId, externalId: inputs.externalId?.trim() || null, @@ -78,6 +120,7 @@ const handlers: Record = { type: inputs.event?.trim() || 'automation', label: inputs.label?.trim() || subject || 'Událost', payload: inputs.payload ? safeJson(inputs.payload) : {}, + fields, create: { subject: subject || inputs.externalId || 'Bez předmětu', body: inputs.body ?? '', @@ -85,6 +128,21 @@ const handlers: Record = { priority: priorities.includes(inputs.priority as TicketPriority) ? (inputs.priority as TicketPriority) : 'normal', + /* + * Zakaznik a kanal se vyplnuji **jen pri zalozeni**. Prepisovat je + * pri kazde dalsi udalosti by znamenalo, ze pozdejsi zprava s prazdnym + * jmenem smaze to, co uz na ticketu bylo. + */ + ...(channel ? { channel } : {}), + ...(inputs.sourceRef?.trim() ? { sourceRef: inputs.sourceRef.trim() } : {}), + customer: { + id: null, + company: inputs.company?.trim() ?? '', + contact: inputs.contact?.trim() ?? '', + reply: inputs.reply?.trim() ?? '', + }, + ...(inputs.assigneeId?.trim() ? { assigneeId: inputs.assigneeId.trim() } : {}), + ...(inputs.groupId?.trim() ? { assigneeGroupId: inputs.groupId.trim() } : {}), }, addTags: inputs.tags ? inputs.tags.split(',').map((tag) => tag.trim()).filter(Boolean) diff --git a/web/src/components/dashboard/StatusBadge.tsx b/web/src/components/dashboard/StatusBadge.tsx index 45c92ea..5490a19 100644 --- a/web/src/components/dashboard/StatusBadge.tsx +++ b/web/src/components/dashboard/StatusBadge.tsx @@ -66,9 +66,39 @@ const incidentStatusMap: Record + + + {stage} + + + {label} + + + ); + } + return ( diff --git a/web/src/components/dashboard/TicketTable.tsx b/web/src/components/dashboard/TicketTable.tsx index e5ad57d..f226541 100644 --- a/web/src/components/dashboard/TicketTable.tsx +++ b/web/src/components/dashboard/TicketTable.tsx @@ -101,7 +101,7 @@ export function TicketTable({ )} - + @@ -143,7 +143,7 @@ export function TicketTable({
{ticket.id} - +

{ticket.subject}

diff --git a/web/src/components/dashboard/flow/FlowCanvas.tsx b/web/src/components/dashboard/flow/FlowCanvas.tsx index ffd3467..97a807f 100644 --- a/web/src/components/dashboard/flow/FlowCanvas.tsx +++ b/web/src/components/dashboard/flow/FlowCanvas.tsx @@ -7,6 +7,7 @@ import { Trash2, Zap, } from 'lucide-react'; +import { useState } from 'react'; import { StepInputs } from '@/components/dashboard/flow/StepInputs'; import { TriggerConfig } from '@/components/dashboard/flow/TriggerConfig'; import { Badge } from '@/components/ui/Badge'; @@ -361,6 +362,7 @@ function ActionCard({ onMove: (offset: number) => void; onUpdateInputs: (inputs: Record) => void; }) { + const [open, setOpen] = useState(false); const resolved = resolveOperation(connectors, step.serviceId, step.operationId, 'action'); if (!resolved) { @@ -376,20 +378,76 @@ function ActionCard({ const Icon = serviceIcon(resolved.connector.icon); const inputs = resolved.operation.inputs; + /* + * Sbalene kroky. + * + * Strom o peti krocich s vyplnenymi poli je pres celou obrazovku a nejde + * v nem najit, co kde je. Sbaleny krok ukazuje jen to podstatne: co dela + * a co ma vyplneno. Vychozi je **sbaleno**, protoze vetsinou clovek strom + * cte, ne prenastavuje. + */ + const filled = Object.entries(step.inputs ?? {}).filter(([, value]) => value.trim() !== ''); + return (
- + + + +
- {inputs && inputs.length > 0 ? ( - - ) : ( - // Akce, ktere zatim nemaji `inputs`. Radeji to rekneme nahlas, - // nez aby uzivatel hledal nastaveni, ktere neexistuje. -

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

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

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

+ ))}
); } diff --git a/web/src/pages/dashboard/PersonDetail.tsx b/web/src/pages/dashboard/PersonDetail.tsx index c2c5cbd..08672fc 100644 --- a/web/src/pages/dashboard/PersonDetail.tsx +++ b/web/src/pages/dashboard/PersonDetail.tsx @@ -202,7 +202,7 @@ function TicketCards({ >
{ticket.id} - +

{ticket.subject} diff --git a/web/src/pages/dashboard/TicketDetail.tsx b/web/src/pages/dashboard/TicketDetail.tsx index 82e05fc..169fd5a 100644 --- a/web/src/pages/dashboard/TicketDetail.tsx +++ b/web/src/pages/dashboard/TicketDetail.tsx @@ -117,7 +117,7 @@ export default function TicketDetail() {

{ticket.data.id} - + {ticket.data.externalId && ( diff --git a/web/src/pages/dashboard/Tickets.tsx b/web/src/pages/dashboard/Tickets.tsx index 52b0de5..7b2e1b6 100644 --- a/web/src/pages/dashboard/Tickets.tsx +++ b/web/src/pages/dashboard/Tickets.tsx @@ -63,6 +63,7 @@ export default function Tickets() { channel: params.get('channel') as TicketChannel | null, typeId: params.get('typeId'), tag: params.get('tag'), + stage: params.get('stage'), groupId: params.get('groupId'), }).current; @@ -75,6 +76,7 @@ export default function Tickets() { /** Filtry, ktere se daji nastavit jen odkazem. V UI k nim tlacitko neni. */ const [typeId, setTypeId] = useState(initial.typeId); const [tag, setTag] = useState(initial.tag); + const [stage, setStage] = useState(initial.stage); const [groupId, setGroupId] = useState(initial.groupId); const [query, setQuery] = useState(''); /** Tabulka na porovnavani, dlazdice na rychly prehled. */ @@ -90,6 +92,7 @@ export default function Tickets() { if (assignee) params.set('assignee', assignee); if (typeId) params.set('typeId', typeId); if (tag) params.set('tag', tag); + if (stage) params.set('stage', stage); if (groupId) params.set('groupId', groupId); if (status) params.set('status', status); if (channel) params.set('channel', channel); @@ -239,6 +242,11 @@ export default function Tickets() { tag: {tag === 'none' ? 'bez tagu' : tag} x )} + {stage && ( + setStage(null)} title="Zrušit filtr na fázi"> + fáze: {stage === 'none' ? 'bez fáze' : stage} x + + )} {groupId && ( setGroupId(null)} title="Zrušit filtr na skupinu"> skupina: {groupId === 'none' ? 'bez skupiny' : groupId} x @@ -293,7 +301,7 @@ export default function Tickets() { >
{ticket.id} - +