Struktura podle zasad: rozdeleni souboru, lint, testy, prisny TypeScript

Projekt srovnan se zasadami v D:\GitHubRepository\CLAUDE.md bez zmeny chovani.

Struktura: scripts/ (skripty konektoru) -> connectors/, src/scripts ->
src/runtime/scripts; src/index.ts jen startuje, novy src/app.ts s createApp();
routes/dashboard.ts a routes/settings.ts rozdeleny do slozek; openapi.ts
rozdelen na openapi/{index,helpers,components} a paths/* (98 cest overeno
shodnych); ticketStore, automationStore a services jsou fasady nad slozkami
data/tickets, data/automations a data/services/catalog. process.env se cte
jen v config.ts. Web: hooky v hooks/, sdilena ui/Table a ui/ServiceIcon,
surove inputy nahrazeny komponentami, sedm velkych souboru rozdeleno.

Nastroje: eslint (typescript-eslint, react-hooks v7), prettier, editorconfig,
nvmrc, .env.example, vitest; skripty lint, format, test. Lint je cisty bez
jedineho eslint-disable (nove hooky useLatest a useSyncFromSource, odvozeny
stav misto setState v effectu). noUncheckedIndexedAccess v obou tsconfig,
84 mist zuzeno bez non-null operatoru; odhalilo zalohu backoffu fronty pri
nule pokusu a Retry-After NaN pri max 0. Cely kod naformatovan prettierem.

Testy: 8 souboru, 105 testu (prava, viditelnost, podminky a opakovani
v executoru, redaktor tajemstvi, sitove guardy, migrace resitelu, tickety,
health a prihlaseni pres supertest). Testy odhalily dve chyby ve vyhodnoceni
podminek, obe opravene: chybejici castka se porovnavala jako nula a podminka
nad vystupem druheho kroku cetla hodnotu prvniho se stejnym nazvem.

Pojmenovane konstanty misto magickych hodnot, ctx.util.base64 pro skripty
konektoru, README a dokumentace aktualizovany vcetne znamych odchylek.
This commit is contained in:
JiriUhlir
2026-09-09 15:11:02 +02:00
parent 42b3da8303
commit 22dda2d139
312 changed files with 21085 additions and 15319 deletions
+5 -2
View File
@@ -5,6 +5,9 @@ import { buildInfo } from '@/config/version';
import { getLocale, translate } from '@/i18n';
import { apiFetch } from '@/lib/api';
/** Delka stacku posilaneho na server. Cely stack z balicku je zbytecne dlouhy. */
const STACK_LIMIT = 4_000;
/**
* Pojistka proti pádu vykreslovani.
*
@@ -65,8 +68,8 @@ function report(error: Error, info: ErrorInfo): void {
method: 'POST',
body: {
message: error.message,
stack: error.stack?.slice(0, 4_000),
componentStack: info.componentStack?.slice(0, 4_000) ?? undefined,
stack: error.stack?.slice(0, STACK_LIMIT),
componentStack: info.componentStack?.slice(0, STACK_LIMIT) ?? undefined,
path,
build: `${buildInfo.version} ${buildInfo.builtAt}${buildInfo.commit ? ` ${buildInfo.commit}` : ''}`,
},
@@ -1,5 +1,5 @@
import { Search } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Field } from '@/components/ui/form/Field';
@@ -8,7 +8,13 @@ import { Textarea } from '@/components/ui/form/Textarea';
import { Modal } from '@/components/ui/Modal';
import { Spinner } from '@/components/ui/Spinner';
import { apiFetch } from '@/lib/api';
import { useSubmit } from '@/lib/useSubmit';
import { useApiQuery } from '@/hooks/useApiQuery';
import { useSubmit } from '@/hooks/useSubmit';
import { useSyncFromSource } from '@/hooks/useSyncFromSource';
/** Od kolika znaku ma smysl ptat se ARES a jak kratky nazev firmy jeste projde. */
const MIN_QUERY_LENGTH = 2;
const MIN_NAME_LENGTH = 2;
/**
* Zalozeni firmy z registru ARES.
@@ -51,6 +57,16 @@ interface Created {
const PLACEHOLDER_SUFFIX = '@placeholder.cz';
/** Osoby z rejstriku jako radky formulare: vsechny vybrane, e-mail jde prepsat. */
function toRows(persons: AresPerson[]): PersonRow[] {
return persons.map((person, index) => ({
...person,
key: `${index}-${person.email}`,
selected: true,
placeholder: person.email,
}));
}
export function AresTenantDialog({
open,
onClose,
@@ -60,6 +76,30 @@ export function AresTenantDialog({
onClose: () => void;
/** Zavola se po zalozeni, aby se seznam firem nacetl znovu. */
onCreated: (tenantId: string) => void;
}) {
return (
<Modal
open={open}
onClose={onClose}
title="Založit firmu přes ARES"
description="Zadejte IČ nebo název. Údaje firmy a osoby, které za ni jednají, se dotáhnou z registru."
className="max-w-3xl"
>
{/*
Obsah zije jen v otevrenem dialogu. Zavreni ho odmontuje, takze nove
otevreni zacina od zacatku a ne u minule firmy - bez dorovnavani stavu.
*/}
<AresTenantForm onClose={onClose} onCreated={onCreated} />
</Modal>
);
}
function AresTenantForm({
onClose,
onCreated,
}: {
onClose: () => void;
onCreated: (tenantId: string) => void;
}) {
const [query, setQuery] = useState('');
const [companies, setCompanies] = useState<AresCompany[] | null>(null);
@@ -67,60 +107,27 @@ export function AresTenantDialog({
const [name, setName] = useState('');
const [note, setNote] = useState('');
const [persons, setPersons] = useState<PersonRow[] | null>(null);
const [personsError, setPersonsError] = useState<string | null>(null);
const [created, setCreated] = useState<Created | null>(null);
// Pri novem otevreni zacit od zacatku, ne u minule firmy.
useEffect(() => {
if (!open) return;
setQuery('');
setCompanies(null);
setCompany(null);
setName('');
setNote('');
setPersons(null);
setPersonsError(null);
setCreated(null);
}, [open]);
const search = useSubmit(async () => {
const result = await apiFetch<{ companies: AresCompany[] }>(
`/api/dashboard/settings/ares/companies?query=${encodeURIComponent(query.trim())}`,
);
setCompanies(result.companies);
setCompany(null);
setPersons(null);
}, 'Hledání v ARES selhalo.');
// Osoby se nacitaji az po vyberu firmy: je to druhe volani ven a u vysledku
// hledani podle nazvu by jich bylo deset naraz.
useEffect(() => {
if (!company) return;
let cancelled = false;
setPersons(null);
setPersonsError(null);
apiFetch<{ persons: AresPerson[] }>(`/api/dashboard/settings/ares/companies/${company.ico}/persons`)
.then((result) => {
if (cancelled) return;
setPersons(
result.persons.map((person, index) => ({
...person,
key: `${index}-${person.email}`,
selected: true,
placeholder: person.email,
})),
);
})
.catch((err: unknown) => {
if (cancelled) return;
console.warn('[ares] osoby se nenacetly:', err);
setPersonsError(err instanceof Error ? err.message : 'Osoby se nepodařilo načíst.');
setPersons([]);
});
return () => {
cancelled = true;
};
}, [company]);
const personsQuery = useApiQuery<{ persons: AresPerson[] }>(
`/api/dashboard/settings/ares/companies/${company?.ico ?? ''}/persons`,
{ enabled: company !== null },
);
// Radky jsou upravitelna kopie odpovedi; jina firma znamena jinou odpoved.
useSyncFromSource(personsQuery.data, (data) => setPersons(data ? toRows(data.persons) : null));
const personsError = personsQuery.error;
// Kdyz rejstrik selze, firma jde zalozit bez uctu - jako kdyz osoby neuvadi.
const personsReady = persons !== null || personsError !== null;
const create = useSubmit(async () => {
if (!company) throw new Error('Nejdřív vyberte firmu.');
@@ -131,7 +138,11 @@ export function AresTenantDialog({
ico: company.ico,
name: name.trim() || undefined,
note: note.trim() || undefined,
persons: selected.map((row) => ({ name: row.name.trim(), email: row.email.trim(), roles: row.roles })),
persons: selected.map((row) => ({
name: row.name.trim(),
email: row.email.trim(),
roles: row.roles,
})),
},
});
setCreated(result);
@@ -147,13 +158,7 @@ export function AresTenantDialog({
const selectedCount = (persons ?? []).filter((row) => row.selected).length;
return (
<Modal
open={open}
onClose={onClose}
title="Založit firmu přes ARES"
description="Zadejte IČ nebo název. Údaje firmy a osoby, které za ni jednají, se dotáhnou z registru."
className="max-w-3xl"
>
<>
{created ? (
<div className="space-y-4">
<p className="text-sm text-white/80">
@@ -205,7 +210,11 @@ export function AresTenantDialog({
autoFocus
/>
</Field>
<Button type="submit" size="sm" disabled={search.busy || query.trim().length < 2}>
<Button
type="submit"
size="sm"
disabled={search.busy || query.trim().length < MIN_QUERY_LENGTH}
>
<Search className="size-4" />
{search.busy ? 'Hledám' : 'Hledat'}
</Button>
@@ -265,7 +274,11 @@ export function AresTenantDialog({
<Input value={company.address} readOnly />
</Field>
<Field label="Poznámka" optional className="sm:col-span-2">
<Textarea rows={2} value={note} onChange={(event) => setNote(event.target.value)} />
<Textarea
rows={2}
value={note}
onChange={(event) => setNote(event.target.value)}
/>
</Field>
</div>
@@ -289,7 +302,7 @@ export function AresTenantDialog({
Vybraní dostanou účet správce firmy. Náhradní e-mail můžete přepsat hned, nebo
později v Uživatelích.
</p>
{persons === null && <Spinner label="Načítám osoby z rejstříku" />}
{!personsReady && <Spinner label="Načítám osoby z rejstříku" />}
{personsError && <p className="text-sm text-red-300">{personsError}</p>}
{persons && persons.length === 0 && !personsError && (
<p className="text-sm text-white/60">
@@ -307,7 +320,9 @@ export function AresTenantDialog({
<input
type="checkbox"
checked={row.selected}
onChange={(event) => updateRow(row.key, { selected: event.target.checked })}
onChange={(event) =>
updateRow(row.key, { selected: event.target.checked })
}
/>
<span className="text-xs text-white/50">{row.roles.join(', ')}</span>
</label>
@@ -340,16 +355,18 @@ export function AresTenantDialog({
</Button>
<Button
size="sm"
disabled={create.busy || persons === null || name.trim().length < 2}
disabled={create.busy || !personsReady || name.trim().length < MIN_NAME_LENGTH}
onClick={() => void create.submit()}
>
{create.busy ? 'Zakládám' : `Založit firmu${selectedCount > 0 ? ` a ${selectedCount} účtů` : ''}`}
{create.busy
? 'Zakládám'
: `Založit firmu${selectedCount > 0 ? ` a ${selectedCount} účtů` : ''}`}
</Button>
</div>
</div>
)}
</div>
)}
</Modal>
</>
);
}
@@ -30,9 +30,12 @@ import { cn } from '@/lib/cn';
import { CollectionsProvider, useAccess } from '@/lib/collections';
import { buildInfo, versionLabel } from '@/config/version';
import { setActiveTenant, useActiveTenant } from '@/lib/tenant';
import { useApiQuery } from '@/lib/useApiQuery';
import { useApiQuery } from '@/hooks/useApiQuery';
import type { Access } from '@/types/dashboard';
/** Pismen v inicialech. Dve se vejdou do kolecka, tri uz ne. */
const INITIALS_LETTERS = 2;
/**
* Mapa modulu na cesty a ikony.
*
@@ -92,10 +95,11 @@ function TenantSwitch({ access }: { access: Access | null }) {
* kazdy dotaz koncil na 403. Radsi se spadne na vychozi firmu uctu.
*/
useEffect(() => {
if (tenants.length === 0) return;
if (activeTenant && tenants.some((tenant) => tenant.id === activeTenant)) return;
setActiveTenant(access?.defaultTenantId ?? tenants[0]?.id ?? null);
}, [tenants, activeTenant, access?.defaultTenantId]);
const known = access?.tenants ?? [];
if (known.length === 0) return;
if (activeTenant && known.some((tenant) => tenant.id === activeTenant)) return;
setActiveTenant(access?.defaultTenantId ?? known[0]?.id ?? null);
}, [access, activeTenant]);
const current = tenants.find((tenant) => tenant.id === activeTenant);
@@ -157,11 +161,12 @@ function DashboardShell() {
);
const navigate = useNavigate();
const location = useLocation();
const [sidebarOpen, setSidebarOpen] = useState(false);
useEffect(() => {
setSidebarOpen(false);
}, [location.pathname]);
/*
* Mobilni sidebar je otevreny jen na strance, kde ho uzivatel otevrel.
* Klik na odkaz v nem tak sidebar zavre sam, bez effectu nad `pathname`.
*/
const [sidebarPath, setSidebarPath] = useState<string | null>(null);
const sidebarOpen = sidebarPath === location.pathname;
const activeTenant = useActiveTenant();
@@ -183,7 +188,7 @@ function DashboardShell() {
<Logo to="/dashboard" />
<button
type="button"
onClick={() => setSidebarOpen(false)}
onClick={() => setSidebarPath(null)}
className="grid size-9 place-items-center rounded-lg text-white/50 hover:bg-white/5 hover:text-white lg:hidden"
aria-label="Zavřít menu"
>
@@ -274,7 +279,7 @@ function DashboardShell() {
<button
type="button"
aria-label="Zavřít menu"
onClick={() => setSidebarOpen(false)}
onClick={() => setSidebarPath(null)}
className="fixed inset-0 z-40 bg-ink-950/70 backdrop-blur-sm lg:hidden"
/>
)}
@@ -286,7 +291,7 @@ function DashboardShell() {
<header className="sticky top-0 z-30 flex h-18 items-center gap-4 border-b border-ink-600/60 bg-ink-900/85 px-5 backdrop-blur-xl sm:px-7">
<button
type="button"
onClick={() => setSidebarOpen(true)}
onClick={() => setSidebarPath(location.pathname)}
className="grid size-10 place-items-center rounded-lg text-white/60 hover:bg-white/5 hover:text-white lg:hidden"
aria-label="Otevřít menu"
>
@@ -384,7 +389,7 @@ function initials(name: string | undefined): string {
return name
.split(' ')
.filter(Boolean)
.slice(0, 2)
.slice(0, INITIALS_LETTERS)
.map((part) => part[0]?.toUpperCase() ?? '')
.join('');
}
+31 -332
View File
@@ -1,19 +1,15 @@
import { AlertTriangle, Pencil, Plus, Trash2 } from 'lucide-react';
import { useMemo, useState } from 'react';
import { Pencil, Plus, Trash2 } from 'lucide-react';
import { useState } from 'react';
import type { ReactNode } from 'react';
import { DataState } from '@/components/dashboard/DataState';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Field } from '@/components/ui/form/Field';
import { Input } from '@/components/ui/form/Input';
import { Select } from '@/components/ui/form/Select';
import { Textarea } from '@/components/ui/form/Textarea';
import { Modal } from '@/components/ui/Modal';
import { Table, TableHead, TableRow, Td, Th } from '@/components/ui/Table';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/cn';
import { useApiQuery } from '@/lib/useApiQuery';
import { useSubmit } from '@/lib/useSubmit';
import { useApiQuery } from '@/hooks/useApiQuery';
import { useSubmit } from '@/hooks/useSubmit';
import type { DashboardEventType } from '@/types/events';
import { EntityForm } from '@/components/dashboard/EntityForm';
/**
* Obecna sprava zaznamu: tabulka, dialog na zalozeni a upravu, mazani.
@@ -26,14 +22,7 @@ import type { DashboardEventType } from '@/types/events';
*/
export type FieldKind =
| 'text'
| 'textarea'
| 'number'
| 'checkbox'
| 'select'
| 'multiselect'
| 'tags'
| 'json';
'text' | 'textarea' | 'number' | 'checkbox' | 'select' | 'multiselect' | 'tags' | 'json';
export interface AdminField<T> {
/** Klic v tele requestu. */
@@ -158,40 +147,36 @@ export function EntityAdmin<T extends { id: string }>({
empty={(items?.length ?? 0) === 0}
>
<div className="glass overflow-x-auto rounded-card">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-ink-600/60 text-left text-xs tracking-wide text-white/40 uppercase">
{columns.map((column) => (
<th key={column.label} className="px-4 py-2.5 font-semibold">
{column.label}
</th>
))}
{canWrite && <th className="px-4 py-2.5" />}
</tr>
</thead>
<Table>
<TableHead>
{columns.map((column) => (
<Th key={column.label}>{column.label}</Th>
))}
{canWrite && <Th />}
</TableHead>
<tbody>
{(items ?? []).map((entity) => (
<tr key={entity.id} className="border-b border-ink-600/30 last:border-0">
<TableRow key={entity.id}>
{columns.map((column) => (
<td
<Td
key={column.label}
className={cn('px-4 py-2.5 align-top text-white/75', column.narrow && 'w-24')}
className={cn('align-top text-white/75', column.narrow && 'w-24')}
>
{column.render(entity)}
</td>
</Td>
))}
{(canWrite || rowActions) && (
<td className="px-4 py-2 text-right whitespace-nowrap">
<Td align="right" className="whitespace-nowrap">
{rowActions?.(entity)}
{canWrite && (
<Button
size="sm"
variant="ghost"
onClick={() => setEditing({ entity })}
aria-label="Upravit"
>
<Pencil className="size-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setEditing({ entity })}
aria-label="Upravit"
>
<Pencil className="size-3.5" />
</Button>
)}
{canWrite && (canDelete?.(entity) ?? true) && (
<Button
@@ -204,12 +189,12 @@ export function EntityAdmin<T extends { id: string }>({
<Trash2 className="size-3.5" />
</Button>
)}
</td>
</Td>
)}
</tr>
</TableRow>
))}
</tbody>
</table>
</Table>
</div>
</DataState>
@@ -233,293 +218,7 @@ export function EntityAdmin<T extends { id: string }>({
);
}
// -------------------------------------------------------------------- formular
function EntityForm<T extends { id: string }>({
title,
endpoint,
entity,
fields,
toBody,
toForm,
onClose,
onSaved,
}: {
title: string;
endpoint: string;
entity: T | null;
fields: Array<AdminField<T>>;
toBody?: (values: Record<string, unknown>, entity: T | null) => Record<string, unknown>;
toForm?: (entity: T) => Record<string, unknown>;
onClose: () => void;
onSaved: () => void;
}) {
const visible = useMemo(
() => fields.filter((field) => (field.visible ? field.visible(entity) : true)),
[fields, entity],
);
const [values, setValues] = useState<Record<string, unknown>>(() => {
if (entity) return toForm ? toForm(entity) : (entity as unknown as Record<string, unknown>);
const initial: Record<string, unknown> = {};
for (const field of fields) {
initial[field.name] = field.initial ?? defaultFor(field.kind);
}
return initial;
});
function set(name: string, value: unknown) {
setValues((previous) => ({ ...previous, [name]: value }));
}
const { busy: saving, error, issues, submit } = useSubmit(async () => {
// Pole jen pro zakladani se pri uprave neposilaji, jinak by prazdne heslo
// prepsalo to stavajici.
const payload = toBody
? toBody(values, entity)
: Object.fromEntries(
visible
.filter((field) => !(entity && field.createOnly))
.map((field) => [field.name, values[field.name]]),
);
await apiFetch(entity ? `${endpoint}/${entity.id}` : endpoint, {
method: entity ? 'PATCH' : 'POST',
body: payload,
});
onSaved();
}, 'Uložení selhalo.');
return (
<Modal open onClose={onClose} title={title}>
<form
autoComplete="off"
onSubmit={(event) => {
event.preventDefault();
void submit();
}}
className="space-y-4 p-5"
>
{visible
.filter((field) => !(entity && field.createOnly))
.map((field) => (
<FieldInput
key={field.name}
field={field}
value={values[field.name]}
onChange={(value) => set(field.name, value)}
/>
))}
{error && (
<div className="rounded-lg border border-danger-500/40 bg-danger-500/8 p-3 text-sm">
<p className="flex items-start gap-2 text-danger-400">
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
{error}
</p>
{issues.length > 0 && (
<ul className="mt-1.5 ml-6 list-disc text-xs text-white/60">
{issues.map((issue) => (
<li key={`${issue.field}-${issue.message}`}>{issue.message}</li>
))}
</ul>
)}
</div>
)}
<div className="flex gap-3 border-t border-ink-600/50 pt-4">
<Button type="submit" size="sm" disabled={saving}>
{saving ? 'Ukládám...' : entity ? 'Uložit' : 'Založit'}
</Button>
<Button type="button" size="sm" variant="secondary" onClick={onClose} disabled={saving}>
Zrušit
</Button>
</div>
</form>
</Modal>
);
}
function defaultFor(kind: FieldKind): unknown {
if (kind === 'checkbox') return false;
if (kind === 'multiselect' || kind === 'tags') return [];
if (kind === 'number') return 0;
if (kind === 'json') return '';
return '';
}
/** Jedno pole podle druhu. Novy druh se pridava jen tady. */
function FieldInput<T>({
field,
value,
onChange,
}: {
field: AdminField<T>;
value: unknown;
onChange: (value: unknown) => void;
}) {
const label = (
<span className="mb-1 flex flex-wrap items-center gap-2 text-sm text-white/70">
{field.label}
{field.required && <span className="text-warn-400">*</span>}
</span>
);
if (field.kind === 'checkbox') {
return (
<label className="flex items-center gap-2 text-sm text-white/70">
<input
type="checkbox"
checked={value === true}
onChange={(event) => onChange(event.target.checked)}
className="size-4"
/>
{field.label}
{field.hint && <span className="text-xs text-white/35">{field.hint}</span>}
</label>
);
}
if (field.kind === 'multiselect') {
const selected = new Set(Array.isArray(value) ? (value as string[]) : []);
// Volby se sdruzuji do skupin, jinak je seznam dvaceti prav neprehledny.
const groups = new Map<string, typeof field.options>();
for (const option of field.options ?? []) {
const key = option.group ?? '';
groups.set(key, [...(groups.get(key) ?? []), option]);
}
return (
<div>
{label}
<div className="max-h-72 space-y-3 overflow-y-auto rounded-lg border border-ink-600/70 bg-ink-950/40 p-3">
{[...groups.entries()].map(([group, options]) => (
<div key={group}>
{group && (
<p className="mb-1 text-xs font-semibold tracking-wide text-white/40 uppercase">
{group}
</p>
)}
<div className="space-y-1">
{(options ?? []).map((option) => (
<label
key={option.value}
className="flex items-start gap-2 text-sm text-white/70"
>
<input
type="checkbox"
checked={selected.has(option.value)}
onChange={(event) => {
const next = new Set(selected);
if (event.target.checked) next.add(option.value);
else next.delete(option.value);
onChange([...next]);
}}
className="mt-0.5 size-3.5 shrink-0"
/>
{option.label}
</label>
))}
</div>
</div>
))}
</div>
{field.hint && <span className="mt-1 block text-xs text-white/35">{field.hint}</span>}
</div>
);
}
if (field.kind === 'tags') {
const tags = Array.isArray(value) ? (value as string[]) : [];
return (
<div>
{label}
<Input
value={tags.join(', ')}
onChange={(event) =>
onChange(
event.target.value
.split(',')
.map((tag) => tag.trim())
.filter(Boolean),
)
}
placeholder="oddělte čárkou"
/>
{tags.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1.5">
{tags.map((tag) => (
<Badge key={tag} tone="neutral">
{tag}
</Badge>
))}
</div>
)}
{field.hint && <span className="mt-1 block text-xs text-white/35">{field.hint}</span>}
</div>
);
}
if (field.kind === 'select') {
return (
<Field label={field.label} required={field.required} hint={field.hint}>
<Select
value={String(value ?? '')}
onChange={(event) => onChange(event.target.value)}
placeholder="Vyberte"
options={field.options ?? []}
/>
</Field>
);
}
if (field.kind === 'textarea' || field.kind === 'json') {
const isJson = field.kind === 'json';
const text = typeof value === 'string' ? value : JSON.stringify(value ?? null, null, 2);
const broken = isJson && text.trim() !== '' && !isValidJson(text);
return (
<Field
label={field.label}
required={field.required}
hint={
broken ? <span className="text-warn-400">Tohle není platný JSON.</span> : field.hint
}
>
<Textarea
rows={isJson ? 8 : 3}
spellCheck={!isJson}
value={text}
onChange={(event) => onChange(event.target.value)}
mono={isJson}
invalid={broken}
/>
</Field>
);
}
return (
<Field label={field.label} required={field.required} hint={field.hint}>
<Input
type={field.kind === 'number' ? 'number' : field.name.includes('password') ? 'password' : 'text'}
autoComplete={field.name.includes('password') ? 'new-password' : 'off'}
value={value === null || value === undefined ? '' : String(value)}
onChange={(event) =>
onChange(field.kind === 'number' ? Number(event.target.value) : event.target.value)
}
/>
</Field>
);
}
function isValidJson(text: string): boolean {
try {
JSON.parse(text);
return true;
} catch {
return false;
}
}
// ------------------------------------------------------------------------ json
/** Prevod textu z pole `json` na hodnotu. Rozbity JSON necha puvodni text. */
export function parseJsonField(value: unknown, fallback: unknown): unknown {
+307
View File
@@ -0,0 +1,307 @@
import { AlertTriangle } from 'lucide-react';
import { useMemo, useState } from 'react';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Field } from '@/components/ui/form/Field';
import { Input } from '@/components/ui/form/Input';
import { Select } from '@/components/ui/form/Select';
import { Textarea } from '@/components/ui/form/Textarea';
import { Modal } from '@/components/ui/Modal';
import { apiFetch } from '@/lib/api';
import { useSubmit } from '@/hooks/useSubmit';
import type { AdminField, FieldKind } from '@/components/dashboard/EntityAdmin';
/**
* Dialog na zalozeni a upravu zaznamu ciselniku. Pole vznikaji z popisu
* `fields`, takze formular je jeden pro vsechny ciselniky.
*/
export function EntityForm<T extends { id: string }>({
title,
endpoint,
entity,
fields,
toBody,
toForm,
onClose,
onSaved,
}: {
title: string;
endpoint: string;
entity: T | null;
fields: Array<AdminField<T>>;
toBody?: (values: Record<string, unknown>, entity: T | null) => Record<string, unknown>;
toForm?: (entity: T) => Record<string, unknown>;
onClose: () => void;
onSaved: () => void;
}) {
const visible = useMemo(
() => fields.filter((field) => (field.visible ? field.visible(entity) : true)),
[fields, entity],
);
const [values, setValues] = useState<Record<string, unknown>>(() => {
if (entity) return toForm ? toForm(entity) : (entity as unknown as Record<string, unknown>);
const initial: Record<string, unknown> = {};
for (const field of fields) {
initial[field.name] = field.initial ?? defaultFor(field.kind);
}
return initial;
});
function set(name: string, value: unknown) {
setValues((previous) => ({ ...previous, [name]: value }));
}
const {
busy: saving,
error,
issues,
submit,
} = useSubmit(async () => {
// Pole jen pro zakladani se pri uprave neposilaji, jinak by prazdne heslo
// prepsalo to stavajici.
const payload = toBody
? toBody(values, entity)
: Object.fromEntries(
visible
.filter((field) => !(entity && field.createOnly))
.map((field) => [field.name, values[field.name]]),
);
await apiFetch(entity ? `${endpoint}/${entity.id}` : endpoint, {
method: entity ? 'PATCH' : 'POST',
body: payload,
});
onSaved();
}, 'Uložení selhalo.');
return (
<Modal open onClose={onClose} title={title}>
<form
autoComplete="off"
onSubmit={(event) => {
event.preventDefault();
void submit();
}}
className="space-y-4 p-5"
>
{visible
.filter((field) => !(entity && field.createOnly))
.map((field) => (
<FieldInput
key={field.name}
field={field}
value={values[field.name]}
onChange={(value) => set(field.name, value)}
/>
))}
{error && (
<div className="rounded-lg border border-danger-500/40 bg-danger-500/8 p-3 text-sm">
<p className="flex items-start gap-2 text-danger-400">
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
{error}
</p>
{issues.length > 0 && (
<ul className="mt-1.5 ml-6 list-disc text-xs text-white/60">
{issues.map((issue) => (
<li key={`${issue.field}-${issue.message}`}>{issue.message}</li>
))}
</ul>
)}
</div>
)}
<div className="flex gap-3 border-t border-ink-600/50 pt-4">
<Button type="submit" size="sm" disabled={saving}>
{saving ? 'Ukládám...' : entity ? 'Uložit' : 'Založit'}
</Button>
<Button type="button" size="sm" variant="secondary" onClick={onClose} disabled={saving}>
Zrušit
</Button>
</div>
</form>
</Modal>
);
}
function defaultFor(kind: FieldKind): unknown {
if (kind === 'checkbox') return false;
if (kind === 'multiselect' || kind === 'tags') return [];
if (kind === 'number') return 0;
if (kind === 'json') return '';
return '';
}
function isValidJson(text: string): boolean {
try {
JSON.parse(text);
return true;
} catch {
return false;
}
}
/** Jedno pole podle druhu. Novy druh se pridava jen tady. */
function FieldInput<T>({
field,
value,
onChange,
}: {
field: AdminField<T>;
value: unknown;
onChange: (value: unknown) => void;
}) {
const label = (
<span className="mb-1 flex flex-wrap items-center gap-2 text-sm text-white/70">
{field.label}
{field.required && <span className="text-warn-400">*</span>}
</span>
);
if (field.kind === 'checkbox') {
return (
<label className="flex items-center gap-2 text-sm text-white/70">
<input
type="checkbox"
checked={value === true}
onChange={(event) => onChange(event.target.checked)}
className="size-4"
/>
{field.label}
{field.hint && <span className="text-xs text-white/35">{field.hint}</span>}
</label>
);
}
if (field.kind === 'multiselect') {
const selected = new Set(Array.isArray(value) ? (value as string[]) : []);
// Volby se sdruzuji do skupin, jinak je seznam dvaceti prav neprehledny.
const groups = new Map<string, typeof field.options>();
for (const option of field.options ?? []) {
const key = option.group ?? '';
groups.set(key, [...(groups.get(key) ?? []), option]);
}
return (
<div>
{label}
<div className="max-h-72 space-y-3 overflow-y-auto rounded-lg border border-ink-600/70 bg-ink-950/40 p-3">
{[...groups.entries()].map(([group, options]) => (
<div key={group}>
{group && (
<p className="mb-1 text-xs font-semibold tracking-wide text-white/40 uppercase">
{group}
</p>
)}
<div className="space-y-1">
{(options ?? []).map((option) => (
<label
key={option.value}
className="flex items-start gap-2 text-sm text-white/70"
>
<input
type="checkbox"
checked={selected.has(option.value)}
onChange={(event) => {
const next = new Set(selected);
if (event.target.checked) next.add(option.value);
else next.delete(option.value);
onChange([...next]);
}}
className="mt-0.5 size-3.5 shrink-0"
/>
{option.label}
</label>
))}
</div>
</div>
))}
</div>
{field.hint && <span className="mt-1 block text-xs text-white/35">{field.hint}</span>}
</div>
);
}
if (field.kind === 'tags') {
const tags = Array.isArray(value) ? (value as string[]) : [];
return (
<div>
{label}
<Input
value={tags.join(', ')}
onChange={(event) =>
onChange(
event.target.value
.split(',')
.map((tag) => tag.trim())
.filter(Boolean),
)
}
placeholder="oddělte čárkou"
/>
{tags.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1.5">
{tags.map((tag) => (
<Badge key={tag} tone="neutral">
{tag}
</Badge>
))}
</div>
)}
{field.hint && <span className="mt-1 block text-xs text-white/35">{field.hint}</span>}
</div>
);
}
if (field.kind === 'select') {
return (
<Field label={field.label} required={field.required} hint={field.hint}>
<Select
value={String(value ?? '')}
onChange={(event) => onChange(event.target.value)}
placeholder="Vyberte"
options={field.options ?? []}
/>
</Field>
);
}
if (field.kind === 'textarea' || field.kind === 'json') {
const isJson = field.kind === 'json';
const text = typeof value === 'string' ? value : JSON.stringify(value ?? null, null, 2);
const broken = isJson && text.trim() !== '' && !isValidJson(text);
return (
<Field
label={field.label}
required={field.required}
hint={broken ? <span className="text-warn-400">Tohle není platný JSON.</span> : field.hint}
>
<Textarea
rows={isJson ? 8 : 3}
spellCheck={!isJson}
value={text}
onChange={(event) => onChange(event.target.value)}
mono={isJson}
invalid={broken}
/>
</Field>
);
}
return (
<Field label={field.label} required={field.required} hint={field.hint}>
<Input
type={
field.kind === 'number' ? 'number' : field.name.includes('password') ? 'password' : 'text'
}
autoComplete={field.name.includes('password') ? 'new-password' : 'off'}
value={value === null || value === undefined ? '' : String(value)}
onChange={(event) =>
onChange(field.kind === 'number' ? Number(event.target.value) : event.target.value)
}
/>
</Field>
);
}
+4 -1
View File
@@ -2,6 +2,9 @@ import { Check, ChevronDown, ChevronRight, Copy } from 'lucide-react';
import { useState } from 'react';
import { cn } from '@/lib/cn';
/** Jak dlouho svitit potvrzeni po zkopirovani. */
const COPIED_HINT_MS = 2000;
/**
* Cele chybove hlaseni.
*
@@ -70,7 +73,7 @@ export function ErrorDetail({
try {
await navigator.clipboard.writeText(fullText);
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
window.setTimeout(() => setCopied(false), COPIED_HINT_MS);
} catch (err) {
// Bez pristupu ke schranke (starsi prohlizec, http) se nic nepredstira.
console.warn('[error] schranka neni dostupna:', err);
@@ -1,5 +1,14 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { ReactNode } from 'react';
import { useLatest } from '@/hooks/useLatest';
import { connectEventStream, type StreamStatus } from '@/lib/eventStream';
import type { DashboardEvent, DashboardEventType } from '@/types/events';
@@ -96,18 +105,16 @@ export function useEventListener(
handler: (event: DashboardEvent) => void,
) {
const { subscribe } = useEventStream();
const handlerRef = useRef(handler);
handlerRef.current = handler;
const handlerRef = useLatest(handler);
// Klic z pole, aby se odber neobnovoval pri kazdem prekresleni.
const key = types.join(',');
useEffect(() => {
if (types.length === 0) return;
if (key === '') return;
const wanted = new Set(key.split(','));
return subscribe((event) => {
if (wanted.has(event.type)) handlerRef.current(event);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [subscribe, key]);
}, [subscribe, key, handlerRef]);
}
+10 -1
View File
@@ -1,4 +1,13 @@
import { AlarmClock, Bell, CheckCircle2, LifeBuoy, UserCheck, Webhook, Workflow, X } from 'lucide-react';
import {
AlarmClock,
Bell,
CheckCircle2,
LifeBuoy,
UserCheck,
Webhook,
Workflow,
X,
} from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import type { LucideIcon } from 'lucide-react';
import { useEventLog } from '@/components/dashboard/EventStreamProvider';
+14 -9
View File
@@ -11,14 +11,17 @@
*/
import { Check, Plus, Trash2, Users } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { DataState } from '@/components/dashboard/DataState';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/form/Input';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/cn';
import { useCollection } from '@/lib/collections';
import { useApiQuery } from '@/lib/useApiQuery';
import { useApiQuery } from '@/hooks/useApiQuery';
/** Nejkratsi nazev sekce, ktery jeste neco rika. */
const MIN_NAME_LENGTH = 2;
interface GroupMember {
personId: string;
@@ -47,7 +50,7 @@ export function GroupPanel() {
const groups = useApiQuery<{ items: Group[] }>('/api/dashboard/settings/groups');
const people = useCollection('people');
const [openId, setOpenId] = useState<string | null>(null);
const [chosenId, setOpenId] = useState<string | null>(null);
const [newName, setNewName] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -55,10 +58,8 @@ export function GroupPanel() {
const items = useMemo(() => groups.data?.items ?? [], [groups.data]);
const persons = people.items;
// Prvni sekce se otevre sama. Prazdny panel vedle seznamu nic nerika.
useEffect(() => {
if (openId === null && items.length > 0) setOpenId(items[0].id);
}, [items, openId]);
// Bez volby se otevre prvni sekce. Prazdny panel vedle seznamu nic nerika.
const openId = chosenId ?? items[0]?.id ?? null;
const open = items.find((group) => group.id === openId) ?? null;
@@ -78,7 +79,7 @@ export function GroupPanel() {
async function create() {
const name = newName.trim();
if (name.length < 2) return;
if (name.length < MIN_NAME_LENGTH) return;
setBusy(true);
setError(null);
try {
@@ -187,7 +188,11 @@ export function GroupPanel() {
placeholder="Nová sekce"
tone="card"
/>
<Button size="sm" disabled={busy || newName.trim().length < 2} onClick={() => void create()}>
<Button
size="sm"
disabled={busy || newName.trim().length < MIN_NAME_LENGTH}
onClick={() => void create()}
>
<Plus className="size-4" />
</Button>
</div>
+29 -27
View File
@@ -5,10 +5,14 @@ import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Field } from '@/components/ui/form/Field';
import { Input } from '@/components/ui/form/Input';
import { Table, TableHead, TableRow, Td, Th } from '@/components/ui/Table';
import { apiFetch } from '@/lib/api';
import { formatDateTime } from '@/lib/format';
import { useApiQuery } from '@/lib/useApiQuery';
import { useSubmit } from '@/lib/useSubmit';
import { useApiQuery } from '@/hooks/useApiQuery';
import { useSubmit } from '@/hooks/useSubmit';
/** Jak dlouho svitit potvrzeni po zkopirovani odkazu. */
const COPIED_HINT_MS = 1600;
/**
* Pozvanky do firmy.
@@ -75,7 +79,7 @@ export function InvitePanel() {
try {
await navigator.clipboard.writeText(url);
setCopied(id);
window.setTimeout(() => setCopied(null), 1600);
window.setTimeout(() => setCopied(null), COPIED_HINT_MS);
} catch (err) {
console.warn('[pozvanky] schranka neni dostupna:', err);
}
@@ -86,8 +90,8 @@ export function InvitePanel() {
<section className="glass rounded-card p-5">
<h2 className="font-semibold text-white">Pozvat do firmy</h2>
<p className="mt-1 text-sm text-white/50">
Vznikne odkaz s jednorázovým kódem. Pošlete ho, jak chcete. Kdo ho otevře, vyplní
si jméno a heslo a je uvnitř. Platí týden.
Vznikne odkaz s jednorázovým kódem. Pošlete ho, jak chcete. Kdo ho otevře, vyplní si jméno
a heslo a je uvnitř. Platí týden.
</p>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
@@ -163,27 +167,25 @@ export function InvitePanel() {
emptyLabel="Zatím žádné pozvánky."
>
<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">Pro koho</th>
<th className="px-4 py-3 font-medium">Odkaz</th>
<th className="px-4 py-3 font-medium">Stav</th>
<th className="px-4 py-3" />
</tr>
</thead>
<Table className="min-w-150">
<TableHead>
<Th>Pro koho</Th>
<Th>Odkaz</Th>
<Th>Stav</Th>
<Th />
</TableHead>
<tbody>
{items.map((invite) => (
<tr key={invite.id} className="border-b border-ink-600/30 last:border-0">
<td className="px-4 py-2.5">
<TableRow key={invite.id}>
<Td>
<span className="font-medium text-white">
{invite.email || 'kdokoliv s odkazem'}
</span>
{invite.note && (
<span className="mt-0.5 block text-xs text-white/40">{invite.note}</span>
)}
</td>
<td className="px-4 py-2.5">
</Td>
<Td>
<button
type="button"
onClick={() => void copy(invite.url, invite.id)}
@@ -197,8 +199,8 @@ export function InvitePanel() {
)}
<span className="truncate">{invite.url}</span>
</button>
</td>
<td className="px-4 py-2.5">
</Td>
<Td>
{invite.usedAt ? (
<Badge tone="ok">použitá {formatDateTime(invite.usedAt)}</Badge>
) : invite.problem ? (
@@ -208,8 +210,8 @@ export function InvitePanel() {
platí do {formatDateTime(invite.expiresAt)}
</span>
)}
</td>
<td className="px-4 py-2.5 text-right">
</Td>
<Td align="right">
<button
type="button"
onClick={() => void remove(invite.id)}
@@ -218,18 +220,18 @@ export function InvitePanel() {
>
<Trash2 className="size-4" />
</button>
</td>
</tr>
</Td>
</TableRow>
))}
</tbody>
</table>
</Table>
</div>
</DataState>
<p className="flex items-start gap-2 text-xs text-white/35">
<Link2 className="mt-0.5 size-3.5 shrink-0" />
Odkaz je jediná ochrana, proto má náhodný kód a jde kdykoliv zrušit. Po použití
přestane platit sám.
Odkaz je jediná ochrana, proto má náhodný kód a jde kdykoliv zrušit. Po použití přestane
platit sám.
</p>
</div>
);
@@ -4,7 +4,12 @@ import { cn } from '@/lib/cn';
const meta = {
connecting: { label: 'Připojuji', dot: 'bg-warn-400', text: 'text-warn-400', pulse: true },
open: { label: 'Živě', dot: 'bg-ok-400', text: 'text-ok-400', pulse: true },
reconnecting: { label: 'Obnovuji spojení', dot: 'bg-warn-400', text: 'text-warn-400', pulse: true },
reconnecting: {
label: 'Obnovuji spojení',
dot: 'bg-warn-400',
text: 'text-warn-400',
pulse: true,
},
closed: { label: 'Odpojeno', dot: 'bg-white/40', text: 'text-white/45', pulse: false },
} as const;
@@ -26,7 +31,9 @@ export function LiveIndicator({ className }: { className?: string }) {
: 'Změny se teď nemusí projevovat okamžitě.'
}
>
<span className={cn('size-1.5 rounded-full', current.dot, current.pulse && 'animate-pulse')} />
<span
className={cn('size-1.5 rounded-full', current.dot, current.pulse && 'animate-pulse')}
/>
{current.label}
</span>
);
@@ -8,7 +8,7 @@ import { Modal } from '@/components/ui/Modal';
import { apiFetch } from '@/lib/api';
import { useCollection } from '@/lib/collections';
import { priorities } from '@/lib/options';
import { useSubmit } from '@/lib/useSubmit';
import { useSubmit } from '@/hooks/useSubmit';
import type { TicketPriority } from '@/types/dashboard';
/**
@@ -116,7 +116,12 @@ export function NewTicketDialog({
</Field>
<Field label="Popis" hint="Celý text požadavku. Nepovinné.">
<Textarea value={body} onChange={(event) => setBody(event.target.value)} rows={5} tone="card" />
<Textarea
value={body}
onChange={(event) => setBody(event.target.value)}
rows={5}
tone="card"
/>
</Field>
<div className="grid gap-4 sm:grid-cols-2">
@@ -168,10 +173,18 @@ export function NewTicketDialog({
{customerOpen ? (
<div className="grid gap-4 sm:grid-cols-3">
<Field label="Firma">
<Input value={company} onChange={(event) => setCompany(event.target.value)} tone="card" />
<Input
value={company}
onChange={(event) => setCompany(event.target.value)}
tone="card"
/>
</Field>
<Field label="Kontaktní osoba">
<Input value={contact} onChange={(event) => setContact(event.target.value)} tone="card" />
<Input
value={contact}
onChange={(event) => setContact(event.target.value)}
tone="card"
/>
</Field>
<Field label="Kam odpovídat">
<Input
+1 -1
View File
@@ -12,7 +12,7 @@
import { Select } from '@/components/ui/form/Select';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/cn';
import { useSubmit } from '@/lib/useSubmit';
import { useSubmit } from '@/hooks/useSubmit';
export function QuickAssign({
ticketId,
+36 -35
View File
@@ -8,7 +8,7 @@
import { useMemo } from 'react';
import { Badge } from '@/components/ui/Badge';
import { EntityAdmin, type NamedRecord } from '@/components/dashboard/EntityAdmin';
import { useApiQuery } from '@/lib/useApiQuery';
import { useApiQuery } from '@/hooks/useApiQuery';
interface CatalogItem {
key: string;
@@ -31,39 +31,40 @@ export function RolesAdmin() {
);
return (
<EntityAdmin
title="Role a práva"
description="Práva jsou data, ne pevný seznam v kódu. Systémové role se nedají měnit."
endpoint="/api/dashboard/settings/roles"
columns={[
{
label: 'Role',
render: (role: NamedRecord) => (
<span className="flex flex-wrap items-center gap-2">
{String(role.name ?? '')}
{role.system === true && <Badge tone="neutral">systémová</Badge>}
</span>
),
},
{ label: 'Popis', render: (role: NamedRecord) => String(role.description ?? '') },
{
label: 'Práv',
narrow: true,
render: (role: NamedRecord) => String((role.permissions as string[] | undefined)?.length ?? 0),
},
]}
canDelete={(role: NamedRecord) => role.system !== true}
fields={[
{ name: 'name', label: 'Název role', kind: 'text', required: true },
{ name: 'description', label: 'Popis', kind: 'text' },
{
name: 'permissions',
label: 'Práva',
kind: 'multiselect',
options: permissionOptions,
hint: 'Práva k akcím na ticketu se objeví, jakmile akci založíte.',
},
]}
/>
<EntityAdmin
title="Role a práva"
description="Práva jsou data, ne pevný seznam v kódu. Systémové role se nedají měnit."
endpoint="/api/dashboard/settings/roles"
columns={[
{
label: 'Role',
render: (role: NamedRecord) => (
<span className="flex flex-wrap items-center gap-2">
{String(role.name ?? '')}
{role.system === true && <Badge tone="neutral">systémová</Badge>}
</span>
),
},
{ label: 'Popis', render: (role: NamedRecord) => String(role.description ?? '') },
{
label: 'Práv',
narrow: true,
render: (role: NamedRecord) =>
String((role.permissions as string[] | undefined)?.length ?? 0),
},
]}
canDelete={(role: NamedRecord) => role.system !== true}
fields={[
{ name: 'name', label: 'Název role', kind: 'text', required: true },
{ name: 'description', label: 'Popis', kind: 'text' },
{
name: 'permissions',
label: 'Práva',
kind: 'multiselect',
options: permissionOptions,
hint: 'Práva k akcím na ticketu se objeví, jakmile akci založíte.',
},
]}
/>
);
}
+10 -5
View File
@@ -1,6 +1,9 @@
import { formatDay, formatNumber } from '@/lib/format';
import type { SeriesPoint } from '@/types/dashboard';
/** Nejnizsi sloupec v procentech vysky, aby byl videt i den s jednim behem. */
const MIN_BAR_HEIGHT_PCT = 4;
/**
* Sloupcovy graf spusteni automatizaci za poslednich 14 dni.
*
@@ -10,13 +13,15 @@ import type { SeriesPoint } from '@/types/dashboard';
* Pod grafem je tabulka pro cteni bez barev (screen readery, tisk).
*/
export function RunsChart({ series }: { series: SeriesPoint[] }) {
if (series.length === 0) {
const first = series[0];
const last = series.at(-1);
if (!first || !last) {
console.warn('[chart] RunsChart dostal prazdnou serii');
return <p className="text-sm text-white/45">Zatím nemáme dost dat pro graf.</p>;
}
const max = Math.max(...series.map((point) => point.runs));
const peak = series.reduce((best, point) => (point.runs > best.runs ? point : best), series[0]);
const peak = series.reduce((best, point) => (point.runs > best.runs ? point : best), first);
const total = series.reduce((sum, point) => sum + point.runs, 0);
const failures = series.reduce((sum, point) => sum + point.failures, 0);
@@ -49,7 +54,7 @@ export function RunsChart({ series }: { series: SeriesPoint[] }) {
<div className="relative flex h-full items-end gap-[2px]">
{series.map((point) => {
const height = Math.max(4, Math.round((point.runs / max) * 100));
const height = Math.max(MIN_BAR_HEIGHT_PCT, Math.round((point.runs / max) * 100));
const isPeak = point.date === peak.date;
return (
@@ -80,8 +85,8 @@ export function RunsChart({ series }: { series: SeriesPoint[] }) {
</div>
<div className="mt-2 flex justify-between text-xs text-white/35">
<span>{formatDay(series[0].date)}</span>
<span>{formatDay(series[series.length - 1].date)}</span>
<span>{formatDay(first.date)}</span>
<span>{formatDay(last.date)}</span>
</div>
{/* Alternativa k barevnemu cteni grafu */}
+3 -8
View File
@@ -97,13 +97,7 @@ const incidentStatusMap: Record<IncidentStatus, { label: string; tone: BadgeTone
* Zadny prevod, zadny ciselnik: co je v datech, to se ukaze. `closed` rika,
* jestli uz to nikdo neresi - podle toho se vybere ikona.
*/
export function TicketStatusBadge({
status,
closed,
}: {
status: TicketStatus;
closed?: boolean;
}) {
export function TicketStatusBadge({ status, closed }: { status: TicketStatus; closed?: boolean }) {
const key = status.trim().toLowerCase();
const tone = knownStatusTones[key] ?? (closed ? 'ok' : 'neutral');
const Icon = closed ? CheckCircle2 : key.startsWith('čeká') ? Clock : CircleDot;
@@ -148,7 +142,8 @@ export function IncidentSeverityBadge({ severity }: { severity: IncidentSeverity
export function IncidentStatusBadge({ status }: { status: IncidentStatus }) {
const { label, tone } = incidentStatusMap[status];
const Icon = status === 'resolved' ? CheckCircle2 : status === 'investigating' ? Search : CircleDot;
const Icon =
status === 'resolved' ? CheckCircle2 : status === 'investigating' ? Search : CircleDot;
return (
<Badge tone={tone}>
<Icon className="size-3.5" />
+10 -10
View File
@@ -1,5 +1,5 @@
import { AlertTriangle, CheckCircle2, Code2, Play, Plus, Save, Trash2 } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { DataState } from '@/components/dashboard/DataState';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
@@ -9,7 +9,8 @@ import { Textarea } from '@/components/ui/form/Textarea';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/cn';
import { formatDateTime } from '@/lib/format';
import { useApiQuery } from '@/lib/useApiQuery';
import { useApiQuery } from '@/hooks/useApiQuery';
import { useSyncFromSource } from '@/hooks/useSyncFromSource';
/**
* Skripty firmy: prevod dat z A do B napsany v JS.
@@ -49,7 +50,7 @@ interface TestResult {
export function TenantScripts() {
const scripts = useApiQuery<ScriptsResponse>('/api/dashboard/tenant-scripts');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [chosenId, setSelectedId] = useState<string | null>(null);
const [draft, setDraft] = useState<ScriptRecord | null>(null);
const [sample, setSample] = useState('{\n "data": {}\n}');
const [test, setTest] = useState<TestResult | null>(null);
@@ -58,16 +59,15 @@ export function TenantScripts() {
const items = useMemo(() => scripts.data?.items ?? [], [scripts.data]);
// Prvni skript se vybere sam, jinak stranka po nacteni vypada prazdne.
useEffect(() => {
if (selectedId === null && items.length > 0) setSelectedId(items[0].id);
}, [items, selectedId]);
// Bez volby se vybere prvni skript, jinak stranka po nacteni vypada prazdne.
const selectedId = chosenId ?? items[0]?.id ?? null;
useEffect(() => {
const found = items.find((item) => item.id === selectedId);
// Rozepsana kopie jde za vybranym zaznamem: po ulozeni i po prepnuti.
const selected = items.find((item) => item.id === selectedId);
useSyncFromSource(selected, (found) => {
setDraft(found ? { ...found } : null);
setTest(null);
}, [items, selectedId]);
});
const dirty =
draft !== null &&
+29 -23
View File
@@ -7,10 +7,11 @@ import { Input } from '@/components/ui/form/Input';
import { Select } from '@/components/ui/form/Select';
import { Textarea } from '@/components/ui/form/Textarea';
import { Modal } from '@/components/ui/Modal';
import { ServiceIcon } from '@/components/ui/ServiceIcon';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/cn';
import { serviceIcon } from '@/lib/serviceIcons';
import { useSubmit } from '@/lib/useSubmit';
import { useSubmit } from '@/hooks/useSubmit';
import { useSyncFromSource } from '@/hooks/useSyncFromSource';
import type { TicketType } from '@/types/dashboard';
/**
@@ -75,21 +76,24 @@ export function TicketActions({
* Seznam akci se nacita s pojistkou proti zastarale odpovedi: po prepnuti
* na jiny ticket by odpoved z toho predchoziho prepsala tlacitka.
*/
const load = useCallback((signal?: { cancelled: boolean }) => {
apiFetch<{ items: TicketActionItem[] }>(`/api/dashboard/tickets/${ticketId}/actions`)
.then((data) => {
if (signal?.cancelled) return;
setItems(data.items);
setListError(null);
})
.catch((err: unknown) => {
// Chybejici akce nesmi zhasnout detail ticketu, ale nesmi ani zmizet potichu.
console.warn('[actions] seznam se nepodarilo nacist:', err);
if (signal?.cancelled) return;
setItems([]);
setListError('Akce se nepodařilo načíst.');
});
}, [ticketId]);
const load = useCallback(
(signal?: { cancelled: boolean }) => {
apiFetch<{ items: TicketActionItem[] }>(`/api/dashboard/tickets/${ticketId}/actions`)
.then((data) => {
if (signal?.cancelled) return;
setItems(data.items);
setListError(null);
})
.catch((err: unknown) => {
// Chybejici akce nesmi zhasnout detail ticketu, ale nesmi ani zmizet potichu.
console.warn('[actions] seznam se nepodarilo nacist:', err);
if (signal?.cancelled) return;
setItems([]);
setListError('Akce se nepodařilo načíst.');
});
},
[ticketId],
);
useEffect(() => {
const signal = { cancelled: false };
@@ -116,7 +120,8 @@ export function TicketActions({
}
}
const hasActionBlock = items.length > 0 || result !== null || error !== null || listError !== null;
const hasActionBlock =
items.length > 0 || result !== null || error !== null || listError !== null;
return (
<div className="glass space-y-4 rounded-card p-4">
@@ -136,7 +141,6 @@ export function TicketActions({
<div className="flex flex-wrap gap-2">
{items.map((action) => {
const Icon = serviceIcon(action.icon);
return (
<Button
key={action.id}
@@ -148,7 +152,7 @@ export function TicketActions({
else setPending(action);
}}
>
<Icon className="size-3.5" />
<ServiceIcon icon={action.icon} className="size-3.5" />
{action.label}
</Button>
);
@@ -224,7 +228,7 @@ function TicketMeta({
const [tags, setTags] = useState(ticket.tags.join(', '));
// Po ulozeni se detail znovu nacte, textove pole musi jit za nim.
useEffect(() => setTags(ticket.tags.join(', ')), [ticket.tags]);
useSyncFromSource(ticket.tags, (fresh) => setTags(fresh.join(', ')));
const { busy, error, submit } = useSubmit(async (path: string, body: unknown) => {
await apiFetch(`/api/dashboard/tickets/${ticket.id}/${path}`, { method: 'POST', body });
@@ -315,7 +319,7 @@ function TypeFields({
const [draft, setDraft] = useState(values);
// Po ulozeni prijde novy ticket, rozepsany stav se musi srovnat se serverem.
useEffect(() => setDraft(values), [values]);
useSyncFromSource(values, setDraft);
/**
* Objekt a seznam sem plni automatizace, rucne se nevyplnuji - textarea
@@ -365,7 +369,9 @@ function TypeFields({
/>
) : (
<Input
type={field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : 'text'}
type={
field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : 'text'
}
value={text}
disabled={busy}
onChange={(event) =>
+6 -3
View File
@@ -17,6 +17,9 @@
import type { ReactNode } from 'react';
/** Kolik dvojic klic: hodnota jde do jednoradkoveho shrnuti. */
const SUMMARY_PAIRS = 4;
/** Co v tele stoji. `text` je vzdycky to, co ma zustat cist jako veta. */
interface ParsedBody {
text: string;
@@ -75,7 +78,7 @@ export function bodyPreview(body: string): string {
? [`${data.length} položek`]
: Object.entries(data as Record<string, unknown>)
.filter(([, value]) => value !== null && typeof value !== 'object')
.slice(0, 4)
.slice(0, SUMMARY_PAIRS)
.map(([key, value]) => `${key}: ${String(value)}`);
const summary = pairs.join(', ');
@@ -89,8 +92,8 @@ export function TicketBody({ body, sourceRef }: { body: string; sourceRef?: stri
if (text === '' && data === undefined) {
return (
<p className="rounded-lg border border-dashed border-ink-600/70 px-3 py-2.5 text-xs text-white/40">
Prázdné. Krok "Založit ticket" neměl nastavené pole Obsah, takže se text původního
požadavku nikam neuložil.
Prázdné. Krok "Založit ticket" neměl nastavené pole Obsah, takže se text původního požadavku
nikam neuložil.
</p>
);
}
+7 -2
View File
@@ -10,6 +10,9 @@ import { cn } from '@/lib/cn';
import { formatDateTime, formatRelative } from '@/lib/format';
import type { TicketChannel, TicketPriority } from '@/types/dashboard';
/** Tagu na karte. Vic by se neveslo na jeden radek vedle kanalu a priority. */
const CARD_TAGS = 2;
/**
* Co karta potrebuje. Zamerne uzsi nez `Ticket`: widgety dostavaji ze serveru
* jen vytah (id, predmet, stav, jmeno resitele) a kreslit se maji stejne.
@@ -92,7 +95,7 @@ export function TicketCard({
);
}
const tags = skip.has('tags') ? [] : (ticket.tags ?? []).slice(0, 2);
const tags = skip.has('tags') ? [] : (ticket.tags ?? []).slice(0, CARD_TAGS);
return (
<Link
@@ -127,7 +130,9 @@ export function TicketCard({
{(!skip.has('channel') || !skip.has('priority') || tags.length > 0 || ticket.reopenCount) && (
<div className="mt-3 flex flex-wrap items-center gap-1.5">
{!skip.has('channel') && ticket.channel && <TicketChannelBadge channel={ticket.channel} />}
{!skip.has('channel') && ticket.channel && (
<TicketChannelBadge channel={ticket.channel} />
)}
{!skip.has('priority') && ticket.priority && (
<TicketPriorityBadge priority={ticket.priority} />
)}
+1 -1
View File
@@ -8,7 +8,7 @@ import {
import { TicketCard } from '@/components/dashboard/TicketCard';
import { cn } from '@/lib/cn';
import { formatDateTime, formatRelative } from '@/lib/format';
import { MD_UP, useMediaQuery } from '@/lib/useMediaQuery';
import { MD_UP, useMediaQuery } from '@/hooks/useMediaQuery';
import type { Ticket } from '@/types/dashboard';
/**
+13 -7
View File
@@ -16,7 +16,11 @@ import type { Service, TicketTraceEntry, TraceStatus } from '@/types/dashboard';
const statusMeta: Record<TraceStatus, { icon: LucideIcon; tone: string; line: string }> = {
ok: { icon: CheckCircle2, tone: 'text-ok-400 bg-ok-500/12', line: 'border-ok-400/25' },
error: { icon: AlertCircle, tone: 'text-danger-400 bg-danger-500/12', line: 'border-danger-400/30' },
error: {
icon: AlertCircle,
tone: 'text-danger-400 bg-danger-500/12',
line: 'border-danger-400/30',
},
skipped: { icon: MinusCircle, tone: 'text-white/40 bg-white/5', line: 'border-white/10' },
info: { icon: Info, tone: 'text-brand-300 bg-brand-500/12', line: 'border-brand-400/20' },
};
@@ -93,16 +97,16 @@ function TraceRow({ node, services }: { node: TraceNode; services: Service[] })
)}
>
<div className="flex items-start gap-3">
<span className={cn('mt-0.5 grid size-7 shrink-0 place-items-center rounded-lg', meta.tone)}>
<span
className={cn('mt-0.5 grid size-7 shrink-0 place-items-center rounded-lg', meta.tone)}
>
<Icon className="size-4" />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<p className="text-sm font-medium text-white/85">{node.label}</p>
{service && (
<span className="font-mono text-xs text-brand-300/80">{service}</span>
)}
{service && <span className="font-mono text-xs text-brand-300/80">{service}</span>}
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-white/35">
@@ -154,8 +158,10 @@ export function TraceSummary({ entries }: { entries: TicketTraceEntry[] }) {
return (
<p className="text-xs text-white/40">
{entries.length} {entries.length === 1 ? 'záznam' : entries.length <= 4 ? 'záznamy' : 'záznamů'}
{services > 0 && ` · ${services} ${services === 1 ? 'služba' : services <= 4 ? 'služby' : 'služeb'}`}
{entries.length}{' '}
{entries.length === 1 ? 'záznam' : entries.length <= 4 ? 'záznamy' : 'záznamů'}
{services > 0 &&
` · ${services} ${services === 1 ? 'služba' : services <= 4 ? 'služby' : 'služeb'}`}
{failed > 0 && <span className="text-danger-400"> · {failed} selhalo</span>}
{skipped > 0 && ` · ${skipped} neproběhlo`}
</p>
@@ -9,55 +9,56 @@ import { EntityAdmin, parseJsonField, type NamedRecord } from '@/components/dash
export function TicketTypesAdmin() {
return (
<EntityAdmin
title="Typy ticketů"
description="Za typem stojí vlastní pole, proto se na typ vážou akce. Tag je jen nálepka."
endpoint="/api/dashboard/settings/ticket-types"
columns={[
{ label: 'Název', render: (type: NamedRecord) => String(type.name ?? '') },
{ label: 'Klíč', render: (type: NamedRecord) => <code>{String(type.key ?? '')}</code> },
{
label: 'Polí',
narrow: true,
render: (type: NamedRecord) => String((type.fields as unknown[] | undefined)?.length ?? 0),
},
]}
fields={[
{ name: 'name', label: 'Název', kind: 'text', required: true },
{
name: 'key',
label: 'Klíč',
kind: 'text',
required: true,
createOnly: true,
hint: 'Malá písmena a pomlčky, například order. Používá se ve filtrech.',
},
{ name: 'icon', label: 'Ikona', kind: 'text', initial: 'LifeBuoy' },
{
name: 'statuses',
label: 'Vlastní stavy',
kind: 'tags',
hint: 'Prázdné = výchozí čtveřice ticketu.',
},
{
name: 'fields',
label: 'Vlastní pole',
kind: 'json',
initial: '[]',
hint: 'Seznam polí: key, label, type (string/number/boolean/date), required.',
},
]}
toBody={(values, entity) => ({
...(entity ? {} : { key: values.key }),
name: values.name,
icon: values.icon,
statuses: values.statuses,
fields: parseJsonField(values.fields, []),
})}
toForm={(type: NamedRecord) => ({
...type,
fields: JSON.stringify(type.fields ?? [], null, 2),
})}
/>
<EntityAdmin
title="Typy ticketů"
description="Za typem stojí vlastní pole, proto se na typ vážou akce. Tag je jen nálepka."
endpoint="/api/dashboard/settings/ticket-types"
columns={[
{ label: 'Název', render: (type: NamedRecord) => String(type.name ?? '') },
{ label: 'Klíč', render: (type: NamedRecord) => <code>{String(type.key ?? '')}</code> },
{
label: 'Polí',
narrow: true,
render: (type: NamedRecord) =>
String((type.fields as unknown[] | undefined)?.length ?? 0),
},
]}
fields={[
{ name: 'name', label: 'Název', kind: 'text', required: true },
{
name: 'key',
label: 'Klíč',
kind: 'text',
required: true,
createOnly: true,
hint: 'Malá písmena a pomlčky, například order. Používá se ve filtrech.',
},
{ name: 'icon', label: 'Ikona', kind: 'text', initial: 'LifeBuoy' },
{
name: 'statuses',
label: 'Vlastní stavy',
kind: 'tags',
hint: 'Prázdné = výchozí čtveřice ticketu.',
},
{
name: 'fields',
label: 'Vlastní pole',
kind: 'json',
initial: '[]',
hint: 'Seznam polí: key, label, type (string/number/boolean/date), required.',
},
]}
toBody={(values, entity) => ({
...(entity ? {} : { key: values.key }),
name: values.name,
icon: values.icon,
statuses: values.statuses,
fields: parseJsonField(values.fields, []),
})}
toForm={(type: NamedRecord) => ({
...type,
fields: JSON.stringify(type.fields ?? [], null, 2),
})}
/>
);
}
@@ -3,11 +3,14 @@ import { memo, useMemo, useState } from 'react';
import type { CanvasContext } from '@/components/dashboard/flow/canvasTypes';
import { BrokenCard, StepControls } from '@/components/dashboard/flow/StepControls';
import { StepInputs } from '@/components/dashboard/flow/StepInputs';
import { ServiceIcon } from '@/components/ui/ServiceIcon';
import { cn } from '@/lib/cn';
import { resolveOperation } from '@/lib/flow';
import { serviceIcon } from '@/lib/serviceIcons';
import type { FlowStep, Service, TriggerField } from '@/types/dashboard';
/** Kolik vyplnenych poli ukaze sbaleny krok; zbytek shrne jako pocet. */
const COLLAPSED_INPUTS = 4;
/**
* Karta akce nad konektorem.
*
@@ -53,7 +56,6 @@ export const ActionCard = memo(function ActionCard({
);
}
const Icon = serviceIcon(resolved.connector.icon);
const inputs = resolved.operation.inputs;
/*
@@ -76,14 +78,10 @@ export const ActionCard = memo(function ActionCard({
title={open ? 'Sbalit krok' : 'Rozbalit krok'}
className="grid size-8 shrink-0 place-items-center rounded-xl bg-ink-700/70 text-brand-300 transition-colors hover:bg-ink-700 @xs:size-10"
>
<Icon className="size-4 @xs:size-4.5" />
<ServiceIcon icon={resolved.connector.icon} className="size-4 @xs:size-4.5" />
</button>
<button
type="button"
onClick={() => setOpen(!open)}
className="min-w-0 flex-1 text-left"
>
<button type="button" onClick={() => setOpen(!open)} className="min-w-0 flex-1 text-left">
<span className="block truncate text-xs text-white/40">{resolved.connector.name}</span>
<span className="mt-0.5 flex flex-wrap items-center gap-2 font-semibold break-words text-white">
{resolved.operation.name}
@@ -109,7 +107,7 @@ export const ActionCard = memo(function ActionCard({
{filled.length === 0 ? (
<span className="text-xs text-white/30">nic nevyplněno</span>
) : (
filled.slice(0, 4).map(([key, value]) => (
filled.slice(0, COLLAPSED_INPUTS).map(([key, value]) => (
<span
key={key}
className="max-w-56 truncate rounded bg-ink-950/60 px-1.5 py-0.5 font-mono text-xs text-white/50"
@@ -119,7 +117,7 @@ export const ActionCard = memo(function ActionCard({
</span>
))
)}
{filled.length > 4 && (
{filled.length > COLLAPSED_INPUTS && (
<span className="text-xs text-white/30">a {filled.length - 4} dalších</span>
)}
</span>
@@ -75,6 +75,10 @@ export const ConditionCard = memo(function ConditionCard({
return;
}
const rule = rules[index];
if (!rule) {
console.warn(`[flow] podminka: pravidlo ${index} neexistuje`);
return;
}
const stillValid = operatorsByType[next.type].includes(rule.operator);
changeRule(index, {
fieldId,
@@ -1,5 +1,5 @@
import { MousePointerClick, Zap } from 'lucide-react';
import { useCallback, useMemo, useRef } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { ActionCard } from '@/components/dashboard/flow/ActionCard';
import type { CanvasCallbacks, CanvasContext } from '@/components/dashboard/flow/canvasTypes';
import { ConditionCard } from '@/components/dashboard/flow/ConditionCard';
@@ -7,15 +7,9 @@ import { ForeachCard } from '@/components/dashboard/flow/ForeachCard';
import { AddStepButton, BrokenCard } from '@/components/dashboard/flow/StepControls';
import { TriggerConfig } from '@/components/dashboard/flow/TriggerConfig';
import { Badge } from '@/components/ui/Badge';
import { serviceIcon } from '@/lib/serviceIcons';
import { ServiceIcon } from '@/components/ui/ServiceIcon';
import { collectScopes, resolveOperation, type FlowPath } from '@/lib/flow';
import type {
AutomationFlow,
ModelNode,
Service,
FlowStep,
TriggerField,
} from '@/types/dashboard';
import type { AutomationFlow, ModelNode, Service, FlowStep, TriggerField } from '@/types/dashboard';
/**
* Vizualizace stromu akci. Sama nic nemeni - vsechny zmeny hlasi nahoru
@@ -42,6 +36,27 @@ function collectIds(steps: FlowStep[], into: string[] = []): string[] {
return into;
}
type Scopes = Map<string, TriggerField[]>;
/**
* Z minule mapy prevezme seznamy, ktere se obsahem nezmenily. Kdyz se
* nezmenil zadny, vrati minulou mapu i s jeji identitou.
*/
function reconcileScopes(previous: Scopes, fresh: Scopes): Scopes {
let changed = previous.size !== fresh.size;
const next: Scopes = new Map();
for (const [stepId, fields] of fresh) {
const known = previous.get(stepId);
if (known && JSON.stringify(known) === JSON.stringify(fields)) {
next.set(stepId, known);
} else {
next.set(stepId, fields);
changed = true;
}
}
return changed ? next : previous;
}
/**
* Scopes se stabilni identitou.
*
@@ -49,25 +64,16 @@ function collectIds(steps: FlowStep[], into: string[] = []): string[] {
* dany krok nic nezmenilo. Karta by pak dostala "jine" props a `memo` by
* nepomohlo. Tady se novy vysledek porovna s minulym a seznam se vymeni jen
* tam, kde se opravdu lisi.
*
* Minula mapa je ve stavu, ne v refu: ref se pri vykresleni cist ani menit
* nesmi, vykresleni musi byt ciste. Stav se prepise jen kdyz se neco zmenilo.
*/
function useStableScopes(
flow: AutomationFlow,
connectors: Service[],
): Map<string, TriggerField[]> {
const stable = useRef(new Map<string, TriggerField[]>());
function useStableScopes(flow: AutomationFlow, connectors: Service[]): Scopes {
const fresh = useMemo(() => collectScopes(flow, connectors), [flow, connectors]);
for (const [stepId, fields] of fresh) {
const previous = stable.current.get(stepId);
if (!previous || JSON.stringify(previous) !== JSON.stringify(fields)) {
stable.current.set(stepId, fields);
}
}
for (const stepId of [...stable.current.keys()]) {
if (!fresh.has(stepId)) stable.current.delete(stepId);
}
return stable.current;
const [stable, setStable] = useState(fresh);
const next = reconcileScopes(stable, fresh);
if (next !== stable) setStable(next);
return next;
}
export function FlowCanvas({
@@ -116,7 +122,8 @@ export function FlowCanvas({
const idsKey = collectIds(flow.steps).join(',');
const context = useMemo<CanvasContext>(() => {
const roots = new Set<string>(['_body', 'item', 'index']);
for (const node of model) roots.add(node.path.split('.')[0].replace(/\[\d*\]$/, ''));
// `split` vraci aspon jeden prvek, jen typ to nevi.
for (const node of model) roots.add((node.path.split('.')[0] ?? '').replace(/\[\d*\]$/, ''));
for (const id of idsKey.split(',')) if (id) roots.add(id);
return { model, extraRoots: roots };
}, [model, idsKey]);
@@ -265,13 +272,11 @@ function TriggerCard({
);
}
const Icon = serviceIcon(resolved.connector.icon);
return (
<div className="rounded-card border border-brand-400/35 bg-gradient-to-br from-brand-500/12 to-transparent p-5">
<div className="flex items-start gap-4">
<span className="grid size-11 shrink-0 place-items-center rounded-xl bg-brand-500/20 text-brand-200">
<Icon className="size-5" />
<ServiceIcon icon={resolved.connector.icon} className="size-5" />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
@@ -1,11 +1,25 @@
import { ArrowRight, Braces, Plus, Trash2 } from 'lucide-react';
import { useId, useMemo, useRef, useState } from 'react';
import { useId, useMemo, useState } from 'react';
import { Badge } from '@/components/ui/Badge';
import { Input } from '@/components/ui/form/Input';
import { Select } from '@/components/ui/form/Select';
import { Textarea } from '@/components/ui/form/Textarea';
let keyCounter = 0;
/** Klice radku a citac pro dalsi. Citac je ve stavu, aby vykresleni nic nemenilo. */
interface RowKeys {
keys: string[];
next: number;
}
/** Dorovna pocet klicu na pocet radku; pri shode vrati tentyz objekt. */
function fitRowKeys(state: RowKeys, count: number): RowKeys {
if (state.keys.length === count) return state;
if (state.keys.length > count) return { keys: state.keys.slice(0, count), next: state.next };
const keys = [...state.keys];
let next = state.next;
while (keys.length < count) keys.push(`row_${(next += 1)}`);
return { keys, next };
}
/**
* Stabilni klice pro radky bez vlastniho ID.
@@ -15,17 +29,21 @@ let keyCounter = 0;
* pro radek, ktery se posunul na jeho misto. Tady se kazdemu radku prideli
* klic pri prvnim vykresleni a pri mazani se odebere ten spravny.
*
* Kdyz se pocet zmeni jinak nez tlacitky (rezim JSON), klice se dorovnaji.
* Kdyz se pocet zmeni jinak nez tlacitky (rezim JSON), klice se dorovnaji
* hned pri vykresleni. `remove` se vola ve stejnem handleru jako zmena
* seznamu, takze pri dalsim vykresleni pocty sedi a nic se nedorovnava.
*/
function useRowKeys(count: number): { keys: string[]; remove: (index: number) => void } {
const keys = useRef<string[]>([]);
while (keys.current.length < count) keys.current.push(`row_${(keyCounter += 1)}`);
if (keys.current.length > count) keys.current.length = count;
const [state, setState] = useState<RowKeys>({ keys: [], next: 0 });
const fitted = fitRowKeys(state, count);
if (fitted !== state) setState(fitted);
return {
keys: keys.current,
remove: (index) => {
keys.current.splice(index, 1);
},
keys: fitted.keys,
remove: (index) =>
setState((previous) => ({
keys: previous.keys.filter((_key, at) => at !== index),
next: previous.next,
})),
};
}
@@ -197,7 +215,9 @@ export function MappingEditor({
tone="deep"
invalid={broken}
/>
{broken && <p className="text-xs text-warn-400">Tohle není platný JSON se seznamem pravidel.</p>}
{broken && (
<p className="text-xs text-warn-400">Tohle není platný JSON se seznamem pravidel.</p>
)}
<button
type="button"
onClick={() => setRawMode(false)}
@@ -0,0 +1,77 @@
import { ChevronRight } from 'lucide-react';
import { useState } from 'react';
import { cn } from '@/lib/cn';
import type { ModelNode } from '@/types/dashboard';
/**
* Cesty z ukazky tela.
*
* Ukazuje se sbalene: model objednavky ma desitky cest a rozbalený seznam by
* schoval vlastni nastaveni kroku. Kdo hleda konkretni cestu, si ho otevre.
*
* U seznamu je videt, ze je to seznam - prave na nej se dava krok
* Pro kazdou polozku, ne odkaz do sablony.
*/
export function ModelTree({
model,
enabled,
onInsert,
}: {
model: ModelNode[];
enabled: boolean;
onInsert: (name: string) => void;
}) {
const [open, setOpen] = useState(false);
return (
<div className="mt-2 border-t border-ink-600/40 pt-2">
<button
type="button"
onClick={() => setOpen(!open)}
className="flex w-full items-center gap-1.5 text-left text-xs text-white/40 transition-colors hover:text-white/70"
>
<ChevronRight className={cn('size-3 transition-transform', open && 'rotate-90')} />
Cesty z ukázky těla ({model.length})
</button>
{open && (
<div className="mt-2 max-h-64 space-y-0.5 overflow-auto pr-1">
{model.map((node) => {
// Do sablony patri hodnota, ne cely objekt nebo seznam.
const insertable = node.type !== 'object' && node.type !== 'list';
const depth = (node.path.match(/\./g) ?? []).length;
return (
<div
key={node.path}
className="flex items-center gap-2 text-xs"
style={{ paddingLeft: `${Math.min(depth, 6) * 10}px` }}
>
<button
type="button"
disabled={!enabled || !insertable}
onClick={() => onInsert(node.path)}
title={insertable ? `Vložit {{${node.path}}}` : 'Struktura, ne hodnota'}
className={cn(
'truncate rounded px-1 py-0.5 font-mono transition-colors',
enabled && insertable
? 'text-brand-200 hover:bg-brand-500/15'
: 'cursor-not-allowed text-white/30',
)}
>
{node.path}
</button>
{node.type === 'list' ? (
<span className="shrink-0 text-white/30">seznam, {node.count ?? 0} polozek</span>
) : node.sample ? (
<span className="min-w-0 truncate text-white/25">{node.sample}</span>
) : null}
</div>
);
})}
</div>
)}
</div>
);
}
@@ -0,0 +1,140 @@
import { ChevronRight } from 'lucide-react';
import { useState } from 'react';
import { Textarea } from '@/components/ui/form/Textarea';
import { cn } from '@/lib/cn';
import { newFieldId } from '@/lib/flow';
import type { ModelNode, TriggerField } from '@/types/dashboard';
/**
* Ukazka skutecneho tela.
*
* Vlepi se to, co odesilatel opravdu posila. Z toho se odvodi model, tedy
* seznam cest - v krocich se pak klikaji misto opisovani a kontrola vi, ze
* `data.order.code` neni preklep.
*
* Neni to kontrakt, na kterem stoji beh: telo se nezahazuje, kdyz se lisi.
* Je to napoveda pro cloveka a to je presne to, co u modelu o padesati cestach
* chybelo.
*/
export function SampleBody({
sample,
model,
fields,
onChange,
onChangeFields,
}: {
sample: unknown;
model: ModelNode[];
fields: TriggerField[];
onChange: (sample: unknown) => void;
onChangeFields: (fields: TriggerField[]) => void;
}) {
const [text, setText] = useState(sample === undefined ? '' : JSON.stringify(sample, null, 2));
const [problem, setProblem] = useState<string | null>(null);
const [open, setOpen] = useState(sample === undefined);
function save() {
const trimmed = text.trim();
if (trimmed === '') {
setProblem(null);
onChange(undefined);
return;
}
try {
onChange(JSON.parse(trimmed));
setProblem(null);
} catch (err) {
setProblem(err instanceof Error ? err.message : 'Neplatný JSON.');
}
}
/**
* Z cest udela deklarovane parametry pro podminky.
*
* Podminka se odkazuje na parametr, ne na cestu - proto tohle tlacitko.
* Bere jen hodnoty, ne objekty a seznamy: na ty se podminka ptat neumi.
* Jmeno vznikne z posledni casti cesty, kolize dostane cislo.
*/
function deriveFields() {
const used = new Set(fields.map((field) => field.name));
const added: TriggerField[] = [];
for (const node of model) {
if (node.type === 'object' || node.type === 'list') continue;
if (node.path.includes('[]')) continue;
if (fields.some((field) => field.path === node.path)) continue;
const base = node.path.split('.').pop() ?? node.path;
let name = base.replace(/[^A-Za-z0-9_]/g, '_');
if (/^[0-9]/.test(name)) name = `f_${name}`;
let unique = name;
let counter = 2;
while (used.has(unique)) unique = `${name}${counter++}`;
used.add(unique);
added.push({
id: newFieldId(),
name: unique,
type: node.type,
required: false,
path: node.path,
});
}
if (added.length > 0) onChangeFields([...fields, ...added]);
}
return (
<div className="rounded-xl border border-ink-600/50 bg-ink-900/40 p-3">
<button
type="button"
onClick={() => setOpen(!open)}
className="flex w-full items-center gap-2 text-left text-xs font-semibold tracking-wide text-white/45 uppercase transition-colors hover:text-white/70"
>
<ChevronRight className={cn('size-3.5 transition-transform', open && 'rotate-90')} />
Ukázka těla
{model.length > 0 && <span className="font-normal normal-case">{model.length} cest</span>}
</button>
{open && (
<>
<p className="mt-2 text-xs text-white/40">
Vložte tělo z reálného volání. Odvodí se z něj cesty, které pak v krocích jen kliknete:{' '}
{'{{data.order.code}}'} i seznamy jako data.order.items.
</p>
<Textarea
mono
tone="deep"
invalid={problem !== null}
value={text}
onChange={(event) => setText(event.target.value)}
onBlur={save}
rows={6}
spellCheck={false}
placeholder={'{\n "data": {\n "order": { "code": "2026000123" }\n }\n}'}
className="mt-2"
/>
{problem && <p className="mt-1 text-xs text-danger-400">{problem}</p>}
{model.length > 0 && (
<div className="mt-2 flex flex-wrap items-center gap-2">
<button
type="button"
onClick={deriveFields}
className="rounded-lg border border-brand-400/40 px-3 py-1.5 text-xs text-brand-200 transition-colors hover:bg-brand-500/15"
>
Doplnit parametry pro podmínky
</button>
<span className="text-xs text-white/30">
Podmínka se ptá na parametr, ne na cestu. Tímhle se z hodnot v ukázce udělají
parametry.
</span>
</div>
)}
</>
)}
</div>
);
}
@@ -1,4 +1,4 @@
import { AlertTriangle, Braces, ChevronRight } from 'lucide-react';
import { AlertTriangle, Braces } from 'lucide-react';
import { useMemo, useRef, useState } from 'react';
import { MappingEditor } from '@/components/dashboard/flow/MappingEditor';
import { Badge } from '@/components/ui/Badge';
@@ -8,6 +8,7 @@ import { Textarea } from '@/components/ui/form/Textarea';
import { brokenReferences } from '@/lib/flow';
import { cn } from '@/lib/cn';
import type { ModelNode, OperationField, TriggerField } from '@/types/dashboard';
import { ModelTree } from '@/components/dashboard/flow/ModelTree';
/** Pole, ktera nesou sablonu s `{{odkazy}}` a daji se u nich hlidat. */
function isPlainTemplate(field: OperationField): boolean {
@@ -213,9 +214,7 @@ export function StepInputs({
/>
<Select
aria-label={`${field.label}: vybrat ze seznamu`}
value={
field.options?.some((option) => option.value === value) ? value : ''
}
value={field.options?.some((option) => option.value === value) ? value : ''}
onChange={(event) => set(field.id, event.target.value)}
size="compact"
tone="card"
@@ -266,8 +265,8 @@ export function StepInputs({
{broken.length > 0 && (
<p className="mt-1 flex items-start gap-1.5 text-xs text-warn-400">
<AlertTriangle className="mt-0.5 size-3 shrink-0" />
Spouštěč nemá parametr {broken.map((name) => `{{${name}}}`).join(', ')}.
Nejspíš se přejmenoval.
Spouštěč nemá parametr {broken.map((name) => `{{${name}}}`).join(', ')}. Nejspíš se
přejmenoval.
</p>
)}
</div>
@@ -336,80 +335,6 @@ function ParameterChips({
</div>
);
}
/**
* Cesty z ukazky tela.
*
* Ukazuje se sbalene: model objednavky ma desitky cest a rozbalený seznam by
* schoval vlastni nastaveni kroku. Kdo hleda konkretni cestu, si ho otevre.
*
* U seznamu je videt, ze je to seznam - prave na nej se dava krok
* Pro kazdou polozku, ne odkaz do sablony.
*/
function ModelTree({
model,
enabled,
onInsert,
}: {
model: ModelNode[];
enabled: boolean;
onInsert: (name: string) => void;
}) {
const [open, setOpen] = useState(false);
return (
<div className="mt-2 border-t border-ink-600/40 pt-2">
<button
type="button"
onClick={() => setOpen(!open)}
className="flex w-full items-center gap-1.5 text-left text-xs text-white/40 transition-colors hover:text-white/70"
>
<ChevronRight className={cn('size-3 transition-transform', open && 'rotate-90')} />
Cesty z ukázky těla ({model.length})
</button>
{open && (
<div className="mt-2 max-h-64 space-y-0.5 overflow-auto pr-1">
{model.map((node) => {
// Do sablony patri hodnota, ne cely objekt nebo seznam.
const insertable = node.type !== 'object' && node.type !== 'list';
const depth = (node.path.match(/\./g) ?? []).length;
return (
<div
key={node.path}
className="flex items-center gap-2 text-xs"
style={{ paddingLeft: `${Math.min(depth, 6) * 10}px` }}
>
<button
type="button"
disabled={!enabled || !insertable}
onClick={() => onInsert(node.path)}
title={insertable ? `Vložit {{${node.path}}}` : 'Struktura, ne hodnota'}
className={cn(
'truncate rounded px-1 py-0.5 font-mono transition-colors',
enabled && insertable
? 'text-brand-200 hover:bg-brand-500/15'
: 'cursor-not-allowed text-white/30',
)}
>
{node.path}
</button>
{node.type === 'list' ? (
<span className="shrink-0 text-white/30">seznam, {node.count ?? 0} polozek</span>
) : node.sample ? (
<span className="min-w-0 truncate text-white/25">{node.sample}</span>
) : null}
</div>
);
})}
</div>
)}
</div>
);
}
/**
* Pole s JSONem. Chyba se hlasi hned pri psani, ne az pri ulozeni - preklep
* v zavorce je jinak videt teprve z padleho behu.
@@ -450,8 +375,8 @@ function JsonField({
)}
<p className="mt-1 text-xs text-white/35">
Hodnoty ze zdroje se vkládají jako <code>{'${cesta}'}</code>. Jiný zápis než{' '}
<code>{'{{parametr}}'}</code> je záměr: šablony kroku se dosazují dřív, než krok
běží, a stihly by tenhle text přepsat.
<code>{'{{parametr}}'}</code> je záměr: šablony kroku se dosazují dřív, než krok běží, a
stihly by tenhle text přepsat.
</p>
</div>
);
@@ -480,8 +405,8 @@ function ObjectField({
if (options.length === 0) {
return (
<p className="mt-1 rounded-lg border border-dashed border-warn-400/50 px-3 py-2 text-xs text-warn-400">
Sem zatím nedoteče žádný objekt ani seznam. Potřebujete krok, který strukturu
vrátí - třeba spouštěč e-shopu nebo jinou transformaci.
Sem zatím nedoteče žádný objekt ani seznam. Potřebujete krok, který strukturu vrátí - třeba
spouštěč e-shopu nebo jinou transformaci.
</p>
);
}
@@ -4,11 +4,19 @@ import { Badge } from '@/components/ui/Badge';
import { Chip } from '@/components/ui/Chip';
import { Input } from '@/components/ui/form/Input';
import { Modal } from '@/components/ui/Modal';
import { ServiceIcon } from '@/components/ui/ServiceIcon';
import { cn } from '@/lib/cn';
import { plural } from '@/lib/format';
import { serviceIcon } from '@/lib/serviceIcons';
import type { CatalogService, ServiceCategory, ServiceOperation } from '@/types/dashboard';
/** Od kolika znaku se hleda. Jedno pismeno by vratilo cely katalog. */
const MIN_QUERY_LENGTH = 2;
/** Co sluzba v danem rezimu nabizi: spoustece, nebo akce. */
function operationsOf(connector: CatalogService, mode: 'trigger' | 'action'): ServiceOperation[] {
return mode === 'trigger' ? connector.triggers : connector.actions;
}
/**
* Vyber toho, co se ma pridat do stromu.
*
@@ -45,9 +53,6 @@ export function StepPicker({
const [query, setQuery] = useState('');
const [category, setCategory] = useState<ServiceCategory | 'all'>('all');
const operationsOf = (connector: CatalogService) =>
mode === 'trigger' ? connector.triggers : connector.actions;
/*
* Co jde ve strome opravdu pouzit.
*
@@ -60,7 +65,7 @@ export function StepPicker({
() =>
connectors.filter(
(connector) =>
operationsOf(connector).length > 0 && (connector.general || connector.connected),
operationsOf(connector, mode).length > 0 && (connector.general || connector.connected),
),
[connectors, mode],
);
@@ -70,7 +75,7 @@ export function StepPicker({
() =>
connectors.filter(
(connector) =>
operationsOf(connector).length > 0 && !connector.general && !connector.connected,
operationsOf(connector, mode).length > 0 && !connector.general && !connector.connected,
).length,
[connectors, mode],
);
@@ -86,15 +91,16 @@ export function StepPicker({
);
const trimmedQuery = query.trim().toLowerCase();
const searching = trimmedQuery.length >= 2;
const searching = trimmedQuery.length >= MIN_QUERY_LENGTH;
// Ploche vysledky hledani - hleda v nazvu konektoru i v nazvech operaci.
const searchResults = useMemo(() => {
if (!searching) return [];
const results: Array<{ connector: CatalogService; operation: ServiceOperation }> = [];
for (const connector of usable) {
for (const operation of operationsOf(connector)) {
const haystack = `${connector.name} ${operation.name} ${operation.description}`.toLowerCase();
for (const operation of operationsOf(connector, mode)) {
const haystack =
`${connector.name} ${operation.name} ${operation.description}`.toLowerCase();
if (haystack.includes(trimmedQuery)) results.push({ connector, operation });
}
}
@@ -147,11 +153,7 @@ export function StepPicker({
Vše
</Chip>
{visibleCategories.map((cat) => (
<Chip
key={cat.id}
active={category === cat.id}
onClick={() => setCategory(cat.id)}
>
<Chip key={cat.id} active={category === cat.id} onClick={() => setCategory(cat.id)}>
{cat.label}
</Chip>
))}
@@ -205,7 +207,7 @@ export function StepPicker({
</div>
<ul className="space-y-2">
{operationsOf(selected).map((operation) => (
{operationsOf(selected, mode).map((operation) => (
<li key={operation.id}>
<OperationRow
connector={selected}
@@ -267,8 +269,8 @@ export function StepPicker({
<span className="block font-semibold text-white">Pro každou položku</span>
<span className="mt-0.5 block text-sm text-white/55">
Projde seznam v přijatých datech, například položky objednávky, a za každou
položku vykoná vnořené kroky. Výsledky se posbírají, takže se dají použít
v dalším kroku.
položku vykoná vnořené kroky. Výsledky se posbírají, takže se dají použít v
dalším kroku.
</span>
</span>
<ChevronRight className="mt-2.5 size-4 shrink-0 text-white/30" />
@@ -280,7 +282,7 @@ export function StepPicker({
<ConnectorCard
key={connector.id}
connector={connector}
operationCount={operationsOf(connector).length}
operationCount={operationsOf(connector, mode).length}
onClick={() => setSelected(connector)}
/>
))}
@@ -294,8 +296,8 @@ export function StepPicker({
*/}
{!selected && missing > 0 && (
<p className="mt-5 border-t border-ink-600/50 pt-4 text-xs text-white/35">
Dalších {missing} služeb se objeví, až k nim přidáte napojení v Konektorech.
Bez přístupových údajů by krok při prvním běhu spadl.
Dalších {missing} služeb se objeví, až k nim přidáte napojení v Konektorech. Bez
přístupových údajů by krok při prvním běhu spadl.
</p>
)}
</div>
@@ -304,10 +306,9 @@ export function StepPicker({
}
function ConnectorGlyph({ connector }: { connector: CatalogService }) {
const Icon = serviceIcon(connector.icon);
return (
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-gradient-to-br from-brand-500/20 to-accent-500/20 text-brand-300">
<Icon className="size-5" />
<ServiceIcon icon={connector.icon} className="size-5" />
</span>
);
}
@@ -330,9 +331,7 @@ function ConnectorCard({
disabled={planned}
className={cn(
'flex w-full items-start gap-3 rounded-xl border border-ink-600/60 bg-ink-850/50 p-4 text-left transition-colors',
planned
? 'cursor-not-allowed opacity-50'
: 'hover:border-brand-400/50 hover:bg-ink-800/70',
planned ? 'cursor-not-allowed opacity-50' : 'hover:border-brand-400/50 hover:bg-ink-800/70',
)}
>
<ConnectorGlyph connector={connector} />
@@ -381,9 +380,7 @@ function OperationRow({
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-white">{operation.name}</span>
{showConnectorName && (
<span className="text-xs text-white/40">· {connector.name}</span>
)}
{showConnectorName && <span className="text-xs text-white/40">· {connector.name}</span>}
{planned && <Badge tone="warn">Na roadmapě</Badge>}
</span>
<span className="mt-1 block text-sm text-white/55">{operation.description}</span>
@@ -1,16 +1,8 @@
import {
AlertTriangle,
Check,
ChevronRight,
Copy,
KeyRound,
Plus,
RefreshCw,
Trash2,
} from 'lucide-react';
import { AlertTriangle, Check, Copy, KeyRound, Plus, RefreshCw, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/cn';
import { Input } from '@/components/ui/form/Input';
import { Select } from '@/components/ui/form/Select';
import { fieldTypeLabels, newFieldId } from '@/lib/flow';
import {
declarableFieldTypes,
@@ -20,6 +12,11 @@ import {
type ModelNode,
type TriggerField,
} from '@/types/dashboard';
import { exampleBody } from '@/lib/exampleBody';
import { SampleBody } from '@/components/dashboard/flow/SampleBody';
/** Jak dlouho svitit potvrzeni po zkopirovani adresy. */
const COPIED_HINT_MS = 2000;
// Nabidka typu se bere ze sdileneho seznamu. Driv tu byla vlastni kopie a ta
// se rozesla: objekt a seznam v ni chybely, takze `data` sla deklarovat jen
@@ -154,92 +151,89 @@ 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, a to je v pořádku. Spouštěč funguje i bez nich.
Přidají se samy, jakmile níže vlepíte ukázku toho, co vám přijde -
ručně je psát nemusíte.
Zatím žádné parametry, a to je v pořádku. Spouštěč funguje i bez nich. Přidají se samy,
jakmile níže vlepíte ukázku toho, co vám přijde - ručně je psát nemusíte.
</p>
) : (
<ul className="mt-3 space-y-2">
{fields.map((field) => {
const duplicate = field.name.trim().length > 0 && duplicates.has(field.name.trim());
const duplicate = field.name.trim().length > 0 && duplicates.has(field.name.trim());
return (
<li
key={field.id}
className="flex flex-wrap items-center gap-2 rounded-xl border border-ink-600/50 bg-ink-850/50 p-2.5"
>
<input
value={field.name}
onChange={(event) => updateField(field.id, { name: event.target.value })}
placeholder="nazev_parametru"
aria-label="Název parametru"
className={cn(
'min-w-0 flex-1 rounded-lg border bg-ink-900/70 px-3 py-1.5 font-mono text-sm text-white placeholder:text-white/25 focus:outline-none',
duplicate
? 'border-danger-400/60'
: 'border-ink-600/70 focus:border-brand-400/70',
)}
/>
return (
<li
key={field.id}
className="flex flex-wrap items-center gap-2 rounded-xl border border-ink-600/50 bg-ink-850/50 p-2.5"
>
<Input
size="compact"
tone="card"
invalid={duplicate}
value={field.name}
onChange={(event) => updateField(field.id, { name: event.target.value })}
placeholder="nazev_parametru"
aria-label="Název parametru"
className="min-w-0 flex-1 font-mono"
/>
<select
value={field.type}
onChange={(event) =>
updateField(field.id, { type: event.target.value as FieldType })
}
aria-label="Typ parametru"
className="rounded-lg border border-ink-600/70 bg-ink-900/70 px-3 py-1.5 text-sm text-white focus:border-brand-400/70 focus:outline-none"
>
{fieldTypes.map((type) => (
<option key={type} value={type} className="bg-ink-850">
{fieldTypeLabels[type]}
</option>
))}
</select>
<Select
size="compact"
tone="card"
fullWidth={false}
value={field.type}
onChange={(event) =>
updateField(field.id, { type: event.target.value as FieldType })
}
aria-label="Typ parametru"
options={fieldTypes.map((type) => ({
value: type,
label: fieldTypeLabels[type],
}))}
/>
{/*
{/*
Cesta v tele. Prazdna = hodnota lezi primo pod nazvem.
Bez toho by slo napojit jen ploche telo a odesilatel
s vnorenym modelem by se musel prizpusobovat nam.
*/}
<Input
size="xs"
tone="card"
value={field.path ?? ''}
onChange={(event) =>
updateField(field.id, { path: event.target.value.trim() || undefined })
}
placeholder="cesta v těle, např. data.order.id"
title="Kde hodnota v přijatém JSONu je. Prázdné = přímo pod názvem."
className="min-w-40 flex-1 font-mono"
/>
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-white/55">
<input
value={field.path ?? ''}
onChange={(event) =>
updateField(field.id, { path: event.target.value.trim() || undefined })
}
placeholder="cesta v těle, např. data.order.id"
title="Kde hodnota v přijatém JSONu je. Prázdné = přímo pod názvem."
className="min-w-40 flex-1 rounded-lg border border-ink-600/70 bg-ink-900/70 px-2.5 py-1.5 font-mono text-xs text-white placeholder:text-white/25 focus:border-brand-400/70 focus:outline-none"
type="checkbox"
checked={field.required}
onChange={(event) => updateField(field.id, { required: event.target.checked })}
className="size-3.5 accent-cyan-400"
/>
povinný
</label>
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-white/55">
<input
type="checkbox"
checked={field.required}
onChange={(event) =>
updateField(field.id, { required: event.target.checked })
}
className="size-3.5 accent-cyan-400"
/>
povinný
</label>
<button
type="button"
onClick={() => removeField(field.id)}
title="Odebrat parametr"
className="grid size-8 place-items-center rounded-lg text-white/35 transition-colors hover:bg-danger-500/12 hover:text-danger-400"
>
<Trash2 className="size-3.5" />
<span className="sr-only">Odebrat parametr</span>
</button>
<button
type="button"
onClick={() => removeField(field.id)}
title="Odebrat parametr"
className="grid size-8 place-items-center rounded-lg text-white/35 transition-colors hover:bg-danger-500/12 hover:text-danger-400"
>
<Trash2 className="size-3.5" />
<span className="sr-only">Odebrat parametr</span>
</button>
{duplicate && (
<p className="w-full text-xs text-danger-400">
Tento název už je použitý - parametry musí být unikátní.
</p>
)}
</li>
);
{duplicate && (
<p className="w-full text-xs text-danger-400">
Tento název už je použitý - parametry musí být unikátní.
</p>
)}
</li>
);
})}
</ul>
)}
@@ -252,19 +246,13 @@ function CustomFields({
* a menit je z builderu by bylo mateni. Podminky se na ne odkazuji stejne
* jako na rucne deklarovane.
*/
function ProvidedFields({
fields,
serviceName,
}: {
fields: TriggerField[];
serviceName: string;
}) {
function ProvidedFields({ fields, serviceName }: { fields: TriggerField[]; serviceName: string }) {
return (
<div>
<h3 className="text-sm font-semibold text-white">Co spouštěč předá dál</h3>
<p className="mt-0.5 text-xs text-white/45">
{serviceName} posílá tyto hodnoty sama. Nastavovat se nedají, ale můžete nad nimi
stavět podmínky.
{serviceName} posílá tyto hodnoty sama. Nastavovat se nedají, ale můžete nad nimi stavět
podmínky.
</p>
<ul className="mt-3 grid gap-2 sm:grid-cols-2">
@@ -286,99 +274,6 @@ function ProvidedFields({
}
/** Registrovana adresa webhooku - token generuje server, tady se jen ukazuje. */
/**
* Ukazkove telo podle deklarovanych parametru.
*
* Respektuje cestu: kdyz je parametr `orderId` na `data.order.id`, ukazka ho
* ukaze zanoreny presne tam. Jinak by odesilatel poslal plocho a nic by nesedelo.
*/
function exampleBody(fields: TriggerField[]): string {
try {
return buildExample(fields);
} catch (err) {
/*
* Ukazka je napoveda. **Nesmi shodit stranku**, at uz do ni prijde
* cokoliv - parametry se pisou znak po znaku a rozdelany stav je normalni.
* Pojistka kolem obsahu portalu to zachyti taky, ale tam uz zmizi cely
* builder i s rozdelanou praci.
*/
console.error('[builder] ukazku tela se nepodarilo sestavit:', err);
return '{\n "cokoliv": "ukázku nejde sestavit, zkontrolujte cesty parametrů"\n}';
}
}
function buildExample(fields: TriggerField[]): string {
if (fields.length === 0) return '{\n "cokoliv": "co pošle odesílatel"\n}';
const body: Record<string, unknown> = {};
const paths = fields.map((field) => field.path ?? field.name);
for (const field of fields) {
const full = field.path ?? field.name;
const path = full.split('.');
let target = body;
/*
* Parametr, kterym vede cesta jineho parametru.
*
* Deje se to bezne: prijde cely objekt a vedle nej se vytahuje konkretni
* hodnota z jeho nitra. Pak plati ta vnitrni struktura, ne zastupna
* hodnota typu - ta by prepsala presne to, co ma ukazka ukazat.
*/
const isParent = paths.some((other) => other !== full && other.startsWith(`${full}.`));
for (let i = 0; i < path.length - 1; i += 1) {
const key = path[i];
const next = path[i + 1];
const current = target[key];
/*
* Do cesty se musi dat zanorit. Kdyz uz na tom miste hodnota je, protoze
* jiny parametr konci tam, kde tenhle pokracuje, prepise se schrankou.
*
* Bez toho by se zapisovalo do retezce a **spadl by cely builder**. Neni
* to okrajovy stav: pri psani cesty `zprava.data` je `zprava` chvili
* hotovy parametr sam o sobe, takze staci mit oba a ukazka se rozbije
* pri kazdem stisku klavesy.
*
* Cislo v ceste znamena pole, ne objekt s klicem "0".
*/
if (current === null || typeof current !== 'object') {
target[key] = /^\d+$/.test(next) ? [] : {};
}
target = target[key] as Record<string, unknown>;
}
const last = path[path.length - 1];
if (isParent) {
// Schranku nechat, at uz ji postavily vnorene parametry, nebo teprve
// prijdou na radu. Prepsat ji zastupnou hodnotou znamena je zahodit.
const current = target[last];
if (current === null || typeof current !== 'object') target[last] = {};
} else {
target[last] = exampleValue(field.type);
}
}
return JSON.stringify(body, null, 2);
}
function exampleValue(type: TriggerField['type']): unknown {
switch (type) {
case 'number':
return 42;
case 'boolean':
return true;
case 'date':
return '2026-01-31';
case 'object':
return { klic: 'hodnota' };
case 'list':
return ['první', 'druhá'];
default:
return 'text';
}
}
function WebhookAddress({
token,
baseUrl,
@@ -420,7 +315,7 @@ function WebhookAddress({
try {
await navigator.clipboard.writeText(url);
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
window.setTimeout(() => setCopied(false), COPIED_HINT_MS);
} catch (err) {
// Clipboard API muze byt zakazane (http, oprávnění) - reklame to uzivateli.
console.warn('[webhook] kopirovani do schranky selhalo:', err);
@@ -465,29 +360,30 @@ function WebhookAddress({
Ukázka požadavku
</p>
<pre className="overflow-x-auto rounded-lg border border-ink-600/70 bg-ink-900 p-3 font-mono text-xs leading-relaxed text-white/70">
{`POST ${url}
{`POST ${url}
Content-Type: application/json
${exampleBody(fields)}`}
</pre>
{fields.length === 0 && (
<p className="mt-1.5 text-xs text-white/35">
Zatím nejsou nadeklarované parametry, takže se přijme jakékoliv tělo.
Doplňte je výš, ať je jasné, co má odesílatel poslat.
Zatím nejsou nadeklarované parametry, takže se přijme jakékoliv tělo. Doplňte je výš, ať
je jasné, co má odesílatel poslat.
</p>
)}
</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.
Nesdílejte ji veřejně a neposílejte ji v odkazech.
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>
{confirming ? (
<div className="mt-3 rounded-lg border border-danger-400/40 bg-danger-500/8 p-3">
<p className="text-xs text-white/70">
Vygenerovat novou adresu? <strong className="text-danger-400">Ta stará okamžitě
přestane fungovat</strong> a musíte ji přenastavit všude, odkud se volá.
Vygenerovat novou adresu?{' '}
<strong className="text-danger-400">Ta stará okamžitě přestane fungovat</strong> a
musíte ji přenastavit všude, odkud se volá.
</p>
<div className="mt-2.5 flex gap-2">
<button
@@ -523,139 +419,3 @@ ${exampleBody(fields)}`}
</div>
);
}
/**
* Ukazka skutecneho tela.
*
* Vlepi se to, co odesilatel opravdu posila. Z toho se odvodi model, tedy
* seznam cest - v krocich se pak klikaji misto opisovani a kontrola vi, ze
* `data.order.code` neni preklep.
*
* Neni to kontrakt, na kterem stoji beh: telo se nezahazuje, kdyz se lisi.
* Je to napoveda pro cloveka a to je presne to, co u modelu o padesati cestach
* chybelo.
*/
function SampleBody({
sample,
model,
fields,
onChange,
onChangeFields,
}: {
sample: unknown;
model: ModelNode[];
fields: TriggerField[];
onChange: (sample: unknown) => void;
onChangeFields: (fields: TriggerField[]) => void;
}) {
const [text, setText] = useState(sample === undefined ? '' : JSON.stringify(sample, null, 2));
const [problem, setProblem] = useState<string | null>(null);
const [open, setOpen] = useState(sample === undefined);
function save() {
const trimmed = text.trim();
if (trimmed === '') {
setProblem(null);
onChange(undefined);
return;
}
try {
onChange(JSON.parse(trimmed));
setProblem(null);
} catch (err) {
setProblem(err instanceof Error ? err.message : 'Neplatný JSON.');
}
}
/**
* Z cest udela deklarovane parametry pro podminky.
*
* Podminka se odkazuje na parametr, ne na cestu - proto tohle tlacitko.
* Bere jen hodnoty, ne objekty a seznamy: na ty se podminka ptat neumi.
* Jmeno vznikne z posledni casti cesty, kolize dostane cislo.
*/
function deriveFields() {
const used = new Set(fields.map((field) => field.name));
const added: TriggerField[] = [];
for (const node of model) {
if (node.type === 'object' || node.type === 'list') continue;
if (node.path.includes('[]')) continue;
if (fields.some((field) => field.path === node.path)) continue;
const base = node.path.split('.').pop() ?? node.path;
let name = base.replace(/[^A-Za-z0-9_]/g, '_');
if (/^[0-9]/.test(name)) name = `f_${name}`;
let unique = name;
let counter = 2;
while (used.has(unique)) unique = `${name}${counter++}`;
used.add(unique);
added.push({
id: newFieldId(),
name: unique,
type: node.type,
required: false,
path: node.path,
});
}
if (added.length > 0) onChangeFields([...fields, ...added]);
}
return (
<div className="rounded-xl border border-ink-600/50 bg-ink-900/40 p-3">
<button
type="button"
onClick={() => setOpen(!open)}
className="flex w-full items-center gap-2 text-left text-xs font-semibold tracking-wide text-white/45 uppercase transition-colors hover:text-white/70"
>
<ChevronRight className={cn('size-3.5 transition-transform', open && 'rotate-90')} />
Ukázka těla
{model.length > 0 && (
<span className="font-normal normal-case">{model.length} cest</span>
)}
</button>
{open && (
<>
<p className="mt-2 text-xs text-white/40">
Vložte tělo z reálného volání. Odvodí se z něj cesty, které pak v krocích jen
kliknete: {'{{data.order.code}}'} i seznamy jako data.order.items.
</p>
<textarea
value={text}
onChange={(event) => setText(event.target.value)}
onBlur={save}
rows={6}
spellCheck={false}
placeholder={'{\n "data": {\n "order": { "code": "2026000123" }\n }\n}'}
className={cn(
'mt-2 w-full rounded-lg border bg-ink-950/60 px-3 py-2 font-mono text-xs text-white/80 placeholder:text-white/20 focus:outline-none',
problem ? 'border-danger-400/60' : 'border-ink-600/70 focus:border-brand-400/70',
)}
/>
{problem && <p className="mt-1 text-xs text-danger-400">{problem}</p>}
{model.length > 0 && (
<div className="mt-2 flex flex-wrap items-center gap-2">
<button
type="button"
onClick={deriveFields}
className="rounded-lg border border-brand-400/40 px-3 py-1.5 text-xs text-brand-200 transition-colors hover:bg-brand-500/15"
>
Doplnit parametry pro podmínky
</button>
<span className="text-xs text-white/30">
Podmínka se ptá na parametr, ne na cestu. Tímhle se z hodnot v ukázce udělají
parametry.
</span>
</div>
)}
</>
)}
</div>
);
}
@@ -0,0 +1,120 @@
import { AlertCircle, Check, ChevronRight } from 'lucide-react';
import { useState } from 'react';
import { cn } from '@/lib/cn';
import { formatDateTime } from '@/lib/format';
import type { WebhookCall } from '@/types/dashboard';
/**
* Poslednich deset volani webhooku.
*
* Odmitnute volani do ted skoncilo jako radek v logu kontejneru. Odesilatel
* dostal 400 a vedel o tom, ale ten, kdo kontrakt psal, se nedozvedel nic -
* automatizace svitila zelene a jen do ni prestalo chodit.
*
* Radek se rozklikne na **rozpad po parametrech a cele prijate telo**. Samotny
* duvod odmitnuti nestaci: "parametr data ma mit typ text" nerekne, co tedy
* prislo, a bez toho zbyva hadat.
*/
export function WebhookCalls({ calls }: { calls: WebhookCall[] }) {
const [openAt, setOpenAt] = useState<string | null>(null);
return (
<div className="glass rounded-card p-5">
<h2 className="font-semibold text-white">Poslední volání</h2>
{calls.length === 0 ? (
<p className="mt-3 text-sm text-white/40">
Zatím nic nepřišlo. Objeví se tu deset posledních volání včetně těch, která jsme odmítli.
</p>
) : (
<ul className="mt-3 space-y-1.5 text-sm">
{calls.map((call) => {
const open = openAt === call.at;
return (
<li key={call.at} className="rounded-lg border border-ink-600/50">
<button
type="button"
onClick={() => setOpenAt(open ? null : call.at)}
className="flex w-full items-start gap-2 px-3 py-2 text-left hover:bg-ink-800/40"
>
{call.ok ? (
<Check className="mt-0.5 size-3.5 shrink-0 text-emerald-400" />
) : (
<AlertCircle className="mt-0.5 size-3.5 shrink-0 text-rose-400" />
)}
<span className="min-w-0 flex-1">
<span className="text-white/70">{formatDateTime(call.at)}</span>
{call.problems.length > 0 && (
<span className="block text-xs leading-relaxed text-rose-300/80">
{call.problems.join(' ')}
</span>
)}
</span>
<ChevronRight
className={cn('mt-0.5 size-3.5 shrink-0 text-white/30', open && 'rotate-90')}
/>
</button>
{open && (
<div className="space-y-3 border-t border-ink-600/50 px-3 py-3">
{call.fields.length > 0 && (
<table className="w-full text-xs">
<thead>
<tr className="text-left text-white/35">
<th className="pb-1 font-normal">Parametr</th>
<th className="pb-1 font-normal">Čekáme</th>
<th className="pb-1 font-normal">Přišlo</th>
</tr>
</thead>
<tbody>
{call.fields.map((field) => (
<tr
key={field.name}
className={field.ok ? 'text-white/60' : 'text-rose-300'}
>
<td className="py-0.5 pr-2 align-top font-mono">
{field.name}
{field.path !== field.name && (
<span className="block text-white/30">{field.path}</span>
)}
</td>
<td className="py-0.5 pr-2 align-top">
{field.type}
{field.required && <span className="text-white/30">, povinný</span>}
</td>
<td className="min-w-0 py-0.5 align-top">
{field.got}
{field.preview !== '' && (
<span className="block break-all font-mono text-white/40">
{field.preview}
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
<div>
<p className="text-xs text-white/35">
Přijaté tělo{call.truncated && ' (useknuté)'}
</p>
<pre className="mt-1 max-h-64 overflow-auto rounded-lg bg-ink-950/60 p-2 text-xs text-white/60">
{call.body === '' ? '(prázdné)' : call.body}
</pre>
</div>
</div>
)}
</li>
);
})}
</ul>
)}
<p className="mt-3 text-xs text-white/35">
Drží se v paměti, restart je zahodí. Na historii jsou běhy.
</p>
</div>
);
}
@@ -0,0 +1,119 @@
import { Code2, RefreshCw, Save } from 'lucide-react';
import { useState } from 'react';
import { Button } from '@/components/ui/Button';
import { Textarea } from '@/components/ui/form/Textarea';
import { apiFetch, ApiError } from '@/lib/api';
import { useSyncFromSource } from '@/hooks/useSyncFromSource';
/**
* Kod skriptu s ulozenim na server. Koncept se srovna se serverem po nacteni
* jineho skriptu i po ulozeni; server v `issues` rika, co v manifestu nesedi.
*/
export function CodeEditor({
scriptId,
code,
canEdit,
onSaved,
}: {
scriptId: string;
code: string;
canEdit: boolean;
onSaved: () => void;
}) {
const [draft, setDraft] = useState(code);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [issues, setIssues] = useState<Array<{ field: string; message: string }>>([]);
const [saved, setSaved] = useState(false);
// Po nacteni jineho skriptu nebo po ulozeni se koncept srovna se serverem.
useSyncFromSource(code, (fresh) => {
setDraft(fresh);
setIssues([]);
setError(null);
});
const dirty = draft !== code;
async function handleSave() {
setSaving(true);
setError(null);
setIssues([]);
setSaved(false);
try {
await apiFetch(`/api/dashboard/scripts/${scriptId}`, {
method: 'PUT',
body: { code: draft },
});
setSaved(true);
onSaved();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Uložení selhalo.');
// Server posila v `issues`, co presne v manifestu nesedi.
if (err instanceof ApiError) setIssues(err.issues());
} finally {
setSaving(false);
}
}
return (
<div className="glass rounded-card p-5">
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
<p className="flex items-center gap-2 text-xs font-semibold tracking-wide text-white/45 uppercase">
<Code2 className="size-3.5" />
Kód skriptu
</p>
{canEdit ? (
<div className="flex items-center gap-3">
{dirty && <span className="text-xs text-warn-400">Neuložené změny</span>}
{saved && !dirty && <span className="text-xs text-ok-400">Uloženo</span>}
<Button
size="sm"
variant="secondary"
onClick={() => setDraft(code)}
disabled={!dirty || saving}
>
<RefreshCw className="size-4" />
Zahodit
</Button>
<Button size="sm" onClick={handleSave} disabled={!dirty || saving}>
<Save className="size-4" />
{saving ? 'Ukládám...' : 'Uložit'}
</Button>
</div>
) : (
<span className="text-xs text-white/35">Upravovat smí jen správce platformy.</span>
)}
</div>
<Textarea
spellCheck={false}
readOnly={!canEdit}
value={draft}
onChange={(event) => setDraft(event.target.value)}
mono
tone="deep"
className="h-96"
/>
{error && (
<div className="mt-3 rounded-lg border border-danger-500/40 bg-danger-500/8 p-3 text-sm">
<p className="text-danger-400">{error}</p>
{issues.length > 0 && (
<ul className="mt-2 ml-4 list-disc text-xs text-white/60">
{issues.map((issue) => (
<li key={`${issue.field}-${issue.message}`}>
{issue.field}: {issue.message}
</li>
))}
</ul>
)}
<p className="mt-2 text-xs text-white/40">
Nic se neuložilo, původní skript funguje dál.
</p>
</div>
)}
</div>
);
}
@@ -0,0 +1,223 @@
import { AlertTriangle, CheckCircle2, Play } from 'lucide-react';
import { useState } from 'react';
import type { ReactNode } from 'react';
import { ErrorDetail } from '@/components/dashboard/ErrorDetail';
import { Button } from '@/components/ui/Button';
import { Field } from '@/components/ui/form/Field';
import { Input } from '@/components/ui/form/Input';
import { Select } from '@/components/ui/form/Select';
import { Textarea } from '@/components/ui/form/Textarea';
import { apiFetch, ApiError } from '@/lib/api';
import { cn } from '@/lib/cn';
import type { ScriptField, ScriptRunResult } from '@/types/dashboard';
// Testovaci spusteni skriptu: formular z manifestu a vypis vysledku.
// Vyclenene ze stranky Skripty, ktera mela pres 650 radku.
/**
* Jedno vstupni pole podle deklarovaneho typu. Diky tomu neni testovaci
* formular rucne psany pro kazdy skript, ale vznika z manifestu.
*/
function FieldInput({
field,
value,
onChange,
}: {
field: ScriptField;
value: string;
onChange: (value: string) => void;
}) {
const control = (): ReactNode => {
if (field.options) {
return (
<Select
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Nevyplněno"
options={field.options}
/>
);
}
if (field.type === 'boolean') {
return (
<Select
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Nevyplněno"
options={[
{ value: 'true', label: 'Ano' },
{ value: 'false', label: 'Ne' },
]}
/>
);
}
if (field.multiline) {
return <Textarea rows={3} value={value} onChange={(e) => onChange(e.target.value)} />;
}
return (
<Input
type={field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : 'text'}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
);
};
return (
<Field label={field.label} required={field.required} hint={field.hint}>
{control()}
</Field>
);
}
export function TestPanel({
scriptId,
fields,
ready,
}: {
scriptId: string;
fields: ScriptField[];
ready: boolean;
}) {
const [values, setValues] = useState<Record<string, string>>({});
const [result, setResult] = useState<ScriptRunResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [running, setRunning] = useState(false);
async function handleRun() {
setRunning(true);
setError(null);
setResult(null);
try {
// Prazdne hodnoty se neposilaji, aby se uplatnily vychozi hodnoty z manifestu.
const inputs = Object.fromEntries(Object.entries(values).filter(([, value]) => value !== ''));
setResult(
await apiFetch<ScriptRunResult>(`/api/dashboard/scripts/${scriptId}/test`, {
method: 'POST',
body: { inputs },
}),
);
} catch (err: unknown) {
const message =
err instanceof ApiError ? err.message : 'Zkušební spuštění se nepodařilo odeslat.';
setError(message);
} finally {
setRunning(false);
}
}
return (
<div className="glass rounded-card p-5">
<p className="text-xs font-semibold tracking-wide text-white/45 uppercase">
Zkušební spuštění
</p>
<p className="mt-1 mb-4 text-sm text-warn-400">
Volá se opravdová služba. Co skript založí, opravdu vznikne.
</p>
{fields.length > 0 && (
<div className="grid gap-3 sm:grid-cols-2">
{fields.map((field) => (
<FieldInput
key={field.id}
field={field}
value={values[field.id] ?? ''}
onChange={(value) => setValues((prev) => ({ ...prev, [field.id]: value }))}
/>
))}
</div>
)}
<div className="mt-4 flex items-center gap-3">
<Button size="sm" onClick={handleRun} disabled={running || !ready}>
<Play className="size-4" />
{running ? 'Spouštím...' : 'Spustit'}
</Button>
{!ready && <span className="text-sm text-white/40">Nejdřív nastavte napojení.</span>}
</div>
{error && <p className="mt-3 text-sm text-danger-400">{error}</p>}
{result && <RunResult result={result} />}
</div>
);
}
function RunResult({ result }: { result: ScriptRunResult }) {
return (
<div className="mt-4 space-y-3 border-t border-ink-600/50 pt-4">
<p
className={cn(
'flex items-center gap-2 text-sm font-semibold',
result.ok ? 'text-ok-400' : 'text-danger-400',
)}
>
{result.ok ? (
<CheckCircle2 className="size-4 shrink-0" />
) : (
<AlertTriangle className="size-4 shrink-0" />
)}
{result.ok ? 'Proběhlo' : 'Selhalo'}
<span className="font-normal text-white/40">
{result.durationMs} ms, volání služby: {result.httpCalls}
</span>
</p>
{result.error && (
<div className="space-y-2">
<ErrorDetail
message={result.error.message}
detail={result.error.detail}
status={result.error.status}
request={result.error.request}
retryable={result.error.retryable}
kind={result.error.kind}
/>
{result.error.issues && result.error.issues.length > 0 && (
<ul className="ml-4 list-disc text-xs text-white/60">
{result.error.issues.map((issue) => (
<li key={`${issue.field}-${issue.message}`}>{issue.message}</li>
))}
</ul>
)}
</div>
)}
{result.ok && (
<div>
<p className="mb-1.5 text-xs font-semibold tracking-wide text-white/45 uppercase">
Výstupy
</p>
<ul className="space-y-1 text-sm">
{Object.entries(result.outputs).map(([key, value]) => (
<li key={key} className="flex flex-wrap gap-2">
<code className="text-brand-200">{key}</code>
<span className="text-white/70">{value === null ? '-' : String(value)}</span>
</li>
))}
</ul>
</div>
)}
{result.logs.length > 0 && (
<div>
<p className="mb-1.5 text-xs font-semibold tracking-wide text-white/45 uppercase">
Průběh
</p>
<ul className="space-y-1 font-mono text-xs text-white/50">
{result.logs.map((entry, index) => (
<li key={`${entry.at}-${index}`}>
{entry.message}
{entry.detail && <span className="text-white/30"> {entry.detail}</span>}
</li>
))}
</ul>
</div>
)}
</div>
);
}
// -------------------------------------------------------------------- editor
@@ -0,0 +1,90 @@
import { useState } from 'react';
import { Badge } from '@/components/ui/Badge';
import { Table, TableHead, TableRow, Td, Th } from '@/components/ui/Table';
import { formatDateTime } from '@/lib/format';
import { useApiQuery } from '@/hooks/useApiQuery';
/** Zaznamu na jedno nacteni. Audit je na hledani problemu, ne na listovani. */
const AUDIT_PAGE_SIZE = 200;
// Audit prav pro spravce platformy. Vyclenene ze stranky Nastaveni.
// ------------------------------------------------------------ zalozky firmy
interface AuditEntry {
id: string;
createdAt: string;
userEmail: string;
actedBy: string | null;
action: string;
target: string | null;
result: 'ok' | 'denied';
}
export function AuditView() {
const [onlyDenied, setOnlyDenied] = useState(false);
const query = useApiQuery<{ items: AuditEntry[] }>(
`/api/admin/audit?limit=${AUDIT_PAGE_SIZE}${onlyDenied ? '&result=denied' : ''}`,
);
return (
<div className="space-y-4">
<div>
<h2 className="font-semibold text-white">Audit</h2>
<p className="mt-0.5 text-sm text-white/45">
Kdo co udělal. Zapisují se i odepření, jinak by pokusy o cizí firmu nikde nezůstaly.
</p>
</div>
<label className="flex items-center gap-2 text-sm text-white/70">
<input
type="checkbox"
checked={onlyDenied}
onChange={(event) => setOnlyDenied(event.target.checked)}
className="size-4"
/>
Jen odepření
</label>
<div className="glass overflow-x-auto rounded-card">
<Table>
<TableHead>
<Th>Kdy</Th>
<Th>Kdo</Th>
<Th>Co</Th>
<Th>Nad čím</Th>
<Th>Výsledek</Th>
</TableHead>
<tbody>
{(query.data?.items ?? []).map((entry) => (
<TableRow key={entry.id}>
<Td className="whitespace-nowrap text-white/50">
{formatDateTime(entry.createdAt)}
</Td>
<Td className="text-white/75">
{entry.userEmail}
{entry.actedBy && (
<span className="block text-xs text-warn-400">za něj: {entry.actedBy}</span>
)}
</Td>
<Td>
<code className="text-white/70">{entry.action}</code>
</Td>
<Td className="text-white/50">{entry.target ?? ''}</Td>
<Td>
{entry.result === 'denied' ? (
<Badge tone="danger">odepřeno</Badge>
) : (
<Badge tone="ok">ok</Badge>
)}
</Td>
</TableRow>
))}
</tbody>
</Table>
</div>
{(query.data?.items.length ?? 0) === 0 && (
<p className="py-6 text-center text-sm text-white/40">Žádné záznamy.</p>
)}
</div>
);
}
@@ -0,0 +1,148 @@
import { useState } from 'react';
import { DataState } from '@/components/dashboard/DataState';
import { Button } from '@/components/ui/Button';
import { Field } from '@/components/ui/form/Field';
import { Input } from '@/components/ui/form/Input';
import { apiFetch } from '@/lib/api';
import { useApiQuery } from '@/hooks/useApiQuery';
import { useSubmit } from '@/hooks/useSubmit';
import { useSyncFromSource } from '@/hooks/useSyncFromSource';
import type { SettingsCatalog } from '@/components/dashboard/settings/types';
// Zalozky a limity firmy. Vyclenene ze stranky Nastaveni (pres 650 radku).
interface TenantFeatures {
modules: string[];
limits: Record<string, number>;
serviceIds: string[];
}
export function FeaturesAdmin({ catalog }: { catalog: SettingsCatalog | null }) {
const saved = useApiQuery<{ features: TenantFeatures }>('/api/dashboard/settings/features');
/** Rozepsana podoba. Ze serveru se prebira pri kazdem nacteni. */
const [features, setFeatures] = useState<TenantFeatures | null>(null);
const [message, setMessage] = useState<string | null>(null);
useSyncFromSource(saved.data, (data) => {
if (data) setFeatures(data.features);
});
const {
busy: saving,
error,
submit: save,
} = useSubmit(async () => {
if (!features) return;
setMessage(null);
await apiFetch('/api/dashboard/settings/features', { method: 'PUT', body: features });
setMessage('Uloženo. Záložky se projeví po obnovení stránky.');
}, 'Uložení selhalo.');
return (
<DataState
loading={saved.loading || features === null || catalog === null}
error={saved.error}
onRetry={saved.reload}
>
{features && catalog && (
<FeaturesForm
features={features}
catalog={catalog}
saving={saving}
message={message}
error={error}
onChange={setFeatures}
onSave={() => void save()}
/>
)}
</DataState>
);
}
function FeaturesForm({
features,
catalog,
saving,
message,
error,
onChange,
onSave,
}: {
features: TenantFeatures;
catalog: SettingsCatalog;
saving: boolean;
message: string | null;
error: string | null;
onChange: (next: TenantFeatures) => void;
onSave: () => void;
}) {
const setFeatures = onChange;
const save = onSave;
return (
<div className="space-y-4">
<div className="glass rounded-card p-5">
<h2 className="font-semibold text-white">Záložky firmy</h2>
<p className="mt-0.5 text-sm text-white/45">
Co má firma zapnuté. Vypnutý modul neexistuje ani pro jejího správce, protože ho nemá. Kdo
z jejich lidí to smí, řeší role.
</p>
<div className="mt-4 space-y-1.5 border-t border-ink-600/50 pt-4">
{catalog.modules
.filter((module) => !module.platform)
.map((module) => (
<label key={module.key} className="flex items-center gap-2 text-sm text-white/75">
<input
type="checkbox"
disabled={module.required}
checked={features.modules.includes(module.key)}
onChange={(event) =>
setFeatures({
...features,
modules: event.target.checked
? [...features.modules, module.key]
: features.modules.filter((key) => key !== module.key),
})
}
className="size-4"
/>
{module.label}
{module.required && <span className="text-xs text-white/35">povinné</span>}
</label>
))}
</div>
</div>
<div className="glass rounded-card p-5">
<h3 className="text-sm font-semibold text-white">Limity</h3>
<div className="mt-3 grid gap-3 sm:grid-cols-4">
{Object.entries(features.limits).map(([key, value]) => (
<Field key={key} label={<span className="text-xs text-white/60">{key}</span>}>
<Input
type="number"
value={value}
onChange={(event) =>
setFeatures({
...features,
limits: { ...features.limits, [key]: Number(event.target.value) },
})
}
/>
</Field>
))}
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button size="sm" onClick={save} disabled={saving}>
{saving ? 'Ukládám...' : 'Uložit'}
</Button>
{message && <span className="text-sm text-ok-400">{message}</span>}
{error && <span className="text-sm text-danger-400">{error}</span>}
</div>
</div>
);
}
// ---------------------------------------------------------------------- audit
@@ -0,0 +1,17 @@
// Katalog nastaveni, jak ho vraci `/api/dashboard/settings/catalog`.
// Cte ho stranka Nastaveni i zalozky firmy, proto lezi mimo obe.
export interface CatalogItem {
key: string;
label: string;
group?: string;
hint?: string;
platform?: boolean;
}
export interface SettingsCatalog {
permissions: CatalogItem[];
modules: Array<{ key: string; label: string; required?: boolean; platform?: boolean }>;
defaultLimits: Record<string, number>;
platformAdmin: boolean;
}
@@ -1,4 +1,11 @@
import { AlertTriangle, ArrowDownRight, ArrowUpRight, Clock, RefreshCw, RotateCcw } from 'lucide-react';
import {
AlertTriangle,
ArrowDownRight,
ArrowUpRight,
Clock,
RefreshCw,
RotateCcw,
} from 'lucide-react';
import { Link } from 'react-router-dom';
import { TicketCard } from '@/components/dashboard/TicketCard';
import { Badge } from '@/components/ui/Badge';
@@ -6,6 +13,9 @@ import { cn } from '@/lib/cn';
import { formatDuration, formatNumber, formatRelative } from '@/lib/format';
import type { AgentStatsRow, CatalogWidget, WidgetValue } from '@/types/dashboard';
/** Radku v tabulkove dlazdici. Dlazdice neni seznam, zbytek je na strance. */
const TABLE_WIDGET_ROWS = 12;
/**
* Vykresleni widgetu, jehoz data pocita server (`/api/dashboard/widget-data`).
*
@@ -242,7 +252,7 @@ function Bars({
return (
<ul className="space-y-2">
{rows.slice(0, 12).map((row) => {
{rows.slice(0, TABLE_WIDGET_ROWS).map((row) => {
const body = (
<>
<div className="flex items-baseline justify-between gap-3 text-sm">
@@ -358,12 +368,17 @@ function Number({
* 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 first = points[0];
const last = points.at(-1);
if (!first || !last) 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)}`)
.map(
(point, index) =>
`${(index * step).toFixed(2)},${(30 - (point.value / max) * 28).toFixed(2)}`,
)
.join(' ');
return (
@@ -380,9 +395,9 @@ function Sparkline({ points }: { points: Array<{ date: string; value: number }>
/>
</svg>
<div className="mt-1 flex justify-between text-xs text-white/35">
<span>{points[0].date.slice(5)}</span>
<span>{first.date.slice(5)}</span>
<span>vrchol {formatNumber(max)}</span>
<span>{points[points.length - 1].date.slice(5)}</span>
<span>{last.date.slice(5)}</span>
</div>
</div>
);
@@ -0,0 +1,91 @@
import { ChevronLeft, ChevronRight, Trash2 } from 'lucide-react';
import type { ReactNode } from 'react';
import { Select } from '@/components/ui/form/Select';
import { cn } from '@/lib/cn';
import type { CatalogWidget, WidgetSize } from '@/types/dashboard';
const sizeLabels: Record<WidgetSize, string> = {
third: 'třetina',
half: 'polovina',
full: 'celá šířka',
};
/** Ovladani jednoho widgetu v rezimu uprav. */
export function EditBar({
definition,
size,
canMoveLeft,
canMoveRight,
onMove,
onResize,
onRemove,
}: {
definition: CatalogWidget;
size: WidgetSize;
canMoveLeft: boolean;
canMoveRight: boolean;
onMove: (offset: number) => void;
onResize: (size: WidgetSize) => void;
onRemove: () => void;
}) {
return (
<div className="mb-2 flex flex-wrap items-center gap-1.5 rounded-xl border border-brand-400/35 bg-brand-500/10 px-2.5 py-2">
<span className="mr-auto min-w-0 truncate text-xs font-medium text-brand-100">
{definition.name}
</span>
{/* Sirku nabizime jen tu, kterou widget unese. */}
<Select
size="xs"
tone="card"
fullWidth={false}
value={size}
onChange={(event) => onResize(event.target.value as WidgetSize)}
aria-label={`Šířka widgetu ${definition.name}`}
options={definition.sizes.map((option) => ({ value: option, label: sizeLabels[option] }))}
/>
<IconButton label="Posunout dřív" disabled={!canMoveLeft} onClick={() => onMove(-1)}>
<ChevronLeft className="size-3.5" />
</IconButton>
<IconButton label="Posunout později" disabled={!canMoveRight} onClick={() => onMove(1)}>
<ChevronRight className="size-3.5" />
</IconButton>
<IconButton label="Odebrat widget" danger onClick={onRemove}>
<Trash2 className="size-3.5" />
</IconButton>
</div>
);
}
function IconButton({
label,
disabled,
danger,
onClick,
children,
}: {
label: string;
disabled?: boolean;
danger?: boolean;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={label}
className={cn(
'grid size-7 place-items-center rounded-lg text-white/50 transition-colors disabled:pointer-events-none disabled:opacity-25',
danger
? 'hover:bg-danger-500/15 hover:text-danger-400'
: 'hover:bg-white/8 hover:text-white',
)}
>
{children}
<span className="sr-only">{label}</span>
</button>
);
}
@@ -1,12 +1,4 @@
import {
AlarmClock,
Activity,
Clock,
Gauge,
LifeBuoy,
Play,
Workflow,
} from 'lucide-react';
import { AlarmClock, Activity, Clock, Gauge, LifeBuoy, Play, Workflow } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import { Link } from 'react-router-dom';
@@ -17,7 +9,7 @@ import { IncidentStatusBadge } from '@/components/dashboard/StatusBadge';
import { TicketCard } from '@/components/dashboard/TicketCard';
import { TicketWorkload } from '@/components/dashboard/TicketWorkload';
import { formatNumber, formatPercent, formatRelative } from '@/lib/format';
import type { QueryState } from '@/lib/useApiQuery';
import type { QueryState } from '@/hooks/useApiQuery';
import type {
DashboardSummary,
Incident,
@@ -35,6 +27,8 @@ import type {
/** Kolik ticketu widget ukazuje. Prehled si podle toho rekne serveru o `limit`. */
export const TICKET_WIDGET_ROWS = 5;
/** Radku v dlazdici incidentu. */
const INCIDENT_WIDGET_ROWS = 5;
export interface WidgetData {
summary: QueryState<DashboardSummary>;
@@ -71,13 +65,7 @@ const metricMeta: Record<
uptime: { icon: Gauge, label: 'Dostupnost', format: (s) => formatPercent(s.uptime, 2) },
};
export function WidgetCard({
definition,
data,
}: {
definition: CatalogWidget;
data: WidgetData;
}) {
export function WidgetCard({ definition, data }: { definition: CatalogWidget; data: WidgetData }) {
if (definition.kind === 'stat') {
const metric = definition.metric ? metricMeta[definition.metric] : undefined;
if (!metric) {
@@ -151,7 +139,7 @@ export function WidgetCard({
emptyLabel="Žádné incidenty."
>
<ul className="divide-y divide-ink-600/50">
{data.incidents.data?.items.slice(0, 5).map((incident) => (
{data.incidents.data?.items.slice(0, INCIDENT_WIDGET_ROWS).map((incident) => (
<li key={incident.id} className="flex items-start gap-3 py-3.5 first:pt-0 last:pb-0">
<span className="mt-0.5 grid size-7 shrink-0 place-items-center rounded-lg bg-white/5 text-white/40">
<Activity className="size-3.5" />
@@ -197,15 +185,7 @@ function toneFor(
return undefined;
}
function Panel({
title,
to,
children,
}: {
title?: string;
to?: string;
children: ReactNode;
}) {
function Panel({ title, to, children }: { title?: string; to?: string; children: ReactNode }) {
return (
<section className="glass h-full rounded-card p-5 sm:p-6">
{title && (
+1 -3
View File
@@ -19,9 +19,7 @@ export function CallToAction() {
<h2 className="relative text-3xl font-bold text-white sm:text-4xl">
{t('cta.titleFirst')} <span className="text-gradient">{t('cta.titleAccent')}</span>
</h2>
<p className="relative mx-auto mt-4 max-w-xl text-white/60">
{t('cta.lead')}
</p>
<p className="relative mx-auto mt-4 max-w-xl text-white/60">{t('cta.lead')}</p>
<div className="relative mt-9 flex flex-wrap justify-center gap-3">
<ButtonLink to="/kontakt" size="lg">
+13 -7
View File
@@ -84,9 +84,7 @@ export function Hero() {
<span className="text-brand-400">{t('hero.titleAccent')}</span>
</h1>
<p className="mt-6 max-w-md text-lg leading-relaxed text-white/65">
{t('hero.lead')}
</p>
<p className="mt-6 max-w-md text-lg leading-relaxed text-white/65">{t('hero.lead')}</p>
<div className="mt-8 flex flex-wrap items-center gap-5">
<ButtonLink to="/kontakt" size="lg">
@@ -317,11 +315,19 @@ function HeroPanel() {
<thead>
<tr className="border-b border-ink-600/60 text-[0.6rem] tracking-wide text-white/40 uppercase">
<th className="py-2 pr-3 font-medium">{t('panel.col.ticket')}</th>
<th className="hidden py-2 pr-3 font-medium md:table-cell">{t('panel.col.customer')}</th>
<th className="hidden py-2 pr-3 font-medium lg:table-cell">{t('panel.col.channel')}</th>
<th className="hidden py-2 pr-3 font-medium md:table-cell">
{t('panel.col.customer')}
</th>
<th className="hidden py-2 pr-3 font-medium lg:table-cell">
{t('panel.col.channel')}
</th>
<th className="py-2 pr-3 font-medium">{t('panel.col.status')}</th>
<th className="hidden py-2 pr-3 font-medium xl:table-cell">{t('panel.col.assignee')}</th>
<th className="hidden py-2 text-right font-medium lg:table-cell">{t('panel.col.updated')}</th>
<th className="hidden py-2 pr-3 font-medium xl:table-cell">
{t('panel.col.assignee')}
</th>
<th className="hidden py-2 text-right font-medium lg:table-cell">
{t('panel.col.updated')}
</th>
</tr>
</thead>
<tbody>
+29 -27
View File
@@ -1,32 +1,33 @@
import { Section, SectionHeading } from '@/components/ui/Section';
import { useT, type MessageKey } from '@/i18n';
const steps: Array<{ number: string; title: MessageKey; text: MessageKey; duration: MessageKey }> = [
{
number: '01',
title: 'process.step1.title',
text: 'process.step1.text',
duration: 'process.step1.duration',
},
{
number: '02',
title: 'process.step2.title',
text: 'process.step2.text',
duration: 'process.step2.duration',
},
{
number: '03',
title: 'process.step3.title',
text: 'process.step3.text',
duration: 'process.step3.duration',
},
{
number: '04',
title: 'process.step4.title',
text: 'process.step4.text',
duration: 'process.step4.duration',
},
];
const steps: Array<{ number: string; title: MessageKey; text: MessageKey; duration: MessageKey }> =
[
{
number: '01',
title: 'process.step1.title',
text: 'process.step1.text',
duration: 'process.step1.duration',
},
{
number: '02',
title: 'process.step2.title',
text: 'process.step2.text',
duration: 'process.step2.duration',
},
{
number: '03',
title: 'process.step3.title',
text: 'process.step3.text',
duration: 'process.step3.duration',
},
{
number: '04',
title: 'process.step4.title',
text: 'process.step4.text',
duration: 'process.step4.duration',
},
];
/** Jak spoluprace probiha - casova osa ve 4 krocich. */
export function Process() {
@@ -38,7 +39,8 @@ export function Process() {
eyebrow={t('process.eyebrow')}
title={
<>
{t('process.titleFirst')} <span className="text-gradient">{t('process.titleAccent')}</span>
{t('process.titleFirst')}{' '}
<span className="text-gradient">{t('process.titleAccent')}</span>
</>
}
subtitle={t('process.subtitle')}
+3 -1
View File
@@ -16,7 +16,9 @@ export function Footer() {
<div className="grid gap-10 md:grid-cols-[1.4fr_1fr_1fr_1.2fr]">
<div>
<Logo />
<p className="mt-4 max-w-xs text-sm leading-relaxed text-white/55">{t('brand.claim')}</p>
<p className="mt-4 max-w-xs text-sm leading-relaxed text-white/55">
{t('brand.claim')}
</p>
</div>
{footerNav.map((column) => (
+1 -6
View File
@@ -12,12 +12,7 @@ import { cn } from '@/lib/cn';
export function LogoMark({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 32 32"
fill="none"
aria-hidden="true"
className={cn('shrink-0', className)}
>
<svg viewBox="0 0 32 32" fill="none" aria-hidden="true" className={cn('shrink-0', className)}>
{/*
Bomba mirici k zemi: nos vlevo dole, kridelka vpravo nahore.
Kreslena svisle a otocena o 45 stupnu, aby se geometrie dala cist.
+14 -8
View File
@@ -10,25 +10,29 @@ import { mainNav } from '@/data/navigation';
import { MULTILANG_ENABLED, useT } from '@/i18n';
import { cn } from '@/lib/cn';
/** Od kolika px odscrollovani hlavicka ztmavne. Malo, aby se nepodbarvila hned. */
const SCROLLED_AFTER_PX = 12;
export function Navbar() {
const [scrolled, setScrolled] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
/*
* Mobilni menu je otevrene jen na strance, kde ho uzivatel otevrel.
* Prechod na jinou stranku ho tak zavre sam, bez effectu nad `pathname`.
*/
const [menuPath, setMenuPath] = useState<string | null>(null);
const location = useLocation();
const { user } = useAuth();
const t = useT();
// Sticky hlavicka ztmavne po odscrollovani.
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 12);
const onScroll = () => setScrolled(window.scrollY > SCROLLED_AFTER_PX);
onScroll();
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, []);
// Zavreni mobilniho menu pri zmene stranky.
useEffect(() => {
setMobileOpen(false);
}, [location.pathname]);
const mobileOpen = menuPath === location.pathname;
return (
<header
@@ -75,7 +79,9 @@ export function Navbar() {
<LogIn className="size-4" />
{t('nav.signIn')}
</ButtonLink>
<ButtonLink to="/kontakt" size="sm">{t('nav.getStarted')}</ButtonLink>
<ButtonLink to="/kontakt" size="sm">
{t('nav.getStarted')}
</ButtonLink>
</>
)}
</div>
@@ -83,7 +89,7 @@ export function Navbar() {
<button
type="button"
className="grid size-10 place-items-center rounded-lg text-white/70 hover:bg-white/5 hover:text-white md:hidden"
onClick={() => setMobileOpen((open) => !open)}
onClick={() => setMenuPath(mobileOpen ? null : location.pathname)}
aria-label={mobileOpen ? 'Zavřít menu' : 'Otevřít menu'}
aria-expanded={mobileOpen}
>
+2 -1
View File
@@ -13,7 +13,8 @@ export function Card({ className, children, interactive = false }: CardProps) {
<div
className={cn(
'glass rounded-card p-6 transition-all duration-300',
interactive && 'hover:-translate-y-1 hover:border-brand-400/50 hover:shadow-2xl hover:shadow-brand-500/10',
interactive &&
'hover:-translate-y-1 hover:border-brand-400/50 hover:shadow-2xl hover:shadow-brand-500/10',
className,
)}
>
+4 -8
View File
@@ -2,6 +2,7 @@ import { X } from 'lucide-react';
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import type { ReactNode } from 'react';
import { useLatest } from '@/hooks/useLatest';
import { cn } from '@/lib/cn';
/**
@@ -39,8 +40,7 @@ export function Modal({
* novou identitu. Kdyz byl v zavislostech, effect se pri kazde udalosti ze
* streamu spustil znovu a vzal uzivateli focus z rozepsaneho pole.
*/
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
const onCloseRef = useLatest(onClose);
useEffect(() => {
if (!open) return;
@@ -68,17 +68,13 @@ export function Modal({
document.removeEventListener('keydown', onKeyDown);
document.body.style.overflow = previousOverflow;
};
}, [open]);
}, [open, onCloseRef]);
if (!open) return null;
return createPortal(
<div className="fixed inset-0 z-[60] flex items-start justify-center overflow-y-auto p-4 sm:items-center sm:p-6">
<div
className="fixed inset-0 bg-ink-950/80 backdrop-blur-sm"
onClick={onClose}
aria-hidden
/>
<div className="fixed inset-0 bg-ink-950/80 backdrop-blur-sm" onClick={onClose} aria-hidden />
<div
role="dialog"
+14
View File
@@ -0,0 +1,14 @@
import { createElement } from 'react';
import { serviceIcon } from '@/lib/serviceIcons';
/**
* Ikona sluzby podle klice z katalogu.
*
* `serviceIcon` jen vybere hotovou komponentu z tabulky (lucide), nic novou
* netvori. Z JSX `const Icon = serviceIcon(key); <Icon />` to ale lint
* nepozna a hlasi komponentu vytvorenou pri vykresleni; `createElement`
* rika totez bez te dvojznacnosti a vsech sest mist ho ma na jednom miste.
*/
export function ServiceIcon({ icon, className }: { icon: string; className?: string }) {
return createElement(serviceIcon(icon), { className });
}
+63
View File
@@ -0,0 +1,63 @@
import type { ReactNode } from 'react';
import { cn } from '@/lib/cn';
/**
* Tabulka seznamu v portalu: hlavicka verzalkami, radky oddelene linkou.
*
* Ctyri stranky (lide, pozvanky, audit, sprava ciselniku) ji kreslily kazda
* zvlast a rozesly se v odstinu linky, vaze pisma i vysce radku. Tady je
* jednou. Obal s posuvnikem a kartou si dava stranka, protoze se lisi.
*
* Huste tabulky (tickety, vykon resitelu) maji vlastni odsazeni a zustavaji
* zvlast - jsou to jine tabulky, ne kopie teto.
*/
export function Table({ className, children }: { className?: string; children: ReactNode }) {
return <table className={cn('w-full border-collapse text-sm', className)}>{children}</table>;
}
/** Hlavicka. Deti jsou primo bunky `Th`, radek si dela sama. */
export function TableHead({ children }: { children: ReactNode }) {
return (
<thead>
<tr className="border-b border-ink-600/60 text-left text-xs tracking-wide text-white/40 uppercase">
{children}
</tr>
</thead>
);
}
type Align = 'left' | 'right';
export function Th({
align = 'left',
className,
children,
}: {
align?: Align;
className?: string;
children?: ReactNode;
}) {
return (
<th className={cn('px-4 py-3 font-medium', align === 'right' && 'text-right', className)}>
{children}
</th>
);
}
export function TableRow({ className, children }: { className?: string; children: ReactNode }) {
return <tr className={cn('border-b border-ink-600/30 last:border-0', className)}>{children}</tr>;
}
export function Td({
align = 'left',
className,
children,
}: {
align?: Align;
className?: string;
children?: ReactNode;
}) {
return (
<td className={cn('px-4 py-2.5', align === 'right' && 'text-right', className)}>{children}</td>
);
}
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useLatest } from '@/hooks/useLatest';
import { useEventStream } from '@/components/dashboard/EventStreamProvider';
import { apiFetchWithMeta } from '@/lib/api';
import { getActiveTenant, onTenantChange } from '@/lib/tenant';
@@ -136,12 +137,21 @@ function scheduleReload(reload: () => void): void {
// --------------------------------------------------------------------- hook
/** Hodnota `settledTick`, dokud pro dany klic zadny dotaz nedobehl. */
const NEVER_SETTLED = -1;
interface HookState<T> {
data: T | null;
total: number | null;
loading: boolean;
refreshing: boolean;
error: string | null;
/**
* Hodnota `tick`, pro kterou dotaz dobehl (uspechem i chybou).
*
* `loading` a `refreshing` se z toho odvozuji pri vykresleni: dotaz bezi,
* dokud se `settledTick` nerovna aktualnimu `tick`. Driv je effect pred
* kazdym dotazem zapisoval do stavu, coz znamenalo vykresleni navic.
*/
settledTick: number;
}
function initialState<T>(key: string | null): HookState<T> {
@@ -149,9 +159,8 @@ function initialState<T>(key: string | null): HookState<T> {
return {
data: (cached?.data as T | undefined) ?? null,
total: cached?.total ?? null,
loading: key !== null && cached === undefined,
refreshing: false,
error: null,
settledTick: NEVER_SETTLED,
};
}
@@ -193,30 +202,16 @@ export function useApiQuery<T>(path: string, options: QueryOptions<T> = {}): Que
if (!key) return;
let cancelled = false;
// Kdyz uz data jsou, jde jen o obnovu na pozadi.
setState((previous) => ({
...previous,
loading: previous.data === null,
refreshing: previous.data !== null,
error: null,
}));
fetchEntry(key, path, bodyKey)
.then((entry) => {
if (cancelled) return;
setState({
data: entry.data as T,
total: entry.total,
loading: false,
refreshing: false,
error: null,
});
setState({ data: entry.data as T, total: entry.total, error: null, settledTick: tick });
})
.catch((err: unknown) => {
if (cancelled) return;
const message = err instanceof Error ? err.message : 'Data se nepodařilo načíst.';
console.error(`[query] ${path} selhalo:`, err);
setState((previous) => ({ ...previous, loading: false, refreshing: false, error: message }));
setState((previous) => ({ ...previous, error: message, settledTick: tick }));
});
return () => {
@@ -232,17 +227,16 @@ export function useApiQuery<T>(path: string, options: QueryOptions<T> = {}): Que
...previous,
data: entry.data as T,
total: entry.total,
loading: false,
error: null,
settledTick: tick,
}));
});
}, [key]);
}, [key, tick]);
// Obnoveni dat pri prichozi udalosti ze streamu.
const { subscribe } = useEventStream();
const refetchKey = (options.refetchOn ?? []).join(',');
const patchRef = useRef(options.patchOn);
patchRef.current = options.patchOn;
const patchRef = useLatest(options.patchOn);
useEffect(() => {
if (refetchKey.length === 0) return;
@@ -267,7 +261,17 @@ export function useApiQuery<T>(path: string, options: QueryOptions<T> = {}): Que
scheduleReload(reload);
});
}, [subscribe, refetchKey, reload, key]);
}, [subscribe, refetchKey, reload, key, patchRef]);
return { ...state, reload };
// Dotaz bezi, dokud pro aktualni `tick` nic nedobehlo. Kdyz uz data jsou,
// jde jen o obnovu na pozadi a stranka je drzi dal.
const fetching = key !== null && state.settledTick !== tick;
return {
data: state.data,
total: state.total,
loading: fetching && state.data === null,
refreshing: fetching && state.data !== null,
error: fetching ? null : state.error,
reload,
};
}
+20
View File
@@ -0,0 +1,20 @@
import { useLayoutEffect, useRef } from 'react';
/**
* Ref s posledni hodnotou pro callbacky, ktere nemaji byt v zavislostech.
*
* Stranky predavaji handlery jako inline lambdy, takze maji pri kazdem
* vykresleni novou identitu. Kdyby byly v zavislostech effectu, effect by se
* pri kazdem prekresleni rusil a zakladal znovu (a napriklad bral focus).
*
* Zapis do refu je v layout effectu, ne primo pri vykresleni: vykresleni musi
* byt ciste a React ho muze zahodit. Layout effect bezi pred ostatnimi
* effecty tehoz commitu, takze ty uz vidi novou hodnotu.
*/
export function useLatest<T>(value: T): { readonly current: T } {
const ref = useRef(value);
useLayoutEffect(() => {
ref.current = value;
});
return ref;
}
+30
View File
@@ -0,0 +1,30 @@
import { useCallback, useSyncExternalStore } from 'react';
/**
* Sirka obrazovky jako stav.
*
* Duvod: tabulka ticketu se kreslila dvakrat, jednou jako tabulka a jednou
* jako karty, a CSS jednu z nich schovalo. Kazdy ticket tak byl v DOMu
* dvakrat. Tady se rozhodne v JS a kresli se jen to, co je videt.
*/
export function useMediaQuery(query: string): boolean {
// Prohlizec je tady externi zdroj pravdy, tak se cte primo z nej misto
// kopie ve stavu: zadny effect, ktery by ji po namontovani dorovnaval.
const subscribe = useCallback(
(onChange: () => void) => {
const list = window.matchMedia(query);
list.addEventListener('change', onChange);
return () => list.removeEventListener('change', onChange);
},
[query],
);
return useSyncExternalStore(
subscribe,
() => window.matchMedia(query).matches,
// Bez okna (SSR, testy) se bere mobilni rozlozeni.
() => false,
);
}
/** Tailwind `md`, tedy od tabletu vys. Musi sedet s breakpointem v CSS. */
export const MD_UP = '(min-width: 48rem)';
@@ -1,4 +1,5 @@
import { useCallback, useRef, useState } from 'react';
import { useCallback, useState } from 'react';
import { useLatest } from '@/hooks/useLatest';
import { ApiError } from '@/lib/api';
export interface SubmitState<Args extends unknown[]> {
@@ -31,27 +32,28 @@ export function useSubmit<Args extends unknown[]>(
const [error, setError] = useState<string | null>(null);
const [issues, setIssues] = useState<Array<{ field: string; message: string }>>([]);
const actionRef = useRef(action);
actionRef.current = action;
const fallbackRef = useRef(fallback);
fallbackRef.current = fallback;
const actionRef = useLatest(action);
const fallbackRef = useLatest(fallback);
const submit = useCallback(async (...args: Args): Promise<boolean> => {
setBusy(true);
setError(null);
setIssues([]);
try {
await actionRef.current(...args);
return true;
} catch (err: unknown) {
console.error('[submit] akce selhala:', err);
setError(err instanceof Error ? err.message : fallbackRef.current);
if (err instanceof ApiError) setIssues(err.issues());
return false;
} finally {
setBusy(false);
}
}, []);
const submit = useCallback(
async (...args: Args): Promise<boolean> => {
setBusy(true);
setError(null);
setIssues([]);
try {
await actionRef.current(...args);
return true;
} catch (err: unknown) {
console.error('[submit] akce selhala:', err);
setError(err instanceof Error ? err.message : fallbackRef.current);
if (err instanceof ApiError) setIssues(err.issues());
return false;
} finally {
setBusy(false);
}
},
[actionRef, fallbackRef],
);
return { busy, error, issues, submit, setError };
}
+23
View File
@@ -0,0 +1,23 @@
import { useState } from 'react';
/**
* Prevzeti dat ze zdroje do rozepsaneho stavu.
*
* Typicky pripad: formular drzi kopii zaznamu ze serveru, uzivatel ji upravuje
* a po ulozeni (nebo pri prepnuti na jiny zaznam) se kopie srovna s tim, co
* prislo. Driv to delal `useEffect(() => apply(source), [source])`, ale ten
* bezi az po zobrazeni, takze stara kopie na okamzik problikla a stranka se
* kreslila dvakrat.
*
* Tady se zmena identity `source` pozna uz pri vykresleni a `apply` se zavola
* hned (React pak vykresleni zopakuje s novym stavem, jeste pred zobrazenim).
* Vola se i pri prvnim vykresleni, stejne jako effect pri namontovani.
* `apply` smi menit jen stav teze komponenty.
*/
export function useSyncFromSource<S>(source: S, apply: (source: S) => void): void {
const [seen, setSeen] = useState<{ value: S } | null>(null);
if (seen === null || seen.value !== source) {
setSeen({ value: source });
apply(source);
}
}
+8 -4
View File
@@ -180,7 +180,8 @@ export const cs = {
'references.titleAccent': 'se kterými to běží',
'references.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.',
'references.mockup': 'Reference jsou v této verzi webu ukázkové (mockup) a slouží k náhledu layoutu.',
'references.mockup':
'Reference jsou v této verzi webu ukázkové (mockup) a slouží k náhledu layoutu.',
'reference.nordis.industry': 'Velkoobchod',
'reference.nordis.quote':
@@ -227,7 +228,8 @@ export const cs = {
'process.step4.duration': 'trvale',
// ------------------------------------------------------------ meta popisy
'meta.home': 'WorkNuke sbírá události ze všech vašich nástrojů, zautomatizuje, co jde, propojí další kroky a zapojí správné lidi. Vaše práce prostě plyne.',
'meta.home':
'WorkNuke sbírá události ze všech vašich nástrojů, zautomatizuje, co jde, propojí další kroky a zapojí správné lidi. Vaše práce prostě plyne.',
// ---------------------------------------------------------------- sluzby
'services.title': 'Služby',
@@ -262,7 +264,8 @@ export const cs = {
'about.milestone2025': 'Provozujeme 120+ automatizací s dostupností 99,98 %.',
'about.valuesEyebrow': 'Jak pracujeme',
'about.valuesTitle': 'Čtyři věci, na kterých si trváme',
'about.valuesSubtitle': 'Nejsou to hodnoty na zeď. Podle nich se rozhodujeme, jaké zakázky bereme.',
'about.valuesSubtitle':
'Nejsou to hodnoty na zeď. Podle nich se rozhodujeme, jaké zakázky bereme.',
'about.value1.title': 'Nejdřív pochopit, potom kódovat',
'about.value1.text':
'Než něco postavíme, projdeme proces s lidmi, kteří ho denně dělají. Většina úspor je vidět už tam.',
@@ -273,7 +276,8 @@ export const cs = {
'about.value3.text':
'Monitoring, alerty a incident management nejsou příplatek. Bez nich automatizace dřív nebo později tiše umře.',
'about.value4.title': 'Mluvíme lidsky',
'about.value4.text': 'Žádné prezentace plné buzzwordů. Řekneme i to, co se automatizovat nevyplatí.',
'about.value4.text':
'Žádné prezentace plné buzzwordů. Řekneme i to, co se automatizovat nevyplatí.',
'about.teamEyebrow': 'Tým',
'about.teamTitle': 'Lidé, se kterými budete mluvit',
'about.teamSubtitle': 'Žádná call centra ani account manažeři mezi vámi a tím, kdo řešení staví.',
+10 -5
View File
@@ -180,7 +180,8 @@ export const en: Partial<Record<MessageKey, string>> = {
'references.titleAccent': 'once it is running',
'references.subtitle':
'A sample of projects across industries. We always measure the numbers before and after deployment - otherwise it would just be marketing.',
'references.mockup': 'References in this version of the site are sample data (mockup) used to preview the layout.',
'references.mockup':
'References in this version of the site are sample data (mockup) used to preview the layout.',
'reference.nordis.industry': 'Wholesale',
'reference.nordis.quote':
@@ -263,7 +264,8 @@ export const en: Partial<Record<MessageKey, string>> = {
'about.milestone2025': 'We run 120+ automations with 99.98% uptime.',
'about.valuesEyebrow': 'How we work',
'about.valuesTitle': 'Four things we insist on',
'about.valuesSubtitle': 'These are not values for the wall. They decide which projects we take on.',
'about.valuesSubtitle':
'These are not values for the wall. They decide which projects we take on.',
'about.value1.title': 'Understand first, code second',
'about.value1.text':
'Before we build anything, we walk through the process with the people who do it every day. Most of the savings are visible right there.',
@@ -274,10 +276,12 @@ export const en: Partial<Record<MessageKey, string>> = {
'about.value3.text':
'Monitoring, alerts and incident management are not an add-on. Without them an automation sooner or later quietly dies.',
'about.value4.title': 'We speak plainly',
'about.value4.text': 'No buzzword-filled decks. We will also tell you what is not worth automating.',
'about.value4.text':
'No buzzword-filled decks. We will also tell you what is not worth automating.',
'about.teamEyebrow': 'Team',
'about.teamTitle': 'The people you will talk to',
'about.teamSubtitle': 'No call centres or account managers between you and the people building the solution.',
'about.teamSubtitle':
'No call centres or account managers between you and the people building the solution.',
'about.team1.role': 'founder, solution architecture',
'about.team2.role': 'integrations and backend',
'about.team3.role': 'voicebots and conversation design',
@@ -314,7 +318,8 @@ export const en: Partial<Record<MessageKey, string>> = {
'E.g.: We retype e-shop orders into the warehouse by hand, about 60 a day...',
'contact.submit': 'Send enquiry',
'contact.submitting': 'Sending...',
'contact.consent': 'By sending you agree to the processing of your data for the purpose of a reply.',
'contact.consent':
'By sending you agree to the processing of your data for the purpose of a reply.',
'contact.failed': 'The message could not be sent.',
'contact.directTitle': 'Direct contacts',
'contact.availability': 'Availability',
+9 -2
View File
@@ -1,6 +1,10 @@
import { apiUrl, getToken, notifyAuthExpired } from '@/lib/api';
import type { DashboardEvent } from '@/types/events';
/** Prvni pokus o znovupripojeni po sekunde, dalsi dvojnasobne az po strop. */
const RETRY_BASE_MS = 1000;
const RETRY_MAX_MS = 15_000;
/**
* Cteni SSE streamu pres fetch.
*
@@ -89,7 +93,7 @@ export function connectEventStream(path: string, handlers: StreamHandlers): () =
// Exponencialni odstup se stropem, at neubijime server pri vypadku.
attempt += 1;
const delay = Math.min(1000 * 2 ** (attempt - 1), 15_000);
const delay = Math.min(RETRY_BASE_MS * 2 ** (attempt - 1), RETRY_MAX_MS);
retryTimer = window.setTimeout(run, delay);
}
}
@@ -103,7 +107,10 @@ export function connectEventStream(path: string, handlers: StreamHandlers): () =
if (dataLines.length === 0) return;
const eventName = lines.find((line) => line.startsWith('event:'))?.slice(6).trim();
const eventName = lines
.find((line) => line.startsWith('event:'))
?.slice(6)
.trim();
if (eventName === 'connected') return;
try {
+96
View File
@@ -0,0 +1,96 @@
import type { TriggerField } from '@/types/dashboard';
/**
* Ukazkove telo podle deklarovanych parametru.
*
* Respektuje cestu: kdyz je parametr `orderId` na `data.order.id`, ukazka ho
* ukaze zanoreny presne tam. Jinak by odesilatel poslal plocho a nic by nesedelo.
*/
export function exampleBody(fields: TriggerField[]): string {
try {
return buildExample(fields);
} catch (err) {
/*
* Ukazka je napoveda. **Nesmi shodit stranku**, at uz do ni prijde
* cokoliv - parametry se pisou znak po znaku a rozdelany stav je normalni.
* Pojistka kolem obsahu portalu to zachyti taky, ale tam uz zmizi cely
* builder i s rozdelanou praci.
*/
console.error('[builder] ukazku tela se nepodarilo sestavit:', err);
return '{\n "cokoliv": "ukázku nejde sestavit, zkontrolujte cesty parametrů"\n}';
}
}
function buildExample(fields: TriggerField[]): string {
if (fields.length === 0) return '{\n "cokoliv": "co pošle odesílatel"\n}';
const body: Record<string, unknown> = {};
const paths = fields.map((field) => field.path ?? field.name);
for (const field of fields) {
const full = field.path ?? field.name;
// Rodice cesty a posledni klic zvlast. `split` vraci aspon jeden prvek,
// takze `pop` neco vrati; typ to nevi.
const parents = full.split('.');
const last = parents.pop() ?? full;
let target = body;
/*
* Parametr, kterym vede cesta jineho parametru.
*
* Deje se to bezne: prijde cely objekt a vedle nej se vytahuje konkretni
* hodnota z jeho nitra. Pak plati ta vnitrni struktura, ne zastupna
* hodnota typu - ta by prepsala presne to, co ma ukazka ukazat.
*/
const isParent = paths.some((other) => other !== full && other.startsWith(`${full}.`));
for (const [i, key] of parents.entries()) {
// Dalsi clanek cesty: dalsi rodic, nebo uz posledni klic.
const next = parents[i + 1] ?? last;
const current = target[key];
/*
* Do cesty se musi dat zanorit. Kdyz uz na tom miste hodnota je, protoze
* jiny parametr konci tam, kde tenhle pokracuje, prepise se schrankou.
*
* Bez toho by se zapisovalo do retezce a **spadl by cely builder**. Neni
* to okrajovy stav: pri psani cesty `zprava.data` je `zprava` chvili
* hotovy parametr sam o sobe, takze staci mit oba a ukazka se rozbije
* pri kazdem stisku klavesy.
*
* Cislo v ceste znamena pole, ne objekt s klicem "0".
*/
if (current === null || typeof current !== 'object') {
target[key] = /^\d+$/.test(next) ? [] : {};
}
target = target[key] as Record<string, unknown>;
}
if (isParent) {
// Schranku nechat, at uz ji postavily vnorene parametry, nebo teprve
// prijdou na radu. Prepsat ji zastupnou hodnotou znamena je zahodit.
const current = target[last];
if (current === null || typeof current !== 'object') target[last] = {};
} else {
target[last] = exampleValue(field.type);
}
}
return JSON.stringify(body, null, 2);
}
function exampleValue(type: TriggerField['type']): unknown {
switch (type) {
case 'number':
return 42;
case 'boolean':
return true;
case 'date':
return '2026-01-31';
case 'object':
return { klic: 'hodnota' };
case 'list':
return ['první', 'druhá'];
default:
return 'text';
}
}
+3 -4
View File
@@ -367,13 +367,13 @@ export function insertStep(
index: number,
step: FlowStep,
): FlowStep[] {
if (path.length === 0) {
const [head, ...rest] = path;
if (!head) {
const next = [...steps];
next.splice(index, 0, step);
return next;
}
const [head, ...rest] = path;
return steps.map((current) => {
if (current.id !== head.stepId) return current;
@@ -417,8 +417,7 @@ export function moveStep(steps: FlowStep[], stepId: string, offset: number): Flo
const target = index + offset;
if (target < 0 || target >= steps.length) return steps;
const next = [...steps];
const [moved] = next.splice(index, 1);
next.splice(target, 0, moved);
next.splice(target, 0, ...next.splice(index, 1));
return next;
}
+10 -4
View File
@@ -1,5 +1,11 @@
/** Formatovaci helpery - vsude cs-CZ, aby se cisla a datumy nemichaly. */
const MINUTE_MS = 60_000;
/** Pod deset sekund se ukazuji desetiny, jinak by kratke behy vypadaly jako 0 s. */
const TENTHS_BELOW_MS = 10_000;
/** Do dvou minut sekundy, dal uz minuty. */
const SECONDS_BELOW_MS = 120_000;
const numberFormat = new Intl.NumberFormat('cs-CZ');
const dateTimeFormat = new Intl.DateTimeFormat('cs-CZ', {
day: 'numeric',
@@ -42,7 +48,7 @@ export function formatRelative(iso: string): string {
console.warn('[format] neplatne datum:', iso);
return '-';
}
const diffMin = Math.round((Date.now() - date.getTime()) / 60_000);
const diffMin = Math.round((Date.now() - date.getTime()) / MINUTE_MS);
if (diffMin < 1) return 'právě teď';
if (diffMin < 60) return `před ${diffMin} min`;
const diffHours = Math.round(diffMin / 60);
@@ -66,7 +72,7 @@ export function plural(count: number, forms: [one: string, few: string, many: st
/** Trvani v ms na citelny tvar (1,2 s / 74 s / 5 min). */
export function formatDuration(ms: number): string {
if (ms < 10_000) return `${(ms / 1000).toFixed(1).replace('.', ',')} s`;
if (ms < 120_000) return `${Math.round(ms / 1000)} s`;
return `${Math.round(ms / 60_000)} min`;
if (ms < TENTHS_BELOW_MS) return `${(ms / 1000).toFixed(1).replace('.', ',')} s`;
if (ms < SECONDS_BELOW_MS) return `${Math.round(ms / 1000)} s`;
return `${Math.round(ms / MINUTE_MS)} min`;
}
-27
View File
@@ -1,27 +0,0 @@
import { useEffect, useState } from 'react';
/**
* Sirka obrazovky jako stav.
*
* Duvod: tabulka ticketu se kreslila dvakrat, jednou jako tabulka a jednou
* jako karty, a CSS jednu z nich schovalo. Kazdy ticket tak byl v DOMu
* dvakrat. Tady se rozhodne v JS a kresli se jen to, co je videt.
*/
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(
() => typeof window !== 'undefined' && window.matchMedia(query).matches,
);
useEffect(() => {
const list = window.matchMedia(query);
const update = (event: MediaQueryListEvent) => setMatches(event.matches);
setMatches(list.matches);
list.addEventListener('change', update);
return () => list.removeEventListener('change', update);
}, [query]);
return matches;
}
/** Tailwind `md`, tedy od tabletu vys. Musi sedet s breakpointem v CSS. */
export const MD_UP = '(min-width: 48rem)';
+1 -1
View File
@@ -5,7 +5,7 @@ import { PageHeader } from '@/components/ui/PageHeader';
import { Section, SectionHeading } from '@/components/ui/Section';
import { brand } from '@/config/brand';
import { useT, type MessageKey } from '@/i18n';
import { usePageMeta } from '@/lib/usePageMeta';
import { usePageMeta } from '@/hooks/usePageMeta';
const values: Array<{ icon: typeof Compass; title: MessageKey; text: MessageKey }> = [
{ icon: Compass, title: 'about.value1.title', text: 'about.value1.text' },
+10 -3
View File
@@ -13,7 +13,7 @@ import { Section } from '@/components/ui/Section';
import { brand } from '@/config/brand';
import { useT, type MessageKey } from '@/i18n';
import { apiFetch } from '@/lib/api';
import { usePageMeta } from '@/lib/usePageMeta';
import { usePageMeta } from '@/hooks/usePageMeta';
/** Hodnota jde na server cesky, popisek se preklada. */
const topics: Array<{ value: string; label: MessageKey }> = [
@@ -109,7 +109,12 @@ export default function Contact() {
/>
</Field>
<Field label={t('contact.phone')} htmlFor="phone" size="md" optional>
<Input id="phone" name="phone" size="md" placeholder={t('contact.phonePlaceholder')} />
<Input
id="phone"
name="phone"
size="md"
placeholder={t('contact.phonePlaceholder')}
/>
</Field>
</div>
@@ -208,7 +213,9 @@ export default function Contact() {
<Card className="border-brand-400/25 bg-brand-500/6">
<h2 className="font-semibold text-white">{t('contact.clientTitle')}</h2>
<p className="mt-2 text-sm leading-relaxed text-white/60">{t('contact.clientText')}</p>
<p className="mt-2 text-sm leading-relaxed text-white/60">
{t('contact.clientText')}
</p>
<Link
to="/prihlaseni"
className="mt-4 inline-flex text-sm font-semibold text-brand-300 hover:text-brand-200"
+1 -1
View File
@@ -6,7 +6,7 @@ import { Products } from '@/components/home/Products';
import { References } from '@/components/home/References';
import { Stats } from '@/components/home/Stats';
import { useT } from '@/i18n';
import { usePageMeta } from '@/lib/usePageMeta';
import { usePageMeta } from '@/hooks/usePageMeta';
export default function Home() {
const t = useT();
+17 -7
View File
@@ -7,8 +7,12 @@ import { Container } from '@/components/ui/Container';
import { Field } from '@/components/ui/form/Field';
import { Input } from '@/components/ui/form/Input';
import { apiFetch } from '@/lib/api';
import { usePageMeta } from '@/lib/usePageMeta';
import { useSubmit } from '@/lib/useSubmit';
import { usePageMeta } from '@/hooks/usePageMeta';
import { useSubmit } from '@/hooks/useSubmit';
/** Nejkratsi jmeno a heslo. Musi sedet s validaci na serveru. */
const MIN_NAME_LENGTH = 2;
const MIN_PASSWORD_LENGTH = 8;
/**
* Prijeti pozvanky do firmy.
@@ -59,7 +63,11 @@ export default function Invite() {
};
}, [code]);
const { busy, error, submit: accept } = useSubmit(async () => {
const {
busy,
error,
submit: accept,
} = useSubmit(async () => {
await apiFetch(`/api/invites/${code}/accept`, {
method: 'POST',
auth: false,
@@ -102,9 +110,7 @@ export default function Invite() {
<Building2 className="size-4 text-brand-300" />
Pozvánka do firmy
</p>
<h1 className="mt-1 text-2xl font-bold text-white">
{info?.tenant ?? 'Načítám...'}
</h1>
<h1 className="mt-1 text-2xl font-bold text-white">{info?.tenant ?? 'Načítám...'}</h1>
<p className="mt-2 text-sm text-white/55">
{info?.knownUser
? 'S touhle adresou už účet máte. Zadejte k němu svoje heslo a připojíte se k firmě.'
@@ -155,7 +161,11 @@ export default function Invite() {
<Button
onClick={() => void accept()}
disabled={busy || name.trim().length < 2 || password.length < 8}
disabled={
busy ||
name.trim().length < MIN_NAME_LENGTH ||
password.length < MIN_PASSWORD_LENGTH
}
className="w-full justify-center"
>
{busy ? 'Odesílám...' : 'Přijmout pozvánku'}
+6 -3
View File
@@ -9,7 +9,7 @@ import { Field } from '@/components/ui/form/Field';
import { Input } from '@/components/ui/form/Input';
import { brand } from '@/config/brand';
import { useT, type MessageKey } from '@/i18n';
import { usePageMeta } from '@/lib/usePageMeta';
import { usePageMeta } from '@/hooks/usePageMeta';
/**
* Demo ucty prototypu.
@@ -29,6 +29,9 @@ const demoAccounts: DemoAccount[] = [
{ email: 'martin.kriz@automia.cz', password: 'demo1234', note: 'login.demo.agent' },
];
/** Ucet predvyplneny ve formulari. Prazdny seznam znamena prazdna pole. */
const [defaultAccount] = demoAccounts;
/** Prihlaseni do klientskeho portalu. Demo ucty jsou vypsane pod formularem. */
export default function Login() {
const t = useT();
@@ -127,7 +130,7 @@ export default function Login() {
autoFocus
size="md"
// Predvyplneno kvuli testovani prototypu. Pred ostrym pouzitim odebrat.
defaultValue={demoAccounts[0].email}
defaultValue={defaultAccount?.email}
placeholder={t('login.emailPlaceholder')}
/>
</Field>
@@ -146,7 +149,7 @@ export default function Login() {
required
autoComplete="current-password"
size="md"
defaultValue={demoAccounts[0].password}
defaultValue={defaultAccount?.password}
placeholder="--------"
/>
</div>
+1 -1
View File
@@ -1,7 +1,7 @@
import { ButtonLink } from '@/components/ui/Button';
import { Container } from '@/components/ui/Container';
import { useT } from '@/i18n';
import { usePageMeta } from '@/lib/usePageMeta';
import { usePageMeta } from '@/hooks/usePageMeta';
export default function NotFound() {
const t = useT();
+1 -1
View File
@@ -4,7 +4,7 @@ import { PageHeader } from '@/components/ui/PageHeader';
import { Section } from '@/components/ui/Section';
import { products } from '@/data/products';
import { useT } from '@/i18n';
import { usePageMeta } from '@/lib/usePageMeta';
import { usePageMeta } from '@/hooks/usePageMeta';
import { cn } from '@/lib/cn';
export default function Services() {
+27 -16
View File
@@ -1,5 +1,5 @@
import { AlertCircle, ArrowLeft, Check, Save } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { DataState } from '@/components/dashboard/DataState';
import { FlowCanvas } from '@/components/dashboard/flow/FlowCanvas';
@@ -22,10 +22,11 @@ import {
type FlowPath,
} from '@/lib/flow';
import { plural } from '@/lib/format';
import { useApiQuery } from '@/lib/useApiQuery';
import { usePageMeta } from '@/lib/usePageMeta';
import { useSubmit } from '@/lib/useSubmit';
import { useUnsavedChanges } from '@/lib/useUnsavedChanges';
import { useApiQuery } from '@/hooks/useApiQuery';
import { usePageMeta } from '@/hooks/usePageMeta';
import { useSubmit } from '@/hooks/useSubmit';
import { useSyncFromSource } from '@/hooks/useSyncFromSource';
import { useUnsavedChanges } from '@/hooks/useUnsavedChanges';
import type { AutomationFlow, FlowStep, TriggerField } from '@/types/dashboard';
/**
@@ -76,13 +77,13 @@ export default function ActionDetail() {
usePageMeta({ title: `${action.data?.label ?? 'Akce'}`, area: 'portal' });
useUnsavedChanges(dirty);
useEffect(() => {
if (!action.data) return;
useSyncFromSource(action.data, (data) => {
if (!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));
setSteps(bodyToSteps(data.body));
setDirty(false);
}, [action.data]);
});
const connectors = catalog.items;
const stepCount = useMemo(() => countSteps(steps), [steps]);
@@ -128,7 +129,8 @@ export default function ActionDetail() {
[change],
);
const onUpdateForeach = useCallback(
(stepId: string, path: string) => change((previous) => updateForeachPath(previous, stepId, path)),
(stepId: string, path: string) =>
change((previous) => updateForeachPath(previous, stepId, path)),
[change],
);
const onMoveStep = useCallback(
@@ -183,7 +185,11 @@ export default function ActionDetail() {
</div>
</div>
<Button size="sm" onClick={() => void saving.submit()} disabled={saving.busy || !dirty}>
<Button
size="sm"
onClick={() => void saving.submit()}
disabled={saving.busy || !dirty}
>
<Save className="size-4" />
{saving.busy ? 'Ukládám...' : 'Uložit strom'}
</Button>
@@ -246,16 +252,16 @@ export default function ActionDetail() {
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-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ěží.
Strom se uloží, ale zatím se nevykoná. Chybí runtime, stejně jako u automatizací.
Akce s jednou operací běží.
</p>
</aside>
</div>
@@ -273,7 +279,12 @@ export default function ActionDetail() {
if (!picker) return;
const target = picker;
change((previous) =>
insertStep(previous, target.path, target.index, createActionStep(serviceId, operationId)),
insertStep(
previous,
target.path,
target.index,
createActionStep(serviceId, operationId),
),
);
setPicker(null);
}}
+109 -102
View File
@@ -6,7 +6,7 @@ import { TenantScripts } from '@/components/dashboard/TenantScripts';
import { Badge } from '@/components/ui/Badge';
import { useCollection } from '@/lib/collections';
import { plural } from '@/lib/format';
import { usePageMeta } from '@/lib/usePageMeta';
import { usePageMeta } from '@/hooks/usePageMeta';
/**
* Definice akci na ticketu.
@@ -50,7 +50,13 @@ export default function Actions() {
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: 'icon',
label: 'Ikona',
kind: 'text',
initial: 'Play',
hint: 'Například Play, Send, FileText.',
},
{
name: 'style',
label: 'Vzhled',
@@ -133,107 +139,108 @@ export default function Actions() {
{tab === 'akce' && (
<>
{ticketTypes.error && (
<p className="text-sm text-danger-400">
Typy ticketů se nepodařilo načíst: {ticketTypes.error}
</p>
)}
<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>
{ticketTypes.error && (
<p className="text-sm text-danger-400">
Typy ticketů se nepodařilo načíst: {ticketTypes.error}
</p>
)}
<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,
})}
/>
<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>
+39 -167
View File
@@ -1,15 +1,5 @@
import {
AlertCircle,
ArrowLeft,
Check,
ChevronRight,
Info,
Pause,
Play,
Save,
Trash2,
} from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { AlertCircle, ArrowLeft, Check, Info, Pause, Play, Save, Trash2 } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { DataState } from '@/components/dashboard/DataState';
import { FlowCanvas } from '@/components/dashboard/flow/FlowCanvas';
@@ -17,7 +7,6 @@ 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 { cn } from '@/lib/cn';
import { useCollection } from '@/lib/collections';
import {
countSteps,
@@ -33,16 +22,13 @@ import {
type FlowPath,
} from '@/lib/flow';
import { formatDateTime, plural } from '@/lib/format';
import { useApiQuery } from '@/lib/useApiQuery';
import { usePageMeta } from '@/lib/usePageMeta';
import { useSubmit } from '@/lib/useSubmit';
import { useUnsavedChanges } from '@/lib/useUnsavedChanges';
import type {
AutomationDetail as Detail,
AutomationFlow,
TriggerField,
WebhookCall,
} from '@/types/dashboard';
import { useApiQuery } from '@/hooks/useApiQuery';
import { usePageMeta } from '@/hooks/usePageMeta';
import { useSubmit } from '@/hooks/useSubmit';
import { useSyncFromSource } from '@/hooks/useSyncFromSource';
import { useUnsavedChanges } from '@/hooks/useUnsavedChanges';
import type { AutomationDetail as Detail, AutomationFlow, TriggerField } from '@/types/dashboard';
import { WebhookCalls } from '@/components/dashboard/flow/WebhookCalls';
/** Kam se ma vlozit dalsi krok - null znamena, ze vyber neni otevreny. */
interface PickerTarget {
@@ -77,14 +63,14 @@ export default function AutomationDetail() {
useUnsavedChanges(dirty);
// Prevzeti dat ze serveru do editovatelneho stavu.
useEffect(() => {
if (!automation.data) return;
setName(automation.data.name);
setFlow(automation.data.flow);
setEnabled(automation.data.enabled);
setIssues(automation.data.issues);
useSyncFromSource(automation.data, (data) => {
if (!data) return;
setName(data.name);
setFlow(data.flow);
setEnabled(data.enabled);
setIssues(data.issues);
setDirty(false);
}, [automation.data]);
});
const connectors = catalog.items;
const categories = catalog.extra?.categories ?? [];
@@ -220,12 +206,7 @@ export default function AutomationDetail() {
const target = picker;
changeFlow((previous) => ({
...previous,
steps: insertStep(
previous.steps,
target.path,
target.index,
createConditionStep(firstField),
),
steps: insertStep(previous.steps, target.path, target.index, createConditionStep(firstField)),
}));
setPicker(null);
}
@@ -280,10 +261,9 @@ export default function AutomationDetail() {
body: { name, enabled, flow },
});
}
const updated = await apiFetch<Detail>(
`/api/dashboard/automations/${id}/webhook/regenerate`,
{ method: 'POST' },
);
const updated = await apiFetch<Detail>(`/api/dashboard/automations/${id}/webhook/regenerate`, {
method: 'POST',
});
setFlow(updated.flow);
setIssues(updated.issues);
setDirty(false);
@@ -360,7 +340,9 @@ export default function AutomationDetail() {
<Badge tone={enabled ? 'ok' : 'neutral'}>
<span
className={
enabled ? 'size-1.5 rounded-full bg-ok-400' : 'size-1.5 rounded-full bg-white/40'
enabled
? 'size-1.5 rounded-full bg-ok-400'
: 'size-1.5 rounded-full bg-white/40'
}
/>
{enabled ? 'Aktivní' : 'Pozastaveno'}
@@ -392,7 +374,11 @@ export default function AutomationDetail() {
{enabled ? <Pause className="size-4" /> : <Play className="size-4" />}
{enabled ? 'Pozastavit' : 'Zapnout'}
</Button>
<Button size="sm" onClick={() => void saving.submit(enabled)} disabled={busy || !dirty}>
<Button
size="sm"
onClick={() => void saving.submit(enabled)}
disabled={busy || !dirty}
>
<Save className="size-4" />
{saving.busy ? 'Ukládám...' : 'Uložit'}
</Button>
@@ -419,8 +405,8 @@ export default function AutomationDetail() {
{catalog.error && (
<p className="flex items-center gap-2 rounded-xl border border-warn-400/30 bg-warn-500/8 px-4 py-3 text-sm text-warn-400">
<AlertCircle className="size-4 shrink-0" />
Katalog služeb se nepodařilo načíst ({catalog.error}). Kroky se ukážou bez názvů
a nový krok nejde vybrat.
Katalog služeb se nepodařilo načíst ({catalog.error}). Kroky se ukážou bez názvů a
nový krok nejde vybrat.
</p>
)}
@@ -479,13 +465,12 @@ export default function AutomationDetail() {
</li>
<li className="flex gap-2">
<span className="font-mono text-brand-300">2.</span>
Klikněte na <span className="font-mono">+</span> a přidejte akci nad
napojenou službou.
Klikněte na <span className="font-mono">+</span> a přidejte akci nad napojenou
službou.
</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
ANO a NE.
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">
<span className="font-mono text-brand-300">4.</span>
@@ -499,7 +484,10 @@ export default function AutomationDetail() {
<h2 className="font-semibold text-white">Provoz</h2>
<dl className="mt-3 space-y-2.5 text-sm">
<Row label="Vytvořeno" value={formatDateTime(automation.data.createdAt)} />
<Row label="Poslední úprava" value={formatDateTime(automation.data.updatedAt)} />
<Row
label="Poslední úprava"
value={formatDateTime(automation.data.updatedAt)}
/>
<Row label="Spuštění dnes" value={String(automation.data.runsToday)} />
<Row label="Spuštění včera" value={String(automation.data.runsYesterday)} />
<Row label="Spuštění celkem" value={String(automation.data.runsTotal)} />
@@ -514,8 +502,8 @@ export default function AutomationDetail() {
<p className="flex gap-2 rounded-xl border border-ink-600/60 px-4 py-3 text-xs leading-relaxed text-white/40">
<Info className="mt-0.5 size-3.5 shrink-0" />
Prototyp: nastavení jednotlivých polí kroku (mapování dat, filtry) zatím
není součástí builderu. Uložený strom se nespouští.
Prototyp: nastavení jednotlivých polí kroku (mapování dat, filtry) zatím není
součástí builderu. Uložený strom se nespouští.
</p>
</aside>
</div>
@@ -537,122 +525,6 @@ export default function AutomationDetail() {
);
}
/**
* Poslednich deset volani webhooku.
*
* Odmitnute volani do ted skoncilo jako radek v logu kontejneru. Odesilatel
* dostal 400 a vedel o tom, ale ten, kdo kontrakt psal, se nedozvedel nic -
* automatizace svitila zelene a jen do ni prestalo chodit.
*
* Radek se rozklikne na **rozpad po parametrech a cele prijate telo**. Samotny
* duvod odmitnuti nestaci: "parametr data ma mit typ text" nerekne, co tedy
* prislo, a bez toho zbyva hadat.
*/
function WebhookCalls({ calls }: { calls: WebhookCall[] }) {
const [openAt, setOpenAt] = useState<string | null>(null);
return (
<div className="glass rounded-card p-5">
<h2 className="font-semibold text-white">Poslední volání</h2>
{calls.length === 0 ? (
<p className="mt-3 text-sm text-white/40">
Zatím nic nepřišlo. Objeví se tu deset posledních volání včetně těch,
která jsme odmítli.
</p>
) : (
<ul className="mt-3 space-y-1.5 text-sm">
{calls.map((call) => {
const open = openAt === call.at;
return (
<li key={call.at} className="rounded-lg border border-ink-600/50">
<button
type="button"
onClick={() => setOpenAt(open ? null : call.at)}
className="flex w-full items-start gap-2 px-3 py-2 text-left hover:bg-ink-800/40"
>
{call.ok ? (
<Check className="mt-0.5 size-3.5 shrink-0 text-emerald-400" />
) : (
<AlertCircle className="mt-0.5 size-3.5 shrink-0 text-rose-400" />
)}
<span className="min-w-0 flex-1">
<span className="text-white/70">{formatDateTime(call.at)}</span>
{call.problems.length > 0 && (
<span className="block text-xs leading-relaxed text-rose-300/80">
{call.problems.join(' ')}
</span>
)}
</span>
<ChevronRight
className={cn('mt-0.5 size-3.5 shrink-0 text-white/30', open && 'rotate-90')}
/>
</button>
{open && (
<div className="space-y-3 border-t border-ink-600/50 px-3 py-3">
{call.fields.length > 0 && (
<table className="w-full text-xs">
<thead>
<tr className="text-left text-white/35">
<th className="pb-1 font-normal">Parametr</th>
<th className="pb-1 font-normal">Čekáme</th>
<th className="pb-1 font-normal">Přišlo</th>
</tr>
</thead>
<tbody>
{call.fields.map((field) => (
<tr
key={field.name}
className={field.ok ? 'text-white/60' : 'text-rose-300'}
>
<td className="py-0.5 pr-2 align-top font-mono">
{field.name}
{field.path !== field.name && (
<span className="block text-white/30">{field.path}</span>
)}
</td>
<td className="py-0.5 pr-2 align-top">
{field.type}
{field.required && <span className="text-white/30">, povinný</span>}
</td>
<td className="min-w-0 py-0.5 align-top">
{field.got}
{field.preview !== '' && (
<span className="block break-all font-mono text-white/40">
{field.preview}
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
<div>
<p className="text-xs text-white/35">
Přijaté tělo{call.truncated && ' (useknuté)'}
</p>
<pre className="mt-1 max-h-64 overflow-auto rounded-lg bg-ink-950/60 p-2 text-xs text-white/60">
{call.body === '' ? '(prázdné)' : call.body}
</pre>
</div>
</div>
)}
</li>
);
})}
</ul>
)}
<p className="mt-3 text-xs text-white/35">
Drží se v paměti, restart je zahodí. Na historii jsou běhy.
</p>
</div>
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex justify-between gap-3">
+8 -4
View File
@@ -18,10 +18,10 @@ import { Field } from '@/components/ui/form/Field';
import { Input } from '@/components/ui/form/Input';
import { Modal } from '@/components/ui/Modal';
import { apiFetch } from '@/lib/api';
import { useSubmit } from '@/lib/useSubmit';
import { useSubmit } from '@/hooks/useSubmit';
import { formatDuration, formatNumber, formatPercent, formatRelative, plural } from '@/lib/format';
import { useApiQuery } from '@/lib/useApiQuery';
import { usePageMeta } from '@/lib/usePageMeta';
import { useApiQuery } from '@/hooks/useApiQuery';
import { usePageMeta } from '@/hooks/usePageMeta';
import type { Automation, AutomationDetail, ListResponse } from '@/types/dashboard';
const kindMeta: Record<Automation['kind'], { label: string; icon: LucideIcon }> = {
@@ -176,7 +176,11 @@ function CreateAutomationModal({
const navigate = useNavigate();
const [name, setName] = useState('');
const { busy: submitting, error, submit } = useSubmit(async () => {
const {
busy: submitting,
error,
submit,
} = useSubmit(async () => {
const created = await apiFetch<AutomationDetail>('/api/dashboard/automations', {
method: 'POST',
body: { name },
+16 -10
View File
@@ -9,8 +9,8 @@ import { ConnectorEditor, type EditorState } from '@/pages/dashboard/connectors/
import { cn } from '@/lib/cn';
import { useCollection } from '@/lib/collections';
import { formatDateTime } from '@/lib/format';
import { useApiQuery } from '@/lib/useApiQuery';
import { usePageMeta } from '@/lib/usePageMeta';
import { useApiQuery } from '@/hooks/useApiQuery';
import { usePageMeta } from '@/hooks/usePageMeta';
import type { EgressIp, ServiceOverview, ServiceWithUsage, StorageStatus } from '@/types/dashboard';
/**
@@ -65,6 +65,9 @@ export default function Connectors() {
[services.data],
);
// Tlacitko "novy konektor" predvybere prvni sluzbu; bez sluzeb neni co zakladat.
const firstConnectable = connectable[0];
function closeEditor() {
setEditor(null);
if (preselected) setParams({});
@@ -84,12 +87,15 @@ export default function Connectors() {
<div>
<h1 className="text-2xl font-bold text-white">Konektory</h1>
<p className="mt-1 max-w-2xl text-sm text-white/50">
Napojení této firmy na jednotlivé služby. Přístupové údaje zůstávají na
serveru, prohlížeč je nikdy nedostane zpátky.
Napojení této firmy na jednotlivé služby. Přístupové údaje zůstávají na serveru,
prohlížeč je nikdy nedostane zpátky.
</p>
</div>
{connectable.length > 0 && (
<Button size="sm" onClick={() => setEditor({ mode: 'create', serviceId: connectable[0].id })}>
{firstConnectable && (
<Button
size="sm"
onClick={() => setEditor({ mode: 'create', serviceId: firstConnectable.id })}
>
<Plus className="size-4" />
Nový konektor
</Button>
@@ -176,8 +182,8 @@ function StorageNotice({ storage }: { storage: StorageStatus }) {
<p className="mt-0.5 text-xs text-white/45">{storage.reason}</p>
{storage.secrets === 'file' && (
<p className="mt-0.5 text-xs text-white/35">
Údaje jsou zašifrované klíčem, který leží ve stejné složce. Chrání to proti
náhodnému přečtení souboru, ne proti přístupu k disku serveru.
Údaje jsou zašifrované klíčem, který leží ve stejné složce. Chrání to proti náhodnému
přečtení souboru, ne proti přístupu k disku serveru.
</p>
)}
</div>
@@ -237,8 +243,8 @@ function EgressNotice({ egress }: { egress: EgressIp }) {
<span className="mt-1 block text-xs text-white/50">
Na seznam povolených patří celý rozsah{' '}
<span className="font-mono text-white">{egress.viaProxy.suggestedRange}</span>, ne
tahle jedna adresa. Docker přiděluje z bloku a při překreslení sítě se čísla
mění, takže povolení jedné adresy vydrží do prvního redeploye.
tahle jedna adresa. Docker přiděluje z bloku a při překreslení sítě se čísla mění,
takže povolení jedné adresy vydrží do prvního redeploye.
</span>
)}
{(egress.viaProxy.forwardedFor || egress.viaProxy.remoteAddress) && (
+39 -36
View File
@@ -1,5 +1,5 @@
import { Plus, Send } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { DataState } from '@/components/dashboard/DataState';
import { TicketStatusBadge } from '@/components/dashboard/StatusBadge';
import { TicketBody, bodyPreview } from '@/components/dashboard/TicketBody';
@@ -13,9 +13,10 @@ import { Modal } from '@/components/ui/Modal';
import { apiFetch } from '@/lib/api';
import { formatDateTime, formatRelative } from '@/lib/format';
import { priorities, priorityLabel } from '@/lib/options';
import { useApiQuery } from '@/lib/useApiQuery';
import { usePageMeta } from '@/lib/usePageMeta';
import { useSubmit } from '@/lib/useSubmit';
import { useApiQuery } from '@/hooks/useApiQuery';
import { usePageMeta } from '@/hooks/usePageMeta';
import { useSubmit } from '@/hooks/useSubmit';
import { useSyncFromSource } from '@/hooks/useSyncFromSource';
import type { Ticket, TicketDetail, TicketPriority } from '@/types/dashboard';
/**
@@ -74,8 +75,8 @@ export default function Helpdesk() {
*/}
{data && !provider && (
<div className="rounded-card border border-warn-400/40 bg-warn-500/8 p-4 text-sm text-warn-400">
Vaše firma zatím nemá nastaveného dodavatele helpdesku, takže není komu
požadavek poslat. Ozvěte se nám a doplníme to.
Vaše firma zatím nemá nastaveného dodavatele helpdesku, takže není komu požadavek poslat.
Ozvěte se nám a doplníme to.
</div>
)}
@@ -109,7 +110,9 @@ export default function Helpdesk() {
</div>
<p className="mt-1.5 font-medium text-white">{ticket.subject}</p>
{ticket.body && (
<p className="mt-1 line-clamp-2 text-sm text-white/45">{bodyPreview(ticket.body)}</p>
<p className="mt-1 line-clamp-2 text-sm text-white/45">
{bodyPreview(ticket.body)}
</p>
)}
</button>
</li>
@@ -124,7 +127,11 @@ export default function Helpdesk() {
onCreated={() => helpdesk.reload()}
/>
<RequestDetail id={openId} onClose={() => setOpenId(null)} onChanged={() => helpdesk.reload()} />
<RequestDetail
id={openId}
onClose={() => setOpenId(null)}
onChanged={() => helpdesk.reload()}
/>
</div>
);
}
@@ -243,40 +250,29 @@ function RequestDetail({
onClose: () => void;
onChanged: () => void;
}) {
const [ticket, setTicket] = useState<TicketDetail | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
// Stejny mechanismus jako zbytek portalu. Jine `id` je jiny klic dotazu,
// takze zastarala odpoved po rychlem otevreni dvou pozadavku se nepouzije.
const detail = useApiQuery<TicketDetail>(`/api/dashboard/helpdesk/${id ?? ''}`, {
enabled: id !== null,
});
const ticket = detail.data;
const [text, setText] = useState('');
useEffect(() => {
if (!id) return;
// Pojistka proti zastarale odpovedi: rychle otevreni dvou pozadavku za
// sebou by jinak ukazalo ten, jehoz odpoved dorazila pozdeji.
let cancelled = false;
setTicket(null);
setLoadError(null);
setText('');
apiFetch<TicketDetail>(`/api/dashboard/helpdesk/${id}`)
.then((data) => {
if (!cancelled) setTicket(data);
})
.catch((err: unknown) => {
if (cancelled) return;
setLoadError(err instanceof Error ? err.message : 'Požadavek se nepodařilo načíst.');
});
return () => {
cancelled = true;
};
}, [id]);
// Rozepsany komentar patri k jednomu pozadavku, s jinym se zacina od zacatku.
useSyncFromSource(id, () => setText(''));
const { busy: sending, error, submit } = useSubmit(async () => {
const {
busy: sending,
error,
submit,
} = useSubmit(async () => {
if (!id) return;
await apiFetch(`/api/dashboard/helpdesk/${id}/comment`, {
method: 'POST',
body: { text: text.trim() },
});
setText('');
const fresh = await apiFetch<TicketDetail>(`/api/dashboard/helpdesk/${id}`);
setTicket(fresh);
detail.reload();
onChanged();
}, 'Komentář se nepodařilo uložit.');
@@ -285,11 +281,13 @@ function RequestDetail({
open={id !== null}
onClose={onClose}
title={ticket ? ticket.subject : 'Požadavek'}
description={ticket ? `${ticket.id}, založeno ${formatDateTime(ticket.createdAt)}` : undefined}
description={
ticket ? `${ticket.id}, založeno ${formatDateTime(ticket.createdAt)}` : undefined
}
className="max-w-3xl"
>
<div className="max-h-[70vh] space-y-4 overflow-y-auto p-5">
<DataState loading={!ticket && !loadError} error={loadError}>
<DataState loading={detail.loading} error={detail.error} onRetry={detail.reload}>
{ticket && (
<>
<div className="flex flex-wrap items-center gap-2">
@@ -347,7 +345,12 @@ function RequestDetail({
tone="card"
/>
</Field>
<Button type="submit" size="sm" className="mt-3" disabled={sending || text.trim() === ''}>
<Button
type="submit"
size="sm"
className="mt-3"
disabled={sending || text.trim() === ''}
>
<Send className="size-4" />
{sending ? 'Odesílám...' : 'Odeslat'}
</Button>
+16 -7
View File
@@ -8,11 +8,14 @@ import { Button } from '@/components/ui/Button';
import { apiFetch } from '@/lib/api';
import { useAccess } from '@/lib/collections';
import { formatDateTime, formatRelative } from '@/lib/format';
import { useApiQuery } from '@/lib/useApiQuery';
import { usePageMeta } from '@/lib/usePageMeta';
import { useSubmit } from '@/lib/useSubmit';
import { useApiQuery } from '@/hooks/useApiQuery';
import { usePageMeta } from '@/hooks/usePageMeta';
import { useSubmit } from '@/hooks/useSubmit';
import type { Incident, IncidentStatus } from '@/types/dashboard';
/** Jak dlouho svitit potvrzeni po zkopirovani. */
const COPIED_HINT_MS = 1500;
/**
* Detail incidentu.
*
@@ -49,11 +52,15 @@ export default function IncidentDetail() {
refetchOn: ['incident.updated', 'incident.resolved'],
});
usePageMeta({ title: incident.data ? `Incident ${incident.data.id}` : 'Incident', area: 'portal' });
usePageMeta({
title: incident.data ? `Incident ${incident.data.id}` : 'Incident',
area: 'portal',
});
const canManage =
user?.platformAdmin === true ||
(incident.data?.tenantId !== null && (access.data?.permissions ?? []).includes('incident.manage'));
(incident.data?.tenantId !== null &&
(access.data?.permissions ?? []).includes('incident.manage'));
const change = useSubmit(async (status: IncidentStatus) => {
await apiFetch(`/api/dashboard/incidents/${id}/status`, { method: 'PATCH', body: { status } });
@@ -97,7 +104,9 @@ export default function IncidentDetail() {
<dl className="mt-5 grid gap-4 border-t border-ink-600/50 pt-4 text-sm sm:grid-cols-3">
<div>
<dt className="text-xs text-white/35">Začátek</dt>
<dd className="mt-0.5 text-white/70">{formatDateTime(incident.data.startedAt)}</dd>
<dd className="mt-0.5 text-white/70">
{formatDateTime(incident.data.startedAt)}
</dd>
</div>
<div>
<dt className="text-xs text-white/35">Trvání</dt>
@@ -181,7 +190,7 @@ function Diagnostics({ text }: { text: string }) {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
setTimeout(() => setCopied(false), COPIED_HINT_MS);
} catch (err) {
console.warn('[incident] kopirovani selhalo:', err);
}
+11 -6
View File
@@ -3,8 +3,8 @@ import { Link } from 'react-router-dom';
import { DataState } from '@/components/dashboard/DataState';
import { IncidentSeverityBadge, IncidentStatusBadge } from '@/components/dashboard/StatusBadge';
import { formatDateTime, formatRelative } from '@/lib/format';
import { useApiQuery } from '@/lib/useApiQuery';
import { usePageMeta } from '@/lib/usePageMeta';
import { useApiQuery } from '@/hooks/useApiQuery';
import { usePageMeta } from '@/hooks/usePageMeta';
import type { Incident, ListResponse } from '@/types/dashboard';
export default function Incidents() {
@@ -19,8 +19,8 @@ export default function Incidents() {
<header>
<h1 className="text-2xl font-bold text-white">Incidenty</h1>
<p className="mt-1 text-sm text-white/50">
Výpadky a chyby automatizací. Detail říká, co to pro vás znamená, a správce ho posune
do dalšího stavu.
Výpadky a chyby automatizací. Detail říká, co to pro vás znamená, a správce ho posune do
dalšího stavu.
</p>
</header>
@@ -49,7 +49,9 @@ export default function Incidents() {
</Link>
</h2>
<p className="mt-1 text-sm text-white/50">Služba: {incident.service}</p>
{incident.impact && <p className="mt-2 text-sm text-white/70">{incident.impact}</p>}
{incident.impact && (
<p className="mt-2 text-sm text-white/70">{incident.impact}</p>
)}
</div>
<div className="flex flex-col items-end gap-2">
<IncidentStatusBadge status={incident.status} />
@@ -80,7 +82,10 @@ export default function Incidents() {
<dt className="text-xs text-white/35">Ticket</dt>
<dd className="mt-0.5 text-white/70">
{incident.ticketId ? (
<Link to={`/dashboard/tickety/${incident.ticketId}`} className="font-mono hover:underline">
<Link
to={`/dashboard/tickety/${incident.ticketId}`}
className="font-mono hover:underline"
>
{incident.ticketId}
</Link>
) : (
+46 -130
View File
@@ -1,8 +1,6 @@
import {
AlertCircle,
Check,
ChevronLeft,
ChevronRight,
LayoutGrid,
Plus,
RefreshCw,
@@ -11,7 +9,6 @@ import {
X,
} from 'lucide-react';
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';
@@ -25,8 +22,8 @@ import { Button } from '@/components/ui/Button';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/cn';
import { patchTicketList } from '@/lib/ticketEvents';
import { useApiQuery } from '@/lib/useApiQuery';
import { usePageMeta } from '@/lib/usePageMeta';
import { useApiQuery } from '@/hooks/useApiQuery';
import { usePageMeta } from '@/hooks/usePageMeta';
import type {
DashboardSummary,
Incident,
@@ -39,6 +36,7 @@ import type {
WidgetSize,
Workload,
} from '@/types/dashboard';
import { EditBar } from '@/components/dashboard/widgets/EditBar';
/** Sirka widgetu v mrizce o sesti sloupcich. */
const span: Record<WidgetSize, string> = {
@@ -53,11 +51,12 @@ const span: Record<WidgetSize, string> = {
*/
const COMPUTED_BUILTINS = new Set(['panel.agents', 'list.myTickets', 'list.unassigned']);
const sizeLabels: Record<WidgetSize, string> = {
third: 'třetina',
half: 'polovina',
full: 'celá šířka',
};
/**
* Nejkratsi prodleva mezi dvema obnovami dat z konektoru. Server posila
* `nextAt`, ktere uz mohlo uplynout; bez spodni meze by se prehled ptal
* v nekonecne smycce.
*/
const MIN_EXTERNAL_REFRESH_MS = 5_000;
export default function Overview() {
usePageMeta({ title: 'Přehled', area: 'portal' });
@@ -111,7 +110,8 @@ export default function Overview() {
// ------------------------------------------------- data vlastnich widgetu
const layoutItems = layout.data?.items ?? [];
// Stabilni prazdny seznam, jinak by se memo pod nim pocitalo pri kazdem vykresleni.
const layoutItems = useMemo(() => layout.data?.items ?? [], [layout.data]);
/**
* Ktere dlazdice si data pocitaji na serveru. Krome vlastnich jsou to
@@ -127,25 +127,22 @@ export default function Overview() {
return [...new Set(ids)].sort();
}, [layoutItems, catalog.data]);
const widgetData = useApiQuery<WidgetDataResponse>(
`/api/dashboard/widget-data${scopeQuery}`,
{
body: { widgetIds: computedIds },
enabled: computedIds.length > 0,
/*
* Data z nasich dat se prekresli hned po udalosti. Data z konektoru
* ne - ta drzi server v mezipameti podle `ttlSec` u widgetu, aby se
* cizi sluzba nevolala pri kazdem hnuti.
*/
refetchOn: [
'ticket.created',
'ticket.updated',
'ticket.assigned',
'ticket.resolved',
'automation.run',
],
},
);
const widgetData = useApiQuery<WidgetDataResponse>(`/api/dashboard/widget-data${scopeQuery}`, {
body: { widgetIds: computedIds },
enabled: computedIds.length > 0,
/*
* Data z nasich dat se prekresli hned po udalosti. Data z konektoru
* ne - ta drzi server v mezipameti podle `ttlSec` u widgetu, aby se
* cizi sluzba nevolala pri kazdem hnuti.
*/
refetchOn: [
'ticket.created',
'ticket.updated',
'ticket.assigned',
'ticket.resolved',
'automation.run',
],
});
/*
* Tikot pro dlazdice nad konektory.
@@ -154,19 +151,20 @@ export default function Overview() {
* se po te dobe zeptá znovu - dokud je mezipamet cerstva, dostane ji zpatky
* bez volani cizi sluzby, takze to nic nestoji.
*/
const { data: widgetResults, reload: reloadWidgetData } = widgetData;
useEffect(() => {
const times = (widgetData.data?.items ?? [])
const times = (widgetResults?.items ?? [])
.map((item) => (item.ok && item.value?.kind === 'external' ? item.value.nextAt : null))
.filter((value): value is string => value !== null)
.map((value) => new Date(value).getTime() - Date.now());
if (times.length === 0) return;
// Nejblizsi obnova rozhoduje, ostatni dostanou svou pri dalsim kole.
const wait = Math.max(Math.min(...times), 5_000);
const wait = Math.max(Math.min(...times), MIN_EXTERNAL_REFRESH_MS);
const timer = setTimeout(() => widgetData.reload(), wait);
const timer = setTimeout(() => reloadWidgetData(), wait);
return () => clearTimeout(timer);
}, [widgetData.data, widgetData.reload]);
}, [widgetResults, reloadWidgetData]);
const computed = useMemo(
() => new Map((widgetData.data?.items ?? []).map((item) => [item.id, item])),
@@ -201,24 +199,24 @@ export default function Overview() {
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
// Ulozene rozlozeni prevezmeme do konceptu, dokud se needituje.
useEffect(() => {
if (layout.data && !editing) setDraft(layout.data.items);
}, [layout.data, editing]);
// Koncept vznika az pri vstupu do uprav, z toho, co je zrovna ulozene.
function startEditing() {
setDraft(layoutItems);
setEditing(true);
}
const definitions = useMemo(
() => new Map((catalog.data?.items ?? []).map((widget) => [widget.id, widget])),
[catalog.data],
);
const items = editing ? draft : (layout.data?.items ?? []);
const items = editing ? draft : layoutItems;
function move(index: number, offset: number) {
const target = index + offset;
if (target < 0 || target >= draft.length) return;
const next = [...draft];
const [moved] = next.splice(index, 1);
next.splice(target, 0, moved);
next.splice(target, 0, ...next.splice(index, 1));
setDraft(next);
}
@@ -234,7 +232,11 @@ export default function Overview() {
setDraft([
...draft,
// Instance musi mit vlastni ID, aby sel tentyz widget pridat vickrat.
{ id: `w_${Date.now().toString(36)}_${draft.length}`, widgetId: widget.id, size: widget.defaultSize },
{
id: `w_${Date.now().toString(36)}_${draft.length}`,
widgetId: widget.id,
size: widget.defaultSize,
},
]);
}
@@ -349,7 +351,7 @@ export default function Overview() {
<RefreshCw className="size-4" />
Obnovit
</Button>
<Button variant="secondary" size="sm" onClick={() => setEditing(true)}>
<Button variant="secondary" size="sm" onClick={startEditing}>
<LayoutGrid className="size-4" />
Upravit dashboard
</Button>
@@ -450,93 +452,7 @@ export default function Overview() {
</div>
</DataState>
<WidgetPicker
open={picker}
widgets={unused}
onClose={() => setPicker(false)}
onPick={add}
/>
<WidgetPicker open={picker} widgets={unused} onClose={() => setPicker(false)} onPick={add} />
</div>
);
}
/** Ovladani jednoho widgetu v rezimu uprav. */
function EditBar({
definition,
size,
canMoveLeft,
canMoveRight,
onMove,
onResize,
onRemove,
}: {
definition: CatalogWidget;
size: WidgetSize;
canMoveLeft: boolean;
canMoveRight: boolean;
onMove: (offset: number) => void;
onResize: (size: WidgetSize) => void;
onRemove: () => void;
}) {
return (
<div className="mb-2 flex flex-wrap items-center gap-1.5 rounded-xl border border-brand-400/35 bg-brand-500/10 px-2.5 py-2">
<span className="mr-auto min-w-0 truncate text-xs font-medium text-brand-100">
{definition.name}
</span>
{/* Sirku nabizime jen tu, kterou widget unese. */}
<select
value={size}
onChange={(event) => onResize(event.target.value as WidgetSize)}
aria-label={`Šířka widgetu ${definition.name}`}
className="rounded-lg border border-ink-600/70 bg-ink-900/70 px-2 py-1 text-xs text-white focus:border-brand-400/70 focus:outline-none"
>
{definition.sizes.map((option) => (
<option key={option} value={option} className="bg-ink-850">
{sizeLabels[option]}
</option>
))}
</select>
<IconButton label="Posunout dřív" disabled={!canMoveLeft} onClick={() => onMove(-1)}>
<ChevronLeft className="size-3.5" />
</IconButton>
<IconButton label="Posunout později" disabled={!canMoveRight} onClick={() => onMove(1)}>
<ChevronRight className="size-3.5" />
</IconButton>
<IconButton label="Odebrat widget" danger onClick={onRemove}>
<Trash2 className="size-3.5" />
</IconButton>
</div>
);
}
function IconButton({
label,
disabled,
danger,
onClick,
children,
}: {
label: string;
disabled?: boolean;
danger?: boolean;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={label}
className={cn(
'grid size-7 place-items-center rounded-lg text-white/50 transition-colors disabled:pointer-events-none disabled:opacity-25',
danger ? 'hover:bg-danger-500/15 hover:text-danger-400' : 'hover:bg-white/8 hover:text-white',
)}
>
{children}
<span className="sr-only">{label}</span>
</button>
);
}
+129 -135
View File
@@ -9,10 +9,11 @@ import { TicketTypesAdmin } from '@/components/dashboard/TicketTypesAdmin';
import { InvitePanel } from '@/components/dashboard/InvitePanel';
import { ViewSwitch, type ListView } from '@/components/dashboard/ViewSwitch';
import { Badge } from '@/components/ui/Badge';
import { Table, TableHead, TableRow, Td, Th } from '@/components/ui/Table';
import { cn } from '@/lib/cn';
import { useAccess, useCollection } from '@/lib/collections';
import { useApiQuery } from '@/lib/useApiQuery';
import { usePageMeta } from '@/lib/usePageMeta';
import { useApiQuery } from '@/hooks/useApiQuery';
import { usePageMeta } from '@/hooks/usePageMeta';
import type { Workload } from '@/types/dashboard';
/**
@@ -92,137 +93,138 @@ export default function People() {
{tab === 'prehled' && (
<>
{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} />
{groups.items.length > 0 && <Stat label="Skupin" value={groups.items.length} />}
</div>
)}
{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} />
{groups.items.length > 0 && <Stat label="Skupin" value={groups.items.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 záložce Řešitelé."
>
{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>
<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 záložce Řešitelé."
>
{view === 'table' ? (
<div className="glass overflow-x-auto rounded-card">
<Table className="min-w-150">
<TableHead>
<Th>Jméno</Th>
<Th>E-mail</Th>
<Th>Zaměření</Th>
<Th align="right">U sebe</Th>
<Th align="right">Kapacita</Th>
<Th align="right">Kritické</Th>
</TableHead>
<tbody>
{items.map((person) => {
const row = byPerson.get(person.id);
return (
<TableRow key={person.id}>
<Td>
<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 === meId && <Badge tone="brand">já</Badge>}
</Link>
</Td>
<Td className="text-white/55">{person.email}</Td>
<Td className="text-white/55">{person.role}</Td>
<Td align="right" className="font-semibold text-white">
{row?.open ?? 0}
</Td>
<Td align="right" className="text-white/45">
{person.capacity}
</Td>
<Td align="right">
{row && row.critical > 0 ? (
<span className="text-danger-400">{row.critical}</span>
) : (
<span className="text-white/25">-</span>
)}
</Td>
</TableRow>
);
})}
</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 (
<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 === 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>
<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>
);
})}
</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;
</div>
)}
</DataState>
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>
{groups.items.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">
{groups.items.map((group) => (
<Badge key={group.id} tone="neutral">
{group.name}
</Badge>
))}
</div>
</section>
)}
{groups.items.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">
{groups.items.map((group) => (
<Badge key={group.id} tone="neutral">
{group.name}
</Badge>
))}
</div>
</section>
)}
</>
)}
@@ -373,15 +375,7 @@ function PeopleAdmin({ onChanged }: { onChanged: () => void }) {
);
}
function Stat({
label,
value,
tone,
}: {
label: string;
value: number;
tone?: 'warn';
}) {
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>
+2 -2
View File
@@ -9,8 +9,8 @@ import { Badge } from '@/components/ui/Badge';
import { Chip } from '@/components/ui/Chip';
import { cn } from '@/lib/cn';
import { formatDuration, formatNumber, formatRelative } from '@/lib/format';
import { useApiQuery } from '@/lib/useApiQuery';
import { usePageMeta } from '@/lib/usePageMeta';
import { useApiQuery } from '@/hooks/useApiQuery';
import { usePageMeta } from '@/hooks/usePageMeta';
import type { AgentStatsRow, Person, Ticket } from '@/types/dashboard';
/**
+27 -359
View File
@@ -1,34 +1,14 @@
import {
AlertTriangle,
CheckCircle2,
Code2,
FileWarning,
Play,
RefreshCw,
Save,
} from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { AlertTriangle, FileWarning } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useAuth } from '@/auth/AuthContext';
import { DataState } from '@/components/dashboard/DataState';
import { ErrorDetail } from '@/components/dashboard/ErrorDetail';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Field } from '@/components/ui/form/Field';
import { Input } from '@/components/ui/form/Input';
import { Select } from '@/components/ui/form/Select';
import { Textarea } from '@/components/ui/form/Textarea';
import { apiFetch, ApiError } from '@/lib/api';
import { cn } from '@/lib/cn';
import { useApiQuery } from '@/lib/useApiQuery';
import { usePageMeta } from '@/lib/usePageMeta';
import type {
Connector,
ScriptCatalog,
ScriptDetail,
ScriptField,
ScriptRunResult,
} from '@/types/dashboard';
import { useApiQuery } from '@/hooks/useApiQuery';
import { usePageMeta } from '@/hooks/usePageMeta';
import type { Connector, ScriptCatalog, ScriptDetail, ScriptField } from '@/types/dashboard';
import { TestPanel } from '@/components/dashboard/scripts/TestPanel';
import { CodeEditor } from '@/components/dashboard/scripts/CodeEditor';
/**
* Vykonna cast konektoru. Jeden skript = jeden soubor, ktery nese manifest
@@ -44,14 +24,13 @@ export default function Scripts() {
const canEdit = user?.platformAdmin === true;
const catalog = useApiQuery<ScriptCatalog>('/api/dashboard/scripts');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [chosenId, setSelectedId] = useState<string | null>(null);
const items = catalog.data?.items ?? [];
// Stabilni prazdny seznam, jinak by se memo pod nim pocitalo pri kazdem vykresleni.
const items = useMemo(() => catalog.data?.items ?? [], [catalog.data]);
// Prvni skript se vybere sam, jinak by stranka po nacteni vypadala prazdne.
useEffect(() => {
if (selectedId === null && items.length > 0) setSelectedId(items[0].id);
}, [items, selectedId]);
// Bez volby se vybere prvni skript, jinak by stranka po nacteni vypadala prazdne.
const selectedId = chosenId ?? items[0]?.id ?? null;
const connectorsByService = catalog.data?.connectors ?? {};
@@ -70,9 +49,9 @@ export default function Scripts() {
<header>
<h1 className="text-2xl font-bold text-white">Skripty konektorů</h1>
<p className="mt-1 text-sm text-white/50">
Výkonná část konektorů. Každý skript nese vstupní i výstupní parametry, takže
s ním umí pracovat strom automatizace. Upravit ho jde tady nebo přímo v souboru,
server si změny všimne sám.
Výkonná část konektorů. Každý skript nese vstupní i výstupní parametry, takže s ním umí
pracovat strom automatizace. Upravit ho jde tady nebo přímo v souboru, server si změny
všimne sám.
</p>
</header>
@@ -182,7 +161,9 @@ function Problems({ problems }: { problems: ScriptCatalog['problems'] }) {
<div className="rounded-card border border-danger-500/40 bg-danger-500/8 p-4">
<p className="flex items-center gap-2 text-sm font-semibold text-danger-400">
<FileWarning className="size-4 shrink-0" />
{problems.length === 1 ? 'Jeden skript se nenačetl' : `${problems.length} skriptů se nenačetlo`}
{problems.length === 1
? 'Jeden skript se nenačetl'
: `${problems.length} skriptů se nenačetlo`}
</p>
<ul className="mt-2 space-y-2 text-sm text-white/70">
{problems.map((problem) => (
@@ -251,15 +232,15 @@ function ScriptPanel({
{detail.connector === null ? (
<p className="mt-3 text-sm text-warn-400">
Služba {detail.serviceName} nemá v této firmě konektor, skript nepůjde
spustit. Vytvořte ho na stránce Konektory.
Služba {detail.serviceName} nemá v této firmě konektor, skript nepůjde spustit.
Vytvořte ho na stránce Konektory.
</p>
) : (
!detail.connector.ready && (
<p className="mt-3 text-sm text-warn-400">
Konektor {detail.connector.name} nemá vyplněné všechny přístupové údaje,
chybí <code>{detail.connector.missing.join(', ')}</code>. Doplňte je na
stránce Konektory.
Konektor {detail.connector.name} nemá vyplněné všechny přístupové údaje, chybí{' '}
<code>{detail.connector.missing.join(', ')}</code>. Doplňte je na stránce
Konektory.
</p>
)
)}
@@ -322,7 +303,10 @@ function FieldTable({ title, fields }: { title: string; fields: ScriptField[] })
) : (
<ul className="space-y-2.5">
{fields.map((field) => (
<li key={field.id} className="border-b border-ink-600/40 pb-2.5 last:border-0 last:pb-0">
<li
key={field.id}
className="border-b border-ink-600/40 pb-2.5 last:border-0 last:pb-0"
>
<p className="flex flex-wrap items-center gap-2 text-sm">
<code className="text-white">{field.id}</code>
<span className="text-xs text-white/35">{field.type}</span>
@@ -337,319 +321,3 @@ function FieldTable({ title, fields }: { title: string; fields: ScriptField[] })
</div>
);
}
// --------------------------------------------------------------------- test
/**
* Jedno vstupni pole podle deklarovaneho typu. Diky tomu neni testovaci
* formular rucne psany pro kazdy skript, ale vznika z manifestu.
*/
function FieldInput({
field,
value,
onChange,
}: {
field: ScriptField;
value: string;
onChange: (value: string) => void;
}) {
const control = (): ReactNode => {
if (field.options) {
return (
<Select
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Nevyplněno"
options={field.options}
/>
);
}
if (field.type === 'boolean') {
return (
<Select
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Nevyplněno"
options={[
{ value: 'true', label: 'Ano' },
{ value: 'false', label: 'Ne' },
]}
/>
);
}
if (field.multiline) {
return <Textarea rows={3} value={value} onChange={(e) => onChange(e.target.value)} />;
}
return (
<Input
type={field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : 'text'}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
);
};
return (
<Field label={field.label} required={field.required} hint={field.hint}>
{control()}
</Field>
);
}
function TestPanel({
scriptId,
fields,
ready,
}: {
scriptId: string;
fields: ScriptField[];
ready: boolean;
}) {
const [values, setValues] = useState<Record<string, string>>({});
const [result, setResult] = useState<ScriptRunResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [running, setRunning] = useState(false);
async function handleRun() {
setRunning(true);
setError(null);
setResult(null);
try {
// Prazdne hodnoty se neposilaji, aby se uplatnily vychozi hodnoty z manifestu.
const inputs = Object.fromEntries(
Object.entries(values).filter(([, value]) => value !== ''),
);
setResult(await apiFetch<ScriptRunResult>(`/api/dashboard/scripts/${scriptId}/test`, {
method: 'POST',
body: { inputs },
}));
} catch (err: unknown) {
const message =
err instanceof ApiError ? err.message : 'Zkušební spuštění se nepodařilo odeslat.';
setError(message);
} finally {
setRunning(false);
}
}
return (
<div className="glass rounded-card p-5">
<p className="text-xs font-semibold tracking-wide text-white/45 uppercase">
Zkušební spuštění
</p>
<p className="mt-1 mb-4 text-sm text-warn-400">
Volá se opravdová služba. Co skript založí, opravdu vznikne.
</p>
{fields.length > 0 && (
<div className="grid gap-3 sm:grid-cols-2">
{fields.map((field) => (
<FieldInput
key={field.id}
field={field}
value={values[field.id] ?? ''}
onChange={(value) => setValues((prev) => ({ ...prev, [field.id]: value }))}
/>
))}
</div>
)}
<div className="mt-4 flex items-center gap-3">
<Button size="sm" onClick={handleRun} disabled={running || !ready}>
<Play className="size-4" />
{running ? 'Spouštím...' : 'Spustit'}
</Button>
{!ready && <span className="text-sm text-white/40">Nejdřív nastavte napojení.</span>}
</div>
{error && <p className="mt-3 text-sm text-danger-400">{error}</p>}
{result && <RunResult result={result} />}
</div>
);
}
function RunResult({ result }: { result: ScriptRunResult }) {
return (
<div className="mt-4 space-y-3 border-t border-ink-600/50 pt-4">
<p
className={cn(
'flex items-center gap-2 text-sm font-semibold',
result.ok ? 'text-ok-400' : 'text-danger-400',
)}
>
{result.ok ? (
<CheckCircle2 className="size-4 shrink-0" />
) : (
<AlertTriangle className="size-4 shrink-0" />
)}
{result.ok ? 'Proběhlo' : 'Selhalo'}
<span className="font-normal text-white/40">
{result.durationMs} ms, volání služby: {result.httpCalls}
</span>
</p>
{result.error && (
<div className="space-y-2">
<ErrorDetail
message={result.error.message}
detail={result.error.detail}
status={result.error.status}
request={result.error.request}
retryable={result.error.retryable}
kind={result.error.kind}
/>
{result.error.issues && result.error.issues.length > 0 && (
<ul className="ml-4 list-disc text-xs text-white/60">
{result.error.issues.map((issue) => (
<li key={`${issue.field}-${issue.message}`}>{issue.message}</li>
))}
</ul>
)}
</div>
)}
{result.ok && (
<div>
<p className="mb-1.5 text-xs font-semibold tracking-wide text-white/45 uppercase">
Výstupy
</p>
<ul className="space-y-1 text-sm">
{Object.entries(result.outputs).map(([key, value]) => (
<li key={key} className="flex flex-wrap gap-2">
<code className="text-brand-200">{key}</code>
<span className="text-white/70">{value === null ? '-' : String(value)}</span>
</li>
))}
</ul>
</div>
)}
{result.logs.length > 0 && (
<div>
<p className="mb-1.5 text-xs font-semibold tracking-wide text-white/45 uppercase">
Průběh
</p>
<ul className="space-y-1 font-mono text-xs text-white/50">
{result.logs.map((entry, index) => (
<li key={`${entry.at}-${index}`}>
{entry.message}
{entry.detail && <span className="text-white/30"> {entry.detail}</span>}
</li>
))}
</ul>
</div>
)}
</div>
);
}
// -------------------------------------------------------------------- editor
function CodeEditor({
scriptId,
code,
canEdit,
onSaved,
}: {
scriptId: string;
code: string;
canEdit: boolean;
onSaved: () => void;
}) {
const [draft, setDraft] = useState(code);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [issues, setIssues] = useState<Array<{ field: string; message: string }>>([]);
const [saved, setSaved] = useState(false);
// Po nacteni jineho skriptu nebo po ulozeni se koncept srovna se serverem.
useEffect(() => {
setDraft(code);
setIssues([]);
setError(null);
}, [code]);
const dirty = draft !== code;
async function handleSave() {
setSaving(true);
setError(null);
setIssues([]);
setSaved(false);
try {
await apiFetch(`/api/dashboard/scripts/${scriptId}`, { method: 'PUT', body: { code: draft } });
setSaved(true);
onSaved();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Uložení selhalo.');
// Server posila v `issues`, co presne v manifestu nesedi.
if (err instanceof ApiError) setIssues(err.issues());
} finally {
setSaving(false);
}
}
return (
<div className="glass rounded-card p-5">
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
<p className="flex items-center gap-2 text-xs font-semibold tracking-wide text-white/45 uppercase">
<Code2 className="size-3.5" />
Kód skriptu
</p>
{canEdit ? (
<div className="flex items-center gap-3">
{dirty && <span className="text-xs text-warn-400">Neuložené změny</span>}
{saved && !dirty && <span className="text-xs text-ok-400">Uloženo</span>}
<Button
size="sm"
variant="secondary"
onClick={() => setDraft(code)}
disabled={!dirty || saving}
>
<RefreshCw className="size-4" />
Zahodit
</Button>
<Button size="sm" onClick={handleSave} disabled={!dirty || saving}>
<Save className="size-4" />
{saving ? 'Ukládám...' : 'Uložit'}
</Button>
</div>
) : (
<span className="text-xs text-white/35">Upravovat smí jen správce platformy.</span>
)}
</div>
<Textarea
spellCheck={false}
readOnly={!canEdit}
value={draft}
onChange={(event) => setDraft(event.target.value)}
mono
tone="deep"
className="h-96"
/>
{error && (
<div className="mt-3 rounded-lg border border-danger-500/40 bg-danger-500/8 p-3 text-sm">
<p className="text-danger-400">{error}</p>
{issues.length > 0 && (
<ul className="mt-2 ml-4 list-disc text-xs text-white/60">
{issues.map((issue) => (
<li key={`${issue.field}-${issue.message}`}>
{issue.field}: {issue.message}
</li>
))}
</ul>
)}
<p className="mt-2 text-xs text-white/40">
Nic se neuložilo, původní skript funguje dál.
</p>
</div>
)}
</div>
);
}
+9 -13
View File
@@ -6,12 +6,12 @@ import { DataState } from '@/components/dashboard/DataState';
import { Badge } from '@/components/ui/Badge';
import { Chip } from '@/components/ui/Chip';
import { Input } from '@/components/ui/form/Input';
import { ServiceIcon } from '@/components/ui/ServiceIcon';
import { brand } from '@/config/brand';
import { cn } from '@/lib/cn';
import { plural } from '@/lib/format';
import { serviceIcon } from '@/lib/serviceIcons';
import { useApiQuery } from '@/lib/useApiQuery';
import { usePageMeta } from '@/lib/usePageMeta';
import { useApiQuery } from '@/hooks/useApiQuery';
import { usePageMeta } from '@/hooks/usePageMeta';
import type {
ServiceCategory,
ServiceOperation,
@@ -57,7 +57,8 @@ export default function Services() {
const [category, setCategory] = useState<ServiceCategory | 'all'>('all');
const [view, setView] = useState<ServiceView | 'all'>('all');
const items = data?.items ?? [];
// Stabilni prazdny seznam, jinak by se memo pod nim pocitalo pri kazdem vykresleni.
const items = useMemo(() => data?.items ?? [], [data]);
const categories = data?.categories ?? [];
const filtered = useMemo(() => {
@@ -92,8 +93,8 @@ export default function Services() {
<header>
<h1 className="text-2xl font-bold text-white">Služby</h1>
<p className="mt-1 text-sm text-white/50">
Co umíme napojit. Každá služba nabízí spouštěče (čím běh začne) a akce (co se
má stát). Obecné služby fungují rovnou, u ostatních si vytvoříte{' '}
Co umíme napojit. Každá služba nabízí spouštěče (čím běh začne) a akce (co se má stát).
Obecné služby fungují rovnou, u ostatních si vytvoříte{' '}
<Link to="/dashboard/konektory" className="text-brand-300 hover:text-brand-200">
konektor
</Link>{' '}
@@ -142,11 +143,7 @@ export default function Services() {
Všechny kategorie
</Chip>
{categories.map((cat) => (
<Chip
key={cat.id}
active={category === cat.id}
onClick={() => setCategory(cat.id)}
>
<Chip key={cat.id} active={category === cat.id} onClick={() => setCategory(cat.id)}>
{cat.label}
</Chip>
))}
@@ -216,7 +213,6 @@ function SummaryTile({
}
function ServiceCard({ service }: { service: ServiceWithUsage }) {
const Icon = serviceIcon(service.icon);
const meta = viewMeta[viewOf(service)];
return (
@@ -224,7 +220,7 @@ function ServiceCard({ service }: { service: ServiceWithUsage }) {
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-3">
<span className="grid size-11 shrink-0 place-items-center rounded-xl bg-gradient-to-br from-brand-500/20 to-accent-500/20 text-brand-300">
<Icon className="size-5" />
<ServiceIcon icon={service.icon} className="size-5" />
</span>
<div className="min-w-0">
<h2 className="flex flex-wrap items-center gap-2 font-semibold text-white">
+54 -252
View File
@@ -1,7 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { useAuth } from '@/auth/AuthContext';
import { AresTenantDialog } from '@/components/dashboard/AresTenantDialog';
import { DataState } from '@/components/dashboard/DataState';
import { EntityAdmin, parseJsonField, type NamedRecord } from '@/components/dashboard/EntityAdmin';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
@@ -11,10 +10,15 @@ import { Input } from '@/components/ui/form/Input';
import { Select } from '@/components/ui/form/Select';
import { apiFetch } from '@/lib/api';
import { useAccess } from '@/lib/collections';
import { formatDateTime } from '@/lib/format';
import { useApiQuery } from '@/lib/useApiQuery';
import { usePageMeta } from '@/lib/usePageMeta';
import { useSubmit } from '@/lib/useSubmit';
import { useApiQuery } from '@/hooks/useApiQuery';
import { usePageMeta } from '@/hooks/usePageMeta';
import { useSubmit } from '@/hooks/useSubmit';
import { FeaturesAdmin } from '@/components/dashboard/settings/FeaturesAdmin';
import { AuditView } from '@/components/dashboard/settings/AuditView';
import type { SettingsCatalog } from '@/components/dashboard/settings/types';
/** Nejkratsi heslo. Musi sedet s validaci na serveru. */
const MIN_PASSWORD_LENGTH = 8;
/**
* Nastaveni portalu.
@@ -29,21 +33,6 @@ import { useSubmit } from '@/lib/useSubmit';
* ne nastaveni portalu.
*/
interface CatalogItem {
key: string;
label: string;
group?: string;
hint?: string;
platform?: boolean;
}
interface SettingsCatalog {
permissions: CatalogItem[];
modules: Array<{ key: string; label: string; required?: boolean; platform?: boolean }>;
defaultLimits: Record<string, number>;
platformAdmin: boolean;
}
export default function Settings() {
usePageMeta({ title: 'Nastavení', area: 'portal' });
@@ -88,8 +77,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">
Firmy, role a typy ticketů. Řešitele, skupiny a pozvánky najdete
v záložce Lidé, akce a widgety mají vlastní záložku.
Firmy, role a typy ticketů. Řešitele, skupiny a pozvánky najdete v záložce Lidé, akce a
widgety mají vlastní záložku.
</p>
</header>
@@ -117,7 +106,10 @@ export default function Settings() {
Object.fromEntries(
Object.entries(values).map(([key, value]) => [
key,
['ico', 'dic', 'address', 'legalForm', 'helpdeskProviderId'].includes(key) && value === '' ? null : value,
['ico', 'dic', 'address', 'legalForm', 'helpdeskProviderId'].includes(key) &&
value === ''
? null
: value,
]),
)
}
@@ -127,7 +119,11 @@ export default function Settings() {
label: 'IČ',
narrow: true,
render: (tenant: NamedRecord) =>
tenant.ico ? <code>{String(tenant.ico)}</code> : <span className="text-white/30">bez IČ</span>,
tenant.ico ? (
<code>{String(tenant.ico)}</code>
) : (
<span className="text-white/30">bez IČ</span>
),
},
{ label: 'ID', render: (tenant: NamedRecord) => <code>{tenant.id}</code> },
{ label: 'Poznámka', render: (tenant: NamedRecord) => String(tenant.note ?? '') },
@@ -144,16 +140,30 @@ export default function Settings() {
label: 'Stav',
narrow: true,
render: (tenant: NamedRecord) =>
tenant.enabled === false ? <Badge tone="warn">vypnutá</Badge> : <Badge tone="ok">aktivní</Badge>,
tenant.enabled === false ? (
<Badge tone="warn">vypnutá</Badge>
) : (
<Badge tone="ok">aktivní</Badge>
),
},
]}
fields={[
{ name: 'name', label: 'Název firmy', kind: 'text', required: true },
{ name: 'ico', label: 'IČ', kind: 'text', hint: '8 číslic. Vyplní se samo při založení přes ARES.' },
{
name: 'ico',
label: 'IČ',
kind: 'text',
hint: '8 číslic. Vyplní se samo při založení přes ARES.',
},
{ name: 'dic', label: 'DIČ', kind: 'text' },
{ name: 'address', label: 'Sídlo', kind: 'text' },
{ name: 'legalForm', label: 'Právní forma', kind: 'text' },
{ name: 'note', label: 'Poznámka', kind: 'textarea', hint: 'Pro nás, klient ji nevidí.' },
{
name: 'note',
label: 'Poznámka',
kind: 'textarea',
hint: 'Pro nás, klient ji nevidí.',
},
{ name: 'enabled', label: 'Aktivní', kind: 'checkbox', initial: true },
{
/*
@@ -178,8 +188,8 @@ export default function Settings() {
>
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-white/10 p-3 text-sm text-white/70">
<span>
Novou firmu jde založit z registru ARES: dotáhne se název, IČ, DIČ, sídlo a lidé, kteří za
ni jednají.
Novou firmu jde založit z registru ARES: dotáhne se název, IČ, DIČ, sídlo a lidé,
kteří za ni jednají.
</span>
<Button size="sm" variant="secondary" onClick={() => setAresOpen(true)}>
Založit přes ARES
@@ -246,7 +256,10 @@ function MyAccount() {
</summary>
<div className="mt-2 flex flex-wrap gap-1.5">
{access.data.permissions.map((permission) => (
<code key={permission} className="rounded bg-ink-950/60 px-1.5 py-0.5 text-xs text-white/60">
<code
key={permission}
className="rounded bg-ink-950/60 px-1.5 py-0.5 text-xs text-white/60"
>
{permission}
</code>
))}
@@ -266,7 +279,11 @@ function MyAccount() {
onChange={(event) => setPassword(event.target.value)}
/>
</Field>
<Button size="sm" onClick={() => void submit()} disabled={busy || password.length < 8}>
<Button
size="sm"
onClick={() => void submit()}
disabled={busy || password.length < MIN_PASSWORD_LENGTH}
>
Změnit
</Button>
</div>
@@ -386,10 +403,13 @@ function UsersAdmin() {
{
label: 'Členství',
render: (user: NamedRecord) => {
const memberships = (user.memberships as Array<{ tenantId: string; roleIds: string[] }>) ?? [];
const memberships =
(user.memberships as Array<{ tenantId: string; roleIds: string[] }>) ?? [];
return memberships
.map((membership) => {
const name = tenants.find((tenant) => tenant.id === membership.tenantId)?.name ?? membership.tenantId;
const name =
tenants.find((tenant) => tenant.id === membership.tenantId)?.name ??
membership.tenantId;
return `${name}: ${membership.roleIds.join(', ')}`;
})
.join(' | ');
@@ -447,222 +467,4 @@ function UsersAdmin() {
/>
);
}
// ------------------------------------------------------------ zalozky firmy
interface TenantFeatures {
modules: string[];
limits: Record<string, number>;
serviceIds: string[];
}
function FeaturesAdmin({ catalog }: { catalog: SettingsCatalog | null }) {
const saved = useApiQuery<{ features: TenantFeatures }>('/api/dashboard/settings/features');
/** Rozepsana podoba. Ze serveru se prebira pri kazdem nacteni. */
const [features, setFeatures] = useState<TenantFeatures | null>(null);
const [message, setMessage] = useState<string | null>(null);
useEffect(() => {
if (saved.data) setFeatures(saved.data.features);
}, [saved.data]);
const { busy: saving, error, submit: save } = useSubmit(async () => {
if (!features) return;
setMessage(null);
await apiFetch('/api/dashboard/settings/features', { method: 'PUT', body: features });
setMessage('Uloženo. Záložky se projeví po obnovení stránky.');
}, 'Uložení selhalo.');
return (
<DataState
loading={saved.loading || features === null || catalog === null}
error={saved.error}
onRetry={saved.reload}
>
{features && catalog && (
<FeaturesForm
features={features}
catalog={catalog}
saving={saving}
message={message}
error={error}
onChange={setFeatures}
onSave={() => void save()}
/>
)}
</DataState>
);
}
function FeaturesForm({
features,
catalog,
saving,
message,
error,
onChange,
onSave,
}: {
features: TenantFeatures;
catalog: SettingsCatalog;
saving: boolean;
message: string | null;
error: string | null;
onChange: (next: TenantFeatures) => void;
onSave: () => void;
}) {
const setFeatures = onChange;
const save = onSave;
return (
<div className="space-y-4">
<div className="glass rounded-card p-5">
<h2 className="font-semibold text-white">Záložky firmy</h2>
<p className="mt-0.5 text-sm text-white/45">
Co má firma zapnuté. Vypnutý modul neexistuje ani pro jejího správce, protože ho
nemá. Kdo z jejich lidí to smí, řeší role.
</p>
<div className="mt-4 space-y-1.5 border-t border-ink-600/50 pt-4">
{catalog.modules
.filter((module) => !module.platform)
.map((module) => (
<label key={module.key} className="flex items-center gap-2 text-sm text-white/75">
<input
type="checkbox"
disabled={module.required}
checked={features.modules.includes(module.key)}
onChange={(event) =>
setFeatures({
...features,
modules: event.target.checked
? [...features.modules, module.key]
: features.modules.filter((key) => key !== module.key),
})
}
className="size-4"
/>
{module.label}
{module.required && <span className="text-xs text-white/35">povinné</span>}
</label>
))}
</div>
</div>
<div className="glass rounded-card p-5">
<h3 className="text-sm font-semibold text-white">Limity</h3>
<div className="mt-3 grid gap-3 sm:grid-cols-4">
{Object.entries(features.limits).map(([key, value]) => (
<Field key={key} label={<span className="text-xs text-white/60">{key}</span>}>
<Input
type="number"
value={value}
onChange={(event) =>
setFeatures({
...features,
limits: { ...features.limits, [key]: Number(event.target.value) },
})
}
/>
</Field>
))}
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button size="sm" onClick={save} disabled={saving}>
{saving ? 'Ukládám...' : 'Uložit'}
</Button>
{message && <span className="text-sm text-ok-400">{message}</span>}
{error && <span className="text-sm text-danger-400">{error}</span>}
</div>
</div>
);
}
// ---------------------------------------------------------------------- audit
interface AuditEntry {
id: string;
createdAt: string;
userEmail: string;
actedBy: string | null;
action: string;
target: string | null;
result: 'ok' | 'denied';
}
function AuditView() {
const [onlyDenied, setOnlyDenied] = useState(false);
const query = useApiQuery<{ items: AuditEntry[] }>(
`/api/admin/audit?limit=200${onlyDenied ? '&result=denied' : ''}`,
);
return (
<div className="space-y-4">
<div>
<h2 className="font-semibold text-white">Audit</h2>
<p className="mt-0.5 text-sm text-white/45">
Kdo co udělal. Zapisují se i odepření, jinak by pokusy o cizí firmu nikde
nezůstaly.
</p>
</div>
<label className="flex items-center gap-2 text-sm text-white/70">
<input
type="checkbox"
checked={onlyDenied}
onChange={(event) => setOnlyDenied(event.target.checked)}
className="size-4"
/>
Jen odepření
</label>
<div className="glass overflow-x-auto rounded-card">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-ink-600/60 text-left text-xs tracking-wide text-white/40 uppercase">
<th className="px-4 py-2.5 font-semibold">Kdy</th>
<th className="px-4 py-2.5 font-semibold">Kdo</th>
<th className="px-4 py-2.5 font-semibold">Co</th>
<th className="px-4 py-2.5 font-semibold">Nad čím</th>
<th className="px-4 py-2.5 font-semibold">Výsledek</th>
</tr>
</thead>
<tbody>
{(query.data?.items ?? []).map((entry) => (
<tr key={entry.id} className="border-b border-ink-600/30 last:border-0">
<td className="px-4 py-2 whitespace-nowrap text-white/50">
{formatDateTime(entry.createdAt)}
</td>
<td className="px-4 py-2 text-white/75">
{entry.userEmail}
{entry.actedBy && (
<span className="block text-xs text-warn-400">za něj: {entry.actedBy}</span>
)}
</td>
<td className="px-4 py-2">
<code className="text-white/70">{entry.action}</code>
</td>
<td className="px-4 py-2 text-white/50">{entry.target ?? ''}</td>
<td className="px-4 py-2">
{entry.result === 'denied' ? (
<Badge tone="danger">odepřeno</Badge>
) : (
<Badge tone="ok">ok</Badge>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{(query.data?.items.length ?? 0) === 0 && (
<p className="py-6 text-center text-sm text-white/40">Žádné záznamy.</p>
)}
</div>
);
}
// ---------------------------------------------------- definice poli formularu
+22 -13
View File
@@ -30,11 +30,14 @@ import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/cn';
import { useCollection } from '@/lib/collections';
import { formatDateTime } from '@/lib/format';
import { useApiQuery } from '@/lib/useApiQuery';
import { usePageMeta } from '@/lib/usePageMeta';
import { useSubmit } from '@/lib/useSubmit';
import { useApiQuery } from '@/hooks/useApiQuery';
import { usePageMeta } from '@/hooks/usePageMeta';
import { useSubmit } from '@/hooks/useSubmit';
import type { TicketDetail as Detail } from '@/types/dashboard';
/** Nejkratsi komentar, ktery se odesle. Jedno pismeno je preklep, ne zprava. */
const MIN_COMMENT_LENGTH = 2;
/**
* Doporucene stavy, kdyz si typ ticketu zadne nenadefinoval.
*
@@ -152,7 +155,7 @@ export default function TicketDetail() {
async function submitComment(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const text = comment.trim();
if (text.length < 2) return;
if (text.length < MIN_COMMENT_LENGTH) return;
// Pri chybe text nechame v poli, aby ho uzivatel nemusel psat znovu.
if (await mutate('/comment', { text })) setComment('');
}
@@ -281,7 +284,7 @@ export default function TicketDetail() {
<Button
type="submit"
size="sm"
disabled={busy || comment.trim().length < 2}
disabled={busy || comment.trim().length < MIN_COMMENT_LENGTH}
>
<MessageSquarePlus className="size-4" />
Zapsat
@@ -316,9 +319,7 @@ export default function TicketDetail() {
Příchozí události
</h2>
<span className="text-xs text-white/35">
{ticket.data.externalId
? `k externímu ID ${ticket.data.externalId}`
: ''}
{ticket.data.externalId ? `k externímu ID ${ticket.data.externalId}` : ''}
</span>
</div>
<TicketEvents events={ticket.data.events} />
@@ -411,7 +412,11 @@ export default function TicketDetail() {
*/}
{groups.items.length > 0 && (
<Field
label={<span className="text-xs font-semibold tracking-wide uppercase">Skupina</span>}
label={
<span className="text-xs font-semibold tracking-wide uppercase">
Skupina
</span>
}
htmlFor="ticket-group"
className="mt-4"
>
@@ -441,10 +446,14 @@ export default function TicketDetail() {
zapisoval do nastaveni. Nabidka je pohodli, ne omezeni.
*/}
<Field
label={<span className="text-xs font-semibold tracking-wide uppercase">Stav</span>}
label={
<span className="text-xs font-semibold tracking-wide uppercase">Stav</span>
}
htmlFor="ticket-status"
className="mt-4"
hint={statusesError ?? 'Stav je volný text. Nabídka je jen z toho, co už používáte.'}
hint={
statusesError ?? 'Stav je volný text. Nabídka je jen z toho, co už používáte.'
}
>
<Input
id="ticket-status"
@@ -499,8 +508,8 @@ export default function TicketDetail() {
{ticket.data.customer.id === null && (
<p className="mt-3 rounded-lg border border-warn-400/30 bg-warn-500/8 px-3 py-2.5 text-xs leading-relaxed text-white/60">
Firmu se nepodařilo dohledat v CRM. Ticket proto zůstal bez řešitele,
není podle čeho určit garanta.
Firmu se nepodařilo dohledat v CRM. Ticket proto zůstal bez řešitele, není
podle čeho určit garanta.
</p>
)}
</div>
+40 -29
View File
@@ -17,8 +17,8 @@ import { cn } from '@/lib/cn';
import { useAccess, useCollection } from '@/lib/collections';
import { formatRelative } from '@/lib/format';
import { patchTicketList } from '@/lib/ticketEvents';
import { useApiQuery } from '@/lib/useApiQuery';
import { usePageMeta } from '@/lib/usePageMeta';
import { useApiQuery } from '@/hooks/useApiQuery';
import { usePageMeta } from '@/hooks/usePageMeta';
import type {
TicketChannel,
TicketListResponse,
@@ -28,7 +28,12 @@ import type {
} from '@/types/dashboard';
/** Vsechny zmeny ticketu, po kterych ma smysl nacist data znovu. */
const ticketEvents = ['ticket.created', 'ticket.updated', 'ticket.assigned', 'ticket.resolved'] as const;
const ticketEvents = [
'ticket.created',
'ticket.updated',
'ticket.assigned',
'ticket.resolved',
] as const;
const channelFilters: Array<{ value: TicketChannel; label: string }> = [
{ value: 'whatsapp', label: 'WhatsApp' },
@@ -43,19 +48,17 @@ const channelFilters: Array<{ value: TicketChannel; label: string }> = [
type Tab = 'moje' | 'fronta' | 'vsechny';
/** Jmena parametru v adrese. Vsechno, co jde filtrovat, je v ni. */
const filterKeys = [
'tab',
'scope',
'tenantId',
'assignee',
'status',
'channel',
'typeId',
'tag',
'stage',
'groupId',
] as const;
type FilterKey = (typeof filterKeys)[number];
type FilterKey =
| 'tab'
| 'scope'
| 'tenantId'
| 'assignee'
| 'status'
| 'channel'
| 'typeId'
| 'tag'
| 'stage'
| 'groupId';
export default function Tickets() {
usePageMeta({ title: 'Tickety', area: 'portal' });
@@ -192,12 +195,14 @@ export default function Tickets() {
const allowedScopes = access.data?.scopes ?? [];
const tenants = access.data?.tenants ?? [];
const ticketTabs = ([
{ key: 'moje', label: 'Moje tickety', show: allowedScopes.includes('mine') },
{ key: 'fronta', label: 'Nezařazené', show: true },
// Kdo vidi jen svoje, nema co prepinat: byl by to tentyz seznam.
{ key: 'vsechny', label: 'Všechny tickety', show: access.data?.seesOthers === true },
] as const).filter((item) => item.show);
const ticketTabs = (
[
{ key: 'moje', label: 'Moje tickety', show: allowedScopes.includes('mine') },
{ key: 'fronta', label: 'Nezařazené', show: true },
// Kdo vidi jen svoje, nema co prepinat: byl by to tentyz seznam.
{ key: 'vsechny', label: 'Všechny tickety', show: access.data?.seesOthers === true },
] as const
).filter((item) => item.show);
/*
* Kdo nesmi videt svoje tickety (chybi rozsah `mine`), nema zalozku Moje. Vychozi stav ji ale
@@ -217,8 +222,8 @@ export default function Tickets() {
<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.
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>
<div className="flex items-center gap-3">
@@ -332,7 +337,11 @@ export default function Tickets() {
jinak clovek prijde z widgetu a uz se z uzkeho vyberu nedostane.
*/}
{typeId && (
<Chip active onClick={() => setFilters({ typeId: null })} title="Zrušit filtr na typ">
<Chip
active
onClick={() => setFilters({ typeId: null })}
title="Zrušit filtr na typ"
>
typ: {typeId === 'none' ? 'bez typu' : typeId} x
</Chip>
)}
@@ -342,7 +351,11 @@ export default function Tickets() {
</Chip>
)}
{stage && (
<Chip active onClick={() => setFilters({ stage: null })} title="Zrušit filtr na fázi">
<Chip
active
onClick={() => setFilters({ stage: null })}
title="Zrušit filtr na fázi"
>
fáze: {stage === 'none' ? 'bez fáze' : stage} x
</Chip>
)}
@@ -396,9 +409,7 @@ export default function Tickets() {
empty={items.length === 0}
onRetry={tickets.reload}
emptyLabel={
filtered || query.trim().length > 0
? 'Nic neodpovídá filtru.'
: 'Žádné tickety.'
filtered || query.trim().length > 0 ? 'Nic neodpovídá filtru.' : 'Žádné tickety.'
}
>
{view === 'grid' ? (
+17 -6
View File
@@ -2,7 +2,10 @@ import { Info } from 'lucide-react';
import { EntityAdmin, parseJsonField, type AdminField } from '@/components/dashboard/EntityAdmin';
import { Badge } from '@/components/ui/Badge';
import { useCollection } from '@/lib/collections';
import { usePageMeta } from '@/lib/usePageMeta';
import { usePageMeta } from '@/hooks/usePageMeta';
/** Kolik sluzeb vyjmenovat u napovedy; dal uz je to jen "a dalsi". */
const SERVICE_PREVIEW = 8;
/**
* Definice vlastnich widgetu.
@@ -106,13 +109,17 @@ export default function Widgets() {
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.
<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ší' : ''}.
Napojitelné služby:{' '}
{services
.slice(0, SERVICE_PREVIEW)
.map((service) => service.id)
.join(', ')}
{services.length > SERVICE_PREVIEW ? ' a další' : ''}.
</>
)}
</span>
@@ -133,7 +140,11 @@ export default function Widgets() {
label: 'Kdo vidí',
narrow: true,
render: (widget) =>
widget.ownerId ? <Badge tone="neutral">jen já</Badge> : <Badge tone="ok">firma</Badge>,
widget.ownerId ? (
<Badge tone="neutral">jen já</Badge>
) : (
<Badge tone="ok">firma</Badge>
),
},
]}
fields={fields}
@@ -3,11 +3,11 @@ import { useState } from 'react';
import { ErrorDetail } from '@/components/dashboard/ErrorDetail';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { ServiceIcon } from '@/components/ui/ServiceIcon';
import { ConnectorLogs } from '@/pages/dashboard/connectors/ConnectorLogs';
import { ConnectorTools } from '@/pages/dashboard/connectors/ConnectorTools';
import { apiFetch } from '@/lib/api';
import { serviceIcon } from '@/lib/serviceIcons';
import { useSubmit } from '@/lib/useSubmit';
import { useSubmit } from '@/hooks/useSubmit';
import type {
Connector,
ConnectorTestResult,
@@ -35,7 +35,6 @@ export function ConnectorCard({
// Prazdny retezec znamena, ze sluzba pevnou adresu nema - u SMTP ji nese
// az konektor ve svych udajich. Radek "miri na" by pak byl prazdny.
const targetUrl = connector.baseUrl ?? (service?.baseUrl || null);
const Icon = serviceIcon(service?.icon ?? 'Plug');
const [test, setTest] = useState<ConnectorTestResult | null>(null);
const [logsOpen, setLogsOpen] = useState(false);
const [toolsOpen, setToolsOpen] = useState(false);
@@ -87,7 +86,7 @@ export function ConnectorCard({
<article className="glass flex h-full flex-col rounded-card p-4">
<div className="flex items-start gap-3">
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-gradient-to-br from-brand-500/20 to-accent-500/20 text-brand-300">
<Icon className="size-4.5" />
<ServiceIcon icon={service?.icon ?? 'Plug'} className="size-4.5" />
</span>
<div className="min-w-0 flex-1">
<h2 className="flex flex-wrap items-center gap-1.5 truncate font-semibold text-white">
@@ -117,8 +116,8 @@ export function ConnectorCard({
{isMcp && !connector.mcp && connector.ready && (
<p className="mt-2 text-xs text-white/45">
Nástroje ještě nejsou načtené. Dokud se nenačtou, nejde ze serveru postavit žádný
krok automatizace.
Nástroje ještě nejsou načtené. Dokud se nenačtou, nejde ze serveru postavit žádný krok
automatizace.
</p>
)}
@@ -171,7 +170,12 @@ export function ConnectorCard({
</Button>
{isMcp ? (
<>
<Button size="sm" variant="ghost" onClick={() => void loadTools.submit()} disabled={busy}>
<Button
size="sm"
variant="ghost"
onClick={() => void loadTools.submit()}
disabled={busy}
>
<Wifi className="size-3.5" />
{loadTools.busy ? 'Načítám...' : 'Načíst nástroje'}
</Button>
@@ -209,11 +213,7 @@ export function ConnectorCard({
onClose={() => setLogsOpen(false)}
/>
<ConnectorTools
connector={connector}
open={toolsOpen}
onClose={() => setToolsOpen(false)}
/>
<ConnectorTools connector={connector} open={toolsOpen} onClose={() => setToolsOpen(false)} />
</article>
);
}
@@ -1,5 +1,5 @@
import { Eye, EyeOff, Star } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Field } from '@/components/ui/form/Field';
@@ -7,34 +7,69 @@ import { Input } from '@/components/ui/form/Input';
import { Select } from '@/components/ui/form/Select';
import { Modal } from '@/components/ui/Modal';
import { apiFetch } from '@/lib/api';
import { useSubmit } from '@/lib/useSubmit';
import { useSubmit } from '@/hooks/useSubmit';
import type { Connector, Service, ServiceCredentialField } from '@/types/dashboard';
export type EditorState =
| { mode: 'create'; serviceId: string }
| { mode: 'edit'; connector: Connector }
| null;
{ mode: 'create'; serviceId: string } | { mode: 'edit'; connector: Connector } | null;
interface EditorProps {
services: Service[];
servicesById: Map<string, Service>;
onClose: () => void;
onSaved: () => void;
}
/** Dialog na zalozeni a upravu konektoru. Pristupove udaje se odsud jen posilaji. */
export function ConnectorEditor({
export function ConnectorEditor({ editor, ...props }: EditorProps & { editor: EditorState }) {
const creating = editor?.mode === 'create';
return (
<Modal
open={editor !== null}
onClose={props.onClose}
title={creating ? 'Nový konektor' : 'Upravit konektor'}
description={
creating
? 'Vyberte službu a vyplňte přístupové údaje. Zůstanou na serveru.'
: 'Prázdné pole u tajného údaje znamená, že se nezmění.'
}
>
{/*
Formular zije jen v otevrenem dialogu: zavreni ho odmontuje a dalsi
otevreni zacina z `editor`, zadne dorovnavani stavu effectem. Klic
pro pripad, ze by se `editor` vymenil bez zavreni.
*/}
{editor && <ConnectorForm key={editorKey(editor)} editor={editor} {...props} />}
</Modal>
);
}
function editorKey(editor: NonNullable<EditorState>): string {
return editor.mode === 'create' ? `create:${editor.serviceId}` : `edit:${editor.connector.id}`;
}
function ConnectorForm({
editor,
services,
servicesById,
onClose,
onSaved,
}: {
editor: EditorState;
services: Service[];
servicesById: Map<string, Service>;
onClose: () => void;
onSaved: () => void;
}) {
const creating = editor?.mode === 'create';
const existing = editor?.mode === 'edit' ? editor.connector : null;
}: EditorProps & { editor: NonNullable<EditorState> }) {
const creating = editor.mode === 'create';
const existing = editor.mode === 'edit' ? editor.connector : null;
const [serviceId, setServiceId] = useState('');
const [name, setName] = useState('');
const [baseUrl, setBaseUrl] = useState('');
// Vychozi hodnoty podle toho, co se upravuje. Prepnuti sluzby pri zakladani
// uz vyplnena pole nemaze, to resi handler u vyberu.
const [serviceId, setServiceId] = useState(
editor.mode === 'create' ? editor.serviceId : editor.connector.serviceId,
);
const [name, setName] = useState(() =>
editor.mode === 'create'
? (servicesById.get(editor.serviceId)?.name ?? '')
: editor.connector.name,
);
const [baseUrl, setBaseUrl] = useState(existing?.baseUrl ?? '');
const [values, setValues] = useState<Record<string, string>>({});
const service = servicesById.get(serviceId) ?? services.find((item) => item.id === serviceId);
@@ -80,40 +115,9 @@ export function ConnectorEditor({
const busy = saving.busy || patching.busy;
const error = saving.error ?? patching.error;
const issues = saving.issues;
const { setError: resetSaveError } = saving;
const { setError: resetPatchError } = patching;
// Pri otevreni dialogu se formular naplni podle toho, co se upravuje.
// Zavislost je na `editor`, ne na `service` - jinak by prepnuti sluzby
// v zakladani smazalo uz vyplnena pole.
useEffect(() => {
if (!editor) return;
if (editor.mode === 'create') {
setServiceId(editor.serviceId);
setName(servicesById.get(editor.serviceId)?.name ?? '');
setBaseUrl('');
} else {
setServiceId(editor.connector.serviceId);
setName(editor.connector.name);
setBaseUrl(editor.connector.baseUrl ?? '');
}
setValues({});
resetSaveError(null);
resetPatchError(null);
}, [editor, servicesById, resetSaveError, resetPatchError]);
return (
<Modal
open={editor !== null}
onClose={onClose}
title={creating ? 'Nový konektor' : 'Upravit konektor'}
description={
creating
? 'Vyberte službu a vyplňte přístupové údaje. Zůstanou na serveru.'
: 'Prázdné pole u tajného údaje znamená, že se nezmění.'
}
>
<>
{/*
Vlastni <form> s vypnutym doplnovanim. Bez toho prohlizec do poli
typu password dosazuje ulozene prihlaseni, uzivatel vidi jednu hodnotu
@@ -175,8 +179,8 @@ export function ConnectorEditor({
</div>
) : (
<p className="border-t border-ink-600/50 pt-4 text-sm text-white/45">
U služby {service?.name ?? serviceId} ještě nejsou popsané přístupové údaje.
Konektor půjde založit, ale volat se bude umět až spolu se službou.
U služby {service?.name ?? serviceId} ještě nejsou popsané přístupové údaje. Konektor
půjde založit, ale volat se bude umět až spolu se službou.
</p>
)}
@@ -248,7 +252,7 @@ export function ConnectorEditor({
)}
</div>
</form>
</Modal>
</>
);
}
@@ -5,7 +5,7 @@ import { ErrorDetail } from '@/components/dashboard/ErrorDetail';
import { Badge } from '@/components/ui/Badge';
import { Modal } from '@/components/ui/Modal';
import { formatDateTime } from '@/lib/format';
import { useApiQuery } from '@/lib/useApiQuery';
import { useApiQuery } from '@/hooks/useApiQuery';
import type { Connector, ConnectorCheck } from '@/types/dashboard';
/**
@@ -54,12 +54,12 @@ export function ConnectorTools({
<p className="font-semibold text-white">
{tool.label}
{tool.label !== tool.name && (
<span className="ml-2 font-mono text-xs font-normal text-white/35">{tool.name}</span>
<span className="ml-2 font-mono text-xs font-normal text-white/35">
{tool.name}
</span>
)}
</p>
{tool.description && (
<p className="mt-1 text-xs text-white/50">{tool.description}</p>
)}
{tool.description && <p className="mt-1 text-xs text-white/50">{tool.description}</p>}
<dl className="mt-2 grid gap-2 text-xs sm:grid-cols-2">
<div>
<dt className="text-white/35">Přijímá</dt>
+1
View File
@@ -16,6 +16,7 @@
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],