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:
@@ -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('');
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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)';
|
||||
@@ -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' },
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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) && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
|
||||
Reference in New Issue
Block a user