Obsah ticketu se na detailu cte, ne jen vypisuje
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
40875439e4
commit
a6722f9379
@@ -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 (
|
||||
<p className="mt-3 rounded-lg border border-dashed border-ink-600/70 px-3 py-2.5 text-xs text-white/40">
|
||||
Prázdné. Krok "Založit ticket" neměl nastavené pole Obsah, takže se text původního
|
||||
požadavku nikam neuložil.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 space-y-3">
|
||||
{text !== '' && (
|
||||
<p className="text-sm leading-relaxed whitespace-pre-wrap text-white/75">{text}</p>
|
||||
)}
|
||||
{data !== undefined && <DataTable value={data} />}
|
||||
{sourceRef && <p className="font-mono text-xs text-white/30">zdroj {sourceRef}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className="overflow-x-auto rounded-lg border border-ink-600/70">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-ink-600/70 bg-ink-900/40">
|
||||
{columns.map((column) => (
|
||||
<th key={column} className="px-3 py-2 text-left font-medium text-white/45">
|
||||
{column}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{value.map((row, index) => (
|
||||
<tr key={index} className="border-b border-ink-600/40 last:border-0">
|
||||
{columns.map((column) => (
|
||||
<td key={column} className="px-3 py-2 align-top text-white/75">
|
||||
<Cell value={(row as Record<string, unknown>)[column]} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const rows: Array<[string, unknown]> = Array.isArray(value)
|
||||
? value.map((item, index) => [`${index + 1}.`, item])
|
||||
: Object.entries(value as Record<string, unknown>);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return <p className="text-xs text-white/35">Struktura bez hodnot.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-ink-600/70">
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
{rows.map(([key, item]) => (
|
||||
<tr key={key} className="border-b border-ink-600/40 last:border-0">
|
||||
{/* Klic je casto cesky primo od odesilatele, nic se neprekladá. */}
|
||||
<th className="w-1/3 px-3 py-2 text-left align-top font-medium text-white/45">
|
||||
{key}
|
||||
</th>
|
||||
<td className="px-3 py-2 align-top text-white/75">
|
||||
<Cell value={item} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 <span className="text-white/25">-</span>;
|
||||
|
||||
if (typeof value === 'boolean') return value ? 'ano' : 'ne';
|
||||
if (typeof value === 'number') return <span className="font-mono">{value}</span>;
|
||||
if (typeof value === 'string') {
|
||||
if (value.trim() === '') return <span className="text-white/25">-</span>;
|
||||
return <span className="whitespace-pre-wrap">{value}</span>;
|
||||
}
|
||||
|
||||
if (Array.isArray(value) && value.every((item) => typeof item !== 'object' || item === null)) {
|
||||
return value.map((item) => String(item)).join(', ');
|
||||
}
|
||||
|
||||
if (depth === 0) return <NestedTable value={value} />;
|
||||
|
||||
return (
|
||||
<pre className="overflow-x-auto font-mono text-xs whitespace-pre-wrap text-white/55">
|
||||
{JSON.stringify(value, null, 2)}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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<string, unknown>);
|
||||
|
||||
if (rows.length === 0) return <span className="text-white/25">-</span>;
|
||||
|
||||
return (
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
{rows.map(([key, item]) => (
|
||||
<tr key={key}>
|
||||
<th className="w-1/3 py-1 pr-3 text-left align-top font-normal text-white/35">{key}</th>
|
||||
<td className="py-1 align-top text-white/70">
|
||||
<Cell value={item} depth={1} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
@@ -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() {
|
||||
<FileText className="size-4 text-brand-300" />
|
||||
Obsah požadavku
|
||||
</h2>
|
||||
{ticket.data.body.trim().length > 0 ? (
|
||||
<p className="mt-3 text-sm leading-relaxed whitespace-pre-wrap text-white/75">
|
||||
{ticket.data.body}
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-3 rounded-lg border border-dashed border-ink-600/70 px-3 py-2.5 text-xs text-white/40">
|
||||
Prázdné. Krok "Založit ticket" neměl nastavené pole Obsah, takže se text
|
||||
původního požadavku nikam neuložil.
|
||||
</p>
|
||||
)}
|
||||
{ticket.data.sourceRef && (
|
||||
<p className="mt-3 font-mono text-xs text-white/30">
|
||||
zdroj {ticket.data.sourceRef}
|
||||
</p>
|
||||
)}
|
||||
<TicketBody body={ticket.data.body} sourceRef={ticket.data.sourceRef} />
|
||||
</section>
|
||||
|
||||
<section className="glass rounded-card p-5 sm:p-6">
|
||||
|
||||
Reference in New Issue
Block a user