diff --git a/.gitignore b/.gitignore index 4417650..ab52083 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,7 @@ Thumbs.db # Lomitko na zacatku je nutne: bez nej by se vzorec shodl s KAZDOU # slozkou 'data', tedy i se src/data - a zdrojove soubory by tise chybely. /data/ + +# Sestavene platno navrhu. 2,5 MB generovaneho souboru, ktery vznikne znovu +# ze zdroju vedle nej (design/*.dc.html a canvas.json). +design/detail-ticketu.html diff --git a/design/Main.dc.html b/design/Main.dc.html new file mode 100644 index 0000000..b508981 --- /dev/null +++ b/design/Main.dc.html @@ -0,0 +1,362 @@ + + + + + + + + + + + + + +
+ +
+
+ + Tickety +
+
+ + +
+
+ + +
+ +
+ + + + +
+ +
+ + + +
+ +
+
+

Prádelna v Brně neodpovídá na platbu kartou

+

+ Přišlo z hlasové linky přes automatizaci TEST, u nás od 1. září. + Zákazník od té doby nepsal. +

+
+ + +
+
+ Obsah + přišlo jako data, ne jako věta +
+ +

+ Hovor byl přesměrován na živého operátora, problém se týkal reklamace platby. +

+ +
+
+
result
+
Chybějící informace
+
+
+
Místo
+
Brno, Veveří
+
+
+
Chybějící informace
+
Číslo transakce a čas platby
+
+
+
rating
+
Hovor byl zahájen správným zjištěním lokality, ale operátor nezískal číslo transakce.
+
+
+
questionsRelevance
+
3
+
+
+
+ +
+ Co k tomu přibylo +
+
+
+ Petr Vomáčka odpověděl zákazníkovi + 1. 9., 15:03 +
+

+ Ověřujeme transakci u provozovatele terminálu, ozveme se do dvou pracovních dnů. +

+
+
+ + Petr Vomáčka si ticket převzal + dnes, 8:12 +
+
+
+ +
+
+ Odpovědět zákazníkovi nebo přidat interní poznámku... +
+ +
+
+ + +
+
+ Má u sebe +
+ PV +
+ Petr Vomáčka + Servicedesk · 7 otevřených +
+
+
+ +
+
+ Stav, jak ho poslal voicebot + Chybějící informace +
+
+ Vyřízený + ne +
+
+ Priorita + vysoká +
+
+ +
+ Zákazník +
+ Firma + CSBot +
+
+ Kontakt + Jan Dvořák +
+
+ Odpověď na + +420 604 838 600 +
+
+ +
+ Jak to jde +
+ První reakce + za 36 minut +
+
+ Otevřeno + 18 hodin +
+
+ Vráceno do práce + 2x +
+
+ Externí ID + CAbc75a8e8c9c3
a17ed530d152dbf5acdb
+
+
+
+
+
+ + + +
+
+

Log průběhu

+ 12 kroků · celkem 214 ms · automatizace TEST +
+ +
+
+ +
+ Přijata událost: stav completed + 1. 9. 14:27:16 +
+
+ +
+ +
+ ticket/upsert · založen TK-4823 + 18 ms +
+
+ +
+ +
+ Podmínka: výsledek není "Chybějící informace" + větev NE +
+
+ +
+ +
+
+ ticket/upsert · doplněn TK-4823, priorita vysoká + 6 ms +
+
{ "ticketId": "TK-4823", "created": false,
+  "externalId": "CAbc75a8e8c9c3a17ed530d152dbf5acdb" }
+
+
+ +
+ +
+
+ ticket/assign-group · skupina nenalezena + 3 ms +
+

+ Skupina grp_reklamace ve firmě neexistuje. Ticket zůstal ve frontě bez řešitele. +

+
+
+
+
+
+ + + +
+
+

Přijaté události

+ Co přišlo zvenku, v původní podobě +
+ +
+
+
+ stav completed +
+ 3x totéž + 1. 9. 14:27:27 +
+
+
{
+  "callSid": "CAbc75a8e8c9c3a17ed530d152dbf5acdb",
+  "status": "completed",
+  "voicebotId": "dffe761f-cc56-4f3b-923a-70fd14cb4e91",
+  "data": { "result": "Chybějící informace", "rating": "Hovor byl zahájen..." }
+}
+
+ +
+ stav in-progress + 1. 9. 14:27:16 +
+
+
+
+ + + +
+
+

Vlastní pole

+ Typ ticketu Hovor · doplní se i další zprávou +
+ +
+
+ Místo +
Brno, Veveří
+
+
+ Přesměrování +
+420 604 838 600
+
+
+ Chybějící informace +
Číslo transakce a čas platby
+
+
+ +
+ +
+
+
+
+
+
+
+ + + + diff --git a/design/Zakaznik.dc.html b/design/Zakaznik.dc.html new file mode 100644 index 0000000..f1235d4 --- /dev/null +++ b/design/Zakaznik.dc.html @@ -0,0 +1,124 @@ + + + + + + + + + + + + + +
+ +
+ + Moje požadavky +
+ + +
+ + +
+
+ + TK-4823 +
+
+ + +
+ + +
+

Prádelna v Brně neodpovídá na platbu kartou

+

+ Máme to u sebe od 1. září. Pracuje na tom Petr Vomáčka + a naposledy se vám ozval včera odpoledne. +

+
+ + +
+
+ Přijali jsme + 1. 9. ve 14:27 +
+
+ Ozvali jsme se + za 36 minut +
+
+ Hotovo + zatím ne +
+
+ + +
+ Co jste nám poslali +
+

+ Volal jsem kvůli prádelně v Brně na Veveří. Platba kartou neprošla, terminál napsal chybu a peníze + mi odešly z účtu. Potřebuju vědět, jestli se vrátí, nebo mám reklamovat u banky. +

+ 1. 9. 2026, 14:27 · telefon +
+
+ + +
+ Co k tomu přibylo + +
+
+
+ Petr Vomáčka + 1. 9., 15:03 +
+

+ Dobrý den, ověřujeme transakci u provozovatele terminálu. Ozveme se nejpozději do dvou pracovních dnů. +

+
+ +
+ + Požadavek si převzal Petr Vomáčka + dnes, 8:12 +
+
+
+ + +
+ Chcete něco doplnit? +
+ Napište nám. Přečte to člověk, který požadavek řeší. +
+
+ Odpovídáme v pracovní dny do 24 hodin. + +
+
+
+
+ +

+ Číslo požadavku TK-4823 se hodí, když nám budete volat. +

+
+
+ + diff --git a/design/canvas.json b/design/canvas.json new file mode 100644 index 0000000..7a24836 --- /dev/null +++ b/design/canvas.json @@ -0,0 +1,37 @@ +{ + "artboards": [ + { "file": "Zakaznik.dc.html", "title": "Zákazník", "x": 0, "y": 0, "w": 900, "h": 1080 }, + { "file": "Main.dc.html", "title": "Řešitel a admin", "x": 1020, "y": 0, "w": 1180, "h": 1180 } + ], + "annotations": [ + { + "id": "note-zakaznik", + "x": 0, + "y": -170, + "w": 460, + "text": "ZÁKAZNÍK\nJedna složka, jeden list. Žádné kroky stavu - ty se z volného řetězce poskládat nedají.\nŠtítek nese jen to, co v datech opravdu je: kdy přišlo, kdy jsme se ozvali, jestli je hotovo." + }, + { + "id": "note-admin", + "x": 1020, + "y": -170, + "w": 540, + "text": "ŘEŠITEL A ADMIN\nTáž složka, jen má rozřaďovače. Log, události a pole jsou další listy uvnitř, ne modaly.\nKlikni na jazyky nahoře - přepínají se.\nStav je vypsaný tak, jak ho poslal odesílatel, a vedle něj zvlášť příznak Vyřízený." + }, + { + "id": "note-stav", + "x": 1620, + "y": -170, + "w": 400, + "text": "PROČ ŽÁDNÉ KROKY\nstatus je volný řetězec, cokoliv pošle cizí aplikace.\nSpolehlivě se dá odvodit jen: kdy přišlo (createdAt), kdy jsme reagovali (firstResponseAt) a jestli je hotovo (closed).\nPřesně to je na štítku." + }, + { + "id": "note-obsah", + "x": 1020, + "y": 1230, + "w": 620, + "text": "OBSAH SE ČTE\nTělo ticketu je jeden řetězec, ale co v něm stojí, určuje ten, kdo ho naplnil.\nVěta od člověka zůstane větou (viz složka zákazníka vlevo). Struktura z automatizace se vypíše jako tabulka.\nA na tenhle případ - text a za ním objekt, protože šablona zní {{voicebotId}}, {{data}} - se rozdělí na obojí.\nHotové, běží to v aplikaci." + } + ], + "launch": { "view": "canvas" } +} diff --git a/documentation/06-tickety.md b/documentation/06-tickety.md index 8f2ec00..54b27a1 100644 --- a/documentation/06-tickety.md +++ b/documentation/06-tickety.md @@ -64,6 +64,33 @@ to napise nahlas misto toho, aby ukazal prazdne misto. skoro vsechno ostatni - podrobnosti nize v "Zalozeni a doplneni delaji totez". Cela historie zustava v udalostech ticketu, at uz je v `body` cokoliv. +### Obsah se na detailu cte, ne jen vypisuje + +`body` je jeden retezec, ale co v nem stoji, urcuje ten, kdo ho naplnil. Casto +je to veta od cloveka, casto ale cela struktura z cizi aplikace: krok dosadi +`{{data}}` a sablona objekt prevede na JSON. Vypsat takovou zavorku jako +odstavec je necitelne, i kdyz jsou v ni presne ty udaje, kvuli kterym ticket +vznikl. + +`TicketBody` proto telo prectе a podle toho vykresli: + +| Co v tele je | Jak se to ukaze | +| ------------------------- | -------------------------------------- | +| veta od cloveka | text tak, jak prisel | +| cely retezec je JSON | tabulka klic a hodnota | +| seznam objektu | tabulka se sloupci podle klicu | +| text a za nim JSON | odstavec a pod nim tabulka | +| cokoliv, co se neprecte | text tak, jak prisel | + +Ta ctvrta radka je bezny pripad: sablona `{{voicebotId}}, {{data}}` da presne +tohle. Deli se **az od prvni zavorky, od ktere je zbytek platny JSON** - jinak +by veta s jednou slozenou zavorkou skoncila rozpulena. + +Zanoreni se kresli o uroven niz jako vnorena tabulka, hloubeji uz jako JSON. +Tabulka v tabulce v tabulce se necte a odesilatele umi zanorit data hluboko. + +**Nic se nezahazuje.** Kdyz se struktura precist neda, ukaze se puvodni text. + Uvnitr ulozista se drzi jen `assigneeId`, jmeno se dopocitava pri cteni. Kdyz resitel ze seznamu zmizi, ticket nespadne - jen se zaloguje a tvari se jako neprirazeny. diff --git a/documentation/99-zmeny.md b/documentation/99-zmeny.md index a20ee9a..7c9862f 100644 --- a/documentation/99-zmeny.md +++ b/documentation/99-zmeny.md @@ -2,6 +2,34 @@ Nejnovejsi nahore. +## 2026-09-02 - Obsah ticketu se na detailu cte + +`body` je jeden retezec, ale co v nem stoji, urcuje ten, kdo ho naplnil. Krok +"Zalozit nebo doplnit ticket" dosadi `{{data}}` a sablona objekt prevede na +JSON, takze v obsahu skoncila slozena zavorka vypsana jako odstavec. Necitelne, +i kdyz jsou v ni presne ty udaje, kvuli kterym ticket vznikl. + +Nova `web/src/components/dashboard/TicketBody.tsx` telo prectе: + +| Co v tele je | Jak se to ukaze | +| ----------------------- | ------------------------------ | +| veta od cloveka | text tak, jak prisel | +| cely retezec je JSON | tabulka klic a hodnota | +| seznam objektu | tabulka se sloupci podle klicu | +| text a za nim JSON | odstavec a pod nim tabulka | +| cokoliv, co se neprecte | text tak, jak prisel | + +Ctvrta radka je bezny pripad: sablona `{{voicebotId}}, {{data}}` da presne +tohle. Deli se az od **prvni zavorky, od ktere je zbytek platny JSON** - bez te +druhe podminky by veta s jednou slozenou zavorkou skoncila rozpulena. + +Zanoreni se kresli o uroven niz jako vnorena tabulka, hloubeji uz jako JSON: +tabulka v tabulce v tabulce se necte a odesilatele umi zanorit data hluboko. +Nic se nezahazuje, neprecteny obsah se ukaze jako puvodni text. + +Overeno na osmi tvarech tela vcetne toho, ktery na instanci opravdu vznika, +vety se slozenou zavorkou a nedopsaneho JSONu. + ## 2026-09-02 - U posledniho volani je videt, co presne prislo Prvni verze seznamu poslednich volani ukazovala cas a duvod odmitnuti, ale ne diff --git a/web/src/components/dashboard/TicketBody.tsx b/web/src/components/dashboard/TicketBody.tsx new file mode 100644 index 0000000..90d8fdf --- /dev/null +++ b/web/src/components/dashboard/TicketBody.tsx @@ -0,0 +1,209 @@ +/** + * Obsah ticketu. + * + * `body` je jeden retezec, ale co v nem stoji, urcuje ten, kdo ho naplnil. + * Casto je to veta od cloveka, casto ale i cela struktura z cizi aplikace: + * krok "Zalozit nebo doplnit ticket" dosadi `{{data}}` a sablona objekt + * prevede na JSON. Vypsat takovou zavorku jako odstavec je pro cloveka + * necitelne, i kdyz jsou v ni presne ty udaje, kvuli kterym ticket vznikl. + * + * Proto se telo **cte**, ne jen vypisuje: + * - cely retezec je JSON -> tabulka, + * - text a za nim JSON -> odstavec a pod nim tabulka (`{{voicebotId}}, {{data}}`), + * - cokoliv jineho -> text tak, jak prisel. + * + * Nic se nezahazuje: kdyz se struktura precist neda, ukaze se puvodni text. + */ + +import type { ReactNode } from 'react'; + +/** Co v tele stoji. `text` je vzdycky to, co ma zustat cist jako veta. */ +interface ParsedBody { + text: string; + data: unknown; +} + +/** + * Rozdeli telo na text a strukturu. + * + * Hleda se **prvni zavorka, od ktere je zbytek platny JSON**. Bez te druhe + * podminky by staciloa jedna slozena zavorka v bezne vete a text by se rozpadl; + * takhle se rozdeli jen to, co se opravdu rozparsuje. + */ +export function parseBody(body: string): ParsedBody { + const trimmed = body.trim(); + if (trimmed === '') return { text: '', data: undefined }; + + const whole = tryParse(trimmed); + if (whole !== undefined) return { text: '', data: whole }; + + const start = trimmed.search(/[[{]/); + if (start > 0) { + const tail = tryParse(trimmed.slice(start)); + if (tail !== undefined) { + // Oddelovac pred strukturou uz nic nenese, casto je to carka ze sablony. + return { text: trimmed.slice(0, start).replace(/[\s,;:-]+$/, ''), data: tail }; + } + } + + return { text: trimmed, data: undefined }; +} + +/** `undefined` = nerozparsovalo se, nebo to neni struktura. */ +function tryParse(text: string): unknown { + if (!/^[[{]/.test(text)) return undefined; + try { + const value: unknown = JSON.parse(text); + return value !== null && typeof value === 'object' ? value : undefined; + } catch { + return undefined; + } +} + +export function TicketBody({ body, sourceRef }: { body: string; sourceRef?: string | null }) { + const { text, data } = parseBody(body); + + if (text === '' && data === undefined) { + return ( +

+ Prázdné. Krok "Založit ticket" neměl nastavené pole Obsah, takže se text původního + požadavku nikam neuložil. +

+ ); + } + + return ( +
+ {text !== '' && ( +

{text}

+ )} + {data !== undefined && } + {sourceRef &&

zdroj {sourceRef}

} +
+ ); +} + +/** + * Struktura jako tabulka. + * + * Seznam objektu dostane sloupce podle klicu, protoze radky se v nem + * porovnavaji mezi sebou. Vsechno ostatni je dvojice klic a hodnota - u jedne + * struktury nikdo nesrovnava sloupce, cte se shora dolu. + */ +function DataTable({ value }: { value: unknown }) { + if (Array.isArray(value) && value.length > 0 && value.every(isPlainObject)) { + const columns = [...new Set(value.flatMap((row) => Object.keys(row)))]; + return ( +
+ + + + {columns.map((column) => ( + + ))} + + + + {value.map((row, index) => ( + + {columns.map((column) => ( + + ))} + + ))} + +
+ {column} +
+ )[column]} /> +
+
+ ); + } + + const rows: Array<[string, unknown]> = Array.isArray(value) + ? value.map((item, index) => [`${index + 1}.`, item]) + : Object.entries(value as Record); + + if (rows.length === 0) { + return

Struktura bez hodnot.

; + } + + return ( +
+ + + {rows.map(([key, item]) => ( + + {/* Klic je casto cesky primo od odesilatele, nic se neprekladá. */} + + + + ))} + +
+ {key} + + +
+
+ ); +} + +/** + * Jedna hodnota. + * + * Zanorena struktura se vykresli znovu jako tabulka, ale **jen o uroven niz**. + * Hlubsi zanoreni se ukaze jako JSON: tabulka v tabulce v tabulce uz se necte + * a odesilatele umi poslat data zanorena hodne hluboko. + */ +function Cell({ value, depth = 0 }: { value: unknown; depth?: number }): ReactNode { + if (value === null || value === undefined) return -; + + if (typeof value === 'boolean') return value ? 'ano' : 'ne'; + if (typeof value === 'number') return {value}; + if (typeof value === 'string') { + if (value.trim() === '') return -; + return {value}; + } + + if (Array.isArray(value) && value.every((item) => typeof item !== 'object' || item === null)) { + return value.map((item) => String(item)).join(', '); + } + + if (depth === 0) return ; + + return ( +
+      {JSON.stringify(value, null, 2)}
+    
+ ); +} + +/** Zanorena struktura. Bez ramecku, aby vnorena tabulka nedelala matriosku. */ +function NestedTable({ value }: { value: object }) { + const rows: Array<[string, unknown]> = Array.isArray(value) + ? value.map((item, index) => [`${index + 1}.`, item]) + : Object.entries(value as Record); + + if (rows.length === 0) return -; + + return ( + + + {rows.map(([key, item]) => ( + + + + + ))} + +
{key} + +
+ ); +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} diff --git a/web/src/pages/dashboard/TicketDetail.tsx b/web/src/pages/dashboard/TicketDetail.tsx index d13997a..3b3e67d 100644 --- a/web/src/pages/dashboard/TicketDetail.tsx +++ b/web/src/pages/dashboard/TicketDetail.tsx @@ -18,6 +18,7 @@ import { TicketStatusBadge, } from '@/components/dashboard/StatusBadge'; import { TicketActions } from '@/components/dashboard/TicketActions'; +import { TicketBody } from '@/components/dashboard/TicketBody'; import { TicketEvents } from '@/components/dashboard/TicketEvents'; import { TicketTrace, TraceSummary } from '@/components/dashboard/TicketTrace'; import { Badge } from '@/components/ui/Badge'; @@ -199,21 +200,7 @@ export default function TicketDetail() { Obsah požadavku - {ticket.data.body.trim().length > 0 ? ( -

- {ticket.data.body} -

- ) : ( -

- Prázdné. Krok "Založit ticket" neměl nastavené pole Obsah, takže se text - původního požadavku nikam neuložil. -

- )} - {ticket.data.sourceRef && ( -

- zdroj {ticket.data.sourceRef} -

- )} +