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:
co-authored by
Claude Opus 5
parent
6c90372991
commit
29de584df8
+1
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Automia — automatizace, voiceboti a integrace na míru</title>
|
||||
<title>Automia - automatizace, voiceboti a integrace na míru</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Automatizujeme procesy, stavíme voiceboty a propojujeme systémy. K tomu dashboardy, tickety a incident management pod jednou střechou."
|
||||
|
||||
+11
-1
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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ď';
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.',
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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.',
|
||||
});
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'] },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user