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:
co-authored by
Claude Opus 5
parent
6abac82d5e
commit
ef3080d3f6
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user