Runtime vykonava strom, prokliky z widgetu, oprava ukladani rozlozeni

Runtime: `src/runtime/executor.ts` jde krok po kroku, u podminky se vetvi,
do poli dosadi parametry, akci pusti pres runScript a vystupy pripise do
kontextu pro dalsi krok. Cely prubeh jde do logu ticketu vcetne toho, co
sluzba vratila. Pouzivaji ho obe cesty: akce na ticketu i webhook.

Opraveno: rozlozeni dashboardu s vlastnim widgetem se NEDALO ULOZIT.
`validateLayout` znala jen vestaveny katalog, takze kazdy pokus skoncil
hlaskou "widget v katalogu neexistuje" - presne to, co hlasil uzivatel.
Katalog je ted jedna funkce a pouziva ji nabidka i kontrola. Zaroven je
za konkretni firmu, driv slo polozit dlazdici jedne firmy na dashboard druhe.

Prokliky: z widgetu lidi na cloveka, ze seskupeni na vyfiltrovany seznam
ticketu. Odkazy sklada server, protoze on jediny zna filtr widgetu. Seznam
ticketu cte filtr z adresy a umi filtrovat na typ, tag a skupinu.

Tabulky: spolecna `TicketTable` pro seznam i detail osoby. Na mobilu se
neposouva do strany, uzka obrazovka dostane karty. Detail osoby ma velkou
tabulku se zalozkami "ma u sebe" a "vyresil" a prepinacem pohledu.

Odebrano: simulace vcetne tlacitka, dialogu i endpointu. Trojice pohledu
nad tickety - vyber firmy je select, "moje" je prepinac, driv to delalo
totez dvakrat.

Pridan zmereny rozbor kapacity pro 200 firem (19-kapacita-200-firem.md):
soucasny stav to nezvladne, protoze data jsou v pameti a vypis je linearni.
Zmereno na 5 000 ticketech, vcetne toho, co s tim a kolik serveru to chce.

Overeno 7 kontrolami proti bezicimu serveru.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-13 15:54:17 +02:00
co-authored by Claude Opus 5
parent 29de584df8
commit 5d186dcd2e
27 changed files with 1357 additions and 1201 deletions
@@ -2,7 +2,6 @@ import {
AlarmClock,
Blocks,
ExternalLink,
FlaskConical,
LayoutDashboard,
LayoutGrid,
LifeBuoy,
@@ -23,7 +22,6 @@ import { useAuth, type AuthUser } from '@/auth/AuthContext';
import { EventStreamProvider } from '@/components/dashboard/EventStreamProvider';
import { EventToasts } from '@/components/dashboard/EventToasts';
import { LiveIndicator } from '@/components/dashboard/LiveIndicator';
import { SimulationModal } from '@/components/dashboard/SimulationModal';
import { Logo } from '@/components/layout/Logo';
import { cn } from '@/lib/cn';
import { useApiQuery } from '@/lib/useApiQuery';
@@ -79,7 +77,6 @@ function DashboardShell() {
const navigate = useNavigate();
const location = useLocation();
const [sidebarOpen, setSidebarOpen] = useState(false);
const [simulationOpen, setSimulationOpen] = useState(false);
useEffect(() => {
setSidebarOpen(false);
@@ -135,16 +132,6 @@ function DashboardShell() {
</NavLink>
);
})}
{/* Nastroj pro nahled zivého dashboardu, proto az za beznou navigaci. */}
<button
type="button"
onClick={() => setSimulationOpen(true)}
className="mt-2 flex w-full items-center gap-3 rounded-xl border border-dashed border-ink-600/70 px-3.5 py-2.5 text-sm font-medium text-white/55 transition-colors hover:border-accent-400/60 hover:text-white"
>
<FlaskConical className="size-4 shrink-0" />
Simulace
</button>
</nav>
<div className="border-t border-ink-600/60 p-3">
@@ -216,7 +203,6 @@ function DashboardShell() {
</div>
<EventToasts />
<SimulationModal open={simulationOpen} onClose={() => setSimulationOpen(false)} />
</div>
);
}
@@ -1,325 +0,0 @@
import { AlarmClock, AlertCircle, CheckCircle2, LifeBuoy, Play, Workflow } from 'lucide-react';
import { useState } from 'react';
import type { FormEvent, ReactNode } from 'react';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/cn';
type Action =
| 'ticket.created'
| 'ticket.resolved'
| 'incident.started'
| 'incident.resolved'
| 'automation.run';
interface Result {
ok: boolean;
message: string;
}
const priorities = [
{ value: 'low', label: 'Nízká' },
{ value: 'normal', label: 'Běžná' },
{ value: 'high', label: 'Vysoká' },
{ value: 'critical', label: 'Kritická' },
] as const;
/** Kanal urcuje nejen text, ale i to, jak bude vypadat log ticketu. */
const channels = [
{ 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 (ručně)' },
] as const;
const severities = [
{ value: 'sev3', label: 'SEV3 - menší' },
{ value: 'sev2', label: 'SEV2 - vážný' },
{ value: 'sev1', label: 'SEV1 - kritický' },
] as const;
const inputClass =
'w-full rounded-xl border border-ink-600/70 bg-ink-850/70 px-3.5 py-2.5 text-sm text-white placeholder:text-white/30 focus:border-brand-400/70 focus:outline-none';
/**
* Simulace provoznich udalosti. Slouzi k nahledu zivého dashboardu bez toho,
* aby se muselo cekat na skutecny provoz.
*
* Nejde o falesne notifikace - kazda akce opravdu zmeni data na serveru,
* takze se projevi i v seznamech a v souhrnu.
*/
export function SimulationModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const [running, setRunning] = useState<Action | null>(null);
const [result, setResult] = useState<Result | null>(null);
const [subject, setSubject] = useState('');
const [contact, setContact] = useState('');
const [priority, setPriority] = useState<(typeof priorities)[number]['value']>('normal');
const [channel, setChannel] = useState<(typeof channels)[number]['value']>('whatsapp');
const [knownCustomer, setKnownCustomer] = useState(true);
const [title, setTitle] = useState('');
const [service, setService] = useState('');
const [severity, setSeverity] = useState<(typeof severities)[number]['value']>('sev2');
async function run(action: Action, body: Record<string, unknown> = {}) {
setRunning(action);
setResult(null);
try {
await apiFetch<unknown>('/api/simulate', {
method: 'POST',
body: { action, ...body },
});
setResult({ ok: true, message: 'Hotovo. Změna se objevila v dashboardu.' });
} catch (err) {
const message = err instanceof Error ? err.message : 'Simulaci se nepodařilo spustit.';
console.error('[simulace] selhalo:', err);
setResult({ ok: false, message });
} finally {
setRunning(null);
}
}
function submitTicket(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
void run('ticket.created', {
// Prazdna pole neposilame, server pak doplni ukazkovou hodnotu.
...(subject.trim() ? { subject: subject.trim() } : {}),
...(contact.trim() ? { contact: contact.trim() } : {}),
channel,
priority,
knownCustomer,
});
setSubject('');
}
function submitIncident(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
void run('incident.started', {
...(title.trim() ? { title: title.trim() } : {}),
...(service.trim() ? { service: service.trim() } : {}),
severity,
});
setTitle('');
}
return (
<Modal
open={open}
onClose={onClose}
title="Simulace provozu"
description="Vyvolá skutečnou událost, aby bylo vidět, jak dashboard reaguje živě."
>
<div className="max-h-[65vh] space-y-5 overflow-y-auto p-5">
<Panel icon={LifeBuoy} title="Nový ticket z kanálu">
<form onSubmit={submitTicket} className="space-y-3">
<div className="flex flex-wrap gap-3">
<select
value={channel}
onChange={(event) =>
setChannel(event.target.value as (typeof channels)[number]['value'])
}
aria-label="Kanál"
className={cn(inputClass, 'w-48')}
>
{channels.map((item) => (
<option key={item.value} value={item.value} className="bg-ink-850">
{item.label}
</option>
))}
</select>
<select
value={priority}
onChange={(event) =>
setPriority(event.target.value as (typeof priorities)[number]['value'])
}
aria-label="Priorita"
className={cn(inputClass, 'min-w-0 flex-1')}
>
{priorities.map((item) => (
<option key={item.value} value={item.value} className="bg-ink-850">
{item.label}
</option>
))}
</select>
</div>
<input
value={subject}
onChange={(event) => setSubject(event.target.value)}
placeholder="Předmět (nepovinné, jinak se doplní ukázkový)"
className={inputClass}
/>
<input
value={contact}
onChange={(event) => setContact(event.target.value)}
placeholder="Kdo píše (nepovinné)"
className={inputClass}
/>
<label className="flex cursor-pointer items-start gap-2.5 text-sm text-white/60">
<input
type="checkbox"
checked={knownCustomer}
onChange={(event) => setKnownCustomer(event.target.checked)}
className="mt-0.5 size-4 accent-cyan-400"
/>
<span>
Zákazníka se podaří dohledat v CRM
<span className="block text-xs text-white/35">
Když vypnete, ticket zůstane bez firmy i bez řešitele a v logu bude vidět,
proč se přiřazení přeskočilo.
</span>
</span>
</label>
<Button type="submit" size="sm" disabled={running !== null}>
<Play className="size-4" />
{running === 'ticket.created' ? 'Zakládám...' : 'Založit ticket'}
</Button>
</form>
</Panel>
<Panel icon={AlarmClock} title="Nový incident">
<form onSubmit={submitIncident} className="space-y-3">
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="Popis (nepovinné, jinak se doplní ukázkový)"
className={inputClass}
/>
<div className="flex flex-wrap gap-3">
<input
value={service}
onChange={(event) => setService(event.target.value)}
placeholder="Služba"
className={cn(inputClass, 'min-w-0 flex-1')}
/>
<select
value={severity}
onChange={(event) =>
setSeverity(event.target.value as (typeof severities)[number]['value'])
}
aria-label="Závažnost"
className={cn(inputClass, 'w-44')}
>
{severities.map((item) => (
<option key={item.value} value={item.value} className="bg-ink-850">
{item.label}
</option>
))}
</select>
</div>
<Button type="submit" size="sm" disabled={running !== null}>
<Play className="size-4" />
{running === 'incident.started' ? 'Zakládám...' : 'Vyvolat incident'}
</Button>
</form>
</Panel>
<Panel icon={Workflow} title="Rychlé akce">
<p className="mb-3 text-sm text-white/50">
Působí na první vhodný záznam, nemusíte nic vybírat.
</p>
<div className="flex flex-wrap gap-2">
<QuickButton
onClick={() => void run('ticket.resolved')}
disabled={running !== null}
busy={running === 'ticket.resolved'}
>
Vyřešit ticket
</QuickButton>
<QuickButton
onClick={() => void run('incident.resolved')}
disabled={running !== null}
busy={running === 'incident.resolved'}
>
Vyřešit incident
</QuickButton>
<QuickButton
onClick={() => void run('automation.run', { ok: true })}
disabled={running !== null}
busy={running === 'automation.run'}
>
Spustit automatizaci
</QuickButton>
<QuickButton
onClick={() => void run('automation.run', { ok: false })}
disabled={running !== null}
busy={running === 'automation.run'}
>
Neúspěšný běh
</QuickButton>
</div>
</Panel>
{result && (
<p
className={cn(
'flex items-center gap-2 rounded-xl border px-4 py-3 text-sm',
result.ok
? 'border-ok-400/30 bg-ok-500/10 text-ok-400'
: 'border-danger-400/30 bg-danger-500/10 text-danger-400',
)}
>
{result.ok ? (
<CheckCircle2 className="size-4 shrink-0" />
) : (
<AlertCircle className="size-4 shrink-0" />
)}
{result.message}
</p>
)}
<p className="rounded-xl border border-ink-600/60 px-4 py-3 text-xs leading-relaxed text-white/40">
Simulace zapisuje do stejných dat jako běžný provoz. Data jsou v paměti API,
takže se restartem serveru vrátí do výchozího stavu.
</p>
</div>
</Modal>
);
}
function Panel({
icon: Icon,
title,
children,
}: {
icon: typeof LifeBuoy;
title: string;
children: ReactNode;
}) {
return (
<section className="rounded-xl border border-ink-600/60 bg-ink-850/40 p-4">
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-white">
<Icon className="size-4 text-brand-300" />
{title}
</h3>
{children}
</section>
);
}
function QuickButton({
onClick,
disabled,
busy,
children,
}: {
onClick: () => void;
disabled: boolean;
busy: boolean;
children: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className="rounded-lg border border-ink-600/70 px-3.5 py-2 text-sm text-white/70 transition-colors hover:border-brand-400/60 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
>
{busy ? 'Spouštím...' : children}
</button>
);
}
@@ -0,0 +1,184 @@
import { Clock, RotateCcw } from 'lucide-react';
import { Link } from 'react-router-dom';
import {
TicketChannelBadge,
TicketPriorityBadge,
TicketStatusBadge,
} from '@/components/dashboard/StatusBadge';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/cn';
import { formatDateTime, formatRelative } from '@/lib/format';
import type { Ticket } from '@/types/dashboard';
/**
* Tabulka ticketu.
*
* Jedna komponenta pro vsechna mista, kde se tickety vypisuji - seznam,
* detail osoby, cokoliv dalsiho. Kdyby si to kazda stranka kreslila po svem,
* jedna by casem ukazovala jine sloupce nez druha.
*
* **Na mobilu se nescroluje do strany.** Uzka obrazovka dostane misto tabulky
* karty se stejnymi udaji: vodorovne posouvani v tabulce o osmi sloupcich je
* na telefonu nepouzitelne a schovavat sloupce znamena, ze udaj proste zmizi.
*/
export function TicketTable({
tickets,
meId,
/** Sloupce, ktere nemaji smysl v danem kontextu. */
hide,
empty = 'Žádné tickety.',
}: {
tickets: Ticket[];
meId?: string | null;
hide?: Array<'assignee' | 'customer' | 'channel'>;
empty?: string;
}) {
if (tickets.length === 0) {
return (
<p className="rounded-xl border border-dashed border-ink-600/70 px-4 py-6 text-center text-sm text-white/40">
{empty}
</p>
);
}
const skip = new Set(hide ?? []);
return (
<>
{/* Tabulka od tabletu vys. */}
<div className="hidden overflow-x-auto md:block">
<table className="w-full border-collapse text-left text-sm">
<thead className="border-b border-ink-600/60 text-xs tracking-wide text-white/40 uppercase">
<tr>
<th className="px-3 py-3 font-medium">Ticket</th>
{!skip.has('customer') && <th className="px-3 py-3 font-medium">Zákazník</th>}
{!skip.has('channel') && <th className="px-3 py-3 font-medium">Kanál</th>}
<th className="px-3 py-3 font-medium">Stav</th>
<th className="px-3 py-3 font-medium">Priorita</th>
{!skip.has('assignee') && <th className="px-3 py-3 font-medium">Řeší</th>}
<th className="px-3 py-3 text-right font-medium">Aktualizace</th>
</tr>
</thead>
<tbody className="divide-y divide-ink-600/40">
{tickets.map((ticket) => (
<tr key={ticket.id} className="transition-colors hover:bg-white/[0.03]">
<td className="px-3 py-3">
<Link
to={`/dashboard/tickety/${ticket.id}`}
className="block text-white/85 transition-colors hover:text-brand-200"
>
{ticket.subject}
</Link>
<span className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 font-mono text-xs text-white/35">
{ticket.id}
{ticket.externalId && <span>externí {ticket.externalId}</span>}
{ticket.reopenCount > 0 && (
<span className="flex items-center gap-1 font-sans text-warn-400">
<RotateCcw className="size-3" />
{ticket.reopenCount}x vráceno
</span>
)}
</span>
</td>
{!skip.has('customer') && (
<td className="px-3 py-3">
{ticket.customer.id ? (
<span className="text-white/55">{ticket.customer.company}</span>
) : (
<span className="text-warn-400">nedohledáno</span>
)}
{ticket.customer.contact && (
<span className="block text-xs text-white/30">{ticket.customer.contact}</span>
)}
</td>
)}
{!skip.has('channel') && (
<td className="px-3 py-3">
<TicketChannelBadge channel={ticket.channel} />
</td>
)}
<td className="px-3 py-3">
<TicketStatusBadge status={ticket.status} />
</td>
<td className="px-3 py-3">
<TicketPriorityBadge priority={ticket.priority} />
</td>
{!skip.has('assignee') && (
<td className="px-3 py-3">
{ticket.assignee ? (
<Link
to={`/dashboard/lide/${ticket.assignee.id}`}
className={cn(
'transition-colors hover:text-brand-200',
ticket.assignee.id === meId ? 'text-brand-200' : 'text-white/55',
)}
>
{ticket.assignee.name}
</Link>
) : (
<span className="text-white/30">ve frontě</span>
)}
</td>
)}
<td className="px-3 py-3 text-right whitespace-nowrap text-white/45">
<span title={formatDateTime(ticket.updatedAt)}>
{formatRelative(ticket.updatedAt)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Na uzke obrazovce karty misto posouvani do strany. */}
<ul className="divide-y divide-ink-600/40 md:hidden">
{tickets.map((ticket) => (
<li key={ticket.id} className="py-3 first:pt-0 last:pb-0">
<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} />
</div>
<p className="mt-1 text-sm text-white/85">{ticket.subject}</p>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
{!skip.has('channel') && <TicketChannelBadge channel={ticket.channel} />}
<TicketPriorityBadge priority={ticket.priority} />
{ticket.externalId && <Badge tone="neutral">externí {ticket.externalId}</Badge>}
{ticket.reopenCount > 0 && <Badge tone="warn">{ticket.reopenCount}x vráceno</Badge>}
</div>
<div className="mt-2 flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-xs text-white/40">
{!skip.has('customer') && (
<span className="truncate">
{ticket.customer.id ? (
ticket.customer.company
) : (
<span className="text-warn-400">zákazník nedohledán</span>
)}
</span>
)}
{!skip.has('assignee') && (
<span className={cn(ticket.assignee?.id === meId && 'text-brand-200')}>
{ticket.assignee?.name ?? 've frontě'}
</span>
)}
<span className="flex items-center gap-1">
<Clock className="size-3" />
{formatRelative(ticket.updatedAt)}
</span>
</div>
</Link>
</li>
))}
</ul>
</>
);
}
@@ -1,4 +1,5 @@
import { AlertTriangle, Inbox, Users } from 'lucide-react';
import { AlertTriangle, ChevronRight, Inbox, Users } from 'lucide-react';
import { Link } from 'react-router-dom';
import { cn } from '@/lib/cn';
import { formatRelative } from '@/lib/format';
import type { Workload } from '@/types/dashboard';
@@ -39,19 +40,35 @@ export function TicketWorkload({
const width = busiest === 0 ? 0 : Math.round((row.open / busiest) * 100);
return (
<li key={row.person.id}>
<li
key={row.person.id}
className={cn(
'relative rounded-xl border transition-colors',
isActive
? 'border-brand-400/50 bg-brand-500/10'
: 'border-transparent hover:border-ink-600/70 hover:bg-white/[0.03]',
)}
>
{/*
Radek filtruje seznam, sipka vpravo vede na cloveka. Odkaz nesmi
byt uvnitr tlacitka - dva ovladace v sobe jsou neplatne HTML
a klavesnici se v tom neda pohybovat.
*/}
<Link
to={`/dashboard/lide/${row.person.id}`}
aria-label={`Detail ${row.person.name}`}
className="absolute top-2 right-2 z-10 grid size-7 place-items-center rounded-lg text-white/25 transition-colors hover:bg-white/8 hover:text-brand-200"
>
<ChevronRight className="size-4" />
</Link>
<button
type="button"
onClick={() => onSelect(isActive ? null : row.person.id)}
aria-pressed={isActive}
className={cn(
'w-full rounded-xl border px-3.5 py-2.5 text-left transition-colors',
isActive
? 'border-brand-400/50 bg-brand-500/10'
: 'border-transparent hover:border-ink-600/70 hover:bg-white/[0.03]',
)}
className="w-full rounded-xl px-3.5 py-2.5 text-left"
>
<div className="flex items-baseline justify-between gap-3">
<div className="flex items-baseline justify-between gap-3 pr-8">
<span className="truncate text-sm font-medium text-white/85">
{row.person.name}
</span>
@@ -56,7 +56,7 @@ export function CustomWidgetCard({
}
if (value.kind === 'number') {
return <Number definition={definition} value={value.value} />;
return <Number definition={definition} value={value.value} href={value.href} />;
}
if (value.kind === 'external') {
@@ -91,7 +91,7 @@ export function CustomWidgetCard({
if (value.kind === 'series') {
return (
<Shell definition={definition}>
<Shell definition={definition} href={value.href}>
<Sparkline points={value.points} />
</Shell>
);
@@ -99,7 +99,7 @@ export function CustomWidgetCard({
if (value.kind === 'tickets') {
return (
<Shell definition={definition}>
<Shell definition={definition} href={value.href}>
{value.items.length === 0 ? (
<p className="text-sm text-white/40">Žádné tickety.</p>
) : (
@@ -127,13 +127,15 @@ export function CustomWidgetCard({
if (value.kind === 'workload') {
return (
<Shell definition={definition}>
<Shell definition={definition} href={value.href}>
<Bars
rows={value.rows.map((row) => ({
key: row.name,
key: row.personId,
label: row.name,
value: row.open,
warn: row.overCapacity,
// Z pruhu s jmenem se chce clovek dostat na toho cloveka.
href: row.href,
}))}
/>
</Shell>
@@ -232,32 +234,56 @@ function AgentTable({ rows }: { rows: AgentStatsRow[] }) {
);
}
/** Vodorovne pruhy. Cislo samo o sobe nerekne, jestli je to hodne nebo malo. */
/**
* Vodorovne pruhy. Cislo samo o sobe nerekne, jestli je to hodne nebo malo.
*
* Radek s `href` je odkaz na to, co je za nim: u lidi na cloveka, u seskupeni
* na uz vyfiltrovany seznam ticketu.
*/
function Bars({
rows,
}: {
rows: Array<{ key: string; label: string; value: number; warn?: boolean }>;
rows: Array<{ key: string; label: string; value: number; warn?: boolean; href?: string }>;
}) {
const max = Math.max(1, ...rows.map((row) => row.value));
return (
<ul className="space-y-2">
{rows.slice(0, 12).map((row) => (
<li key={row.key}>
<div className="flex items-baseline justify-between gap-3 text-sm">
<span className="min-w-0 truncate text-white/70">{row.label}</span>
<span className={cn('font-semibold', row.warn ? 'text-warn-400' : 'text-white')}>
{formatNumber(row.value)}
</span>
</div>
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-white/6">
<div
className={cn('h-full rounded-full', row.warn ? 'bg-warn-400/70' : 'bg-brand-400/70')}
style={{ width: `${Math.round((row.value / max) * 100)}%` }}
/>
</div>
</li>
))}
{rows.slice(0, 12).map((row) => {
const body = (
<>
<div className="flex items-baseline justify-between gap-3 text-sm">
<span className="min-w-0 truncate text-white/70 group-hover:text-brand-200">
{row.label}
</span>
<span className={cn('font-semibold', row.warn ? 'text-warn-400' : 'text-white')}>
{formatNumber(row.value)}
</span>
</div>
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-white/6">
<div
className={cn(
'h-full rounded-full',
row.warn ? 'bg-warn-400/70' : 'bg-brand-400/70',
)}
style={{ width: `${Math.round((row.value / max) * 100)}%` }}
/>
</div>
</>
);
return (
<li key={row.key}>
{row.href ? (
<Link to={row.href} className="group block">
{body}
</Link>
) : (
body
)}
</li>
);
})}
</ul>
);
}
@@ -268,21 +294,38 @@ function Number({
value,
at,
stale,
href,
}: {
definition: WidgetDefinition;
value: number | null;
at?: string;
stale?: boolean;
href?: string;
}) {
const target = definition.target;
const share = target && target > 0 && value !== null ? Math.min(value / target, 1) : null;
const number = (
<span
className={cn(
'text-3xl font-bold tabular-nums',
href ? 'text-white transition-colors group-hover:text-brand-200' : 'text-white',
)}
>
{value === null ? '-' : formatNumber(value)}
</span>
);
return (
<Shell definition={definition} at={at} stale={stale}>
<Shell definition={definition} at={at} stale={stale} href={href}>
<div className="flex items-end gap-2">
<span className="text-3xl font-bold text-white tabular-nums">
{value === null ? '-' : formatNumber(value)}
</span>
{href ? (
<Link to={href} className="group">
{number}
</Link>
) : (
number
)}
{target !== undefined && target > 0 && (
<span className="mb-1 flex items-center gap-1 text-xs text-white/40">
{value !== null && value >= target ? (
@@ -345,18 +388,30 @@ function Shell({
definition,
at,
stale,
href,
children,
}: {
definition: WidgetDefinition;
at?: string;
stale?: boolean;
/** Kam vede nadpis. Bez nej je to jen text. */
href?: string;
children: React.ReactNode;
}) {
return (
<section className="glass h-full rounded-card p-5">
<div className="mb-3 flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="truncate font-semibold text-white">{definition.name}</h2>
{href ? (
<Link
to={href}
className="block truncate font-semibold text-white transition-colors hover:text-brand-200"
>
{definition.name}
</Link>
) : (
<h2 className="truncate font-semibold text-white">{definition.name}</h2>
)}
{definition.description && (
<p className="mt-0.5 truncate text-xs text-white/35">{definition.description}</p>
)}
+109 -76
View File
@@ -1,7 +1,10 @@
import { ArrowLeft, Clock, Mail, RotateCcw, Users } from 'lucide-react';
import { ArrowLeft, Mail, Users } from 'lucide-react';
import { useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { DataState } from '@/components/dashboard/DataState';
import { TicketPriorityBadge, TicketStatusBadge } from '@/components/dashboard/StatusBadge';
import { TicketTable } from '@/components/dashboard/TicketTable';
import { ViewSwitch, type ListView } from '@/components/dashboard/ViewSwitch';
import { TicketStatusBadge } from '@/components/dashboard/StatusBadge';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/cn';
import { formatDuration, formatNumber, formatRelative } from '@/lib/format';
@@ -26,6 +29,10 @@ interface PersonDetailResponse {
export default function PersonDetail() {
const { id = '' } = useParams();
/** Co ukazat: co ma u sebe, nebo co vyresil. */
const [tab, setTab] = useState<'open' | 'resolved'>('open');
const [view, setView] = useState<ListView>('table');
const detail = useApiQuery<PersonDetailResponse>(`/api/dashboard/people/${id}`, {
refetchOn: ['ticket.created', 'ticket.updated', 'ticket.assigned', 'ticket.resolved'],
});
@@ -105,19 +112,34 @@ export default function PersonDetail() {
</div>
)}
<div className="grid gap-6 xl:grid-cols-2">
<TicketColumn
title="Má u sebe"
empty="Nic nemá u sebe."
tickets={detail.data.open}
showAge
/>
<TicketColumn
title="Naposledy vyřešil"
empty="Zatím nic nevyřešil."
tickets={detail.data.resolved}
/>
</div>
<section className="glass rounded-card p-4 sm:p-5">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
<TabChip active={tab === 'open'} onClick={() => setTab('open')}>
Má u sebe ({detail.data.open.length})
</TabChip>
<TabChip active={tab === 'resolved'} onClick={() => setTab('resolved')}>
Vyřešil ({detail.data.resolved.length})
</TabChip>
</div>
<ViewSwitch view={view} onChange={setView} />
</div>
{view === 'table' ? (
<TicketTable
tickets={tab === 'open' ? detail.data.open : detail.data.resolved}
// Sloupec Resi je u detailu jednoho cloveka k nicemu.
hide={['assignee']}
empty={tab === 'open' ? 'Nic nemá u sebe.' : 'Zatím nic nevyřešil.'}
/>
) : (
<TicketCards
tickets={tab === 'open' ? detail.data.open : detail.data.resolved}
showAge={tab === 'open'}
empty={tab === 'open' ? 'Nic nemá u sebe.' : 'Zatím nic nevyřešil.'}
/>
)}
</section>
</div>
)}
</DataState>
@@ -125,71 +147,82 @@ export default function PersonDetail() {
);
}
function TicketColumn({
title,
empty,
tickets,
showAge,
/** Zalozka nad tabulkou. Maly prvek, patri k teto strance. */
function TabChip({
active,
onClick,
children,
}: {
title: string;
empty: string;
tickets: Ticket[];
showAge?: boolean;
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<section className="glass rounded-card p-5">
<div className="mb-3 flex items-center justify-between gap-3">
<h2 className="font-semibold text-white">{title}</h2>
<span className="text-xs text-white/35">{tickets.length}</span>
</div>
{tickets.length === 0 ? (
<p className="text-sm text-white/40">{empty}</p>
) : (
<ul className="divide-y divide-ink-600/50">
{tickets.map((ticket) => (
<li key={ticket.id} className="py-3 first:pt-0 last:pb-0">
<div className="flex items-start gap-3">
<span className="mt-0.5 font-mono text-xs text-white/35">{ticket.id}</span>
<div className="min-w-0 flex-1">
<Link
to={`/dashboard/tickety/${ticket.id}`}
className="block truncate text-sm text-white/80 transition-colors hover:text-brand-200"
>
{ticket.subject}
</Link>
<p className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-white/40">
{ticket.externalId && (
<span className="font-mono">externí {ticket.externalId}</span>
)}
{showAge ? (
<span className="flex items-center gap-1">
<Clock className="size-3" />
leží {formatRelative(ticket.createdAt)}
</span>
) : (
ticket.resolvedAt && <span>vyřešeno {formatRelative(ticket.resolvedAt)}</span>
)}
{ticket.reopenCount > 0 && (
<span className="flex items-center gap-1 text-warn-400">
<RotateCcw className="size-3" />
vráceno {ticket.reopenCount}x
</span>
)}
</p>
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<TicketStatusBadge status={ticket.status} />
{(ticket.priority === 'critical' || ticket.priority === 'high') && (
<TicketPriorityBadge priority={ticket.priority} />
)}
</div>
</div>
</li>
))}
</ul>
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={cn(
'rounded-full border px-3 py-1.5 text-xs font-medium transition-colors',
active
? 'border-brand-400/50 bg-brand-500/15 text-brand-200'
: 'border-ink-600/70 text-white/50 hover:border-white/20 hover:text-white',
)}
</section>
>
{children}
</button>
);
}
/** Dlazdicovy pohled na tytéz tickety. */
function TicketCards({
tickets,
showAge,
empty,
}: {
tickets: Ticket[];
showAge: boolean;
empty: string;
}) {
if (tickets.length === 0) {
return (
<p className="rounded-xl border border-dashed border-ink-600/70 px-4 py-6 text-center text-sm text-white/40">
{empty}
</p>
);
}
return (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{tickets.map((ticket) => (
<Link
key={ticket.id}
to={`/dashboard/tickety/${ticket.id}`}
className="group rounded-card border border-ink-600/50 bg-ink-900/40 p-4 transition-colors hover:border-brand-400/40"
>
<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} />
</div>
<p className="mt-1.5 line-clamp-2 text-sm text-white/85 group-hover:text-brand-200">
{ticket.subject}
</p>
<p className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-white/40">
{ticket.externalId && <span className="font-mono">externí {ticket.externalId}</span>}
<span>
{showAge
? `leží ${formatRelative(ticket.createdAt)}`
: ticket.resolvedAt
? `vyřešeno ${formatRelative(ticket.resolvedAt)}`
: ''}
</span>
{ticket.reopenCount > 0 && (
<span className="text-warn-400">vráceno {ticket.reopenCount}x</span>
)}
</p>
</Link>
))}
</div>
);
}
+104 -119
View File
@@ -1,7 +1,7 @@
import { ChevronRight, Search } from 'lucide-react';
import { useMemo, useState } from 'react';
import { Search } from 'lucide-react';
import { useMemo, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { Link, useSearchParams } from 'react-router-dom';
import { DataState } from '@/components/dashboard/DataState';
import {
TicketChannelBadge,
@@ -9,6 +9,7 @@ import {
TicketStatusBadge,
} from '@/components/dashboard/StatusBadge';
import { TicketWorkload } from '@/components/dashboard/TicketWorkload';
import { TicketTable } from '@/components/dashboard/TicketTable';
import { ViewSwitch } from '@/components/dashboard/ViewSwitch';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/cn';
@@ -34,12 +35,6 @@ const statusFilters: Array<{ value: TicketStatus; label: string }> = [
{ value: 'resolved', label: 'Vyřešené' },
];
const scopeOptions: Array<{ value: TicketScope; label: string; hint: string }> = [
{ value: 'all', label: 'Vše', hint: 'Napříč všemi firmami. Jen pro správce platformy.' },
{ value: 'tenant', label: 'Firma', hint: 'Všechny tickety vybrané firmy.' },
{ value: 'mine', label: 'Moje', hint: 'Jen tickety, které mám u sebe.' },
];
const channelFilters: Array<{ value: TicketChannel; label: string }> = [
{ value: 'whatsapp', label: 'WhatsApp' },
{ value: 'facebook', label: 'Facebook' },
@@ -56,15 +51,36 @@ export default function Tickets() {
// Co uzivatel smi, rozhoduje server. Klient jen kresli, co prislo.
const access = useApiQuery<Access>('/api/dashboard/access');
const [scope, setScope] = useState<TicketScope | null>(null);
const [tenantId, setTenantId] = useState<string | null>(null);
/*
* Pocatecni filtr z adresy. Diky tomu vede proklik z widgetu na uz
* vyfiltrovany seznam a odkaz jde poslat kolegovi.
*/
const [params] = useSearchParams();
const initial = useRef({
tenantId: params.get('tenantId'),
assignee: params.get('assignee'),
status: params.get('status') as TicketStatus | null,
channel: params.get('channel') as TicketChannel | null,
typeId: params.get('typeId'),
tag: params.get('tag'),
groupId: params.get('groupId'),
}).current;
const [scope, setScope] = useState<TicketScope | null>(initial.tenantId ? 'tenant' : null);
const [tenantId, setTenantId] = useState<string | null>(initial.tenantId);
/** null = vsichni, 'unassigned' = fronta, jinak ID resitele. */
const [assignee, setAssignee] = useState<string | null>(null);
const [status, setStatus] = useState<TicketStatus | null>(null);
const [channel, setChannel] = useState<TicketChannel | null>(null);
const [assignee, setAssignee] = useState<string | null>(initial.assignee);
const [status, setStatus] = useState<TicketStatus | null>(initial.status);
const [channel, setChannel] = useState<TicketChannel | null>(initial.channel);
/** 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 [groupId, setGroupId] = useState<string | null>(initial.groupId);
const [query, setQuery] = useState('');
/** Tabulka na porovnavani, dlazdice na rychly prehled. */
const [view, setView] = useState<'table' | 'grid'>('table');
/** Jen moje. Server to resi pohledem `mine`, klient jen prepina. */
const [mine, setMine] = useState(false);
// Filtrovani resi server, aby seznam a prehled nikdy neukazovaly jina cisla.
const path = useMemo(() => {
@@ -72,6 +88,9 @@ export default function Tickets() {
if (scope) params.set('scope', scope);
if (tenantId) params.set('tenantId', tenantId);
if (assignee) params.set('assignee', assignee);
if (typeId) params.set('typeId', typeId);
if (tag) params.set('tag', tag);
if (groupId) params.set('groupId', groupId);
if (status) params.set('status', status);
if (channel) params.set('channel', channel);
const search = params.toString();
@@ -140,29 +159,34 @@ export default function Tickets() {
/>
</div>
{/* Pohled je jina osa nez filtry - urcuje, ktera data vubec dorazi. */}
{/*
Dva ovladace, ne tri. Firma se vybira selectem, "moje" prepinacem.
Puvodni trojice pohledu delala totez podruhe: pohled Firma znamenal
tentyz vyber jako select vedle nej a pohled Moje totez co prepinac.
*/}
<div className="mt-4 flex flex-wrap items-center gap-2">
{scopeOptions
.filter((option) => allowedScopes.includes(option.value))
.map((option) => (
<Chip
key={option.value}
active={activeScope === option.value}
onClick={() => setScope(option.value)}
title={option.hint}
>
{option.label}
</Chip>
))}
{/* Prepinac firmy davá smysl jen tomu, kdo jich ma vic. */}
{tenants.length > 1 && activeScope !== 'all' && (
{tenants.length > 1 && (
<select
value={tenantId ?? data?.tenantId ?? ''}
onChange={(event) => setTenantId(event.target.value)}
value={activeScope === 'all' ? '' : (tenantId ?? data?.tenantId ?? '')}
onChange={(event) => {
const value = event.target.value;
if (value === '') {
// Prazdna volba = napric firmami. Smi ji jen ten, kdo na ni ma pravo.
setScope('all');
setTenantId(null);
} else {
setScope(mine ? 'mine' : 'tenant');
setTenantId(value);
}
}}
aria-label="Firma"
className="rounded-full border border-ink-600/70 bg-ink-850/70 px-3 py-1.5 text-xs text-white focus:border-brand-400/70 focus:outline-none"
>
{allowedScopes.includes('all') && (
<option value="" className="bg-ink-850">
Všechny firmy
</option>
)}
{tenants.map((tenant) => (
<option key={tenant.id} value={tenant.id} className="bg-ink-850">
{tenant.name}
@@ -170,18 +194,56 @@ export default function Tickets() {
))}
</select>
)}
</div>
<div className="mt-2 flex flex-wrap gap-2">
<Chip active={assignee === null} onClick={() => setAssignee(null)}>
Kdokoliv
</Chip>
{allowedScopes.includes('mine') && (
<Chip
active={mine}
onClick={() => {
const next = !mine;
setMine(next);
setScope(next ? 'mine' : activeScope === 'all' ? 'all' : 'tenant');
if (next) setAssignee(null);
}}
title="Jen tickety, které mám u sebe."
>
Moje tickety
</Chip>
)}
<Chip
active={assignee === 'unassigned'}
onClick={() => setAssignee(assignee === 'unassigned' ? null : 'unassigned')}
onClick={() => {
setAssignee(assignee === 'unassigned' ? null : 'unassigned');
// Fronta a moje se vylucuji, ticket ve fronte nema resitele.
if (assignee !== 'unassigned' && mine) {
setMine(false);
setScope('tenant');
}
}}
title="Tickety, které nemá nikdo u sebe."
>
Ve frontě
</Chip>
{/*
Filtry z odkazu. V UI k nim tlacitko neni, ale musi jit zrusit -
jinak clovek prijde z widgetu a uz se z uzkeho vyberu nedostane.
*/}
{typeId && (
<Chip active onClick={() => setTypeId(null)} title="Zrušit filtr na typ">
typ: {typeId === 'none' ? 'bez typu' : typeId} x
</Chip>
)}
{tag && (
<Chip active onClick={() => setTag(null)} title="Zrušit filtr na tag">
tag: {tag === 'none' ? 'bez tagu' : tag} x
</Chip>
)}
{groupId && (
<Chip active onClick={() => setGroupId(null)} title="Zrušit filtr na skupinu">
skupina: {groupId === 'none' ? 'bez skupiny' : groupId} x
</Chip>
)}
</div>
<div className="mt-2 flex flex-wrap gap-2">
@@ -283,87 +345,10 @@ export default function Tickets() {
))}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[58rem] text-left text-sm">
<thead className="border-b border-ink-600/60 text-xs tracking-wide text-white/40 uppercase">
<tr>
<th className="px-5 py-3.5 font-medium">ID</th>
<th className="px-5 py-3.5 font-medium">Předmět</th>
<th className="px-5 py-3.5 font-medium">Zákazník</th>
<th className="px-5 py-3.5 font-medium">Kanál</th>
<th className="px-5 py-3.5 font-medium">Stav</th>
<th className="px-5 py-3.5 font-medium">Priorita</th>
<th className="px-5 py-3.5 font-medium">Řeší</th>
<th className="px-5 py-3.5 font-medium">Aktualizace</th>
<th className="px-5 py-3.5">
<span className="sr-only">Detail</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-ink-600/40">
{items.map((ticket) => (
<tr key={ticket.id} className="group transition-colors hover:bg-white/[0.03]">
<td className="px-5 py-4 font-mono text-xs text-white/45">{ticket.id}</td>
<td className="px-5 py-4">
<Link
to={`/dashboard/tickety/${ticket.id}`}
className="text-white/85 transition-colors hover:text-brand-200"
>
{ticket.subject}
</Link>
</td>
<td className="px-5 py-4">
{ticket.customer.id ? (
<span className="text-white/55">{ticket.customer.company}</span>
) : (
<span className="text-warn-400">nedohledáno</span>
)}
<span className="block text-xs text-white/30">
{ticket.customer.contact}
</span>
</td>
<td className="px-5 py-4">
<TicketChannelBadge channel={ticket.channel} />
</td>
<td className="px-5 py-4">
<TicketStatusBadge status={ticket.status} />
</td>
<td className="px-5 py-4">
<TicketPriorityBadge priority={ticket.priority} />
</td>
<td className="px-5 py-4">
{ticket.assignee ? (
<span
className={cn(
'text-white/55',
ticket.assignee.id === meId && 'text-brand-200',
)}
>
{ticket.assignee.name}
</span>
) : (
<span className="text-white/30">ve frontě</span>
)}
</td>
<td className="px-5 py-4 text-white/45">
<span title={formatDateTime(ticket.updatedAt)}>
{formatRelative(ticket.updatedAt)}
</span>
</td>
<td className="px-5 py-4">
<Link
to={`/dashboard/tickety/${ticket.id}`}
aria-label={`Detail ticketu ${ticket.id}`}
className="grid size-8 place-items-center rounded-lg text-white/25 transition-colors hover:bg-white/5 hover:text-white group-hover:text-white/60"
>
<ChevronRight className="size-4" />
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
/* Tatáz tabulka jako na detailu osoby, vcetne chovani na mobilu. */
<div className="p-2 sm:p-3">
<TicketTable tickets={items} meId={meId} />
</div>
)}
</DataState>
</div>
+19 -4
View File
@@ -494,14 +494,29 @@ export type WidgetRender = 'stat' | 'chart' | 'list' | 'table' | 'gauge';
/** Hodnota vlastniho widgetu ze serveru. Podle `kind` se pozna, co s tim. */
export type WidgetValue =
| { kind: 'number'; value: number }
| { kind: 'groups'; rows: Array<{ key: string; label: string; value: number }> }
| { kind: 'series'; points: Array<{ date: string; value: number }> }
/** `href` je odkaz na to, co je za cislem. Sklada ho server, ne klient. */
| { kind: 'number'; value: number; href?: string }
| {
kind: 'groups';
rows: Array<{ key: string; label: string; value: number; href?: string }>;
}
| { kind: 'series'; points: Array<{ date: string; value: number }>; href?: string }
| {
kind: 'tickets';
items: Array<{ id: string; subject: string; status: TicketStatus; assignee: string | null }>;
href?: string;
}
| {
kind: 'workload';
rows: Array<{
personId: string;
name: string;
open: number;
overCapacity: boolean;
href: string;
}>;
href?: string;
}
| { kind: 'workload'; rows: Array<{ name: string; open: number; overCapacity: boolean }> }
| { kind: 'agents'; rows: AgentStatsRow[] }
/**
* Vysledek z konektoru. `stale` znamena, ze je to z mezipameti serveru -