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 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) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-13 17:21:32 +02:00
co-authored by Claude Opus 5
parent 6abac82d5e
commit ef3080d3f6
17 changed files with 1170 additions and 757 deletions
+42
View File
@@ -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
+20
View File
@@ -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).
*
+510 -438
View File
@@ -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;
+36 -1
View File
@@ -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',
+9 -2
View File
@@ -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 {}
+61
View File
@@ -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 },
],
+294 -282
View File
@@ -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": "<a41c@automia.cz>", "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": "<a41c@automia.cz>", "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(
{
+2
View File
@@ -475,6 +475,8 @@ function ticketFilterFrom(query: Record<string, unknown>, 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;
+3 -1
View File
@@ -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'),
+5
View File
@@ -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',
+58
View File
@@ -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<string, unknown>): Record<string, string | number | boolean | null> {
const result: Record<string, string | number | boolean | null> = {};
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<string, Handler> = {
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<string, Handler> = {
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<string, Handler> = {
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)
+31 -1
View File
@@ -66,9 +66,39 @@ const incidentStatusMap: Record<IncidentStatus, { label: string; tone: BadgeTone
resolved: { label: 'Vyřešeno', tone: 'ok' },
};
export function TicketStatusBadge({ status }: { status: TicketStatus }) {
/**
* Stav ticketu.
*
* Kdyz ma ticket **fazi** z workflow sveho typu, ukaze se **ona**, protoze to
* je hodnota, kterou tam dala automatizace nebo clovek a kterou lidi ve firme
* pouzivaji. Nas zivotni cyklus (novy, v reseni, ceka, vyreseno) zustava
* vedle jako mensi znacka - pocitaji se z nej statistiky a fronta, takze
* schovat ho uplne by znamenalo, ze nikdo nepozna, proc je ticket ve fronte.
*/
export function TicketStatusBadge({
status,
stage,
}: {
status: TicketStatus;
stage?: string | null;
}) {
const { label, tone } = ticketStatusMap[status];
const Icon = status === 'resolved' ? CheckCircle2 : status === 'waiting' ? Clock : CircleDot;
if (stage) {
return (
<span className="inline-flex flex-wrap items-center gap-1.5">
<Badge tone={tone}>
<Icon className="size-3.5" />
{stage}
</Badge>
<span className="text-xs text-white/30" title="Stav v životním cyklu, podle něj se počítají statistiky">
{label}
</span>
</span>
);
}
return (
<Badge tone={tone}>
<Icon className="size-3.5" />
+2 -2
View File
@@ -101,7 +101,7 @@ export function TicketTable({
)}
<td className="px-3 py-3">
<TicketStatusBadge status={ticket.status} />
<TicketStatusBadge status={ticket.status} stage={ticket.stage} />
</td>
<td className="px-3 py-3">
<TicketPriorityBadge priority={ticket.priority} />
@@ -143,7 +143,7 @@ export function TicketTable({
<Link to={`/dashboard/tickety/${ticket.id}`} className="block">
<div className="flex items-start justify-between gap-2">
<span className="font-mono text-xs text-white/35">{ticket.id}</span>
<TicketStatusBadge status={ticket.status} />
<TicketStatusBadge status={ticket.status} stage={ticket.stage} />
</div>
<p className="mt-1 text-sm text-white/85">{ticket.subject}</p>
@@ -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<string, string>) => 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 (
<div className="@container rounded-card border border-ink-600/60 bg-ink-800/60 p-3 transition-colors hover:border-ink-600 sm:p-4">
<div className="flex items-start gap-2.5 sm:gap-3">
<span className="grid size-8 shrink-0 place-items-center rounded-xl bg-ink-700/70 text-brand-300 @xs:size-10">
<button
type="button"
onClick={() => setOpen(!open)}
aria-expanded={open}
title={open ? 'Sbalit krok' : 'Rozbalit krok'}
className="grid size-8 shrink-0 place-items-center rounded-xl bg-ink-700/70 text-brand-300 transition-colors hover:bg-ink-700 @xs:size-10"
>
<Icon className="size-4 @xs:size-4.5" />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-xs text-white/40">{resolved.connector.name}</p>
<p className="mt-0.5 font-semibold break-words text-white">{resolved.operation.name}</p>
{/* Popis je jen doplnek. V uzkem sloupci by se lamal po slovech. */}
<p className="mt-0.5 hidden text-sm text-white/50 @xs:block">
{resolved.operation.description}
</p>
</div>
</button>
<button
type="button"
onClick={() => setOpen(!open)}
className="min-w-0 flex-1 text-left"
>
<span className="block truncate text-xs text-white/40">{resolved.connector.name}</span>
<span className="mt-0.5 flex flex-wrap items-center gap-2 font-semibold break-words text-white">
{resolved.operation.name}
<ChevronDown
className={cn(
'size-4 shrink-0 text-white/30 transition-transform',
open && 'rotate-180',
)}
/>
</span>
{open ? (
/* Popis je jen doplnek. V uzkem sloupci by se lamal po slovech. */
<span className="mt-0.5 hidden text-sm text-white/50 @xs:block">
{resolved.operation.description}
</span>
) : (
/*
* Sbalene: co ma krok vyplneno. Tohle clovek hleda, kdyz cte strom -
* ne popis operace, ktery zna.
*/
<span className="mt-1 flex flex-wrap gap-1.5">
{filled.length === 0 ? (
<span className="text-xs text-white/30">nic nevyplněno</span>
) : (
filled.slice(0, 4).map(([key, value]) => (
<span
key={key}
className="max-w-56 truncate rounded bg-ink-950/60 px-1.5 py-0.5 font-mono text-xs text-white/50"
title={`${key}: ${value}`}
>
{key}: {value}
</span>
))
)}
{filled.length > 4 && (
<span className="text-xs text-white/30">a {filled.length - 4} dalších</span>
)}
</span>
)}
</button>
<StepControls
canMoveUp={canMoveUp}
canMoveDown={canMoveDown}
@@ -398,23 +456,24 @@ function ActionCard({
/>
</div>
{inputs && inputs.length > 0 ? (
<StepInputs
fields={inputs}
values={step.inputs ?? {}}
available={available}
onChange={onUpdateInputs}
/>
) : (
// Akce, ktere zatim nemaji `inputs`. Radeji to rekneme nahlas,
// nez aby uzivatel hledal nastaveni, ktere neexistuje.
<p className="mt-3 border-t border-ink-600/50 pt-3 text-xs text-white/35">
Tuhle akci zatím nejde nastavit.
{resolved.operation.fields && resolved.operation.fields.length > 0 && (
<> Až půjde, budou to pole: {resolved.operation.fields.join(', ')}.</>
)}
</p>
)}
{open &&
(inputs && inputs.length > 0 ? (
<StepInputs
fields={inputs}
values={step.inputs ?? {}}
available={available}
onChange={onUpdateInputs}
/>
) : (
// Akce, ktere zatim nemaji `inputs`. Radeji to rekneme nahlas,
// nez aby uzivatel hledal nastaveni, ktere neexistuje.
<p className="mt-3 border-t border-ink-600/50 pt-3 text-xs text-white/35">
Tuhle akci zatím nejde nastavit.
{resolved.operation.fields && resolved.operation.fields.length > 0 && (
<> Až půjde, budou to pole: {resolved.operation.fields.join(', ')}.</>
)}
</p>
))}
</div>
);
}
+1 -1
View File
@@ -202,7 +202,7 @@ function TicketCards({
>
<div className="flex items-start justify-between gap-2">
<span className="font-mono text-xs text-white/35">{ticket.id}</span>
<TicketStatusBadge status={ticket.status} />
<TicketStatusBadge status={ticket.status} stage={ticket.stage} />
</div>
<p className="mt-1.5 line-clamp-2 text-sm text-white/85 group-hover:text-brand-200">
{ticket.subject}
+1 -1
View File
@@ -117,7 +117,7 @@ export default function TicketDetail() {
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs text-white/35">{ticket.data.id}</span>
<TicketChannelBadge channel={ticket.data.channel} />
<TicketStatusBadge status={ticket.data.status} />
<TicketStatusBadge status={ticket.data.status} stage={ticket.data.stage} />
<TicketPriorityBadge priority={ticket.data.priority} />
{ticket.data.externalId && (
<Badge tone="neutral">
+9 -1
View File
@@ -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<string | null>(initial.typeId);
const [tag, setTag] = useState<string | null>(initial.tag);
const [stage, setStage] = useState<string | null>(initial.stage);
const [groupId, setGroupId] = useState<string | null>(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
</Chip>
)}
{stage && (
<Chip active onClick={() => setStage(null)} title="Zrušit filtr na fázi">
fáze: {stage === 'none' ? 'bez fáze' : stage} x
</Chip>
)}
{groupId && (
<Chip active onClick={() => setGroupId(null)} title="Zrušit filtr na skupinu">
skupina: {groupId === 'none' ? 'bez skupiny' : groupId} x
@@ -293,7 +301,7 @@ export default function Tickets() {
>
<div className="flex items-start justify-between gap-2">
<span className="font-mono text-xs text-white/35">{ticket.id}</span>
<TicketStatusBadge status={ticket.status} />
<TicketStatusBadge status={ticket.status} stage={ticket.stage} />
</div>
<p className="mt-2 line-clamp-2 text-sm text-white/85 group-hover:text-brand-200">