From a6722f93797d921ef1cdd2595f9b6d540184cadc Mon Sep 17 00:00:00 2001 From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:41:18 +0200 Subject: [PATCH] Obsah ticketu se na detailu cte, ne jen vypisuje MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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е: - 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. Vedle toho zdroje vizualniho navrhu detailu ticketu v design/. Sestavene platno je v gitignore, je to 2,5 MB generovaneho souboru, ktery vznikne znovu ze zdroju vedle nej. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 4 + design/Main.dc.html | 362 ++++++++++++++++++++ design/Zakaznik.dc.html | 124 +++++++ design/canvas.json | 37 ++ documentation/06-tickety.md | 27 ++ documentation/99-zmeny.md | 28 ++ web/src/components/dashboard/TicketBody.tsx | 209 +++++++++++ web/src/pages/dashboard/TicketDetail.tsx | 17 +- 8 files changed, 793 insertions(+), 15 deletions(-) create mode 100644 design/Main.dc.html create mode 100644 design/Zakaznik.dc.html create mode 100644 design/canvas.json create mode 100644 web/src/components/dashboard/TicketBody.tsx 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} -

- )} +