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
+
+ 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 (
+