Ticketovaci system: udalosti, externi ID, statistiky a widgety nad konektory

Jakakoliv udalost se muze stat ticketem. Prijem je verejny endpoint na firmu
(`POST /webhook/ticket/:token`), takze zalozit ticket jde i bez stavby stromu.

Externi ID je unikatni V RAMCI FIRMY: dalsi zprava se stejnym ID se navesi na
existujici ticket misto zalozeni druheho, a stejne ID u jine firmy je jiny
ticket. Cislo a retezec jsou tentyz klic. Udalosti se drzi cele vcetne
prijatych dat a jdou rozbalit v detailu - je to neco jineho nez log.

Ticket nove nese firstResponseAt, resolvedAt, resolvedById a reopenCount.
Bez nich neslo rict, kdo kolik odbavil ani jak dlouho zakaznik cekal.
`getAgentStats` z toho pocita vykon resitelu vcetne medianovych casu
a vracenych ticketu. Pocet vyresenych sam o sobe odmenuje toho, kdo tickety
zaviral predcasne, proto je vraceni videt vedle nej.

Widgety: klient konecne vola /widget-data. Endpoint existoval, ale nikdo ho
nepouzival, takze vlastni widget hlasil "nepodarilo se zobrazit". Pribyl zdroj
`connector` - co umi zjistit napojena sluzba, jde vytahnout do dlazdice pres
tentyz skript, ktery pouziva krok automatizace. Vysledek se cachuje.

Akce a widgety uz nejsou v nastaveni, maji vlastni zalozku vedle automatizaci.
Telo akce se sklada stromem, ne JSONem v textarei - je to tentyz editor,
jen misto karty spoustece je "spousti clovek tlacitkem na ticketu".

Nova zalozka Lide se seznamem a detailem osoby. Seznam ticketu i lidi ma dva
pohledy, tabulku a dlazdice.

Opraveno: createTicket bral typeId, fields, tags i assigneeGroupId, ale nikdy
je neukladal. Ticket zalozeny s typem zustaval bez typu a bez vlastnich poli.

Dlouhe pomlcky, sipky, vypustky a bullety pryc z celeho projektu.

Overeno 21 kontrolami proti bezicimu serveru v rezimu souboru.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-13 15:13:09 +02:00
co-authored by Claude Opus 5
parent 6c90372991
commit 29de584df8
54 changed files with 3479 additions and 323 deletions
+11 -1
View File
@@ -23,6 +23,11 @@ const Tickets = lazy(() => import('@/pages/dashboard/Tickets'));
const TicketDetail = lazy(() => import('@/pages/dashboard/TicketDetail'));
const Incidents = lazy(() => import('@/pages/dashboard/Incidents'));
const Settings = lazy(() => import('@/pages/dashboard/Settings'));
const Actions = lazy(() => import('@/pages/dashboard/Actions'));
const ActionDetail = lazy(() => import('@/pages/dashboard/ActionDetail'));
const Widgets = lazy(() => import('@/pages/dashboard/Widgets'));
const People = lazy(() => import('@/pages/dashboard/People'));
const PersonDetail = lazy(() => import('@/pages/dashboard/PersonDetail'));
/**
* Routovani. Cesty jsou zamerne cesky (SEO + citelnost URL),
@@ -51,7 +56,7 @@ export default function App() {
<Suspense
fallback={
<div className="grid min-h-screen place-items-center bg-ink-950">
<Spinner label="Načítám portál…" />
<Spinner label="Načítám portál..." />
</div>
}
>
@@ -69,6 +74,11 @@ export default function App() {
<Route path="tickety/:id" element={<TicketDetail />} />
<Route path="incidenty" element={<Incidents />} />
<Route path="nastaveni" element={<Settings />} />
<Route path="akce" element={<Actions />} />
<Route path="akce/:id" element={<ActionDetail />} />
<Route path="widgety" element={<Widgets />} />
<Route path="lide" element={<People />} />
<Route path="lide/:id" element={<PersonDetail />} />
<Route path="*" element={<NotFound />} />
</Route>
</Route>
+1 -1
View File
@@ -10,7 +10,7 @@ export function RequireAuth() {
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center bg-ink-900">
<Spinner label="Načítám session…" />
<Spinner label="Načítám session..." />
</div>
);
}
@@ -1,16 +1,19 @@
import {
AlarmClock,
Blocks,
ExternalLink,
FlaskConical,
LayoutDashboard,
LayoutGrid,
LifeBuoy,
LogOut,
Menu,
MousePointerClick,
Plug,
Blocks,
ScrollText,
Settings,
ShieldAlert,
Users,
Workflow,
X,
} from 'lucide-react';
@@ -40,6 +43,9 @@ const moduleRoutes: Record<string, { to: string; icon: typeof LayoutDashboard; e
connectors: { to: '/dashboard/konektory', icon: Plug, end: false },
scripts: { to: '/dashboard/skripty', icon: ScrollText, end: false },
tickets: { to: '/dashboard/tickety', icon: LifeBuoy, end: false },
people: { to: '/dashboard/lide', icon: Users, end: false },
actions: { to: '/dashboard/akce', icon: MousePointerClick, end: false },
widgets: { to: '/dashboard/widgety', icon: LayoutGrid, end: false },
incidents: { to: '/dashboard/incidenty', icon: AlarmClock, end: false },
settings: { to: '/dashboard/nastaveni', icon: Settings, end: false },
};
+1 -1
View File
@@ -25,7 +25,7 @@ export function DataState({
if (loading) {
return (
<div className="grid min-h-40 place-items-center">
<Spinner label="Načítám data…" />
<Spinner label="Načítám data..." />
</div>
);
}
+13 -2
View File
@@ -71,6 +71,13 @@ export interface EntityAdminProps<T extends { id: string }> {
canDelete?: (entity: T) => boolean;
/** Doplnujici obsah nad tabulkou. */
children?: ReactNode;
/**
* Vlastni ovladani u radku, vlevo od tuzky a kose.
*
* Je to tady kvuli zaznamum, ktere se z casti upravuji jinde nez v dialogu -
* telo akce je strom a ten se kresli na vlastni strance.
*/
rowActions?: (entity: T) => ReactNode;
/** Zavola se po kazdem uspesnem zapisu. */
onChanged?: () => void;
}
@@ -90,6 +97,7 @@ export function EntityAdmin<T extends { id: string }>({
canWrite = true,
canDelete,
children,
rowActions,
onChanged,
}: EntityAdminProps<T>) {
const [items, setItems] = useState<T[] | null>(null);
@@ -161,8 +169,10 @@ export function EntityAdmin<T extends { id: string }>({
{column.render(entity)}
</td>
))}
{canWrite && (
{(canWrite || rowActions) && (
<td className="px-4 py-2 text-right whitespace-nowrap">
{rowActions?.(entity)}
{canWrite && (
<Button
size="sm"
variant="ghost"
@@ -171,7 +181,8 @@ export function EntityAdmin<T extends { id: string }>({
>
<Pencil className="size-3.5" />
</Button>
{(canDelete?.(entity) ?? true) && (
)}
{canWrite && (canDelete?.(entity) ?? true) && (
<Button
size="sm"
variant="ghost"
+3 -3
View File
@@ -54,9 +54,9 @@ const ticketChannelMap: Record<TicketChannel, { label: string; icon: LucideIcon
};
const incidentSeverityMap: Record<IncidentSeverity, { label: string; tone: BadgeTone }> = {
sev1: { label: 'SEV1 — kritický', tone: 'danger' },
sev2: { label: 'SEV2 — vážný', tone: 'warn' },
sev3: { label: 'SEV3 — menší', tone: 'neutral' },
sev1: { label: 'SEV1 - kritický', tone: 'danger' },
sev2: { label: 'SEV2 - vážný', tone: 'warn' },
sev3: { label: 'SEV3 - menší', tone: 'neutral' },
};
const incidentStatusMap: Record<IncidentStatus, { label: string; tone: BadgeTone }> = {
@@ -0,0 +1,74 @@
import { ChevronRight, Inbox } from 'lucide-react';
import { useState } from 'react';
import { formatDateTime } from '@/lib/format';
import type { TicketEvent } from '@/types/dashboard';
/**
* Prichozi udalosti ticketu.
*
* Rozdil proti logu: log je nase stopa toho, co se delo uvnitr, udalost je
* **fakt zvenku**. Odesilatel posle "objednavka 3 vznikla" a pozdeji
* "objednavka 3 vyfakturovana" - obojí patri k jednomu ticketu, protoze maji
* stejne externi ID.
*
* Data se ukazuji cela a nezkracene. Kdyz se nekdo pta, proc ticket vypada
* takhle, je to jedina odpoved.
*/
export function TicketEvents({ events }: { events: TicketEvent[] }) {
if (events.length === 0) {
return (
<p className="rounded-lg border border-dashed border-ink-600/70 px-3 py-2.5 text-xs text-white/40">
Žádné příchozí události. Ticket vznikl v portálu nebo ze simulace.
</p>
);
}
return (
<ol className="space-y-2">
{events.map((event) => (
<EventRow key={event.id} event={event} />
))}
</ol>
);
}
function EventRow({ event }: { event: TicketEvent }) {
const [open, setOpen] = useState(false);
const hasPayload = Object.keys(event.payload).length > 0;
return (
<li className="rounded-lg border border-ink-600/50 bg-ink-900/40">
<button
type="button"
onClick={() => hasPayload && setOpen(!open)}
disabled={!hasPayload}
className="flex w-full items-start gap-3 p-3 text-left disabled:cursor-default"
>
<span className="mt-0.5 grid size-7 shrink-0 place-items-center rounded-lg bg-white/5 text-white/40">
<Inbox className="size-3.5" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm text-white/80">{event.label}</span>
<span className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-white/35">
<code className="text-brand-300/80">{event.type}</code>
<span>{event.source}</span>
<span>{formatDateTime(event.at)}</span>
</span>
</span>
{hasPayload && (
<ChevronRight
className={`mt-1 size-4 shrink-0 text-white/30 transition-transform ${open ? 'rotate-90' : ''}`}
/>
)}
</button>
{open && (
<pre className="mx-3 mb-3 max-h-64 overflow-auto rounded bg-ink-950/70 p-2.5 font-mono text-xs whitespace-pre-wrap text-white/60">
{JSON.stringify(event.payload, null, 2)}
</pre>
)}
</li>
);
}
@@ -0,0 +1,49 @@
import { LayoutGrid, Table2 } from 'lucide-react';
import { cn } from '@/lib/cn';
/**
* Prepinac tabulka nebo dlazdice.
*
* Tabulka se hodi na porovnavani cisel a hledani v mnoha radcich, dlazdice
* na rychly prehled. Kazdy seznam v portalu tim umi obojí a nikdo se nemusi
* rozhodovat za uzivatele.
*/
export type ListView = 'table' | 'grid';
export function ViewSwitch({
view,
onChange,
}: {
view: ListView;
onChange: (view: ListView) => void;
}) {
const options = [
{ value: 'table' as const, label: 'Tabulka', icon: Table2 },
{ value: 'grid' as const, label: 'Dlaždice', icon: LayoutGrid },
];
return (
<div className="flex rounded-lg border border-ink-600/70 p-0.5">
{options.map((option) => {
const Icon = option.icon;
return (
<button
key={option.value}
type="button"
onClick={() => onChange(option.value)}
aria-pressed={view === option.value}
className={cn(
'flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors',
view === option.value
? 'bg-brand-500/15 text-brand-200'
: 'text-white/45 hover:text-white',
)}
>
<Icon className="size-3.5" />
{option.label}
</button>
);
})}
</div>
);
}
@@ -1,4 +1,12 @@
import { ChevronDown, ChevronUp, GitBranch, Plus, Trash2, Zap } from 'lucide-react';
import {
ChevronDown,
ChevronUp,
GitBranch,
MousePointerClick,
Plus,
Trash2,
Zap,
} from 'lucide-react';
import { StepInputs } from '@/components/dashboard/flow/StepInputs';
import { TriggerConfig } from '@/components/dashboard/flow/TriggerConfig';
import { Badge } from '@/components/ui/Badge';
@@ -45,6 +53,7 @@ export function FlowCanvas({
connectors,
webhookBaseUrl,
regenerating,
start,
onPickTrigger,
onChangeFields,
onRegenerateToken,
@@ -54,29 +63,44 @@ export function FlowCanvas({
connectors: Service[];
webhookBaseUrl: string;
regenerating: boolean;
/**
* Cim strom zacina, kdyz to neni spoustec.
*
* Telo akce na ticketu spousti clovek tlacitkem, takze karta spoustece by
* tam lhala. Misto ni se ukaze tohle a `fields` jsou parametry, ktere ma
* strom k dispozici - u akce jsou to udaje ticketu.
*/
start?: { label: string; hint: string; fields: TriggerField[] };
onPickTrigger: () => void;
onChangeFields: (fields: TriggerField[]) => void;
onRegenerateToken: () => void;
}) {
// Bez spoustece se kroky kresli jen v rezimu akce, jinak neni na cem stavet.
const fallbackFields = start ? start.fields : flow.trigger?.fields;
return (
<div className="flex flex-col items-stretch">
<TriggerCard
flow={flow}
connectors={connectors}
webhookBaseUrl={webhookBaseUrl}
regenerating={regenerating}
onPick={onPickTrigger}
onChangeFields={onChangeFields}
onRegenerateToken={onRegenerateToken}
/>
{start ? (
<StartCard start={start} />
) : (
<TriggerCard
flow={flow}
connectors={connectors}
webhookBaseUrl={webhookBaseUrl}
regenerating={regenerating}
onPick={onPickTrigger}
onChangeFields={onChangeFields}
onRegenerateToken={onRegenerateToken}
/>
)}
{flow.trigger && (
{fallbackFields && (
<StepSequence
steps={flow.steps}
path={[]}
connectors={connectors}
scopes={collectScopes(flow, connectors)}
fallbackFields={flow.trigger.fields}
scopes={collectScopes({ ...flow, trigger: startTrigger(flow, start) }, connectors)}
fallbackFields={fallbackFields}
{...callbacks}
/>
)}
@@ -84,6 +108,55 @@ export function FlowCanvas({
);
}
/**
* Aby `collectScopes` videlo parametry i bez spoustece, podstrci se mu
* nahradni. Do ulozeneho stromu se to nikdy nedostane, je to jen pro vypocet,
* na co se da v krocich odkazat.
*/
function startTrigger(
flow: AutomationFlow,
start?: { fields: TriggerField[] },
): AutomationFlow['trigger'] {
if (!start) return flow.trigger;
return { serviceId: 'ticket', operationId: 'action', fields: start.fields };
}
/** Prvni karta u akce: spousti to clovek, ne sluzba. */
function StartCard({ start }: { start: { label: string; hint: string; fields: TriggerField[] } }) {
return (
<div className="rounded-card border border-brand-400/30 bg-brand-500/8 p-5">
<div className="flex items-start gap-3">
<span className="mt-0.5 grid size-8 shrink-0 place-items-center rounded-lg bg-brand-500/15 text-brand-300">
<MousePointerClick className="size-4" />
</span>
<div className="min-w-0">
<p className="font-semibold text-white">{start.label}</p>
<p className="mt-0.5 text-sm text-white/50">{start.hint}</p>
{start.fields.length > 0 && (
<div className="mt-3">
<p className="text-xs tracking-wide text-white/35 uppercase">
Co je v krocích k dispozici
</p>
<div className="mt-1.5 flex flex-wrap gap-1.5">
{start.fields.map((field) => (
<code
key={field.id}
className="rounded bg-ink-950/60 px-1.5 py-0.5 font-mono text-xs text-white/60"
title={`typ ${field.type}`}
>
{`{{${field.name}}}`}
</code>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
}
/** Prvni karta stromu - dokud neni vybrany spoustec, je to vyzva ke kliknuti. */
function TriggerCard({
flow,
@@ -421,7 +494,7 @@ function ConditionCard({
>
{!field && (
<option value="" className="bg-ink-850">
— vyberte parametr —
- vyberte parametr -
</option>
)}
{fields.map((option) => (
@@ -502,7 +575,7 @@ function ConditionCard({
</p>
{step[branch].length === 0 && (
<p className="mb-1 text-xs text-white/35">
Zatím prázdná větev — přidejte krok tlačítkem níž.
Zatím prázdná větev - přidejte krok tlačítkem níž.
</p>
)}
<StepSequence
@@ -99,7 +99,7 @@ export function StepPicker({
title={mode === 'trigger' ? 'Čím má automatizace začít?' : 'Co se má stát?'}
description={
mode === 'trigger'
? 'Vyberte spouštěč — webhook, čas, nebo událost ve službě, kterou máte napojenou.'
? 'Vyberte spouštěč - webhook, čas, nebo událost ve službě, kterou máte napojenou.'
: 'Vyberte službu nebo konektor a pak konkrétní akci.'
}
>
@@ -110,7 +110,7 @@ export function StepPicker({
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={mode === 'trigger' ? 'Hledat spouštěč…' : 'Hledat akci nebo službu…'}
placeholder={mode === 'trigger' ? 'Hledat spouštěč...' : 'Hledat akci nebo službu...'}
className="w-full rounded-xl border border-ink-600/70 bg-ink-850/70 py-2.5 pr-4 pl-10 text-sm text-white placeholder:text-white/30 focus:border-brand-400/70 focus:outline-none"
/>
</div>
@@ -217,7 +217,7 @@ export function StepPicker({
<span className="block font-semibold text-white">Rozvětvit podmínkou</span>
<span className="mt-0.5 block text-sm text-white/55">
{canAddCondition === false
? 'Nejdřív u spouštěče nadeklarujte vstupní parametry — bez nich není podle čeho se rozhodovat.'
? 'Nejdřív u spouštěče nadeklarujte vstupní parametry - bez nich není podle čeho se rozhodovat.'
: 'Porovná hodnotu vstupního parametru (např. score ≥ 15) a rozdělí běh na větev ANO a NE.'}
</span>
</span>
@@ -110,7 +110,7 @@ function CustomFields({
{fields.length === 0 ? (
<p className="mt-3 rounded-xl border border-dashed border-ink-600/70 px-4 py-3 text-xs text-white/40">
Zatím žádné parametry. Bez nich nelze přidat podmínku — nebylo by podle čeho
Zatím žádné parametry. Bez nich nelze přidat podmínku - nebylo by podle čeho
se rozhodovat.
</p>
) : (
@@ -175,7 +175,7 @@ function CustomFields({
{duplicate && (
<p className="w-full text-xs text-danger-400">
Tento název už je použitý — parametry musí být unikátní.
Tento název už je použitý - parametry musí být unikátní.
</p>
)}
</li>
@@ -294,7 +294,7 @@ function WebhookAddress({
</div>
<p className="mt-2.5 text-xs leading-relaxed text-white/45">
Token v adrese je jediná ochrana — kdo ji zná, může automatizaci spustit.
Token v adrese je jediná ochrana - kdo ji zná, může automatizaci spustit.
Nesdílejte ji veřejně a neposílejte ji v odkazech.
</p>
@@ -314,7 +314,7 @@ function WebhookAddress({
disabled={regenerating}
className="rounded-lg bg-danger-500/20 px-3 py-1.5 text-xs font-semibold text-danger-400 transition-colors hover:bg-danger-500/30 disabled:opacity-50"
>
{regenerating ? 'Generuji…' : 'Ano, vygenerovat novou'}
{regenerating ? 'Generuji...' : 'Ano, vygenerovat novou'}
</button>
<button
type="button"
@@ -0,0 +1,378 @@
import { AlertTriangle, ArrowDownRight, ArrowUpRight, Clock, RotateCcw } from 'lucide-react';
import { Link } from 'react-router-dom';
import { TicketStatusBadge } from '@/components/dashboard/StatusBadge';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/cn';
import { formatDuration, formatNumber, formatRelative } from '@/lib/format';
import type { AgentStatsRow, WidgetDefinition, WidgetValue } from '@/types/dashboard';
/**
* Vykresleni widgetu, jehoz data pocita server (`/api/dashboard/widget-data`).
*
* Rozdeleni je zamerne: `render` rika, jak se to kresli, `value.kind` rika,
* co prislo. Kombinace, ktere nedavaji smysl, odmitne uz server pri ukladani
* definice - tady se jen rekne, ze to nesedi, misto prazdne dlazdice.
*
* Data si widget **nenacita sam**. Prehled se zepta jednim requestem na
* vsechny naraz, jinak by deset dlazdic znamenalo deset volani.
*/
export function CustomWidgetCard({
definition,
value,
error,
loading,
}: {
definition: WidgetDefinition;
value: WidgetValue | undefined;
error: string | undefined;
loading: boolean;
}) {
if (loading && !value && !error) {
return (
<Shell definition={definition}>
<div className="h-16 animate-pulse rounded-lg bg-white/5" />
</Shell>
);
}
if (error) {
return (
<Shell definition={definition}>
<p className="flex items-start gap-2 text-sm text-danger-400">
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
{/* Cele hlaseni, nezkracene. U konektoru je to jedina stopa, proc to nejde. */}
<span className="break-words">{error}</span>
</p>
</Shell>
);
}
if (!value) {
return (
<Shell definition={definition}>
<p className="text-sm text-white/40">Bez dat.</p>
</Shell>
);
}
if (value.kind === 'number') {
return <Number definition={definition} value={value.value} />;
}
if (value.kind === 'external') {
if (value.value !== null && value.rows.length === 0) {
return <Number definition={definition} value={value.value} at={value.at} stale={value.stale} />;
}
if (value.rows.length > 0) {
return (
<Shell definition={definition} at={value.at} stale={value.stale}>
<Bars rows={value.rows} />
</Shell>
);
}
return (
<Shell definition={definition} at={value.at} stale={value.stale}>
<p className="text-sm break-words text-white/70">{value.text ?? 'Bez dat.'}</p>
</Shell>
);
}
if (value.kind === 'groups') {
return (
<Shell definition={definition}>
{value.rows.length === 0 ? (
<p className="text-sm text-white/40">Nic, co by sem patřilo.</p>
) : (
<Bars rows={value.rows} />
)}
</Shell>
);
}
if (value.kind === 'series') {
return (
<Shell definition={definition}>
<Sparkline points={value.points} />
</Shell>
);
}
if (value.kind === 'tickets') {
return (
<Shell definition={definition}>
{value.items.length === 0 ? (
<p className="text-sm text-white/40">Žádné tickety.</p>
) : (
<ul className="divide-y divide-ink-600/50">
{value.items.map((ticket) => (
<li key={ticket.id} className="flex items-center gap-3 py-2.5 first:pt-0 last:pb-0">
<span className="font-mono text-xs text-white/35">{ticket.id}</span>
<Link
to={`/dashboard/tickety/${ticket.id}`}
className="min-w-0 flex-1 truncate text-sm text-white/80 transition-colors hover:text-brand-200"
>
{ticket.subject}
</Link>
<span className="hidden text-xs text-white/40 sm:block">
{ticket.assignee ?? 've frontě'}
</span>
<TicketStatusBadge status={ticket.status} />
</li>
))}
</ul>
)}
</Shell>
);
}
if (value.kind === 'workload') {
return (
<Shell definition={definition}>
<Bars
rows={value.rows.map((row) => ({
key: row.name,
label: row.name,
value: row.open,
warn: row.overCapacity,
}))}
/>
</Shell>
);
}
return (
<Shell definition={definition}>
<AgentTable rows={value.rows} />
</Shell>
);
}
/**
* Tabulka vykonu resitelu.
*
* Zamerne vedle sebe **odbaveno** a **vraceno**: pocet vyresenych sam o sobe
* odmenuje toho, kdo tickety zaviral predcasne. Median misto prumeru, jeden
* ticket zapomenuty pres dovolenou by jinak prebil vsechno ostatni.
*/
function AgentTable({ rows }: { rows: AgentStatsRow[] }) {
if (rows.length === 0) return <p className="text-sm text-white/40">Žádní řešitelé.</p>;
const best = Math.max(...rows.map((row) => row.resolved));
return (
<div className="-mx-1 overflow-x-auto">
<table className="w-full min-w-125 border-collapse text-sm">
<thead>
<tr className="text-left text-xs tracking-wide text-white/35 uppercase">
<th className="px-1 pb-2 font-medium">Řešitel</th>
<th className="px-1 pb-2 text-right font-medium">Odbaveno</th>
<th className="px-1 pb-2 text-right font-medium">Ve frontě</th>
<th className="px-1 pb-2 text-right font-medium" title="Medián doby do vyřešení">
Doba řešení
</th>
<th className="px-1 pb-2 text-right font-medium" title="Medián doby do první reakce">
Reakce
</th>
<th className="px-1 pb-2 text-right font-medium" title="Kolikrát se ticket vrátil">
Vráceno
</th>
</tr>
</thead>
<tbody className="divide-y divide-ink-600/50">
{rows.map((row) => (
<tr key={row.personId} className="text-white/75">
<td className="px-1 py-2">
<Link
to={`/dashboard/lide/${row.personId}`}
className="flex flex-wrap items-center gap-2 transition-colors hover:text-brand-200"
>
{row.name}
{row.overloaded && <Badge tone="warn">přetížený</Badge>}
{row.critical > 0 && <Badge tone="danger">{row.critical} kritické</Badge>}
</Link>
{row.oldestOpenAt && (
<span className="mt-0.5 flex items-center gap-1 text-xs text-white/35">
<Clock className="size-3" />
nejstarší {formatRelative(row.oldestOpenAt)}
</span>
)}
</td>
<td className="px-1 py-2 text-right">
<span
className={cn(
'font-semibold',
row.resolved > 0 && row.resolved === best ? 'text-ok-400' : 'text-white',
)}
>
{formatNumber(row.resolved)}
</span>
</td>
<td className="px-1 py-2 text-right">{formatNumber(row.open)}</td>
<td className="px-1 py-2 text-right text-white/60">
{row.medianResolveMs === null ? '-' : formatDuration(row.medianResolveMs)}
</td>
<td className="px-1 py-2 text-right text-white/60">
{row.medianResponseMs === null ? '-' : formatDuration(row.medianResponseMs)}
</td>
<td className="px-1 py-2 text-right">
{row.reopened === 0 ? (
<span className="text-white/30">-</span>
) : (
<span className="inline-flex items-center gap-1 text-warn-400">
<RotateCcw className="size-3" />
{row.reopened}
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
/** Vodorovne pruhy. Cislo samo o sobe nerekne, jestli je to hodne nebo malo. */
function Bars({
rows,
}: {
rows: Array<{ key: string; label: string; value: number; warn?: boolean }>;
}) {
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>
))}
</ul>
);
}
/** Jedno cislo. Velke, at je citelne pres celou mistnost. */
function Number({
definition,
value,
at,
stale,
}: {
definition: WidgetDefinition;
value: number | null;
at?: string;
stale?: boolean;
}) {
const target = definition.target;
const share = target && target > 0 && value !== null ? Math.min(value / target, 1) : null;
return (
<Shell definition={definition} at={at} stale={stale}>
<div className="flex items-end gap-2">
<span className="text-3xl font-bold text-white tabular-nums">
{value === null ? '-' : formatNumber(value)}
</span>
{target !== undefined && target > 0 && (
<span className="mb-1 flex items-center gap-1 text-xs text-white/40">
{value !== null && value >= target ? (
<ArrowUpRight className="size-3.5 text-ok-400" />
) : (
<ArrowDownRight className="size-3.5 text-warn-400" />
)}
z {formatNumber(target)}
</span>
)}
</div>
{share !== null && (
<div className="mt-3 h-1.5 overflow-hidden rounded-full bg-white/6">
<div
className={cn('h-full rounded-full', share >= 1 ? 'bg-ok-400/70' : 'bg-brand-400/70')}
style={{ width: `${Math.round(share * 100)}%` }}
/>
</div>
)}
</Shell>
);
}
/**
* Cara z casove rady. Zamerne bez os a mrizky - na dlazdici jde o tvar,
* cisla se ctou jinde.
*/
function Sparkline({ points }: { points: Array<{ date: string; value: number }> }) {
if (points.length === 0) return <p className="text-sm text-white/40">Bez dat.</p>;
const max = Math.max(1, ...points.map((point) => point.value));
const step = points.length > 1 ? 100 / (points.length - 1) : 0;
const line = points
.map((point, index) => `${(index * step).toFixed(2)},${(30 - (point.value / max) * 28).toFixed(2)}`)
.join(' ');
return (
<div>
<svg viewBox="0 0 100 30" preserveAspectRatio="none" className="h-20 w-full" role="img">
<title>Časová řada</title>
<polyline
points={line}
fill="none"
stroke="currentColor"
strokeWidth="1"
vectorEffect="non-scaling-stroke"
className="text-brand-400"
/>
</svg>
<div className="mt-1 flex justify-between text-xs text-white/35">
<span>{points[0].date.slice(5)}</span>
<span>vrchol {formatNumber(max)}</span>
<span>{points[points.length - 1].date.slice(5)}</span>
</div>
</div>
);
}
function Shell({
definition,
at,
stale,
children,
}: {
definition: WidgetDefinition;
at?: string;
stale?: boolean;
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>
{definition.description && (
<p className="mt-0.5 truncate text-xs text-white/35">{definition.description}</p>
)}
</div>
{/* U dat z cizi sluzby je stari podstatne - jinak se ctou cisla z vcerejska. */}
{at && (
<span
className="shrink-0 text-xs text-white/30"
title={stale ? 'Z mezipaměti, aby se cizí služba nevolala při každém otevření.' : undefined}
>
{stale ? 'z mezipaměti ' : ''}
{formatRelative(at)}
</span>
)}
</div>
{children}
</section>
);
}
+2 -2
View File
@@ -27,7 +27,7 @@ export function Hero() {
</h1>
<p className="mt-6 max-w-xl text-lg leading-relaxed text-white/65">
Jsme malý tým, který firmám staví automatizace, hlasové asistenty a propojení systémů —
Jsme malý tým, který firmám staví automatizace, hlasové asistenty a propojení systémů -
a pak je i provozuje. Vy vidíte výsledky v dashboardu, my držíme provoz.
</p>
@@ -78,7 +78,7 @@ function HeroPanel() {
<PhoneCall className="size-4" />
</span>
<div>
<p className="text-sm font-semibold text-white">Voicebot — příjem poptávek</p>
<p className="text-sm font-semibold text-white">Voicebot - příjem poptávek</p>
<p className="font-mono text-xs text-white/40">AUT-02 · běží</p>
</div>
</div>
+3 -3
View File
@@ -5,19 +5,19 @@ const steps = [
number: '01',
title: 'Konzultace a mapování',
text: 'Projdeme s vámi proces tak, jak reálně běží. Najdeme místa, kde se ztrácí čas a data.',
duration: '1–2 týdny',
duration: '1-2 týdny',
},
{
number: '02',
title: 'Návrh a prototyp',
text: 'Postavíme funkční prototyp na vašich datech. Uvidíte, jak to bude fungovat, než se doplatí zbytek.',
duration: '2–3 týdny',
duration: '2-3 týdny',
},
{
number: '03',
title: 'Nasazení do provozu',
text: 'Napojíme reálné systémy, zapneme monitoring a zaškolíme lidi. Přepínáme postupně, ne přes noc.',
duration: '2–4 týdny',
duration: '2-4 týdny',
},
{
number: '04',
+1 -1
View File
@@ -14,7 +14,7 @@ export function References() {
Co říkají firmy, <span className="text-gradient">se kterými to běží</span>
</>
}
subtitle="Ukázka spoluprací napříč obory. Čísla vždy měříme před nasazením a po něm — jinak by to byl jen marketing."
subtitle="Ukázka spoluprací napříč obory. Čísla vždy měříme před nasazením a po něm - jinak by to byl jen marketing."
/>
<div className="grid gap-5 md:grid-cols-2">
+1 -1
View File
@@ -73,7 +73,7 @@ export function Footer() {
© {year} {brand.legalName} · IČO {brand.ico}
</p>
<p className="font-mono">
Prototyp webu — obsah a reference jsou ukázkové (mockup).
Prototyp webu - obsah a reference jsou ukázkové (mockup).
</p>
</div>
</Container>
+1 -1
View File
@@ -26,7 +26,7 @@ export const brand = {
},
/** Otevírací doba podpory zobrazená na kontaktech. */
support: {
hours: 'Po–Pá 8:00–18:00',
hours: 'Po-Pá 8:00-18:00',
sla: 'SLA 24/7 pro klienty s tarifem Provoz',
},
} as const;
+1 -1
View File
@@ -129,7 +129,7 @@ export function describeCondition(
if (!field) return 'Neznámý parametr';
const label = operatorLabel(operator, field.type);
if (isUnaryOperator(operator)) return `${field.name} ${label}`;
return `${field.name} ${label} ${value?.trim() || '…'}`;
return `${field.name} ${label} ${value?.trim() || '...'}`;
}
// -------------------------------------------------------- co je kde videt
+3 -3
View File
@@ -21,7 +21,7 @@ export function formatDateTime(iso: string): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) {
console.warn('[format] neplatne datum:', iso);
return '—';
return '-';
}
return dateTimeFormat.format(date);
}
@@ -30,7 +30,7 @@ export function formatDay(iso: string): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) {
console.warn('[format] neplatne datum:', iso);
return '—';
return '-';
}
return dayFormat.format(date);
}
@@ -40,7 +40,7 @@ export function formatRelative(iso: string): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) {
console.warn('[format] neplatne datum:', iso);
return '—';
return '-';
}
const diffMin = Math.round((Date.now() - date.getTime()) / 60_000);
if (diffMin < 1) return 'právě teď';
+27 -2
View File
@@ -16,6 +16,16 @@ interface QueryOptions {
* Diky tomu je dashboard zivy bez pravidelneho dotazovani serveru.
*/
refetchOn?: DashboardEventType[];
/**
* Kdyz je vyplnene, posle se POST s timhle telem.
*
* Je to tady kvuli davkovemu nacitani (`/widget-data`), ktere se ptá na
* seznam ID. GET s dvaceti ID v adrese by narazil na delku URL a druhy hook
* jen kvuli metode by znamenal dvakrat tentyz refetch a debounce.
*/
body?: unknown;
/** false = nedotazovat se vubec. Pro pripad, kdy jeste neni na co. */
enabled?: boolean;
}
/**
@@ -30,12 +40,27 @@ export function useApiQuery<T>(path: string, options: QueryOptions = {}): QueryS
const reload = useCallback(() => setTick((value) => value + 1), []);
const enabled = options.enabled !== false;
// Telo se do zavislosti effectu neda dat primo - nova reference pri kazdem
// vykresleni by znamenala dotaz v nekonecne smycce.
const bodyKey = options.body === undefined ? '' : JSON.stringify(options.body);
useEffect(() => {
if (!enabled) {
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
setError(null);
apiFetch<T>(path)
const request =
bodyKey === ''
? apiFetch<T>(path)
: apiFetch<T>(path, { method: 'POST', body: JSON.parse(bodyKey) as unknown });
request
.then((result) => {
if (!cancelled) setData(result);
})
@@ -51,7 +76,7 @@ export function useApiQuery<T>(path: string, options: QueryOptions = {}): QueryS
return () => {
cancelled = true;
};
}, [path, tick]);
}, [path, tick, bodyKey, enabled]);
// Obnoveni dat pri prichozi udalosti ze streamu.
const { subscribe } = useEventStream();
+6 -6
View File
@@ -37,7 +37,7 @@ const team = [
];
const milestones = [
{ year: '2018', text: 'Vznik firmy — první integrace e-shopů s účetnictvím.' },
{ year: '2018', text: 'Vznik firmy - první integrace e-shopů s účetnictvím.' },
{ year: '2020', text: 'První hlasová linka v produkci, tým se rozrůstá na čtyři lidi.' },
{ year: '2023', text: 'Spouštíme vlastní klientský portál s tickety a incidenty.' },
{ year: '2025', text: 'Provozujeme 120+ automatizací s dostupností 99,98 %.' },
@@ -45,9 +45,9 @@ const milestones = [
export default function About() {
usePageMeta({
title: 'O nás — Automia',
title: 'O nás - Automia',
description:
'Malý tým, který firmám staví automatizace, voiceboty a integrace — a pak je i provozuje.',
'Malý tým, který firmám staví automatizace, voiceboty a integrace - a pak je i provozuje.',
});
return (
@@ -59,7 +59,7 @@ export default function About() {
Malý tým, <span className="text-gradient">který za svou práci ručí</span>
</>
}
subtitle={`${brand.name} funguje od roku ${brand.founded}. Nejsme softwarový dům na sto lidí — jsme parta, která staví věci, co musí fungovat v pondělí ráno.`}
subtitle={`${brand.name} funguje od roku ${brand.founded}. Nejsme softwarový dům na sto lidí - jsme parta, která staví věci, co musí fungovat v pondělí ráno.`}
/>
<Section>
@@ -68,13 +68,13 @@ export default function About() {
<h2 className="text-2xl font-bold text-white">Jak jsme se k tomu dostali</h2>
<p className="leading-relaxed">
Začínali jsme tím, že jsme jedné velkoobchodní firmě přestali přepisovat objednávky
z e-mailu do skladu. Ukázalo se, že tenhle problém má skoro každý — jen mu každý říká
z e-mailu do skladu. Ukázalo se, že tenhle problém má skoro každý - jen mu každý říká
jinak. Od té doby jsme postavili automatizace pro firmy od pěti do pěti set lidí.
</p>
<p className="leading-relaxed">
Postupně se k automatizacím přidaly hlasové linky, protože telefon je pořád kanál,
kterým přichází nejvíc poptávek. A když jsme začali provozovat věci nonstop, přišly
nutně i dashboardy, tickety a incident management — abychom o problémech věděli
nutně i dashboardy, tickety a incident management - abychom o problémech věděli
dřív než klient.
</p>
<p className="leading-relaxed">
+7 -7
View File
@@ -26,7 +26,7 @@ const inputClass =
export default function Contact() {
usePageMeta({
title: 'Kontakt — Automia',
title: 'Kontakt - Automia',
description: 'Napište nám nebo zavolejte. Ozveme se do jednoho pracovního dne.',
});
@@ -62,7 +62,7 @@ export default function Contact() {
Ozvěte se. <span className="text-gradient">Odpovídáme rychle.</span>
</>
}
subtitle="Napište, co řešíte — i když ještě nevíte, jak by to mělo fungovat. Na první hovor stačí popis problému."
subtitle="Napište, co řešíte - i když ještě nevíte, jak by to mělo fungovat. Na první hovor stačí popis problému."
/>
<Section>
@@ -83,7 +83,7 @@ export default function Contact() {
<input id="company" name="company" className={inputClass} placeholder="Firma s.r.o." />
</Field>
<Field label="Telefon" htmlFor="phone" optional>
<input id="phone" name="phone" className={inputClass} placeholder="+420 …" />
<input id="phone" name="phone" className={inputClass} placeholder="+420 ..." />
</Field>
</div>
@@ -105,14 +105,14 @@ export default function Contact() {
minLength={10}
rows={5}
className={`${inputClass} resize-y`}
placeholder="Např.: Objednávky z e-shopu přepisujeme ručně do skladu, denně asi 60 kusů…"
placeholder="Např.: Objednávky z e-shopu přepisujeme ručně do skladu, denně asi 60 kusů..."
/>
</Field>
<div className="flex flex-wrap items-center gap-4 pt-2">
<Button type="submit" disabled={status.kind === 'sending'}>
<Send className="size-4" />
{status.kind === 'sending' ? 'Odesílám…' : 'Odeslat poptávku'}
{status.kind === 'sending' ? 'Odesílám...' : 'Odeslat poptávku'}
</Button>
<p className="text-xs text-white/35">
Odesláním souhlasíte se zpracováním údajů pro účely odpovědi.
@@ -185,14 +185,14 @@ export default function Contact() {
<Card className="border-brand-400/25 bg-brand-500/6">
<h2 className="font-semibold text-white">Jste náš klient?</h2>
<p className="mt-2 text-sm leading-relaxed text-white/60">
Požadavky a incidenty zadávejte přímo v klientském portálu — mají tam SLA a vidíte
Požadavky a incidenty zadávejte přímo v klientském portálu - mají tam SLA a vidíte
jejich stav.
</p>
<Link
to="/prihlaseni"
className="mt-4 inline-flex text-sm font-semibold text-brand-300 hover:text-brand-200"
>
Přejít do portálu →
Přejít do portálu
</Link>
</Card>
</div>
+1 -1
View File
@@ -9,7 +9,7 @@ import { usePageMeta } from '@/lib/usePageMeta';
export default function Home() {
usePageMeta({
title: 'Automia — automatizace, voiceboti a integrace na míru',
title: 'Automia - automatizace, voiceboti a integrace na míru',
description:
'Automatizujeme firemní procesy, stavíme voiceboty a propojujeme systémy. K tomu dashboardy, tickety a incident management.',
});
+4 -4
View File
@@ -32,7 +32,7 @@ const inputClass =
/** Prihlaseni do klientskeho portalu. Demo ucty jsou vypsane pod formularem. */
export default function Login() {
usePageMeta({
title: 'Přihlášení — Automia',
title: 'Přihlášení - Automia',
description: 'Přihlášení do klientského portálu Automia.',
});
@@ -140,7 +140,7 @@ export default function Login() {
autoComplete="current-password"
defaultValue={demoAccounts[0].password}
className={inputClass}
placeholder="••••••••"
placeholder="--------"
/>
</div>
@@ -153,7 +153,7 @@ export default function Login() {
<Button type="submit" className="w-full" disabled={submitting}>
<LockKeyhole className="size-4" />
{submitting ? 'Přihlašuji…' : 'Přihlásit se'}
{submitting ? 'Přihlašuji...' : 'Přihlásit se'}
</Button>
</form>
@@ -187,7 +187,7 @@ export default function Login() {
<Link to="/kontakt" className="font-medium text-brand-300 hover:text-brand-200">
Ozvěte se nám
</Link>{' '}
— portál zřizujeme klientům {brand.name}.
- portál zřizujeme klientům {brand.name}.
</p>
</div>
</div>
+1 -1
View File
@@ -3,7 +3,7 @@ import { Container } from '@/components/ui/Container';
import { usePageMeta } from '@/lib/usePageMeta';
export default function NotFound() {
usePageMeta({ title: 'Stránka nenalezena — Automia' });
usePageMeta({ title: 'Stránka nenalezena - Automia' });
return (
<Container className="flex min-h-[60vh] flex-col items-center justify-center py-20 text-center">
+1 -1
View File
@@ -8,7 +8,7 @@ import { cn } from '@/lib/cn';
export default function Services() {
usePageMeta({
title: 'Služby — Automia',
title: 'Služby - Automia',
description:
'Automatizace procesů, voiceboti, integrace systémů, dashboardy, tickety a incident management.',
});
+288
View File
@@ -0,0 +1,288 @@
import { AlertCircle, ArrowLeft, Check, Save } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { DataState } from '@/components/dashboard/DataState';
import { FlowCanvas } from '@/components/dashboard/flow/FlowCanvas';
import { StepPicker } from '@/components/dashboard/flow/StepPicker';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { apiFetch } from '@/lib/api';
import {
countSteps,
createActionStep,
createConditionStep,
insertStep,
moveStep,
removeStep,
updateCondition,
updateStepInputs,
type FlowPath,
} from '@/lib/flow';
import { useApiQuery } from '@/lib/useApiQuery';
import { usePageMeta } from '@/lib/usePageMeta';
import type {
AutomationFlow,
FlowStep,
ServiceCatalog,
TriggerField,
} from '@/types/dashboard';
/**
* Editor tela akce.
*
* Zamerne **tentyz editor jako u automatizaci** - je to tentyz strom kroku,
* jen ho nespousti sluzba, ale clovek tlacitkem na ticketu. Druhy editor pro
* totez by znamenal dve mista, ktera se casem rozejdou.
*
* Misto karty spoustece je karta "spousti clovek" a parametry, na ktere jde
* v krocích odkazovat, jsou udaje ticketu. Ty pocita server podle typu, na
* ktery je akce navazana.
*/
interface ActionRecord {
id: string;
label: string;
ticketTypeIds: string[];
tags: string[];
body: { kind: string; steps?: FlowStep[] };
}
interface ScopeResponse {
fields: TriggerField[];
formFields: TriggerField[];
}
interface PickerTarget {
path: FlowPath;
index: number;
}
export default function ActionDetail() {
const { id = '' } = useParams();
const action = useApiQuery<ActionRecord>(`/api/dashboard/settings/actions/${id}`);
const scope = useApiQuery<ScopeResponse>(`/api/dashboard/settings/actions/${id}/scope`);
const catalog = useApiQuery<ServiceCatalog>('/api/dashboard/services');
const [steps, setSteps] = useState<FlowStep[]>([]);
const [picker, setPicker] = useState<PickerTarget | null>(null);
const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [savedAt, setSavedAt] = useState<string | null>(null);
usePageMeta({ title: `${action.data?.label ?? 'Akce'} - portál Automia` });
useEffect(() => {
if (!action.data) return;
// Akce mohla byt driv nastavena jako jedna operace. Ta se ukaze jako
// strom o jednom kroku, aby se dala upravit stejnou cestou jako zbytek.
setSteps(bodyToSteps(action.data.body));
setDirty(false);
}, [action.data]);
const connectors = catalog.data?.items ?? [];
const stepCount = useMemo(() => countSteps(steps), [steps]);
/** Parametry, na ktere jde v krocich odkazovat: udaje ticketu plus dialog. */
const startFields = useMemo(
() => [...(scope.data?.fields ?? []), ...(scope.data?.formFields ?? [])],
[scope.data],
);
/** FlowCanvas pracuje s celym tokem. Akce ma jen kroky, spoustec je clovek. */
const flow: AutomationFlow = useMemo(() => ({ trigger: null, steps }), [steps]);
const change = useCallback((next: FlowStep[]) => {
setSteps(next);
setDirty(true);
setSavedAt(null);
}, []);
async function save() {
setSaving(true);
setSaveError(null);
try {
await apiFetch(`/api/dashboard/settings/actions/${id}`, {
method: 'PATCH',
body: { body: { kind: 'tree', steps } },
});
setDirty(false);
setSavedAt(new Date().toISOString());
action.reload();
} catch (err: unknown) {
setSaveError(err instanceof Error ? err.message : 'Uložení se nepodařilo.');
} finally {
setSaving(false);
}
}
return (
<div className="space-y-6">
<Link
to="/dashboard/akce"
className="inline-flex items-center gap-2 text-sm text-white/45 transition-colors hover:text-white"
>
<ArrowLeft className="size-4" />
Zpět na akce
</Link>
<DataState
loading={action.loading || catalog.loading}
error={action.error ?? catalog.error}
onRetry={() => {
action.reload();
catalog.reload();
}}
>
{action.data && (
<div className="space-y-6">
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<h1 className="text-2xl font-bold text-white">{action.data.label}</h1>
<div className="mt-2 flex flex-wrap items-center gap-2">
<span className="font-mono text-xs text-white/35">{action.data.id}</span>
<span className="text-xs text-white/40">
{stepCount} {stepCount === 1 ? 'krok' : stepCount >= 2 && stepCount <= 4 ? 'kroky' : 'kroků'}
</span>
{dirty && <Badge tone="warn">Neuložené změny</Badge>}
{!dirty && savedAt && (
<span className="inline-flex items-center gap-1.5 text-xs text-ok-400">
<Check className="size-3.5" />
Uloženo
</span>
)}
</div>
</div>
<Button size="sm" onClick={() => void save()} disabled={saving || !dirty}>
<Save className="size-4" />
{saving ? 'Ukládám...' : 'Uložit strom'}
</Button>
</header>
{saveError && (
<p className="flex items-center gap-2 rounded-xl border border-danger-400/30 bg-danger-500/10 px-4 py-3 text-sm text-danger-400">
<AlertCircle className="size-4 shrink-0" />
{saveError}
</p>
)}
<div className="grid gap-6 xl:grid-cols-[1fr_18rem]">
<section className="glass rounded-card p-5 sm:p-6">
<div className="mb-6 flex items-center justify-between gap-3">
<h2 className="font-semibold text-white">Co akce udělá</h2>
<p className="text-xs text-white/35">
Klikněte na <span className="font-mono">+</span> a vyberte krok
</p>
</div>
<FlowCanvas
flow={flow}
connectors={connectors}
webhookBaseUrl=""
regenerating={false}
start={{
label: 'Spouští člověk tlačítkem na ticketu',
hint: startHint(action.data),
fields: startFields,
}}
onPickTrigger={() => undefined}
onChangeFields={() => undefined}
onRegenerateToken={() => undefined}
onAddStep={(path, index) => setPicker({ path, index })}
onRemoveStep={(stepId) => change(removeStep(steps, stepId))}
onUpdateCondition={(stepId, patch) =>
change(updateCondition(steps, stepId, patch))
}
onUpdateInputs={(stepId, inputs) => change(updateStepInputs(steps, stepId, inputs))}
onMoveStep={(stepId, offset) => change(moveStep(steps, stepId, offset))}
/>
</section>
<aside className="space-y-4">
<div className="glass rounded-card p-5">
<h2 className="font-semibold text-white">Jak to funguje</h2>
<ol className="mt-3 space-y-2.5 text-sm text-white/55">
<li className="flex gap-2">
<span className="font-mono text-brand-300">1.</span>
Akce se ukáže jako tlačítko na ticketu, který sedí na typ nebo tag.
</li>
<li className="flex gap-2">
<span className="font-mono text-brand-300">2.</span>
Kroky se skládají stejně jako v automatizaci, včetně větvení podmínkou.
</li>
<li className="flex gap-2">
<span className="font-mono text-brand-300">3.</span>
V polích kroku jde odkazovat na údaje ticketu přes{' '}
<span className="font-mono text-xs">{'{{název}}'}</span>.
</li>
</ol>
</div>
<p className="rounded-xl border border-warn-400/30 bg-warn-500/8 px-4 py-3 text-xs leading-relaxed text-white/60">
Strom se uloží, ale zatím se nevykoná. Chybí runtime, stejně jako
u automatizací. Akce s jednou operací běží.
</p>
</aside>
</div>
</div>
)}
</DataState>
<StepPicker
open={picker !== null}
mode="action"
connectors={connectors}
categories={catalog.data?.categories ?? []}
onClose={() => setPicker(null)}
onPickOperation={(serviceId, operationId) => {
if (!picker) return;
change(insertStep(steps, picker.path, picker.index, createActionStep(serviceId, operationId)));
setPicker(null);
}}
onPickCondition={() => {
const first = startFields[0];
if (!picker || !first) return;
change(insertStep(steps, picker.path, picker.index, createConditionStep(first)));
setPicker(null);
}}
canAddCondition={startFields.length > 0}
/>
</div>
);
}
/** Popis toho, kde se akce ukaze. Prazdna vazba je chyba, ne detail. */
function startHint(action: ActionRecord): string {
const parts = [
action.ticketTypeIds.length > 0 ? `typ ${action.ticketTypeIds.join(', ')}` : null,
action.tags.length > 0 ? `tag ${action.tags.join(', ')}` : null,
].filter(Boolean);
return parts.length > 0
? `Ukáže se na ticketech, kde sedí ${parts.join(' nebo ')}.`
: 'Akce nemá typ ani tag, takže se zatím neukáže nikde.';
}
/**
* Telo akce na kroky.
*
* Jedna operace je strom o jednom kroku - tim se stara definice da upravit
* stejnou cestou jako nova a nemusi existovat druhy editor.
*/
function bodyToSteps(body: ActionRecord['body']): FlowStep[] {
if (body?.kind === 'tree') return body.steps ?? [];
if (body?.kind === 'operation') {
const operation = body as unknown as {
serviceId: string;
operationId: string;
inputs?: Record<string, string>;
};
const step = createActionStep(operation.serviceId, operation.operationId);
return [step.kind === 'action' ? { ...step, inputs: operation.inputs ?? {} } : step];
}
return [];
}
+224
View File
@@ -0,0 +1,224 @@
import { GitBranch, Info } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { EntityAdmin, parseJsonField, type AdminField } from '@/components/dashboard/EntityAdmin';
import { Badge } from '@/components/ui/Badge';
import { apiFetch } from '@/lib/api';
import { usePageMeta } from '@/lib/usePageMeta';
/**
* Definice akci na ticketu.
*
* Je to **stejna uroven jako automatizace**, ne polozka v nastaveni: je to
* definice toho, co aplikace umi, ne nastaveni uctu.
*
* Rozdeleni prace: tady se nastavi, **kdy se tlacitko ukaze** (typ, tag,
* podminky) a na co se doptat. **Co akce dela** je strom a ten se kresli
* na vlastni strance stejnym editorem jako automatizace.
*/
interface ActionRecord {
id: string;
label: string;
icon: string;
style: 'primary' | 'default' | 'danger';
order: number;
ticketTypeIds: string[];
tags: string[];
confirm: string | null;
enabled: boolean;
form: unknown[];
visibleWhen: unknown[];
body: { kind: string; steps?: unknown[] };
[key: string]: unknown;
}
export default function Actions() {
usePageMeta({ title: 'Akce na ticketu - portál Automia' });
const [types, setTypes] = useState<Array<{ id: string; name: string }>>([]);
useEffect(() => {
apiFetch<{ items: Array<{ id: string; name: string }> }>(
'/api/dashboard/settings/ticket-types',
)
.then((data) => setTypes(data.items))
.catch(() => setTypes([]));
}, []);
const fields: Array<AdminField<ActionRecord>> = [
{ name: 'label', label: 'Nápis na tlačítku', kind: 'text', required: true },
{ name: 'icon', label: 'Ikona', kind: 'text', initial: 'Play', hint: 'Například Play, Send, FileText.' },
{
name: 'style',
label: 'Vzhled',
kind: 'select',
initial: 'default',
options: [
{ value: 'primary', label: 'Hlavní' },
{ value: 'default', label: 'Běžný' },
{ value: 'danger', label: 'Nebezpečný' },
],
},
{ name: 'order', label: 'Pořadí', kind: 'number', initial: 10 },
{
name: 'ticketTypeIds',
label: 'Typy ticketu',
kind: 'multiselect',
options: types.map((type) => ({ value: type.id, label: type.name })),
hint: 'Za typem stojí vlastní pole, takže z nich akce může čerpat.',
},
{
name: 'tags',
label: 'Tagy',
kind: 'tags',
hint: 'Za tagem žádná pole nestojí, akce pak umí jen vestavěné údaje ticketu.',
},
{
name: 'confirm',
label: 'Text potvrzení',
kind: 'text',
hint: 'Prázdné = akce se spustí hned po kliknutí.',
},
{
name: 'visibleWhen',
label: 'Kdy se tlačítko ukáže',
kind: 'json',
initial: '[]',
hint: 'Podmínky nad ticketem, například [{"fieldId":"ticket.status","operator":"neq","value":"resolved"}]. Prázdné = vždy.',
},
{
name: 'form',
label: 'Na co se zeptat před spuštěním',
kind: 'json',
initial: '[]',
hint: 'Pole dialogu: id, label, kind (text, longtext, choice), required.',
},
{ name: 'enabled', label: 'Zapnutá', kind: 'checkbox', initial: true },
];
return (
<div className="space-y-6">
<header>
<h1 className="text-2xl font-bold text-white">Akce na ticketu</h1>
<p className="mt-1 text-sm text-white/50">
Tlačítko, které řešitel zmáčkne na ticketu. Váže se na typ nebo tag.
</p>
</header>
<p className="flex gap-2 rounded-xl border border-ink-600/60 px-4 py-3 text-sm leading-relaxed text-white/45">
<Info className="mt-0.5 size-4 shrink-0 text-brand-300" />
<span>
Akce a automatizace jsou dvě různé věci. Automatizace běží sama, akci spouští člověk.
Tady se určí, <strong className="font-semibold text-white/70">kdy se tlačítko ukáže</strong>;
co akce dělá, se skládá ze stromu kroků tlačítkem{' '}
<span className="whitespace-nowrap">Upravit strom</span>.
</span>
</p>
<EntityAdmin<ActionRecord>
title="Akce"
endpoint="/api/dashboard/settings/actions"
listEndpoint="/api/dashboard/settings/actions-overview"
columns={[
{
label: 'Akce',
render: (action) => (
<span className="flex flex-wrap items-center gap-2">
<Link
to={`/dashboard/akce/${action.id}`}
className="font-medium text-white transition-colors hover:text-brand-200"
>
{action.label}
</Link>
{action.style === 'primary' && <Badge tone="brand">hlavní</Badge>}
{action.style === 'danger' && <Badge tone="danger">nebezpečná</Badge>}
</span>
),
},
{
label: 'Na čem se ukáže',
render: (action) => {
const names = action.ticketTypeIds.map(
(id) => types.find((type) => type.id === id)?.name ?? id,
);
const parts = [
names.length > 0 ? `typ: ${names.join(', ')}` : null,
action.tags.length > 0 ? `tag: ${action.tags.join(', ')}` : null,
].filter(Boolean);
return parts.length > 0 ? (
parts.join(' | ')
) : (
<span className="text-warn-400">nikde, chybí typ i tag</span>
);
},
},
{
label: 'Co dělá',
render: (action) => <BodySummary body={action.body} />,
},
{
label: 'Stav',
narrow: true,
render: (action) =>
action.enabled === false ? (
<Badge tone="warn">vypnutá</Badge>
) : (
<Badge tone="ok">zapnutá</Badge>
),
},
]}
fields={fields}
rowActions={(action) => (
<Link
to={`/dashboard/akce/${action.id}`}
className="mr-1 inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium text-brand-300 transition-colors hover:bg-brand-500/12 hover:text-brand-200"
>
<GitBranch className="size-3.5" />
Upravit strom
</Link>
)}
toBody={(values) => ({
label: values.label,
icon: values.icon,
style: values.style || 'default',
order: values.order,
ticketTypeIds: values.ticketTypeIds,
tags: values.tags,
confirm: values.confirm === '' ? null : values.confirm,
visibleWhen: parseJsonField(values.visibleWhen, []),
form: parseJsonField(values.form, []),
// Telo se tady nemeni. Nova akce zacina prazdnym stromem a naplni
// se v editoru, jinak by tu byl JSON, ktery nikdo psat nechce.
...(values.body === undefined ? { body: { kind: 'tree', steps: [] } } : {}),
...(values.enabled !== undefined ? { enabled: values.enabled } : {}),
})}
toForm={(action) => ({
...action,
confirm: action.confirm ?? '',
visibleWhen: JSON.stringify(action.visibleWhen ?? [], null, 2),
form: JSON.stringify(action.form ?? [], null, 2),
// Telo se do formulare zamerne nedava, upravuje se stromem.
body: undefined,
})}
/>
</div>
);
}
/** Jednou vetou, co akce dela. Podrobnosti jsou ve stromu. */
function BodySummary({ body }: { body: { kind: string; steps?: unknown[] } }) {
if (body?.kind === 'tree') {
const count = body.steps?.length ?? 0;
return count === 0 ? (
<span className="text-warn-400">prázdný strom</span>
) : (
<span className="text-white/60">
strom, {count} {count === 1 ? 'krok' : count <= 4 ? 'kroky' : 'kroků'}
</span>
);
}
if (body?.kind === 'operation') return <span className="text-white/60">jedna operace</span>;
if (body?.kind === 'script') return <span className="text-white/60">skript</span>;
return <span className="text-white/35">nenastaveno</span>;
}
+5 -5
View File
@@ -54,7 +54,7 @@ export default function AutomationDetail() {
/** Co podle serveru chybi k zapnuti. Prepocitava ho server pri kazdem ulozeni. */
const [issues, setIssues] = useState<string[]>([]);
usePageMeta({ title: `${name || 'Automatizace'} — portál Automia` });
usePageMeta({ title: `${name || 'Automatizace'} - portál Automia` });
// Prevzeti dat ze serveru do editovatelneho stavu.
useEffect(() => {
@@ -272,7 +272,7 @@ export default function AutomationDetail() {
{enabled ? 'Aktivní' : 'Pozastaveno'}
</Badge>
) : (
<Badge tone="warn">Koncept — chybí spouštěč</Badge>
<Badge tone="warn">Koncept - chybí spouštěč</Badge>
)}
<span className="text-xs text-white/40">
{stepCount} {stepCount === 1 ? 'krok' : stepCount >= 2 && stepCount <= 4 ? 'kroky' : 'kroků'}
@@ -300,7 +300,7 @@ export default function AutomationDetail() {
</Button>
<Button size="sm" onClick={() => void save()} disabled={saving || !dirty}>
<Save className="size-4" />
{saving ? 'Ukládám…' : 'Uložit'}
{saving ? 'Ukládám...' : 'Uložit'}
</Button>
<button
type="button"
@@ -377,7 +377,7 @@ export default function AutomationDetail() {
<ol className="mt-3 space-y-2.5 text-sm text-white/55">
<li className="flex gap-2">
<span className="font-mono text-brand-300">1.</span>
Vyberte spouštěč — čím automatizace začne.
Vyberte spouštěč - čím automatizace začne.
</li>
<li className="flex gap-2">
<span className="font-mono text-brand-300">2.</span>
@@ -386,7 +386,7 @@ export default function AutomationDetail() {
</li>
<li className="flex gap-2">
<span className="font-mono text-brand-300">3.</span>
Potřebujete rozhodování? Přidejte podmínku — běh se rozdělí na větev
Potřebujete rozhodování? Přidejte podmínku - běh se rozdělí na větev
ANO a NE.
</li>
<li className="flex gap-2">
+5 -5
View File
@@ -29,7 +29,7 @@ const kindMeta: Record<Automation['kind'], { label: string; icon: LucideIcon }>
};
export default function Automations() {
usePageMeta({ title: 'Automatizace — portál Automia' });
usePageMeta({ title: 'Automatizace - portál Automia' });
const { data, loading, error, reload } = useApiQuery<ListResponse<Automation>>(
'/api/dashboard/automations',
{
@@ -62,7 +62,7 @@ export default function Automations() {
to="/dashboard/konektory"
className="rounded-full px-4 py-2 text-sm font-medium text-white/55 transition-colors hover:bg-white/5 hover:text-white"
>
Konektory →
Konektory
</Link>
</div>
</header>
@@ -136,7 +136,7 @@ function AutomationCard({ automation }: { automation: Automation }) {
<Metric label="Úspěšnost" value={formatPercent(automation.successRate)} />
<Metric
label="Prům. běh"
value={automation.avgDurationMs === 0 ? '—' : formatDuration(automation.avgDurationMs)}
value={automation.avgDurationMs === 0 ? '-' : formatDuration(automation.avgDurationMs)}
/>
<Metric label="Naposled" value={formatRelative(automation.lastRunAt)} />
</dl>
@@ -210,7 +210,7 @@ function CreateAutomationModal({
onChange={(event) => setName(event.target.value)}
required
minLength={3}
placeholder="Např.: Poptávka z webu → CRM → e-mail"
placeholder="Např.: Poptávka z webu na CRM na e-mail"
className="w-full rounded-xl border border-ink-600/70 bg-ink-850/70 px-4 py-3 text-sm text-white placeholder:text-white/30 focus:border-brand-400/70 focus:outline-none"
/>
<p className="mt-2 text-xs text-white/35">
@@ -230,7 +230,7 @@ function CreateAutomationModal({
Zrušit
</Button>
<Button type="submit" disabled={submitting}>
{submitting ? 'Vytvářím…' : 'Vytvořit a pokračovat'}
{submitting ? 'Vytvářím...' : 'Vytvořit a pokračovat'}
</Button>
</div>
</form>
+1 -1
View File
@@ -6,7 +6,7 @@ import { usePageMeta } from '@/lib/usePageMeta';
import type { Incident, ListResponse } from '@/types/dashboard';
export default function Incidents() {
usePageMeta({ title: 'Incidenty — portál Automia' });
usePageMeta({ title: 'Incidenty - portál Automia' });
const { data, loading, error, reload } = useApiQuery<ListResponse<Incident>>(
'/api/dashboard/incidents',
{ refetchOn: ['incident.started', 'incident.updated', 'incident.resolved'] },
+77 -6
View File
@@ -14,6 +14,7 @@ import { useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { useAuth } from '@/auth/AuthContext';
import { DataState } from '@/components/dashboard/DataState';
import { CustomWidgetCard } from '@/components/dashboard/widgets/CustomWidget';
import { WidgetCard, type WidgetData } from '@/components/dashboard/widgets/WidgetCard';
import { WidgetPicker } from '@/components/dashboard/widgets/WidgetPicker';
import { Button } from '@/components/ui/Button';
@@ -29,6 +30,7 @@ import type {
LayoutResponse,
ListResponse,
TicketListResponse,
WidgetDataResponse,
WidgetDefinition,
WidgetSize,
Workload,
@@ -41,6 +43,12 @@ const span: Record<WidgetSize, string> = {
full: 'col-span-6',
};
/**
* Vestavene widgety, jejichz data pocita server stejne jako u vlastnich.
* Musi sedet s `builtinSources` v `src/data/widgets.ts`.
*/
const COMPUTED_BUILTINS = new Set(['panel.agents']);
const sizeLabels: Record<WidgetSize, string> = {
third: 'třetina',
half: 'polovina',
@@ -48,7 +56,7 @@ const sizeLabels: Record<WidgetSize, string> = {
};
export default function Overview() {
usePageMeta({ title: 'Přehled — portál Automia' });
usePageMeta({ title: 'Přehled - portál Automia' });
const { user } = useAuth();
const access = useApiQuery<Access>('/api/dashboard/access');
@@ -89,6 +97,38 @@ export default function Overview() {
const data: WidgetData = { summary, tickets, incidents, workload };
// ------------------------------------------------- data vlastnich widgetu
const layoutItems = layout.data?.items ?? [];
/**
* Ktere dlazdice si data pocitaji na serveru. Krome vlastnich jsou to
* i vestavene, ktere maji zdroj (vykon resitelu) - klient nepozna rozdil
* a nemusi.
*/
const computedIds = useMemo(() => {
const byId = new Map((catalog.data?.items ?? []).map((widget) => [widget.id, widget]));
const ids = layoutItems
.map((item) => item.widgetId)
.filter((id) => byId.get(id)?.custom === true || COMPUTED_BUILTINS.has(id));
// Tentyz widget muze byt na dashboardu vickrat, data se ptaji jednou.
return [...new Set(ids)].sort();
}, [layoutItems, catalog.data]);
const widgetData = useApiQuery<WidgetDataResponse>(
`/api/dashboard/widget-data${scopeQuery}`,
{
body: { widgetIds: computedIds },
enabled: computedIds.length > 0,
refetchOn: ['ticket.created', 'ticket.updated', 'ticket.assigned', 'ticket.resolved'],
},
);
const computed = useMemo(
() => new Map((widgetData.data?.items ?? []).map((item) => [item.id, item])),
[widgetData.data],
);
// ----------------------------------------------------------- rezim uprav
const [editing, setEditing] = useState(false);
@@ -238,7 +278,7 @@ export default function Overview() {
</Button>
<Button size="sm" onClick={() => void save()} disabled={saving}>
<Check className="size-4" />
{saving ? 'Ukládám…' : 'Uložit'}
{saving ? 'Ukládám...' : 'Uložit'}
</Button>
</>
) : (
@@ -291,17 +331,39 @@ export default function Overview() {
const definition = definitions.get(item.widgetId);
if (!definition) {
// Widget zmizel z katalogu. Nesmi to tise vypadnout z rozlozeni.
/*
* Widget zmizel z katalogu, typicky ho nekdo smazal v definicich.
* Nesmi to tise vypadnout z rozlozeni, ale musi jit odstranit -
* jinak by mrtvy odkaz zustal na dashboardu napord.
*/
return (
<div
key={item.id}
className="col-span-6 rounded-card border border-danger-400/40 bg-danger-500/8 p-4 text-sm text-danger-400"
className="col-span-6 flex flex-wrap items-center justify-between gap-3 rounded-card border border-danger-400/40 bg-danger-500/8 p-4 text-sm text-danger-400"
>
Widget „{item.widgetId}" už v katalogu není.
<span>
Widget <code>{item.widgetId}</code> už v katalogu není. Nejspíš ho někdo smazal.
</span>
{editing ? (
<Button size="sm" variant="secondary" onClick={() => remove(item.id)}>
<Trash2 className="size-3.5" />
Odebrat z dashboardu
</Button>
) : (
<span className="text-xs text-white/40">
Odebrat ho jde v režimu Upravit dashboard.
</span>
)}
</div>
);
}
// Widget s vypoctem na serveru se kresli z `/widget-data`,
// pevny widget z dat, ktera uz prehled ma.
const result = computed.get(item.widgetId);
const usesServerData =
definition.custom === true || COMPUTED_BUILTINS.has(item.widgetId);
return (
<div key={item.id} className={cn(span[item.size], 'min-w-0')}>
{editing && (
@@ -316,7 +378,16 @@ export default function Overview() {
/>
)}
<div className={cn(editing && 'pointer-events-none opacity-70')}>
<WidgetCard definition={definition} data={data} />
{usesServerData ? (
<CustomWidgetCard
definition={definition}
value={result?.ok ? result.value : undefined}
error={result && !result.ok ? result.error : (widgetData.error ?? undefined)}
loading={widgetData.loading}
/>
) : (
<WidgetCard definition={definition} data={data} />
)}
</div>
</div>
);
+198
View File
@@ -0,0 +1,198 @@
import { Mail, Users } from 'lucide-react';
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { DataState } from '@/components/dashboard/DataState';
import { ViewSwitch, type ListView } from '@/components/dashboard/ViewSwitch';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/cn';
import { useApiQuery } from '@/lib/useApiQuery';
import { usePageMeta } from '@/lib/usePageMeta';
import type { PeopleResponse, Workload } from '@/types/dashboard';
/**
* Lide firmy: kdo resi tickety a jak na tom je.
*
* Dva pohledy, tabulka a dlazdice. Tabulka se hodi na porovnavani cisel,
* dlazdice na rychly prehled - proto obojí, ne jen jedno.
*/
export default function People() {
usePageMeta({ title: 'Lidé - portál Automia' });
const [view, setView] = useState<ListView>('table');
const people = useApiQuery<PeopleResponse>('/api/dashboard/people');
const workload = useApiQuery<Workload>('/api/dashboard/tickets/workload', {
refetchOn: ['ticket.created', 'ticket.updated', 'ticket.assigned', 'ticket.resolved'],
});
const rows = workload.data?.rows ?? [];
const byPerson = new Map(rows.map((row) => [row.person.id, row]));
const items = people.data?.items ?? [];
return (
<div className="space-y-6">
<header className="flex flex-wrap items-end justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-white">Lidé</h1>
<p className="mt-1 text-sm text-white/50">
Kdo řeší tickety, co má u sebe a jak dlouho mu to leží.
</p>
</div>
<ViewSwitch view={view} onChange={setView} />
</header>
{workload.data && (
<div className="flex flex-wrap gap-3 text-sm">
<Stat label="Nevyřízených celkem" value={workload.data.openTotal} />
<Stat
label="Bez řešitele"
value={workload.data.unassigned}
tone={workload.data.unassigned > 0 ? 'warn' : undefined}
/>
<Stat label="Řešitelů" value={items.length} />
{(people.data?.groups ?? []).length > 0 && (
<Stat label="Skupin" value={(people.data?.groups ?? []).length} />
)}
</div>
)}
<DataState
loading={people.loading}
error={people.error}
onRetry={people.reload}
empty={items.length === 0}
emptyLabel="Firma zatím nemá žádné řešitele. Přidejte je v Nastavení."
>
{view === 'table' ? (
<div className="glass overflow-x-auto rounded-card">
<table className="w-full min-w-150 border-collapse text-sm">
<thead>
<tr className="border-b border-ink-600/50 text-left text-xs tracking-wide text-white/35 uppercase">
<th className="px-4 py-3 font-medium">Jméno</th>
<th className="px-4 py-3 font-medium">E-mail</th>
<th className="px-4 py-3 font-medium">Zaměření</th>
<th className="px-4 py-3 text-right font-medium">U sebe</th>
<th className="px-4 py-3 text-right font-medium">Kapacita</th>
<th className="px-4 py-3 text-right font-medium">Kritické</th>
</tr>
</thead>
<tbody>
{items.map((person) => {
const row = byPerson.get(person.id);
return (
<tr key={person.id} className="border-b border-ink-600/30 last:border-0">
<td className="px-4 py-2.5">
<Link
to={`/dashboard/lide/${person.id}`}
className="flex flex-wrap items-center gap-2 font-medium text-white transition-colors hover:text-brand-200"
>
{person.name}
{row?.overloaded && <Badge tone="warn">přetížený</Badge>}
{person.id === people.data?.meId && <Badge tone="brand">já</Badge>}
</Link>
</td>
<td className="px-4 py-2.5 text-white/55">{person.email}</td>
<td className="px-4 py-2.5 text-white/55">{person.role}</td>
<td className="px-4 py-2.5 text-right font-semibold text-white">
{row?.open ?? 0}
</td>
<td className="px-4 py-2.5 text-right text-white/45">{person.capacity}</td>
<td className="px-4 py-2.5 text-right">
{row && row.critical > 0 ? (
<span className="text-danger-400">{row.critical}</span>
) : (
<span className="text-white/25">-</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{items.map((person) => {
const row = byPerson.get(person.id);
const share = person.capacity > 0 ? Math.min((row?.open ?? 0) / person.capacity, 1) : 0;
return (
<Link
key={person.id}
to={`/dashboard/lide/${person.id}`}
className="glass group rounded-card p-5 transition-colors hover:border-brand-400/40"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate font-semibold text-white group-hover:text-brand-200">
{person.name}
</p>
<p className="mt-0.5 truncate text-xs text-white/40">{person.role}</p>
</div>
{row?.overloaded && <Badge tone="warn">přetížený</Badge>}
</div>
<p className="mt-3 flex items-center gap-1.5 truncate text-xs text-white/40">
<Mail className="size-3.5 shrink-0" />
{person.email}
</p>
<div className="mt-4 flex items-baseline justify-between">
<span className="text-2xl font-bold text-white tabular-nums">
{row?.open ?? 0}
</span>
<span className="text-xs text-white/40">z kapacity {person.capacity}</span>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-white/6">
<div
className={cn(
'h-full rounded-full',
row?.overloaded ? 'bg-warn-400/70' : 'bg-brand-400/70',
)}
style={{ width: `${Math.round(share * 100)}%` }}
/>
</div>
</Link>
);
})}
</div>
)}
</DataState>
{(people.data?.groups ?? []).length > 0 && (
<section className="glass rounded-card p-5">
<h2 className="flex items-center gap-2 font-semibold text-white">
<Users className="size-4 text-brand-300" />
Skupiny
</h2>
<div className="mt-3 flex flex-wrap gap-2">
{(people.data?.groups ?? []).map((group) => (
<Badge key={group.id} tone="neutral">
{group.name}
</Badge>
))}
</div>
</section>
)}
</div>
);
}
function Stat({
label,
value,
tone,
}: {
label: string;
value: number;
tone?: 'warn';
}) {
return (
<div className="glass rounded-xl px-4 py-2.5">
<span className="text-xs text-white/40">{label}</span>
<p className={cn('text-lg font-bold', tone === 'warn' ? 'text-warn-400' : 'text-white')}>
{value}
</p>
</div>
);
}
+221
View File
@@ -0,0 +1,221 @@
import { ArrowLeft, Clock, Mail, RotateCcw, Users } from 'lucide-react';
import { Link, useParams } from 'react-router-dom';
import { DataState } from '@/components/dashboard/DataState';
import { TicketPriorityBadge, TicketStatusBadge } from '@/components/dashboard/StatusBadge';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/cn';
import { formatDuration, formatNumber, formatRelative } from '@/lib/format';
import { useApiQuery } from '@/lib/useApiQuery';
import { usePageMeta } from '@/lib/usePageMeta';
import type { AgentStatsRow, Person, Ticket } from '@/types/dashboard';
/**
* Detail resitele: kdo to je, jak mu to jde a co ma u sebe.
*
* Cisla sedi s widgetem vykonu, protoze je pocita tatáz funkce na serveru.
* Kdyby si je stranka pocitala sama, na dvou mistech by vyslo neco jineho.
*/
interface PersonDetailResponse {
person: Person;
stats: AgentStatsRow | null;
groups: Array<{ id: string; name: string }>;
open: Ticket[];
resolved: Ticket[];
}
export default function PersonDetail() {
const { id = '' } = useParams();
const detail = useApiQuery<PersonDetailResponse>(`/api/dashboard/people/${id}`, {
refetchOn: ['ticket.created', 'ticket.updated', 'ticket.assigned', 'ticket.resolved'],
});
usePageMeta({ title: `${detail.data?.person.name ?? 'Řešitel'} - portál Automia` });
return (
<div className="space-y-6">
<Link
to="/dashboard/lide"
className="inline-flex items-center gap-2 text-sm text-white/45 transition-colors hover:text-white"
>
<ArrowLeft className="size-4" />
Zpět na lidi
</Link>
<DataState loading={detail.loading} error={detail.error} onRetry={detail.reload}>
{detail.data && (
<div className="space-y-6">
<header>
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-bold text-white">{detail.data.person.name}</h1>
{detail.data.stats?.overloaded && <Badge tone="warn">přetížený</Badge>}
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-white/45">
<span className="flex items-center gap-1.5">
<Mail className="size-3.5" />
{detail.data.person.email}
</span>
<span>{detail.data.person.role}</span>
{detail.data.groups.length > 0 && (
<span className="flex items-center gap-1.5">
<Users className="size-3.5" />
{detail.data.groups.map((group) => group.name).join(', ')}
</span>
)}
</div>
</header>
{detail.data.stats && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-6">
<Tile
label="Odbaveno za 30 dní"
value={formatNumber(detail.data.stats.resolved)}
tone="ok"
/>
<Tile label="Má u sebe" value={formatNumber(detail.data.stats.open)} />
<Tile
label="Kapacita"
value={formatNumber(detail.data.person.capacity)}
hint={detail.data.stats.overloaded ? 'překročená' : undefined}
/>
<Tile
label="Doba řešení"
value={
detail.data.stats.medianResolveMs === null
? '-'
: formatDuration(detail.data.stats.medianResolveMs)
}
hint="medián"
/>
<Tile
label="První reakce"
value={
detail.data.stats.medianResponseMs === null
? '-'
: formatDuration(detail.data.stats.medianResponseMs)
}
hint="medián"
/>
<Tile
label="Vrácené tickety"
value={formatNumber(detail.data.stats.reopened)}
tone={detail.data.stats.reopened > 0 ? 'warn' : undefined}
hint="z vyřešeno zpět"
/>
</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>
</div>
)}
</DataState>
</div>
);
}
function TicketColumn({
title,
empty,
tickets,
showAge,
}: {
title: string;
empty: string;
tickets: Ticket[];
showAge?: boolean;
}) {
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>
)}
</section>
);
}
function Tile({
label,
value,
hint,
tone,
}: {
label: string;
value: string;
hint?: string;
tone?: 'ok' | 'warn';
}) {
return (
<div className="glass rounded-card p-4">
<p className="text-xs text-white/40">{label}</p>
<p
className={cn(
'mt-1 text-xl font-bold tabular-nums',
tone === 'ok' ? 'text-ok-400' : tone === 'warn' ? 'text-warn-400' : 'text-white',
)}
>
{value}
</p>
{hint && <p className="mt-0.5 text-xs text-white/30">{hint}</p>}
</div>
);
}
+6 -188
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { useAuth } from '@/auth/AuthContext';
import { EntityAdmin, parseJsonField, type AdminField } from '@/components/dashboard/EntityAdmin';
import { EntityAdmin, parseJsonField } from '@/components/dashboard/EntityAdmin';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { apiFetch } from '@/lib/api';
@@ -16,9 +16,8 @@ import type { Access } from '@/types/dashboard';
* u dane entity jine: sloupce tabulky a pole formulare. Nova entita znamena
* jeden zaznam v `tabs` nize.
*
* Slozitejsi struktury (pole typu ticketu, telo akce, zdroj widgetu) se upravuji
* jako JSON. Klikaci editor by u nich byl vetsi prace nez cely zbytek nastaveni
* a JSON je zatim citelnejsi nez deset vnorenych formularu.
* Akce a widgety tady **nejsou**: jsou to definice toho, co aplikace umi,
* stejna uroven jako automatizace, a maji vlastni zalozku.
*/
interface CatalogItem {
@@ -79,8 +78,6 @@ export default function Settings() {
{ key: 'groups', label: 'Skupiny', show: can('group.manage') },
{ key: 'roles', label: 'Role a práva', show: can('role.manage') },
{ key: 'types', label: 'Typy ticketů', show: can('ticketType.manage') },
{ key: 'actions', label: 'Akce na ticketu', show: can('action.manage') },
{ key: 'widgets', label: 'Vlastní widgety', show: can('widget.manage') },
{ key: 'users', label: 'Uživatelé', show: platformAdmin },
{ key: 'tenants', label: 'Firmy', show: platformAdmin },
{ key: 'features', label: 'Záložky firmy', show: platformAdmin },
@@ -94,7 +91,8 @@ export default function Settings() {
<header>
<h1 className="text-2xl font-bold text-white">Nastavení</h1>
<p className="mt-1 text-sm text-white/50">
Co která firma má, kdo co smí a co se dá na ticketu udělat.
Firmy, lidé, role a typy ticketů. Akce a widgety mají vlastní záložku,
jsou to definice, ne nastavení.
</p>
</header>
@@ -267,98 +265,7 @@ export default function Settings() {
/>
)}
{tab === 'actions' && (
<EntityAdmin
title="Akce na ticketu"
description="Tlačítko na ticketu. Váže se na typ nebo tag a spouští operaci služby, strom nebo skript."
endpoint="/api/dashboard/settings/actions"
listEndpoint="/api/dashboard/settings/actions-overview"
columns={[
{ label: 'Akce', render: (action: NamedRecord) => String(action.label ?? '') },
{
label: 'Na čem',
render: (action: NamedRecord) => {
const types = (action.ticketTypeIds as string[] | undefined) ?? [];
const tags = (action.tags as string[] | undefined) ?? [];
return [types.length > 0 ? `typy: ${types.join(', ')}` : null, tags.length > 0 ? `tagy: ${tags.join(', ')}` : null]
.filter(Boolean)
.join(' | ');
},
},
{
label: 'Tělo',
narrow: true,
render: (action: NamedRecord) => {
const body = action.body as { kind?: string } | undefined;
return <code>{body?.kind ?? '?'}</code>;
},
},
{
label: 'Stav',
narrow: true,
render: (action: NamedRecord) =>
action.enabled === false ? <Badge tone="warn">vypnutá</Badge> : <Badge tone="ok">zapnutá</Badge>,
},
]}
fields={actionFields}
toBody={(values, _entity) => ({
label: values.label,
icon: values.icon,
style: values.style || 'default',
order: values.order,
ticketTypeIds: values.ticketTypeIds,
tags: values.tags,
confirm: values.confirm === '' ? null : values.confirm,
visibleWhen: parseJsonField(values.visibleWhen, []),
form: parseJsonField(values.form, []),
body: parseJsonField(values.body, { kind: 'tree', steps: [] }),
...(values.enabled !== undefined ? { enabled: values.enabled } : {}),
})}
toForm={(action: NamedRecord) => ({
...action,
confirm: action.confirm ?? '',
visibleWhen: JSON.stringify(action.visibleWhen ?? [], null, 2),
form: JSON.stringify(action.form ?? [], null, 2),
body: JSON.stringify(action.body ?? {}, null, 2),
})}
/>
)}
{tab === 'widgets' && (
<EntityAdmin
title="Vlastní widgety"
description="Dlaždice na přehled. Filtr je tentýž, který umí seznam ticketů."
endpoint="/api/dashboard/settings/widgets"
listEndpoint="/api/dashboard/settings/widgets-overview"
columns={[
{ label: 'Název', render: (widget: NamedRecord) => String(widget.name ?? '') },
{ label: 'Vykreslení', narrow: true, render: (widget: NamedRecord) => <code>{String(widget.render ?? '')}</code> },
{
label: 'Zdroj',
render: (widget: NamedRecord) => {
const source = widget.source as { kind?: string; groupBy?: string } | undefined;
return `${source?.kind ?? '?'}${source?.groupBy ? ` podle ${source.groupBy}` : ''}`;
},
},
{ label: 'Šířka', narrow: true, render: (widget: NamedRecord) => String(widget.size ?? '') },
]}
fields={widgetFields}
toBody={(values) => ({
name: values.name,
description: values.description,
render: values.render,
size: values.size,
...(values.target ? { target: Number(values.target) } : {}),
source: parseJsonField(values.source, { kind: 'ticketCount', filter: {} }),
})}
toForm={(widget: NamedRecord) => ({
...widget,
source: JSON.stringify(widget.source ?? {}, null, 2),
})}
/>
)}
{tab === 'users' && <UsersAdmin />}
{tab === 'users' && <UsersAdmin />}
{tab === 'tenants' && (
<EntityAdmin
@@ -849,92 +756,3 @@ function AuditView() {
// ---------------------------------------------------- definice poli formularu
const actionFields: Array<AdminField<NamedRecord>> = [
{ name: 'label', label: 'Nápis na tlačítku', kind: 'text', required: true },
{ name: 'icon', label: 'Ikona', kind: 'text', initial: 'Play' },
{
name: 'style',
label: 'Vzhled',
kind: 'select',
initial: 'default',
options: [
{ value: 'primary', label: 'Hlavní' },
{ value: 'default', label: 'Běžný' },
{ value: 'danger', label: 'Nebezpečný' },
],
},
{ name: 'order', label: 'Pořadí', kind: 'number', initial: 10 },
{
name: 'ticketTypeIds',
label: 'Typy ticketu',
kind: 'tags',
hint: 'ID typů oddělená čárkou. Za typem stojí pole, takže akce z nich může čerpat.',
},
{
name: 'tags',
label: 'Tagy',
kind: 'tags',
hint: 'Za tagem nestojí žádná pole, takže akce umí jen vestavěná pole ticketu.',
},
{ name: 'confirm', label: 'Text potvrzení', kind: 'text', hint: 'Prázdné = spustí se hned.' },
{
name: 'visibleWhen',
label: 'Kdy se tlačítko ukáže',
kind: 'json',
initial: '[]',
hint: 'Podmínky: fieldId, operator, value. Například [{"fieldId":"ticket.status","operator":"neq","value":"resolved"}]',
},
{
name: 'form',
label: 'Doptávací formulář',
kind: 'json',
initial: '[]',
hint: 'Na co se zeptat před spuštěním: id, label, kind, required.',
},
{
name: 'body',
label: 'Co akce dělá',
kind: 'json',
initial: '{\n "kind": "operation",\n "serviceId": "idoklad",\n "operationId": "create-issued-invoice",\n "connectorId": null,\n "inputs": {}\n}',
hint: 'kind: operation (jeden krok služby), tree (vlastní strom) nebo script.',
},
{ name: 'enabled', label: 'Zapnutá', kind: 'checkbox', initial: true },
];
const widgetFields: Array<AdminField<NamedRecord>> = [
{ name: 'name', label: 'Název', kind: 'text', required: true },
{ name: 'description', label: 'Popis', kind: 'text' },
{
name: 'render',
label: 'Vykreslení',
kind: 'select',
initial: 'stat',
options: [
{ value: 'stat', label: 'Jedno číslo' },
{ value: 'table', label: 'Tabulka' },
{ value: 'list', label: 'Seznam' },
{ value: 'chart', label: 'Graf' },
{ value: 'gauge', label: 'Ukazatel' },
],
},
{
name: 'size',
label: 'Šířka',
kind: 'select',
initial: 'third',
options: [
{ value: 'third', label: 'Třetina' },
{ value: 'half', label: 'Polovina' },
{ value: 'full', label: 'Celá šířka' },
],
hint: 'Číslo a ukazatel umí třetinu a polovinu, ostatní polovinu a celou šířku.',
},
{ name: 'target', label: 'Cílová hodnota', kind: 'number', hint: 'Jen u ukazatele.' },
{
name: 'source',
label: 'Zdroj dat',
kind: 'json',
initial: '{\n "kind": "ticketCount",\n "filter": { "status": ["new", "open", "waiting"] }\n}',
hint: 'kind: ticketCount, ticketList, ticketSeries nebo workload. U ticketCount lze groupBy.',
},
];
+30 -2
View File
@@ -3,6 +3,7 @@ import {
ArrowLeft,
Building2,
FileText,
Inbox,
MessageSquarePlus,
ScrollText,
UserCheck,
@@ -17,6 +18,7 @@ import {
TicketStatusBadge,
} from '@/components/dashboard/StatusBadge';
import { TicketActions } from '@/components/dashboard/TicketActions';
import { TicketEvents } from '@/components/dashboard/TicketEvents';
import { TicketTrace, TraceSummary } from '@/components/dashboard/TicketTrace';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
@@ -55,7 +57,7 @@ export default function TicketDetail() {
const [actionError, setActionError] = useState<string | null>(null);
const [comment, setComment] = useState('');
usePageMeta({ title: `${ticket.data ? ticket.data.id : 'Ticket'} — portál Automia` });
usePageMeta({ title: `${ticket.data ? ticket.data.id : 'Ticket'} - portál Automia` });
// Po zmene ticketu zmizi stara chyba, at nevisi u uz opraveneho stavu.
useEffect(() => {
@@ -117,6 +119,16 @@ export default function TicketDetail() {
<TicketChannelBadge channel={ticket.data.channel} />
<TicketStatusBadge status={ticket.data.status} />
<TicketPriorityBadge priority={ticket.data.priority} />
{ticket.data.externalId && (
<Badge tone="neutral">
{/* Podle tohohle se na ticket navesuji dalsi udalosti. */}
externí ID {ticket.data.externalId}
{ticket.data.externalSource ? ` (${ticket.data.externalSource})` : ''}
</Badge>
)}
{ticket.data.reopenCount > 0 && (
<Badge tone="warn">vráceno {ticket.data.reopenCount}x</Badge>
)}
{ticket.data.customer.id === null && (
<Badge tone="warn">
<AlertCircle className="size-3.5" />
@@ -164,6 +176,22 @@ export default function TicketDetail() {
)}
</section>
<section className="glass rounded-card p-5 sm:p-6">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<h2 className="flex items-center gap-2 font-semibold text-white">
<Inbox className="size-4 text-brand-300" />
Příchozí události
</h2>
<span className="text-xs text-white/35">
{ticket.data.events.length}
{ticket.data.externalId
? ` k externímu ID ${ticket.data.externalId}`
: ''}
</span>
</div>
<TicketEvents events={ticket.data.events} />
</section>
<section className="glass rounded-card p-5 sm:p-6">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<h2 className="flex items-center gap-2 font-semibold text-white">
@@ -193,7 +221,7 @@ export default function TicketDetail() {
id="ticket-comment"
value={comment}
onChange={(event) => setComment(event.target.value)}
placeholder="Co jste zjistili nebo udělali…"
placeholder="Co jste zjistili nebo udělali..."
className="min-w-0 flex-1 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"
/>
<Button type="submit" size="sm" disabled={busy || comment.trim().length < 2}>
+79 -9
View File
@@ -9,6 +9,8 @@ import {
TicketStatusBadge,
} from '@/components/dashboard/StatusBadge';
import { TicketWorkload } from '@/components/dashboard/TicketWorkload';
import { ViewSwitch } from '@/components/dashboard/ViewSwitch';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/cn';
import { formatDateTime, formatRelative } from '@/lib/format';
import { useApiQuery } from '@/lib/useApiQuery';
@@ -49,7 +51,7 @@ const channelFilters: Array<{ value: TicketChannel; label: string }> = [
];
export default function Tickets() {
usePageMeta({ title: 'Tickety — portál Automia' });
usePageMeta({ title: 'Tickety - portál Automia' });
// Co uzivatel smi, rozhoduje server. Klient jen kresli, co prislo.
const access = useApiQuery<Access>('/api/dashboard/access');
@@ -61,6 +63,8 @@ export default function Tickets() {
const [status, setStatus] = useState<TicketStatus | null>(null);
const [channel, setChannel] = useState<TicketChannel | null>(null);
const [query, setQuery] = useState('');
/** Tabulka na porovnavani, dlazdice na rychly prehled. */
const [view, setView] = useState<'table' | 'grid'>('table');
// Filtrovani resi server, aby seznam a prehled nikdy neukazovaly jina cisla.
const path = useMemo(() => {
@@ -111,12 +115,15 @@ export default function Tickets() {
return (
<div className="space-y-6">
<header>
<h1 className="text-2xl font-bold text-white">Tickety</h1>
<p className="mt-1 text-sm text-white/50">
Požadavky ze všech kanálů. Každý má svého řešitele a dohledatelný průběh.
V detailu je vidět, co která služba vrátila.
</p>
<header className="flex flex-wrap items-end justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-white">Tickety</h1>
<p className="mt-1 text-sm text-white/50">
Požadavky ze všech kanálů. Každý má svého řešitele a dohledatelný průběh.
V detailu je vidět, co která služba vrátila.
</p>
</div>
<ViewSwitch view={view} onChange={setView} />
</header>
<div className="grid gap-6 xl:grid-cols-[1fr_20rem]">
@@ -128,7 +135,7 @@ export default function Tickets() {
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Hledat podle čísla, předmětu nebo firmy…"
placeholder="Hledat podle čísla, předmětu nebo firmy..."
className="w-full rounded-xl border border-ink-600/70 bg-ink-850/70 py-2.5 pr-4 pl-10 text-sm text-white placeholder:text-white/30 focus:border-brand-400/70 focus:outline-none"
/>
</div>
@@ -202,7 +209,7 @@ export default function Tickets() {
</div>
</div>
<div className="glass overflow-hidden rounded-card">
<div className={cn(view === 'table' && 'glass overflow-hidden rounded-card')}>
<DataState
loading={tickets.loading}
error={tickets.error}
@@ -214,6 +221,68 @@ export default function Tickets() {
: 'Žádné tickety.'
}
>
{view === 'grid' ? (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{items.map((ticket) => (
<Link
key={ticket.id}
to={`/dashboard/tickety/${ticket.id}`}
className="glass group flex flex-col rounded-card 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-2 line-clamp-2 text-sm text-white/85 group-hover:text-brand-200">
{ticket.subject}
</p>
<p className="mt-1 truncate text-xs text-white/40">
{ticket.customer.id ? (
ticket.customer.company
) : (
<span className="text-warn-400">zákazník nedohledán</span>
)}
</p>
{ticket.externalId && (
<p className="mt-1 font-mono text-xs text-white/30">
externí {ticket.externalId}
</p>
)}
<div className="mt-3 flex flex-wrap items-center gap-1.5">
<TicketChannelBadge channel={ticket.channel} />
<TicketPriorityBadge priority={ticket.priority} />
{ticket.tags.slice(0, 2).map((tag) => (
<Badge key={tag} tone="neutral">
{tag}
</Badge>
))}
</div>
<div className="mt-3 flex items-center justify-between gap-2 border-t border-ink-600/50 pt-3 text-xs">
<span
className={cn(
'truncate',
ticket.assignee
? ticket.assignee.id === meId
? 'text-brand-200'
: 'text-white/55'
: 'text-white/30',
)}
>
{ticket.assignee?.name ?? 've frontě'}
</span>
<span className="shrink-0 text-white/35" title={formatDateTime(ticket.updatedAt)}>
{formatRelative(ticket.updatedAt)}
</span>
</div>
</Link>
))}
</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">
@@ -295,6 +364,7 @@ export default function Tickets() {
</tbody>
</table>
</div>
)}
</DataState>
</div>
</div>
+186
View File
@@ -0,0 +1,186 @@
import { Info } from 'lucide-react';
import { useEffect, useState } from 'react';
import { EntityAdmin, parseJsonField, type AdminField } from '@/components/dashboard/EntityAdmin';
import { Badge } from '@/components/ui/Badge';
import { apiFetch } from '@/lib/api';
import { usePageMeta } from '@/lib/usePageMeta';
import type { Service } from '@/types/dashboard';
/**
* Definice vlastnich widgetu.
*
* Stejna uroven jako automatizace a akce. Widget je dvojice:
* - `render` jak se to kresli,
* - `source` odkud jsou data.
*
* Zdroj muze byt i **konektor**: co umi zjistit napojena sluzba, jde vytahnout
* do dlazdice. Vola se pritom tentyz skript jako v kroku automatizace, takze
* widget nema vlastni cestu k cizi sluzbe.
*/
interface WidgetRecord {
id: string;
name: string;
description: string;
render: string;
size: string;
source: { kind: string; [key: string]: unknown };
ownerId: string | null;
target?: number;
[key: string]: unknown;
}
const sourceHint = `Příklady:
{"kind":"ticketCount","filter":{"status":["new","open","waiting"]}}
{"kind":"ticketCount","filter":{},"groupBy":"assignee"}
{"kind":"ticketList","filter":{"status":["new"]},"limit":5}
{"kind":"ticketSeries","filter":{},"bucket":"day"}
{"kind":"agentStats","period":"30d"}
{"kind":"connector","serviceId":"idoklad","operationId":"find-issued-invoice","connectorId":null,"inputs":{},"path":"total","ttlSec":300}`;
export default function Widgets() {
usePageMeta({ title: 'Widgety - portál Automia' });
const [services, setServices] = useState<Service[]>([]);
useEffect(() => {
apiFetch<{ items: Service[] }>('/api/dashboard/services')
.then((data) => setServices(data.items))
.catch(() => setServices([]));
}, []);
const fields: Array<AdminField<WidgetRecord>> = [
{ name: 'name', label: 'Název', kind: 'text', required: true },
{ name: 'description', label: 'Popis', kind: 'text' },
{
name: 'render',
label: 'Jak se kreslí',
kind: 'select',
initial: 'stat',
options: [
{ value: 'stat', label: 'Jedno číslo' },
{ value: 'table', label: 'Tabulka' },
{ value: 'list', label: 'Seznam' },
{ value: 'chart', label: 'Graf' },
{ value: 'gauge', label: 'Ukazatel k cíli' },
],
},
{
name: 'size',
label: 'Šířka',
kind: 'select',
initial: 'third',
options: [
{ value: 'third', label: 'Třetina' },
{ value: 'half', label: 'Polovina' },
{ value: 'full', label: 'Celá šířka' },
],
},
{
name: 'target',
label: 'Cílová hodnota',
kind: 'number',
hint: 'Jen u ukazatele. Podle ní se kreslí, jak daleko k cíli je.',
},
{
name: 'source',
label: 'Odkud data',
kind: 'json',
required: true,
initial: '{"kind":"ticketCount","filter":{"status":["new","open","waiting"]}}',
hint: sourceHint,
},
{
name: 'ownerId',
label: 'Jen můj widget',
kind: 'checkbox',
hint: 'Zaškrtnuté = vidíte ho jen vy. Nezaškrtnuté = celá firma.',
},
];
return (
<div className="space-y-6">
<header>
<h1 className="text-2xl font-bold text-white">Widgety</h1>
<p className="mt-1 text-sm text-white/50">
Dlaždice, které si jde položit na přehled. Data počítá server.
</p>
</header>
<p className="flex gap-2 rounded-xl border border-ink-600/60 px-4 py-3 text-sm leading-relaxed text-white/45">
<Info className="mt-0.5 size-4 shrink-0 text-brand-300" />
<span>
Zdroj <code className="text-white/70">connector</code> vytáhne data z napojené služby:
zavolá stejnou operaci jako krok automatizace a z výsledku vezme, co je v{' '}
<code className="text-white/70">path</code>. Výsledek se drží v mezipaměti (
<code className="text-white/70">ttlSec</code>), aby otevření přehledu neznamenalo
volání cizí služby za každou dlaždici.
{services.length > 0 && (
<>
{' '}
Napojitelné služby: {services.slice(0, 8).map((service) => service.id).join(', ')}
{services.length > 8 ? ' a další' : ''}.
</>
)}
</span>
</p>
<EntityAdmin<WidgetRecord>
title="Vlastní widgety"
endpoint="/api/dashboard/settings/widgets"
listEndpoint="/api/dashboard/settings/widgets-overview"
columns={[
{ label: 'Název', render: (widget) => widget.name },
{
label: 'Zdroj',
render: (widget) => <code className="text-xs">{describeSource(widget.source)}</code>,
},
{ label: 'Kreslení', narrow: true, render: (widget) => widget.render },
{
label: 'Kdo vidí',
narrow: true,
render: (widget) =>
widget.ownerId ? <Badge tone="neutral">jen já</Badge> : <Badge tone="ok">firma</Badge>,
},
]}
fields={fields}
toBody={(values) => ({
name: values.name,
description: values.description ?? '',
render: values.render,
size: values.size,
source: parseJsonField(values.source, { kind: 'ticketCount', filter: {} }),
...(values.target ? { target: values.target } : {}),
// Zaskrtnute pole znamena osobni widget. Server dosadi ID prihlaseneho,
// klient sve ID posilat nemusi a nema.
personal: values.ownerId === true,
})}
toForm={(widget) => ({
...widget,
source: JSON.stringify(widget.source, null, 2),
ownerId: widget.ownerId !== null,
})}
/>
</div>
);
}
/** Zdroj jednou radkou, aby se tabulka dala precist. */
function describeSource(source: { kind: string; [key: string]: unknown }): string {
switch (source.kind) {
case 'ticketCount':
return source.groupBy ? `počet ticketů podle ${String(source.groupBy)}` : 'počet ticketů';
case 'ticketList':
return 'seznam ticketů';
case 'ticketSeries':
return 'časová řada ticketů';
case 'workload':
return 'vytížení týmu';
case 'agentStats':
return `výkon řešitelů (${String(source.period ?? 'vše')})`;
case 'connector':
return `konektor ${String(source.serviceId)}/${String(source.operationId)}`;
default:
return source.kind;
}
}
+100 -1
View File
@@ -63,10 +63,28 @@ export interface TicketCustomer {
reply: string;
}
/** Prichozi udalost k ticketu. Fakt zvenku, na rozdil od radku logu. */
export interface TicketEvent {
id: string;
/** Typ od odesilatele, napr. `order.created`. */
type: string;
source: string;
label: string;
payload: Record<string, unknown>;
at: string;
}
export interface Ticket {
id: string;
/** Firma, ktere ticket patri. */
tenantId: string;
/**
* ID u odesilatele, napr. cislo objednavky. **Unikatni v ramci firmy.**
* Dalsi zprava se stejnym ID se navesi na tenhle ticket.
*/
externalId: string | null;
/** Odkud externi ID pochazi. Jen informace, do klice nevstupuje. */
externalSource: string | null;
subject: string;
/** Cely text pozadavku. Prazdny = krok "Zalozit ticket" obsah nenaplnil. */
body: string;
@@ -88,6 +106,14 @@ export interface Ticket {
fields: Record<string, string | number | boolean | null>;
/** Volne tagy. Akce se muze vazat na typ **nebo** na tag. */
tags: string[];
/** Kdy se ticketu prvne nekdo venoval. */
firstResponseAt: string | null;
/** Kdy byl vyresen. Znovuotevreni to vynuluje. */
resolvedAt: string | null;
/** Kdo ho vyresil. */
resolvedById: string | null;
/** Kolikrat se vratil z vyreseno zpatky do prace. */
reopenCount: number;
createdAt: string;
updatedAt: string;
}
@@ -112,6 +138,8 @@ export interface TicketTraceEntry {
export interface TicketDetail extends Ticket {
trace: TicketTraceEntry[];
/** Prichozi udalosti, nejstarsi nahore. */
events: TicketEvent[];
}
/** Odpoved seznamu ticketu - `meId` rika, ktery resitel je prihlaseny uzivatel. */
@@ -449,7 +477,74 @@ export interface ListResponse<T> {
export type WidgetSize = 'third' | 'half' | 'full';
export type WidgetKind = 'stat' | 'chart' | 'ticketList' | 'incidentList' | 'workload';
/**
* Pevny widget z katalogu. Vlastni widget pouziva `WidgetRender`, protoze
* u nej je kresleni a zdroj dat rozdelene.
*/
export type WidgetKind =
| 'stat'
| 'chart'
| 'ticketList'
| 'incidentList'
| 'workload'
| WidgetRender;
/** Jak se vlastni widget kresli. Odkud bere data, rika `WidgetSource`. */
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 }> }
| {
kind: 'tickets';
items: Array<{ id: string; subject: string; status: TicketStatus; assignee: string | null }>;
}
| { 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 -
* cizi sluzba se nevola pri kazdem otevreni prehledu.
*/
| {
kind: 'external';
value: number | null;
text: string | null;
rows: Array<{ key: string; label: string; value: number }>;
at: string;
stale: boolean;
};
/**
* Vykon jednoho resitele. Slouzi k tomu, aby slo videt, kdo kolik odbavil
* a komu to nejde - proto vedle poctu i casy a vraceni.
*/
export interface AgentStatsRow {
personId: string;
name: string;
/** Kolik vyresil za sledovane obdobi. */
resolved: number;
/** Kolik ma prave ted nevyrizenych. */
open: number;
/** Kolik z nevyrizenych je po lhute vyssi priority. */
critical: number;
/** Medianovy cas do vyreseni v milisekundach. null = nic nevyresil. */
medianResolveMs: number | null;
/** Medianovy cas do prvni reakce. null = nikde nereagoval. */
medianResponseMs: number | null;
/** Kolikrat se mu ticket vratil z vyreseno zpatky. */
reopened: number;
/** Nejstarsi nevyrizeny, aby bylo videt, co lezi. */
oldestOpenAt: string | null;
/** Ma vic prace, nez unese. */
overloaded: boolean;
}
/** Odpoved davkoveho nacteni dat vlastnich widgetu. */
export interface WidgetDataResponse {
items: Array<{ id: string; ok: boolean; value?: WidgetValue; error?: string }>;
}
export type WidgetMetric =
| 'openTickets'
@@ -468,6 +563,10 @@ export interface WidgetDefinition {
sizes: WidgetSize[];
defaultSize: WidgetSize;
metric?: WidgetMetric;
/** true = widget si firma nadefinovala sama, data chodi z `/widget-data`. */
custom?: boolean;
/** Porovnavaci hodnota u ukazatele. */
target?: number;
}
export interface LayoutItem {