Revize projektu: prava, vykon, runtime, portal a ARES

Prava a bezpecnost: spravce firmy uz nemuze nastavit priznak spravce
platformy ani clenstvi v cizi firme; pozvanky, konektory a automatizace
kontroluji sve pravo; cizi firma v query je 404; zivy stream posila
udalosti jen firmam, kterych se tykaji; akce nad ticketem maji kontrolu
prava za firmu ticketu a strop viditelnosti; tokeny se nelogujou; limit
pokusu na prihlaseni, kontakt a pozvanky; bezpecnostni hlavicky;
zachyceni chyb v async handlerech; timing-safe porovnani tokenu.

Vykon: audit neskenuje celou kolekci pri kazdem zapisu a konecne maze
firemni zaznamy; ticket se uklada jednou misto trikrat; zapisy do
Postgresu jsou serializovane podle ID; prava se pocitaji jednou na
request; widgety nacitaji tickety jednou; strankovani seznamu; worker
je pool misto kol; na webu udalost ze streamu neodmontuje stranku,
dotazy maji spolecny debounce a cache, ciselniky drzi typovany sklad.

Runtime: opakuji se jen chyby oznacene retryable; smycka nenarazi na
strop 50 kroku (novy strop 1000 akci); podminka nad datem funguje;
vystup MCP nastroje neprepisuje spoustec; sandbox skriptu firmy nejde
opustit; MCP session id se drzi mezi volanimi; incident z kroku patri
firme; jedno rozhodnuti o rezimu uloziste; snapshot neprepise soubor
po chybe cteni.

Refaktory: sdilene typy API v src/shared (web nic nekopiruje, osm
rozjetych tvaru sjednoceno); spolecny modul net/guard pro volani ven;
formularova vrstva ui/form; rozdeleni Connectors a FlowCanvas; jeden
helper pro firmu z query, validaci a CRUD udalosti; pomucky ctx.util
pro skripty konektoru; i18n verejneho webu vcetne anglictiny.

Nova funkce: zalozeni firmy z registru ARES v Nastaveni (IC nebo nazev,
dotazeni IC, DIC, sidla a pravni formy, vyber soucasnych statutarnich
zastupcu a prokury, ucty spravce firmy s nahradnim e-mailem
IC-poradi@placeholder.cz).

Dokumentace: zaznam v 99-zmeny.md a aktualizace 15 dalsich dokumentu.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-09-09 10:26:07 +02:00
co-authored by Claude Fable 5.1
parent 0c405ea55a
commit 104ae36783
215 changed files with 13226 additions and 8320 deletions
+10 -1
View File
@@ -1,7 +1,6 @@
import { Suspense, lazy } from 'react';
import { Route, Routes } from 'react-router-dom';
import { RequireAuth } from '@/auth/RequireAuth';
import { DashboardLayout } from '@/components/dashboard/DashboardLayout';
import { PublicLayout } from '@/components/layout/PublicLayout';
import { Spinner } from '@/components/ui/Spinner';
@@ -14,6 +13,16 @@ import Login from '@/pages/Login';
import NotFound from '@/pages/NotFound';
import Services from '@/pages/Services';
/*
* I ram portalu je liny. Byl importovany staticky, takze stream, toasty
* a cely sidebar lezely v balicku verejneho webu, i kdyz se navstevnik
* nikdy neprihlasil.
*/
const DashboardLayout = lazy(() =>
import('@/components/dashboard/DashboardLayout').then((module) => ({
default: module.DashboardLayout,
})),
);
const Overview = lazy(() => import('@/pages/dashboard/Overview'));
const Automations = lazy(() => import('@/pages/dashboard/Automations'));
const AutomationDetail = lazy(() => import('@/pages/dashboard/AutomationDetail'));
+50 -23
View File
@@ -1,25 +1,12 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { apiFetch, getToken, setToken } from '@/lib/api';
import { ApiError, AUTH_EXPIRED_EVENT, apiFetch, getToken, setToken } from '@/lib/api';
/**
* Clenstvi ve firme. `roleIds` je seznam, protoze role uz nejsou dva pevne
* stavy - viz src/data/permissions.ts na serveru.
*/
export interface Membership {
tenantId: string;
roleIds: string[];
}
import type { Membership, PublicUser } from '@shared/users';
export interface AuthUser {
id: string;
email: string;
name: string;
/** Vidi napric vsemi firmami a muze mezi nimi prepinat. */
platformAdmin: boolean;
/** Uzivatel muze patrit do vic firem, v kazde s jinou roli. */
memberships: Membership[];
}
/** Tvar uctu je sdileny se serverem, viz src/shared/users.ts. */
export type { Membership };
export type AuthUser = PublicUser;
/** Kdyz je prihlaseni prepnute na jiny ucet, tady je na koho. */
export interface ImpersonationState {
@@ -32,6 +19,11 @@ interface AuthState {
user: AuthUser | null;
/** true dokud probiha prvotni overeni ulozeneho tokenu */
loading: boolean;
/**
* true = prihlaseni vyprselo za behu. Stranka prihlaseni podle toho rekne
* proc je clovek zpatky na ni, misto aby to vypadalo jako chyba.
*/
expired: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
/** Vyplnene jen pri prepnuti na jiny ucet. */
@@ -54,6 +46,23 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null);
const [loading, setLoading] = useState(true);
const [impersonation, setImpersonation] = useState<ImpersonationState | null>(null);
const [expired, setExpired] = useState(false);
/*
* Vyprsely token hlasi `apiFetch` a stream udalosti okna. Tady se na ni
* zareaguje odhlasenim - RequireAuth pak posle na prihlaseni sam.
*/
useEffect(() => {
const onExpired = () => {
console.warn('[auth] prihlaseni vyprselo, odhlasuji');
sessionStorage.removeItem(OWN_TOKEN_KEY);
setUser(null);
setImpersonation(null);
setExpired(true);
};
window.addEventListener(AUTH_EXPIRED_EVENT, onExpired);
return () => window.removeEventListener(AUTH_EXPIRED_EVENT, onExpired);
}, []);
// Pri startu zkusime obnovit session z ulozeneho tokenu.
useEffect(() => {
@@ -79,9 +88,17 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
}
} catch (err) {
// Expirovany/neplatny token - zahodime ho a pokracujeme jako neprihlaseny.
console.warn('[auth] obnoveni session selhalo, mazu token:', err);
setToken(null);
/*
* Token se zahazuje **jen kdyz ho server odmitl** (401, to uz udelal
* `apiFetch`). Vypadek site nebo 503 neznamena, ze prihlaseni
* neplati - smazat ho by cloveka odhlasilo kvuli chvilce bez spojeni.
*/
if (err instanceof ApiError && err.status === 401) {
console.warn('[auth] token uz neplati, mazu ho:', err);
setToken(null);
} else {
console.warn('[auth] obnoveni session selhalo, token nechavam:', err);
}
if (!cancelled) setUser(null);
} finally {
if (!cancelled) setLoading(false);
@@ -102,6 +119,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
});
setToken(data.token);
setUser(data.user);
setExpired(false);
}, []);
const startImpersonation = useCallback(async (userId: string, allowWrites: boolean) => {
@@ -149,8 +167,17 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, []);
const value = useMemo<AuthState>(
() => ({ user, loading, login, logout, impersonation, startImpersonation, stopImpersonation }),
[user, loading, login, logout, impersonation, startImpersonation, stopImpersonation],
() => ({
user,
loading,
expired,
login,
logout,
impersonation,
startImpersonation,
stopImpersonation,
}),
[user, loading, expired, login, logout, impersonation, startImpersonation, stopImpersonation],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
+10 -2
View File
@@ -4,7 +4,7 @@ import { Spinner } from '@/components/ui/Spinner';
/** Obalka pro chranene routy. Neprihlaseneho posle na /prihlaseni a zapamatuje cil. */
export function RequireAuth() {
const { user, loading } = useAuth();
const { user, loading, expired } = useAuth();
const location = useLocation();
if (loading) {
@@ -16,7 +16,15 @@ export function RequireAuth() {
}
if (!user) {
return <Navigate to="/prihlaseni" state={{ from: location.pathname }} replace />;
// `reason` rekne prihlasovaci strance, ze clovek neprisel poprve, ale
// ze mu vyprselo prihlaseni - jinak by to vypadalo jako chyba portalu.
return (
<Navigate
to="/prihlaseni"
state={{ from: location.pathname, reason: expired ? 'expired' : undefined }}
replace
/>
);
}
return <Outlet />;
+7 -6
View File
@@ -2,6 +2,7 @@ import { Component, type ErrorInfo, type ReactNode } from 'react';
import { AlertTriangle, RotateCcw } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { buildInfo } from '@/config/version';
import { getLocale, translate } from '@/i18n';
import { apiFetch } from '@/lib/api';
/**
@@ -102,16 +103,16 @@ export class ErrorBoundary extends Component<Props, State> {
if (!error) return this.props.children;
if (this.props.fallback !== undefined) return this.props.fallback;
// Tridni komponenta nema hooky, preklad se bere primo z modulu.
const t = (key: Parameters<typeof translate>[1]) => translate(getLocale(), key);
return (
<div className="glass rounded-card border border-danger-500/40 p-5">
<p className="flex items-center gap-2 font-semibold text-danger-400">
<AlertTriangle className="size-4 shrink-0" />
Tuhle část se nepodařilo vykreslit
</p>
<p className="mt-1.5 text-sm text-white/60">
Zbytek portálu funguje dál. Zkuste to znovu, a když to bude přetrvávat,
pošlete nám prosím tuhle hlášku.
{t('error.title')}
</p>
<p className="mt-1.5 text-sm text-white/60">{t('error.text')}</p>
<p className="mt-2 font-mono text-xs break-all text-white/35">{error.message}</p>
<Button
className="mt-4"
@@ -120,7 +121,7 @@ export class ErrorBoundary extends Component<Props, State> {
onClick={() => this.setState({ error: null })}
>
<RotateCcw className="size-3.5" />
Zkusit znovu
{t('state.retry')}
</Button>
</div>
);
@@ -0,0 +1,353 @@
import { Search } from 'lucide-react';
import { useEffect, 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 { 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';
/**
* Zalozeni firmy z registru ARES.
*
* Dva kroky v jednom dialogu: hledani (IC nebo nazev) a vyber osob. Osoby
* jsou soucasni clenove statutarnich organu a prokura; ARES e-maily nevede,
* takze kazda ma navrzeny nahradni `IC-poradi@placeholder.cz`, ktery jde
* prepsat rovnou tady, nebo pozdeji v Uzivatelich. Vybrani dostanou roli
* spravce firmy - rozhodnuti zadavatele, firma si dalsi lidi zalozi sama.
*/
interface AresCompany {
ico: string;
name: string;
dic: string | null;
address: string;
legalForm: string;
/** Firma uz v portalu je. Druhe zalozeni server odmitne, tak se ani nenabizi. */
existingTenantId: string | null;
}
interface AresPerson {
name: string;
roles: string[];
email: string;
}
/** Radek vyberu: osoba plus to, co v ni clovek prepsal. */
interface PersonRow extends AresPerson {
key: string;
selected: boolean;
/** Puvodni navrh, aby slo poznat, ze e-mail je nahradni. */
placeholder: string;
}
interface Created {
tenant: { id: string; name: string };
users: Array<{ id: string; email: string; name: string }>;
}
const PLACEHOLDER_SUFFIX = '@placeholder.cz';
export function AresTenantDialog({
open,
onClose,
onCreated,
}: {
open: boolean;
onClose: () => void;
/** Zavola se po zalozeni, aby se seznam firem nacetl znovu. */
onCreated: (tenantId: string) => void;
}) {
const [query, setQuery] = useState('');
const [companies, setCompanies] = useState<AresCompany[] | null>(null);
const [company, setCompany] = useState<AresCompany | null>(null);
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 create = useSubmit(async () => {
if (!company) throw new Error('Nejdřív vyberte firmu.');
const selected = (persons ?? []).filter((row) => row.selected);
const result = await apiFetch<Created>('/api/dashboard/settings/ares/tenants', {
method: 'POST',
body: {
ico: company.ico,
name: name.trim() || undefined,
note: note.trim() || undefined,
persons: selected.map((row) => ({ name: row.name.trim(), email: row.email.trim() })),
},
});
setCreated(result);
onCreated(result.tenant.id);
}, 'Firmu se nepodařilo založit.');
const updateRow = (key: string, patch: Partial<PersonRow>) =>
setPersons((rows) => (rows ?? []).map((row) => (row.key === key ? { ...row, ...patch } : row)));
const selectAll = (selected: boolean) =>
setPersons((rows) => (rows ?? []).map((row) => ({ ...row, selected })));
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">
Firma <strong className="text-white">{created.tenant.name}</strong> je založená.
</p>
{created.users.length > 0 ? (
<div className="space-y-2">
<p className="text-sm text-white/60">Založené účty, všechny jako správce firmy:</p>
<ul className="space-y-1 text-sm">
{created.users.map((user) => (
<li key={user.id} className="flex flex-wrap items-center gap-2">
<span className="text-white">{user.name}</span>
<code className="text-white/70">{user.email}</code>
{user.email.endsWith(PLACEHOLDER_SUFFIX) && (
<Badge tone="warn">náhradní e-mail, nahraďte skutečným</Badge>
)}
</li>
))}
</ul>
<p className="text-xs text-white/50">
Heslo je náhodné. Nastavíte ho v Uživatelích, nebo člověku pošlete pozvánku.
</p>
</div>
) : (
<p className="text-sm text-white/60">Bez účtů. Lidi můžete přidat v Uživatelích.</p>
)}
<div className="flex justify-end">
<Button size="sm" onClick={onClose}>
Zavřít
</Button>
</div>
</div>
) : (
<div className="space-y-5">
<form
className="flex items-end gap-2"
onSubmit={(event) => {
event.preventDefault();
void search.submit();
}}
>
<Field label="IČ nebo název firmy" className="flex-1">
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="27074358 nebo Asseco"
autoFocus
/>
</Field>
<Button type="submit" size="sm" disabled={search.busy || query.trim().length < 2}>
<Search className="size-4" />
{search.busy ? 'Hledám' : 'Hledat'}
</Button>
</form>
{search.error && <p className="text-sm text-red-300">{search.error}</p>}
{companies && companies.length === 0 && (
<p className="text-sm text-white/60">ARES nic nenašel. Zkuste přesné IČ.</p>
)}
{companies && companies.length > 0 && (
<ul className="max-h-56 space-y-1 overflow-y-auto">
{companies.map((item) => {
const active = company?.ico === item.ico;
const taken = item.existingTenantId !== null;
return (
<li key={item.ico}>
<button
type="button"
disabled={taken}
onClick={() => {
setCompany(item);
setName(item.name);
}}
className={
'w-full rounded-lg border px-3 py-2 text-left text-sm transition ' +
(active
? 'border-brand-400/70 bg-brand-400/10'
: 'border-white/10 hover:border-white/30 disabled:opacity-50')
}
>
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-white">{item.name}</span>
<code className="text-xs text-white/60">IČ {item.ico}</code>
{taken && <Badge tone="neutral">už v portálu</Badge>}
</div>
<div className="text-xs text-white/50">
{[item.legalForm, item.address].filter(Boolean).join(', ')}
</div>
</button>
</li>
);
})}
</ul>
)}
{company && (
<div className="space-y-4 border-t border-white/10 pt-4">
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Název v portálu" required>
<Input value={name} onChange={(event) => setName(event.target.value)} />
</Field>
<Field label="DIČ">
<Input value={company.dic ?? ''} readOnly />
</Field>
<Field label="Sídlo" className="sm:col-span-2">
<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)} />
</Field>
</div>
<div className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-medium text-white">
Osoby z rejstříku {persons ? `(${selectedCount} z ${persons.length})` : ''}
</p>
{persons && persons.length > 0 && (
<div className="flex gap-2">
<Button size="sm" variant="ghost" onClick={() => selectAll(true)}>
Všechny
</Button>
<Button size="sm" variant="ghost" onClick={() => selectAll(false)}>
Žádnou
</Button>
</div>
)}
</div>
<p className="text-xs text-white/50">
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" />}
{personsError && <p className="text-sm text-red-300">{personsError}</p>}
{persons && persons.length === 0 && !personsError && (
<p className="text-sm text-white/60">
Rejstřík u této firmy žádné osoby neuvádí. Firmu jde založit bez účtů.
</p>
)}
{persons && persons.length > 0 && (
<ul className="max-h-64 space-y-2 overflow-y-auto">
{persons.map((row) => (
<li
key={row.key}
className="grid items-center gap-2 rounded-lg border border-white/10 p-2 sm:grid-cols-[auto_1fr_1fr]"
>
<label className="flex items-center gap-2 text-sm text-white/80">
<input
type="checkbox"
checked={row.selected}
onChange={(event) => updateRow(row.key, { selected: event.target.checked })}
/>
<span className="text-xs text-white/50">{row.roles.join(', ')}</span>
</label>
<Input
size="compact"
value={row.name}
disabled={!row.selected}
onChange={(event) => updateRow(row.key, { name: event.target.value })}
/>
<div className="flex items-center gap-2">
<Input
size="compact"
type="email"
value={row.email}
disabled={!row.selected}
onChange={(event) => updateRow(row.key, { email: event.target.value })}
/>
{row.email === row.placeholder && <Badge tone="warn">náhradní</Badge>}
</div>
</li>
))}
</ul>
)}
</div>
{create.error && <p className="text-sm text-red-300">{create.error}</p>}
<div className="flex justify-end gap-2">
<Button size="sm" variant="ghost" onClick={onClose}>
Zrušit
</Button>
<Button
size="sm"
disabled={create.busy || persons === null || name.trim().length < 2}
onClick={() => void create.submit()}
>
{create.busy ? 'Zakládám' : `Založit firmu${selectedCount > 0 ? ` a ${selectedCount} účtů` : ''}`}
</Button>
</div>
</div>
)}
</div>
)}
</Modal>
);
}
@@ -27,6 +27,7 @@ import { EventToasts } from '@/components/dashboard/EventToasts';
import { LiveIndicator } from '@/components/dashboard/LiveIndicator';
import { Logo } from '@/components/layout/Logo';
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';
@@ -61,8 +62,11 @@ const moduleRoutes: Record<string, { to: string; icon: typeof LayoutDashboard; e
export function DashboardLayout() {
return (
// Jedno spojeni na stream pro cely portal, proto az tady nahore.
// Ciselniky pod nim: opravuji se podle udalosti z tohoto streamu.
<EventStreamProvider>
<DashboardShell />
<CollectionsProvider>
<DashboardShell />
</CollectionsProvider>
</EventStreamProvider>
);
}
@@ -130,16 +134,18 @@ function TenantSwitch({ access }: { access: Access | null }) {
function accessLabel(user: AuthUser | null, access: Access | null): string {
if (user?.platformAdmin) return 'Správce platformy, vidí všechny firmy';
// Nazvy roli posila server. Driv se role hadala z poctu prav, coz u vlastni
// role s trinacti pravy udelalo ze resitele "spravce".
const roles = (access?.roleNames ?? []).filter((name) => name.trim() !== '');
const count = access?.tenants.length ?? 0;
if (count > 1) return `Přístup do ${count} firem`;
// Role uz nejsou dva pevne stavy, takze se ukazuje pocet prav.
const permissions = access?.permissions.length ?? 0;
return permissions > 12 ? 'Správce firmy' : 'Řešitel';
const parts = [roles.join(', '), count > 1 ? `přístup do ${count} firem` : ''].filter(Boolean);
return parts.join(', ');
}
function DashboardShell() {
const { user, logout } = useAuth();
const access = useApiQuery<Access>('/api/dashboard/access');
// Z ulozist ciselniku: totez cte kazda stranka pod ramem, dotaz jde jednou.
const access = useAccess();
/*
* Cislo u zalozky Tickety a upozorneni. Obnovuje se na udalost, ne casovacem -
+28 -4
View File
@@ -2,30 +2,41 @@ import { AlertCircle, Inbox, RefreshCw } from 'lucide-react';
import type { ReactNode } from 'react';
import { Button } from '@/components/ui/Button';
import { Spinner } from '@/components/ui/Spinner';
import { useT } from '@/i18n';
/**
* Jednotne stavy pro data v dashboardu: nacitani / chyba / prazdno.
* Chyba se vzdy zobrazi uzivateli, nikdy se nespolkne.
*
* `loading` znamena "jeste nic nemame". Obnova na pozadi (`refreshing`) deti
* **nechava namontovane** a jen k nim prida nenapadny ukazatel - driv se pri
* kazde udalosti ze streamu cela stranka vymenila za spinner a zpatky, vcetne
* vsech dotazu, ktere si komponenty uvnitr delaly po namontovani.
*/
export function DataState({
loading,
refreshing = false,
error,
empty,
onRetry,
children,
emptyLabel = 'Žádná data k zobrazení.',
emptyLabel,
}: {
loading: boolean;
/** Bezi obnova, data zustavaji. Viz `QueryState.refreshing`. */
refreshing?: boolean;
error: string | null;
empty?: boolean;
onRetry?: () => void;
children: ReactNode;
emptyLabel?: string;
}) {
const t = useT();
if (loading) {
return (
<div className="grid min-h-40 place-items-center">
<Spinner label="Načítám data..." />
<Spinner label={t('state.loading')} />
</div>
);
}
@@ -40,7 +51,7 @@ export function DataState({
{onRetry && (
<Button variant="secondary" size="sm" onClick={onRetry}>
<RefreshCw className="size-4" />
Zkusit znovu
{t('state.retry')}
</Button>
)}
</div>
@@ -51,7 +62,20 @@ export function DataState({
return (
<div className="flex min-h-40 flex-col items-center justify-center gap-2 text-center text-white/45">
<Inbox className="size-6" />
<p className="text-sm">{emptyLabel}</p>
<p className="text-sm">{emptyLabel ?? t('state.empty')}</p>
</div>
);
}
if (refreshing) {
return (
<div className="relative">
{children}
<span
className="pointer-events-none absolute top-2 right-2 size-3.5 animate-spin rounded-full border-2 border-white/15 border-t-brand-400"
role="status"
aria-label={t('state.refreshing')}
/>
</div>
);
}
+72 -90
View File
@@ -1,12 +1,19 @@
import { AlertTriangle, Pencil, Plus, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useMemo, 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 { apiFetch, ApiError } from '@/lib/api';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/cn';
import { useApiQuery } from '@/lib/useApiQuery';
import { useSubmit } from '@/lib/useSubmit';
import type { DashboardEventType } from '@/types/events';
/**
* Obecna sprava zaznamu: tabulka, dialog na zalozeni a upravu, mazani.
@@ -45,6 +52,18 @@ export interface AdminField<T> {
initial?: unknown;
}
/**
* Zaznam ciselniku tak, jak ho vraci server. Sloupce a formulare ho ctou
* pres `String(record.x ?? '')`, protoze tvar urcuje endpoint, ne klient.
* Byl opsany ve ctyrech souborech, ted je tady jednou.
*/
export interface NamedRecord {
id: string;
name?: string;
label?: string;
[key: string]: unknown;
}
export interface AdminColumn<T> {
label: string;
render: (entity: T) => ReactNode;
@@ -80,11 +99,10 @@ export interface EntityAdminProps<T extends { id: string }> {
rowActions?: (entity: T) => ReactNode;
/** Zavola se po kazdem uspesnem zapisu. */
onChanged?: () => void;
/** Udalosti ze streamu, po kterych se seznam nacte znovu (zapis odjinud). */
refetchOn?: DashboardEventType[];
}
const inputClass =
'w-full rounded-lg border border-ink-600/70 bg-ink-850/70 px-3 py-2 text-sm text-white placeholder:text-white/25 focus:border-brand-400/70 focus:outline-none';
export function EntityAdmin<T extends { id: string }>({
title,
description,
@@ -99,34 +117,21 @@ export function EntityAdmin<T extends { id: string }>({
children,
rowActions,
onChanged,
refetchOn,
}: EntityAdminProps<T>) {
const [items, setItems] = useState<T[] | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Seznam pres useApiQuery: sdilena mezipamet, stejne chyby jako zbytek portalu.
const list = useApiQuery<{ items: T[] }>(listEndpoint ?? endpoint, { refetchOn });
const items = list.data?.items ?? null;
const load = list.reload;
const [editing, setEditing] = useState<{ entity: T | null } | null>(null);
const load = useCallback(() => {
setLoading(true);
setError(null);
apiFetch<{ items: T[] }>(listEndpoint ?? endpoint)
.then((data) => setItems(data.items))
.catch((err: unknown) => {
setError(err instanceof Error ? err.message : 'Data se nepodařilo načíst.');
})
.finally(() => setLoading(false));
}, [endpoint, listEndpoint]);
useEffect(load, [load]);
async function remove(entity: T) {
try {
await apiFetch(`${endpoint}/${entity.id}`, { method: 'DELETE' });
load();
onChanged?.();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Smazání selhalo.');
}
}
const removal = useSubmit(async (entity: T) => {
await apiFetch(`${endpoint}/${entity.id}`, { method: 'DELETE' });
load();
onChanged?.();
}, 'Smazání selhalo.');
const remove = removal.submit;
const error = list.error ?? removal.error;
return (
<section className="space-y-4">
@@ -145,7 +150,13 @@ export function EntityAdmin<T extends { id: string }>({
{children}
<DataState loading={loading} error={error} onRetry={load} empty={(items?.length ?? 0) === 0}>
<DataState
loading={list.loading}
refreshing={list.refreshing}
error={error}
onRetry={load}
empty={(items?.length ?? 0) === 0}
>
<div className="glass overflow-x-auto rounded-card">
<table className="w-full text-sm">
<thead>
@@ -186,7 +197,8 @@ export function EntityAdmin<T extends { id: string }>({
<Button
size="sm"
variant="ghost"
onClick={() => remove(entity)}
onClick={() => void remove(entity)}
disabled={removal.busy}
aria-label="Smazat"
>
<Trash2 className="size-3.5" />
@@ -256,19 +268,11 @@ function EntityForm<T extends { id: string }>({
return initial;
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [issues, setIssues] = useState<Array<{ field: string; message: string }>>([]);
function set(name: string, value: unknown) {
setValues((previous) => ({ ...previous, [name]: value }));
}
async function submit() {
setSaving(true);
setError(null);
setIssues([]);
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
@@ -279,19 +283,12 @@ function EntityForm<T extends { id: string }>({
.map((field) => [field.name, values[field.name]]),
);
try {
await apiFetch(entity ? `${endpoint}/${entity.id}` : endpoint, {
method: entity ? 'PATCH' : 'POST',
body: payload,
});
onSaved();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Uložení selhalo.');
if (err instanceof ApiError) setIssues(err.issues());
} finally {
setSaving(false);
}
}
await apiFetch(entity ? `${endpoint}/${entity.id}` : endpoint, {
method: entity ? 'PATCH' : 'POST',
body: payload,
});
onSaved();
}, 'Uložení selhalo.');
return (
<Modal open onClose={onClose} title={title}>
@@ -437,7 +434,7 @@ function FieldInput<T>({
return (
<div>
{label}
<input
<Input
value={tags.join(', ')}
onChange={(event) =>
onChange(
@@ -448,7 +445,6 @@ function FieldInput<T>({
)
}
placeholder="oddělte čárkou"
className={inputClass}
/>
{tags.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1.5">
@@ -466,24 +462,14 @@ function FieldInput<T>({
if (field.kind === 'select') {
return (
<label className="block">
{label}
<select
<Field label={field.label} required={field.required} hint={field.hint}>
<Select
value={String(value ?? '')}
onChange={(event) => onChange(event.target.value)}
className={inputClass}
>
<option value="" className="bg-ink-850">
Vyberte
</option>
{(field.options ?? []).map((option) => (
<option key={option.value} value={option.value} className="bg-ink-850">
{option.label}
</option>
))}
</select>
{field.hint && <span className="mt-1 block text-xs text-white/35">{field.hint}</span>}
</label>
placeholder="Vyberte"
options={field.options ?? []}
/>
</Field>
);
}
@@ -493,40 +479,36 @@ function FieldInput<T>({
const broken = isJson && text.trim() !== '' && !isValidJson(text);
return (
<label className="block">
{label}
<textarea
<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)}
className={cn(
inputClass,
'resize-y',
isJson && 'font-mono text-xs',
broken && 'border-warn-400/60',
)}
mono={isJson}
invalid={broken}
/>
{broken && <span className="mt-1 block text-xs text-warn-400">Tohle není platný JSON.</span>}
{field.hint && <span className="mt-1 block text-xs text-white/35">{field.hint}</span>}
</label>
</Field>
);
}
return (
<label className="block">
{label}
<input
<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)
}
className={inputClass}
/>
{field.hint && <span className="mt-1 block text-xs text-white/35">{field.hint}</span>}
</label>
</Field>
);
}
+30 -8
View File
@@ -1,5 +1,5 @@
import { AlarmClock, Bell, CheckCircle2, LifeBuoy, UserCheck, Webhook, Workflow, X } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import type { LucideIcon } from 'lucide-react';
import { useEventLog } from '@/components/dashboard/EventStreamProvider';
import { cn } from '@/lib/cn';
@@ -9,7 +9,8 @@ import type { DashboardEvent, DashboardEventType } from '@/types/events';
const LIFETIME_MS = 6_000;
const MAX_VISIBLE = 4;
const style: Record<DashboardEventType, { icon: LucideIcon; tone: string }> = {
// Partial: zmeny ciselniku (`person.updated` apod.) maji obecny vzhled nize.
const style: Partial<Record<DashboardEventType, { icon: LucideIcon; tone: string }>> = {
'ticket.created': { icon: LifeBuoy, tone: 'text-brand-300 bg-brand-500/12' },
'ticket.updated': { icon: LifeBuoy, tone: 'text-brand-300 bg-brand-500/12' },
'ticket.assigned': { icon: UserCheck, tone: 'text-accent-300 bg-accent-500/12' },
@@ -29,12 +30,19 @@ const style: Record<DashboardEventType, { icon: LucideIcon; tone: string }> = {
export function EventToasts() {
const events = useEventLog();
const [visible, setVisible] = useState<DashboardEvent[]>([]);
const [dismissed, setDismissed] = useState<Set<string>>(new Set());
/*
* Kazda bublina ma vlastni casovac, drzeny mimo stav.
*
* Driv byl casovac v effectu a jeho cleanup ho rusil pri kazde dalsi
* udalosti - takze kdyz prisly dve do sesti sekund, ta prvni uz nikdy
* nezmizela sama.
*/
const timers = useRef(new Map<string, number>());
const dismissed = useRef(new Set<string>());
// Nove udalosti pridame mezi viditelne a po chvili je odebereme.
useEffect(() => {
const newest = events[0];
if (!newest || dismissed.has(newest.id)) return;
if (!newest || dismissed.current.has(newest.id) || timers.current.has(newest.id)) return;
setVisible((previous) => {
if (previous.some((e) => e.id === newest.id)) return previous;
@@ -42,14 +50,28 @@ export function EventToasts() {
});
const timer = window.setTimeout(() => {
timers.current.delete(newest.id);
setVisible((previous) => previous.filter((e) => e.id !== newest.id));
}, LIFETIME_MS);
timers.current.set(newest.id, timer);
}, [events]);
return () => window.clearTimeout(timer);
}, [events, dismissed]);
// Pri odmontovani zrusit vsechny bezici casovace, ne jen ten posledni.
useEffect(() => {
const running = timers.current;
return () => {
for (const timer of running.values()) window.clearTimeout(timer);
running.clear();
};
}, []);
function dismiss(id: string) {
setDismissed((previous) => new Set(previous).add(id));
dismissed.current.add(id);
const timer = timers.current.get(id);
if (timer !== undefined) {
window.clearTimeout(timer);
timers.current.delete(id);
}
setVisible((previous) => previous.filter((e) => e.id !== id));
}
+11 -8
View File
@@ -14,10 +14,11 @@ import { Check, Plus, Trash2, Users } from 'lucide-react';
import { useEffect, 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 type { PeopleResponse } from '@/types/dashboard';
interface GroupMember {
personId: string;
@@ -38,12 +39,13 @@ function membersOf(group: Group): GroupMember[] {
return (group.personIds ?? []).map((personId) => ({ personId, seesAll: false }));
}
const inputClass =
'w-full rounded-lg border border-ink-600/70 bg-ink-900/70 px-3 py-2 text-sm text-white placeholder:text-white/25 focus:border-brand-400/70 focus:outline-none';
export function GroupPanel() {
/*
* Skupiny s cleny jsou pod pravem `group.manage`, proto vlastni dotaz -
* uloziste ciselniku nese jen jmena skupin pro kazdeho. Lide odtamtud.
*/
const groups = useApiQuery<{ items: Group[] }>('/api/dashboard/settings/groups');
const people = useApiQuery<PeopleResponse>('/api/dashboard/people');
const people = useCollection('people');
const [openId, setOpenId] = useState<string | null>(null);
const [newName, setNewName] = useState('');
@@ -51,7 +53,7 @@ export function GroupPanel() {
const [error, setError] = useState<string | null>(null);
const items = useMemo(() => groups.data?.items ?? [], [groups.data]);
const persons = people.data?.items ?? [];
const persons = people.items;
// Prvni sekce se otevre sama. Prazdny panel vedle seznamu nic nerika.
useEffect(() => {
@@ -130,6 +132,7 @@ export function GroupPanel() {
return (
<DataState
loading={groups.loading || people.loading}
refreshing={groups.refreshing}
error={groups.error ?? people.error}
onRetry={() => {
groups.reload();
@@ -176,13 +179,13 @@ export function GroupPanel() {
<label htmlFor="nova-sekce" className="sr-only">
Název nové sekce
</label>
<input
<Input
id="nova-sekce"
value={newName}
disabled={busy}
onChange={(event) => setNewName(event.target.value)}
placeholder="Nová sekce"
className={inputClass}
tone="card"
/>
<Button size="sm" disabled={busy || newName.trim().length < 2} onClick={() => void create()}>
<Plus className="size-4" />
+31 -43
View File
@@ -3,9 +3,12 @@ import { useState } 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 { apiFetch } from '@/lib/api';
import { formatDateTime } from '@/lib/format';
import { useApiQuery } from '@/lib/useApiQuery';
import { useSubmit } from '@/lib/useSubmit';
/**
* Pozvanky do firmy.
@@ -38,9 +41,6 @@ interface InvitesResponse {
roles: Array<{ id: string; key: string; name: string }>;
}
const inputClass =
'w-full rounded-lg border border-ink-600/70 bg-ink-850/70 px-3 py-2 text-sm text-white placeholder:text-white/25 focus:border-brand-400/70 focus:outline-none';
export function InvitePanel() {
const invites = useApiQuery<InvitesResponse>('/api/dashboard/invites');
@@ -48,39 +48,30 @@ export function InvitePanel() {
const [note, setNote] = useState('');
const [roleIds, setRoleIds] = useState<string[]>([]);
const [asPerson, setAsPerson] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState<string | null>(null);
const roles = invites.data?.roles ?? [];
const items = invites.data?.items ?? [];
async function create() {
setBusy(true);
setError(null);
try {
await apiFetch('/api/dashboard/invites', {
method: 'POST',
body: { email, note, roleIds, asPerson },
});
setEmail('');
setNote('');
invites.reload();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Pozvánku se nepodařilo vytvořit.');
} finally {
setBusy(false);
}
}
const creation = useSubmit(async () => {
await apiFetch('/api/dashboard/invites', {
method: 'POST',
body: { email, note, roleIds, asPerson },
});
setEmail('');
setNote('');
invites.reload();
}, 'Pozvánku se nepodařilo vytvořit.');
async function remove(id: string) {
try {
await apiFetch(`/api/dashboard/invites/${id}`, { method: 'DELETE' });
invites.reload();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Pozvánku se nepodařilo zrušit.');
}
}
const removal = useSubmit(async (id: string) => {
await apiFetch(`/api/dashboard/invites/${id}`, { method: 'DELETE' });
invites.reload();
}, 'Pozvánku se nepodařilo zrušit.');
const busy = creation.busy;
const error = creation.error ?? removal.error;
const create = creation.submit;
const remove = removal.submit;
async function copy(url: string, id: string) {
try {
@@ -102,29 +93,25 @@ export function InvitePanel() {
</p>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<label className="block">
<span className="mb-1 block text-sm text-white/70">E-mail</span>
<input
<Field
label="E-mail"
hint="Když ho vyplníte, odkaz přijme jen tahle adresa. Prázdné = kdokoliv s odkazem."
>
<Input
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="nepovinné"
className={inputClass}
/>
<span className="mt-1 block text-xs text-white/35">
Když ho vyplníte, odkaz přijme jen tahle adresa. Prázdné = kdokoliv s odkazem.
</span>
</label>
</Field>
<label className="block">
<span className="mb-1 block text-sm text-white/70">Poznámka</span>
<input
<Field label="Poznámka">
<Input
value={note}
onChange={(event) => setNote(event.target.value)}
placeholder="komu jsem to poslal"
className={inputClass}
/>
</label>
</Field>
</div>
<fieldset className="mt-4">
@@ -186,6 +173,7 @@ export function InvitePanel() {
<DataState
loading={invites.loading}
refreshing={invites.refreshing}
error={invites.error}
onRetry={invites.reload}
empty={items.length === 0}
+67 -156
View File
@@ -1,8 +1,14 @@
import { useEffect, useState } from 'react';
import { useState } from 'react';
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 { cn } from '@/lib/cn';
import { useCollection } from '@/lib/collections';
import { priorities } from '@/lib/options';
import { useSubmit } from '@/lib/useSubmit';
import type { TicketPriority } from '@/types/dashboard';
/**
@@ -16,45 +22,6 @@ import type { TicketPriority } from '@/types/dashboard';
* ne pozadavek od nekoho zvenku, a povinna firma s kontaktem by znamenala, ze
* si je clovek vymysli.
*/
interface TicketTypeOption {
id: string;
name: string;
}
interface PeopleResponse {
items: Array<{ id: string; name: string }>;
groups: Array<{ id: string; name: string }>;
}
const priorities: Array<{ value: TicketPriority; label: string }> = [
{ value: 'low', label: 'Nízká' },
{ value: 'normal', label: 'Normální' },
{ value: 'high', label: 'Vysoká' },
{ value: 'critical', label: 'Kritická' },
];
const inputClass =
'w-full rounded-lg border border-ink-600/70 bg-ink-900/70 px-3 py-1.5 text-sm text-white placeholder:text-white/25 focus:border-brand-400/70 focus:outline-none';
function Field({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: React.ReactNode;
}) {
return (
<label className="block">
<span className="text-xs text-white/55">{label}</span>
{children}
{hint && <span className="mt-0.5 block text-xs text-white/35">{hint}</span>}
</label>
);
}
export function NewTicketDialog({
open,
onClose,
@@ -75,27 +42,32 @@ export function NewTicketDialog({
const [contact, setContact] = useState('');
const [reply, setReply] = useState('');
const [types, setTypes] = useState<TicketTypeOption[]>([]);
const [people, setPeople] = useState<PeopleResponse | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
/*
* Typy i resitele jsou vlastnost firmy a berou se z ulozist ciselniku.
* Firma se sem nepredava - doplnuje ji `apiFetch` podle prepinace nad
* dashboardem, takze se nemuze rozejit s tim, co ma uzivatel vybrane.
*/
const types = useCollection('ticketTypes');
const people = useCollection('people');
const groups = useCollection('groups');
useEffect(() => {
if (!open) return;
setError(null);
/*
Typy i řešitelé jsou vlastnost firmy. Její ID se sem nepředává - doplní
ho `apiFetch` podle přepínače nad dashboardem, takže se nemůže rozejít
s tím, co má uživatel právě vybrané.
*/
apiFetch<{ items: TicketTypeOption[] }>('/api/dashboard/settings/ticket-types')
.then((response) => setTypes(response.items))
.catch(() => setTypes([]));
apiFetch<PeopleResponse>('/api/dashboard/people')
.then(setPeople)
.catch(() => setPeople(null));
}, [open]);
const { busy, error, submit, setError } = useSubmit(async () => {
await apiFetch('/api/dashboard/tickets', {
method: 'POST',
// apiFetch telo samo prevede na JSON. Druhy JSON.stringify z nej udelal
// retezec, ktery parser tela odmitne - a to skoncilo jako 500.
body: {
subject: subject.trim(),
body,
priority,
typeId: typeId === '' ? null : typeId,
assigneeId: assigneeId === '' ? null : assigneeId,
assigneeGroupId: assigneeGroupId === '' ? null : assigneeGroupId,
// Prazdne udaje se neposilaji: zakaznik je nepovinny.
...(company || contact || reply ? { customer: { company, contact, reply } } : {}),
},
});
}, 'Ticket se nepodařilo založit.');
function reset() {
setSubject('');
@@ -111,38 +83,16 @@ export function NewTicketDialog({
setError(null);
}
async function submit(event: React.FormEvent) {
async function handleSubmit(event: React.FormEvent) {
event.preventDefault();
if (subject.trim() === '') {
setError('Vyplňte předmět, jinak ticket v seznamu nikdo nepozná.');
return;
}
setSaving(true);
setError(null);
try {
await apiFetch('/api/dashboard/tickets', {
method: 'POST',
// apiFetch telo samo prevede na JSON. Druhy JSON.stringify z nej udelal
// retezec, ktery parser tela odmitne - a to skoncilo jako 500.
body: {
subject: subject.trim(),
body,
priority,
typeId: typeId === '' ? null : typeId,
assigneeId: assigneeId === '' ? null : assigneeId,
assigneeGroupId: assigneeGroupId === '' ? null : assigneeGroupId,
// Prázdné údaje se neposílají: zákazník je nepovinný.
...(company || contact || reply ? { customer: { company, contact, reply } } : {}),
},
});
if (await submit()) {
reset();
onCreated();
onClose();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Ticket se nepodařilo založit.');
} finally {
setSaving(false);
}
}
@@ -154,120 +104,81 @@ export function NewTicketDialog({
description="Požadavek, který přišel mimo automatizaci - telefonem, osobně, odkudkoliv."
className="max-w-2xl"
>
<form onSubmit={submit} className="space-y-4 p-5">
<form onSubmit={handleSubmit} className="space-y-4 p-5">
<Field label="Předmět">
<input
<Input
value={subject}
onChange={(event) => setSubject(event.target.value)}
placeholder="Co se má vyřešit"
className={cn(inputClass, 'mt-1')}
tone="card"
autoFocus
/>
</Field>
<Field label="Popis" hint="Celý text požadavku. Nepovinné.">
<textarea
value={body}
onChange={(event) => setBody(event.target.value)}
rows={5}
className={cn(inputClass, 'mt-1 resize-y')}
/>
<Textarea value={body} onChange={(event) => setBody(event.target.value)} rows={5} tone="card" />
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Priorita">
<select
<Select
value={priority}
onChange={(event) => setPriority(event.target.value as TicketPriority)}
className={cn(inputClass, 'mt-1')}
>
{priorities.map((item) => (
<option key={item.value} value={item.value} className="bg-ink-850">
{item.label}
</option>
))}
</select>
tone="card"
options={priorities}
/>
</Field>
<Field label="Typ" hint="Za typem stojí vlastní pole a akce na ticketu.">
<select
<Select
value={typeId}
onChange={(event) => setTypeId(event.target.value)}
className={cn(inputClass, 'mt-1')}
>
<option value="" className="bg-ink-850">
Bez typu
</option>
{types.map((type) => (
<option key={type.id} value={type.id} className="bg-ink-850">
{type.name}
</option>
))}
</select>
tone="card"
placeholder="Bez typu"
options={types.items.map((type) => ({ value: type.id, label: type.name }))}
/>
</Field>
<Field label="Řešitel">
<select
<Select
value={assigneeId}
onChange={(event) => setAssigneeId(event.target.value)}
className={cn(inputClass, 'mt-1')}
>
<option value="" className="bg-ink-850">
Nechat ve frontě
</option>
{(people?.items ?? []).map((person) => (
<option key={person.id} value={person.id} className="bg-ink-850">
{person.name}
</option>
))}
</select>
tone="card"
placeholder="Nechat ve frontě"
options={people.items.map((person) => ({ value: person.id, label: person.name }))}
/>
</Field>
<Field label="Skupina">
<select
<Select
value={assigneeGroupId}
onChange={(event) => setAssigneeGroupId(event.target.value)}
className={cn(inputClass, 'mt-1')}
>
<option value="" className="bg-ink-850">
Bez skupiny
</option>
{(people?.groups ?? []).map((group) => (
<option key={group.id} value={group.id} className="bg-ink-850">
{group.name}
</option>
))}
</select>
tone="card"
placeholder="Bez skupiny"
options={groups.items.map((group) => ({ value: group.id, label: group.name }))}
/>
</Field>
</div>
{/*
Zákazník je schovaný, protože u ručně založeného ticketu obvykle
žádný není. Rozbalí ho ten, kdo ho opravdu má.
Zakaznik je schovany, protoze u rucne zalozeneho ticketu obvykle
zadny neni. Rozbali ho ten, kdo ho opravdu ma.
*/}
<div className="border-t border-ink-600/50 pt-4">
{customerOpen ? (
<div className="grid gap-4 sm:grid-cols-3">
<Field label="Firma">
<input
value={company}
onChange={(event) => setCompany(event.target.value)}
className={cn(inputClass, 'mt-1')}
/>
<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)}
className={cn(inputClass, 'mt-1')}
/>
<Input value={contact} onChange={(event) => setContact(event.target.value)} tone="card" />
</Field>
<Field label="Kam odpovídat">
<input
<Input
value={reply}
onChange={(event) => setReply(event.target.value)}
placeholder="E-mail nebo telefon"
className={cn(inputClass, 'mt-1')}
tone="card"
/>
</Field>
</div>
@@ -289,10 +200,10 @@ export function NewTicketDialog({
)}
<div className="flex gap-3 border-t border-ink-600/50 pt-4">
<Button type="submit" size="sm" disabled={saving}>
{saving ? 'Zakládám...' : 'Založit ticket'}
<Button type="submit" size="sm" disabled={busy}>
{busy ? 'Zakládám...' : 'Založit ticket'}
</Button>
<Button type="button" size="sm" variant="secondary" onClick={onClose} disabled={saving}>
<Button type="button" size="sm" variant="secondary" onClick={onClose} disabled={busy}>
Zrušit
</Button>
</div>
+23 -47
View File
@@ -9,12 +9,10 @@
* pro ucetni" jde rict bez toho, aby se resilo, kdo z nich ma dovolenou.
*/
import { useState } from 'react';
import { Select } from '@/components/ui/form/Select';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/cn';
const selectClass =
'rounded-lg border border-ink-600/70 bg-ink-900/70 px-2.5 py-1.5 text-xs text-white focus:border-brand-400/70 focus:outline-none disabled:opacity-50';
import { useSubmit } from '@/lib/useSubmit';
export function QuickAssign({
ticketId,
@@ -32,22 +30,10 @@ export function QuickAssign({
meId: string | null;
onDone: () => void;
}) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function send(path: string, body: unknown) {
setBusy(true);
setError(null);
try {
await apiFetch(`/api/dashboard/tickets/${ticketId}${path}`, { method: 'POST', body });
onDone();
} catch (err) {
console.error(`[fronta] ${path} selhalo:`, err);
setError(err instanceof Error ? err.message : 'Nepodařilo se uložit.');
} finally {
setBusy(false);
}
}
const { busy, error, submit } = useSubmit(async (path: string, body: unknown) => {
await apiFetch(`/api/dashboard/tickets/${ticketId}${path}`, { method: 'POST', body });
onDone();
}, 'Nepodařilo se uložit.');
return (
<div className="flex flex-wrap items-center gap-2">
@@ -59,7 +45,7 @@ export function QuickAssign({
<button
type="button"
disabled={busy}
onClick={() => void send('/claim', {})}
onClick={() => void submit('/claim', {})}
className={cn(
'rounded-full bg-brand-400 px-3 py-1.5 text-xs font-semibold text-ink-950',
'transition-colors hover:bg-brand-300 disabled:opacity-50',
@@ -74,24 +60,19 @@ export function QuickAssign({
<label htmlFor={`prirad-${ticketId}`} className="sr-only">
Přiřadit řešiteli
</label>
<select
<Select
id={`prirad-${ticketId}`}
value=""
disabled={busy}
onChange={(event) => {
if (event.target.value) void send('/assign', { assigneeId: event.target.value });
if (event.target.value) void submit('/assign', { assigneeId: event.target.value });
}}
className={selectClass}
>
<option value="" className="bg-ink-850">
Komu...
</option>
{people.map((person) => (
<option key={person.id} value={person.id} className="bg-ink-850">
{person.name}
</option>
))}
</select>
size="compact"
tone="card"
fullWidth={false}
placeholder="Komu..."
options={people.map((person) => ({ value: person.id, label: person.name }))}
/>
</>
)}
@@ -100,24 +81,19 @@ export function QuickAssign({
<label htmlFor={`sekce-${ticketId}`} className="sr-only">
Předat sekci
</label>
<select
<Select
id={`sekce-${ticketId}`}
value=""
disabled={busy}
onChange={(event) => {
if (event.target.value) void send('/group', { groupId: event.target.value });
if (event.target.value) void submit('/group', { groupId: event.target.value });
}}
className={selectClass}
>
<option value="" className="bg-ink-850">
Do sekce...
</option>
{groups.map((group) => (
<option key={group.id} value={group.id} className="bg-ink-850">
{group.name}
</option>
))}
</select>
size="compact"
tone="card"
fullWidth={false}
placeholder="Do sekce..."
options={groups.map((group) => ({ value: group.id, label: group.name }))}
/>
</>
)}
+1 -10
View File
@@ -7,8 +7,7 @@
import { useMemo } from 'react';
import { Badge } from '@/components/ui/Badge';
import { EntityAdmin } from '@/components/dashboard/EntityAdmin';
import { EntityAdmin, type NamedRecord } from '@/components/dashboard/EntityAdmin';
import { useApiQuery } from '@/lib/useApiQuery';
interface CatalogItem {
@@ -18,14 +17,6 @@ interface CatalogItem {
group: string;
}
/** Zaznam ciselniku tak, jak ho vraci EntityAdmin. Stejny tvar jako v nastaveni. */
interface NamedRecord {
id: string;
name?: string;
label?: string;
[key: string]: unknown;
}
export function RolesAdmin() {
const catalog = useApiQuery<{ permissions: CatalogItem[] }>('/api/dashboard/settings/catalog');
+30 -25
View File
@@ -3,6 +3,9 @@ import { useEffect, useMemo, useState } 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 { Textarea } from '@/components/ui/form/Textarea';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/cn';
import { formatDateTime } from '@/lib/format';
@@ -43,9 +46,6 @@ interface TestResult {
durationMs: number;
}
const inputClass =
'w-full rounded-lg border border-ink-600/70 bg-ink-900/70 px-3 py-2 text-sm text-white placeholder:text-white/25 focus:border-brand-400/70 focus:outline-none';
export function TenantScripts() {
const scripts = useApiQuery<ScriptsResponse>('/api/dashboard/tenant-scripts');
@@ -186,6 +186,7 @@ export function TenantScripts() {
<DataState
loading={scripts.loading}
refreshing={scripts.refreshing}
error={scripts.error}
onRetry={scripts.reload}
empty={items.length === 0}
@@ -226,40 +227,42 @@ export function TenantScripts() {
{draft && (
<div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="mb-1 block text-xs text-white/55">Název</span>
<input
<Field label="Název">
<Input
value={draft.name}
onChange={(event) => setDraft({ ...draft, name: event.target.value })}
className={inputClass}
tone="card"
/>
</label>
<label className="block">
<span className="mb-1 block text-xs text-white/55">K čemu to je</span>
<input
</Field>
<Field label="K čemu to je">
<Input
value={draft.description}
onChange={(event) => setDraft({ ...draft, description: event.target.value })}
placeholder="Objednávka ze Shoptetu na doklad pro iDoklad"
className={inputClass}
tone="card"
/>
</label>
</Field>
</div>
<label className="block">
<span className="mb-1 block text-xs text-white/55">
Kód
<span className="ml-2 text-white/30">
k dispozici je input a util.get, util.num, util.day
</span>
</span>
<textarea
<Field
label={
<>
Kód
<span className="text-xs text-white/30">
k dispozici je input a util.get, util.num, util.day
</span>
</>
}
>
<Textarea
value={draft.code}
onChange={(event) => setDraft({ ...draft, code: event.target.value })}
rows={16}
spellCheck={false}
className={cn(inputClass, 'resize-y font-mono text-xs leading-relaxed')}
mono
tone="card"
/>
</label>
</Field>
<div className="flex flex-wrap items-center gap-3">
<Button onClick={() => void save()} disabled={busy || !dirty} size="sm">
@@ -296,12 +299,14 @@ export function TenantScripts() {
Vlepte skutečné tělo. Zkouší se rozepsaný kód, nic se neodesílá ven.
</p>
<textarea
<Textarea
value={sample}
onChange={(event) => setSample(event.target.value)}
rows={5}
spellCheck={false}
className={cn(inputClass, 'mt-2 resize-y font-mono text-xs')}
mono
tone="card"
className="mt-2"
/>
<Button onClick={() => void runTest()} disabled={busy} size="sm" className="mt-2">
+79 -117
View File
@@ -2,10 +2,16 @@ import { AlertTriangle, CheckCircle2 } from 'lucide-react';
import { useCallback, useEffect, 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 { cn } from '@/lib/cn';
import { serviceIcon } from '@/lib/serviceIcons';
import { useSubmit } from '@/lib/useSubmit';
import type { TicketType } from '@/types/dashboard';
/**
* Tlacitka akci na ticketu.
@@ -48,50 +54,50 @@ export interface ActionableTicket {
fields: Record<string, string | number | boolean | null>;
}
/**
* Typ ticketu tak, jak ho vraci nastaveni. Jen to, co je tady potreba.
*
* `type` je stejna sada jako u parametru kroku (`FieldType` na serveru),
* aby se na jedno pole dalo odkazovat z podminky i ze sablony.
*/
interface TicketTypeOption {
id: string;
name: string;
fields: Array<{
id: string;
key: string;
label: string;
type: 'string' | 'number' | 'boolean' | 'date' | 'object' | 'list';
required: boolean;
options?: Array<{ value: string; label: string }>;
hint?: string;
}>;
}
export function TicketActions({
ticket,
types,
onDone,
}: {
ticket: ActionableTicket;
/** Typy ticketu z ulozist. Nacita je stranka, ne kazda komponenta zvlast. */
types: TicketType[];
onDone: () => void;
}) {
const ticketId = ticket.id;
const [items, setItems] = useState<TicketActionItem[]>([]);
const [listError, setListError] = useState<string | null>(null);
const [pending, setPending] = useState<TicketActionItem | null>(null);
const [result, setResult] = useState<{ action: string; value: RunResult } | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(() => {
/*
* 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) => setItems(data.items))
.then((data) => {
if (signal?.cancelled) return;
setItems(data.items);
setListError(null);
})
.catch((err: unknown) => {
// Chybejici akce nesmi zhasnout detail ticketu.
// 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(load, [load]);
useEffect(() => {
const signal = { cancelled: false };
load(signal);
return () => {
signal.cancelled = true;
};
}, [load]);
async function run(action: TicketActionItem, form: Record<string, string>) {
setError(null);
@@ -110,12 +116,13 @@ export function TicketActions({
}
}
const hasActionBlock = items.length > 0 || result !== null || error !== null;
const hasActionBlock = items.length > 0 || result !== null || error !== null || listError !== null;
return (
<div className="glass space-y-4 rounded-card p-4">
<TicketMeta
ticket={ticket}
types={types}
onChanged={() => {
onDone();
// Zmena typu nebo tagu meni i seznam akci, proto se nacte znovu.
@@ -148,6 +155,7 @@ export function TicketActions({
})}
</div>
{listError && <p className="text-xs text-white/40">{listError}</p>}
{error && <p className="text-sm text-danger-400">{error}</p>}
{result && (
@@ -206,38 +214,22 @@ export function TicketActions({
*/
function TicketMeta({
ticket,
types,
onChanged,
}: {
ticket: ActionableTicket;
types: TicketType[];
onChanged: () => void;
}) {
const [types, setTypes] = useState<TicketTypeOption[]>([]);
const [tags, setTags] = useState(ticket.tags.join(', '));
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
apiFetch<{ items: TicketTypeOption[] }>('/api/dashboard/settings/ticket-types')
.then((data) => setTypes(data.items))
// Bez prava na typy se jen nenabidnou, detail ticketu tim nespadne.
.catch(() => setTypes([]));
}, []);
// Po ulozeni se detail znovu nacte, textove pole musi jit za nim.
useEffect(() => setTags(ticket.tags.join(', ')), [ticket.tags]);
async function send(path: string, body: unknown) {
setBusy(true);
setError(null);
try {
await apiFetch(`/api/dashboard/tickets/${ticket.id}/${path}`, { method: 'POST', body });
onChanged();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Změnu se nepodařilo uložit.');
} finally {
setBusy(false);
}
}
const { busy, error, submit } = useSubmit(async (path: string, body: unknown) => {
await apiFetch(`/api/dashboard/tickets/${ticket.id}/${path}`, { method: 'POST', body });
onChanged();
}, 'Změnu se nepodařilo uložit.');
const type = types.find((item) => item.id === ticket.typeId) ?? null;
@@ -246,25 +238,20 @@ function TicketMeta({
<p className="text-xs font-semibold tracking-wide text-white/45 uppercase">Typ a tagy</p>
<div className="flex flex-wrap items-center gap-2">
<select
<Select
value={ticket.typeId ?? ''}
disabled={busy || types.length === 0}
onChange={(event) =>
void send('type', { typeId: event.target.value === '' ? null : event.target.value })
void submit('type', { typeId: event.target.value === '' ? null : event.target.value })
}
className={metaFieldClass}
>
<option value="" className="bg-ink-850">
Bez typu
</option>
{types.map((item) => (
<option key={item.id} value={item.id} className="bg-ink-850">
{item.name}
</option>
))}
</select>
size="compact"
tone="card"
fullWidth={false}
placeholder="Bez typu"
options={types.map((item) => ({ value: item.id, label: item.name }))}
/>
<input
<Input
value={tags}
disabled={busy}
onChange={(event) => setTags(event.target.value)}
@@ -274,10 +261,13 @@ function TicketMeta({
.map((tag) => tag.trim())
.filter(Boolean);
// Bez zmeny se neposila nic, aby kazde opusteni pole nedelalo zapis.
if (next.join(',') !== ticket.tags.join(',')) void send('tags', { tags: next });
if (next.join(',') !== ticket.tags.join(',')) void submit('tags', { tags: next });
}}
placeholder="tagy oddělené čárkou"
className={cn(metaFieldClass, 'min-w-48 flex-1')}
size="compact"
tone="card"
fullWidth={false}
className="min-w-48 flex-1"
/>
</div>
@@ -296,7 +286,7 @@ function TicketMeta({
type={type}
values={ticket.fields}
busy={busy}
onSave={(fields) => void send('type', { typeId: type.id, fields })}
onSave={(fields) => void submit('type', { typeId: type.id, fields })}
/>
)}
@@ -305,9 +295,6 @@ function TicketMeta({
);
}
const metaFieldClass =
'rounded-lg border border-ink-600/70 bg-ink-900/70 px-2.5 py-1 text-sm text-white focus:border-brand-400/70 focus:outline-none';
/**
* Vlastni pole zvoleneho typu ticketu.
*
@@ -320,7 +307,7 @@ function TypeFields({
busy,
onSave,
}: {
type: TicketTypeOption;
type: TicketType;
values: Record<string, string | number | boolean | null>;
busy: boolean;
onSave: (fields: Record<string, string | number | boolean | null>) => void;
@@ -352,12 +339,12 @@ function TypeFields({
const value = draft[field.key];
const text = value === null || value === undefined ? '' : String(value);
return (
<label key={field.id} className="block">
<span className="mb-1 flex items-center gap-1.5 text-xs text-white/55">
{field.label}
{field.required && <span className="text-warn-400">*</span>}
</span>
<Field
key={field.id}
label={<span className="text-xs text-white/55">{field.label}</span>}
required={field.required}
hint={field.hint}
>
{field.type === 'boolean' ? (
<input
type="checkbox"
@@ -367,23 +354,17 @@ function TypeFields({
className="size-4 accent-brand-400"
/>
) : field.options && field.options.length > 0 ? (
<select
<Select
value={text}
disabled={busy}
onChange={(event) => set(field.key, event.target.value)}
className={cn(metaFieldClass, 'w-full')}
>
<option value="" className="bg-ink-850">
Nevyplněno
</option>
{field.options.map((option) => (
<option key={option.value} value={option.value} className="bg-ink-850">
{option.label}
</option>
))}
</select>
size="compact"
tone="card"
placeholder="Nevyplněno"
options={field.options}
/>
) : (
<input
<Input
type={field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : 'text'}
value={text}
disabled={busy}
@@ -398,11 +379,11 @@ function TypeFields({
: event.target.value,
)
}
className={cn(metaFieldClass, 'w-full')}
size="compact"
tone="card"
/>
)}
{field.hint && <span className="mt-1 block text-xs text-white/35">{field.hint}</span>}
</label>
</Field>
);
})}
</div>
@@ -438,52 +419,33 @@ function ActionDialog({
(field) => field.required && (values[field.id] ?? '').trim() === '',
);
const inputClass =
'w-full rounded-lg border border-ink-600/70 bg-ink-850/70 px-3 py-2 text-sm text-white focus:border-brand-400/70 focus:outline-none';
return (
<Modal open onClose={onClose} title={action.label}>
<div className="space-y-4 p-5">
{action.confirm && <p className="text-sm text-white/70">{action.confirm}</p>}
{action.form.map((field) => (
<label key={field.id} className="block">
<span className="mb-1 flex items-center gap-2 text-sm text-white/70">
{field.label}
{field.required && <span className="text-warn-400">*</span>}
</span>
<Field key={field.id} label={field.label} required={field.required} hint={field.hint}>
{field.kind === 'choice' ? (
<select
<Select
value={values[field.id] ?? ''}
onChange={(event) => setValues((p) => ({ ...p, [field.id]: event.target.value }))}
className={inputClass}
>
<option value="" className="bg-ink-850">
Vyberte
</option>
{(field.options ?? []).map((option) => (
<option key={option.value} value={option.value} className="bg-ink-850">
{option.label}
</option>
))}
</select>
placeholder="Vyberte"
options={field.options ?? []}
/>
) : field.kind === 'longtext' ? (
<textarea
<Textarea
rows={3}
value={values[field.id] ?? ''}
onChange={(event) => setValues((p) => ({ ...p, [field.id]: event.target.value }))}
className={cn(inputClass, 'resize-y')}
/>
) : (
<input
<Input
value={values[field.id] ?? ''}
onChange={(event) => setValues((p) => ({ ...p, [field.id]: event.target.value }))}
className={inputClass}
/>
)}
{field.hint && <span className="mt-1 block text-xs text-white/35">{field.hint}</span>}
</label>
</Field>
))}
<div className="flex flex-wrap items-center gap-3 border-t border-ink-600/50 pt-4">
+174
View File
@@ -0,0 +1,174 @@
import { Clock } from 'lucide-react';
import { Link } from 'react-router-dom';
import {
TicketChannelBadge,
TicketPriorityBadge,
TicketStatusBadge,
} from '@/components/dashboard/StatusBadge';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/cn';
import { formatDateTime, formatRelative } from '@/lib/format';
import type { TicketChannel, TicketPriority } from '@/types/dashboard';
/**
* Co karta potrebuje. Zamerne uzsi nez `Ticket`: widgety dostavaji ze serveru
* jen vytah (id, predmet, stav, jmeno resitele) a kreslit se maji stejne.
*/
export interface TicketCardData {
id: string;
subject: string;
status: string;
closed?: boolean;
priority?: TicketPriority;
channel?: TicketChannel;
tags?: string[];
externalId?: string | null;
reopenCount?: number;
customer?: { id: string | null; company: string } | null;
/** Objekt z detailu, nebo jen jmeno z widgetu. null = ve fronte. */
assignee?: { id: string; name: string } | string | null;
updatedAt?: string;
}
export type TicketCardHide = 'assignee' | 'customer' | 'channel' | 'priority' | 'tags';
/**
* Jedna karta ticketu.
*
* Byla nakreslena petkrat (seznam, tabulka na mobilu, detail cloveka, dva
* widgety) a kazda kopie ukazovala neco jineho. Ted je jedna:
* - `compact` = radek v seznamu s oddelovaci (widgety na prehledu),
* - jinak samostatna karta do mrizky.
*/
export function TicketCard({
ticket,
compact = false,
meId,
hide,
note,
className,
}: {
ticket: TicketCardData;
compact?: boolean;
/** Prihlaseny resitel: jeho jmeno se zvyrazni. */
meId?: string | null;
hide?: TicketCardHide[];
/** Text misto casu posledni zmeny, napr. "leží 3 dny". */
note?: string;
className?: string;
}) {
const skip = new Set(hide ?? []);
const assigneeName =
typeof ticket.assignee === 'string' ? ticket.assignee : (ticket.assignee?.name ?? null);
const assigneeId = typeof ticket.assignee === 'object' ? ticket.assignee?.id : undefined;
const mine = assigneeId !== undefined && assigneeId === meId;
const to = `/dashboard/tickety/${ticket.id}`;
if (compact) {
const meta = [
!skip.has('customer') && ticket.customer ? ticket.customer.company : null,
!skip.has('assignee') ? (assigneeName ?? 've frontě') : null,
note ?? (ticket.updatedAt ? formatRelative(ticket.updatedAt) : null),
].filter(Boolean);
return (
<li className={cn('flex items-start gap-3 py-3 first:pt-0 last:pb-0', className)}>
<span className="mt-0.5 shrink-0 font-mono text-xs text-white/35">{ticket.id}</span>
<div className="min-w-0 flex-1">
<Link
to={to}
className="block truncate text-sm text-white/80 transition-colors hover:text-brand-200"
>
{ticket.subject}
</Link>
{meta.length > 0 && (
<p className={cn('mt-0.5 truncate text-xs', mine ? 'text-brand-200' : 'text-white/40')}>
{meta.join(' | ')}
</p>
)}
</div>
<TicketStatusBadge status={ticket.status} closed={ticket.closed} />
</li>
);
}
const tags = skip.has('tags') ? [] : (ticket.tags ?? []).slice(0, 2);
return (
<Link
to={to}
className={cn(
'glass group flex flex-col rounded-card p-4 transition-colors hover:border-brand-400/40',
className,
)}
>
<div className="flex items-start justify-between gap-2">
<span className="font-mono text-xs text-white/35">{ticket.id}</span>
<TicketStatusBadge status={ticket.status} closed={ticket.closed} />
</div>
<p className="mt-2 line-clamp-2 text-sm text-white/85 group-hover:text-brand-200">
{ticket.subject}
</p>
{!skip.has('customer') && ticket.customer && (
<p className="mt-1 truncate text-xs text-white/40">
{ticket.customer.id ? (
ticket.customer.company
) : (
<span className="text-warn-400">zákazník nedohledán</span>
)}
</p>
)}
{ticket.externalId && (
<p className="mt-1 font-mono text-xs text-white/30">externí {ticket.externalId}</p>
)}
{(!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('priority') && ticket.priority && (
<TicketPriorityBadge priority={ticket.priority} />
)}
{(ticket.reopenCount ?? 0) > 0 && (
<Badge tone="warn">{ticket.reopenCount}x vráceno</Badge>
)}
{tags.map((tag) => (
<Badge key={tag} tone="neutral">
{tag}
</Badge>
))}
</div>
)}
<div className="mt-3 flex items-center justify-between gap-2 border-t border-ink-600/50 pt-3 text-xs">
{!skip.has('assignee') ? (
<span
className={cn(
'truncate',
assigneeName ? (mine ? 'text-brand-200' : 'text-white/55') : 'text-white/30',
)}
>
{assigneeName ?? 've frontě'}
</span>
) : (
<span />
)}
{note ? (
<span className="shrink-0 text-white/35">{note}</span>
) : (
ticket.updatedAt && (
<span
className="flex shrink-0 items-center gap-1 text-white/35"
title={formatDateTime(ticket.updatedAt)}
>
<Clock className="size-3" />
{formatRelative(ticket.updatedAt)}
</span>
)
)}
</div>
</Link>
);
}
+103 -133
View File
@@ -1,13 +1,14 @@
import { Clock, RotateCcw } from 'lucide-react';
import { RotateCcw } from 'lucide-react';
import { Link } from 'react-router-dom';
import {
TicketChannelBadge,
TicketPriorityBadge,
TicketStatusBadge,
} from '@/components/dashboard/StatusBadge';
import { Badge } from '@/components/ui/Badge';
import { TicketCard } from '@/components/dashboard/TicketCard';
import { cn } from '@/lib/cn';
import { formatDateTime, formatRelative } from '@/lib/format';
import { MD_UP, useMediaQuery } from '@/lib/useMediaQuery';
import type { Ticket } from '@/types/dashboard';
/**
@@ -20,6 +21,9 @@ import type { Ticket } from '@/types/dashboard';
* **Na mobilu se nescroluje do strany.** Uzka obrazovka dostane misto tabulky
* karty se stejnymi udaji: vodorovne posouvani v tabulce o osmi sloupcich je
* na telefonu nepouzitelne a schovavat sloupce znamena, ze udaj proste zmizi.
*
* O tom, co se kresli, rozhoduje `matchMedia`, ne CSS. Driv byly v DOMu obe
* podoby a jedna z nich schovana - kazdy ticket dvakrat.
*/
export function TicketTable({
tickets,
@@ -33,6 +37,8 @@ export function TicketTable({
hide?: Array<'assignee' | 'customer' | 'channel'>;
empty?: string;
}) {
const wide = useMediaQuery(MD_UP);
if (tickets.length === 0) {
return (
<p className="rounded-xl border border-dashed border-ink-600/70 px-4 py-6 text-center text-sm text-white/40">
@@ -43,142 +49,106 @@ export function TicketTable({
const skip = new Set(hide ?? []);
return (
<>
{/* Tabulka od tabletu vys. */}
<div className="hidden overflow-x-auto md:block">
<table className="w-full border-collapse text-left text-sm">
<thead className="border-b border-ink-600/60 text-xs tracking-wide text-white/40 uppercase">
<tr>
<th className="px-3 py-3 font-medium">Ticket</th>
{!skip.has('customer') && <th className="px-3 py-3 font-medium">Zákazník</th>}
{!skip.has('channel') && <th className="px-3 py-3 font-medium">Kanál</th>}
<th className="px-3 py-3 font-medium">Stav</th>
<th className="px-3 py-3 font-medium">Priorita</th>
{!skip.has('assignee') && <th className="px-3 py-3 font-medium">Řeší</th>}
<th className="px-3 py-3 text-right font-medium">Aktualizace</th>
</tr>
</thead>
<tbody className="divide-y divide-ink-600/40">
{tickets.map((ticket) => (
<tr key={ticket.id} className="transition-colors hover:bg-white/[0.03]">
<td className="px-3 py-3">
<Link
to={`/dashboard/tickety/${ticket.id}`}
className="block text-white/85 transition-colors hover:text-brand-200"
>
{ticket.subject}
</Link>
<span className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 font-mono text-xs text-white/35">
{ticket.id}
{ticket.externalId && <span>externí {ticket.externalId}</span>}
{ticket.reopenCount > 0 && (
<span className="flex items-center gap-1 font-sans text-warn-400">
<RotateCcw className="size-3" />
{ticket.reopenCount}x vráceno
</span>
)}
</span>
</td>
{!skip.has('customer') && (
<td className="px-3 py-3">
{ticket.customer.id ? (
<span className="text-white/55">{ticket.customer.company}</span>
) : (
<span className="text-warn-400">nedohledáno</span>
)}
{ticket.customer.contact && (
<span className="block text-xs text-white/30">{ticket.customer.contact}</span>
)}
</td>
)}
{!skip.has('channel') && (
<td className="px-3 py-3">
<TicketChannelBadge channel={ticket.channel} />
</td>
)}
<td className="px-3 py-3">
<TicketStatusBadge status={ticket.status} closed={ticket.closed} />
</td>
<td className="px-3 py-3">
<TicketPriorityBadge priority={ticket.priority} />
</td>
{!skip.has('assignee') && (
<td className="px-3 py-3">
{ticket.assignee ? (
<Link
to={`/dashboard/lide/${ticket.assignee.id}`}
className={cn(
'transition-colors hover:text-brand-200',
ticket.assignee.id === meId ? 'text-brand-200' : 'text-white/55',
)}
>
{ticket.assignee.name}
</Link>
) : (
<span className="text-white/30">ve frontě</span>
)}
</td>
)}
<td className="px-3 py-3 text-right whitespace-nowrap text-white/45">
<span title={formatDateTime(ticket.updatedAt)}>
{formatRelative(ticket.updatedAt)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Na uzke obrazovce karty misto posouvani do strany. */}
<ul className="divide-y divide-ink-600/40 md:hidden">
if (!wide) {
// Na uzke obrazovce karty misto posouvani do strany.
return (
<div className="grid gap-3">
{tickets.map((ticket) => (
<li key={ticket.id} className="py-3 first:pt-0 last:pb-0">
<Link to={`/dashboard/tickety/${ticket.id}`} className="block">
<div className="flex items-start justify-between gap-2">
<span className="font-mono text-xs text-white/35">{ticket.id}</span>
<TicketCard key={ticket.id} ticket={ticket} meId={meId} hide={hide} />
))}
</div>
);
}
return (
<div className="overflow-x-auto">
<table className="w-full border-collapse text-left text-sm">
<thead className="border-b border-ink-600/60 text-xs tracking-wide text-white/40 uppercase">
<tr>
<th className="px-3 py-3 font-medium">Ticket</th>
{!skip.has('customer') && <th className="px-3 py-3 font-medium">Zákazník</th>}
{!skip.has('channel') && <th className="px-3 py-3 font-medium">Kanál</th>}
<th className="px-3 py-3 font-medium">Stav</th>
<th className="px-3 py-3 font-medium">Priorita</th>
{!skip.has('assignee') && <th className="px-3 py-3 font-medium">Řeší</th>}
<th className="px-3 py-3 text-right font-medium">Aktualizace</th>
</tr>
</thead>
<tbody className="divide-y divide-ink-600/40">
{tickets.map((ticket) => (
<tr key={ticket.id} className="transition-colors hover:bg-white/[0.03]">
<td className="px-3 py-3">
<Link
to={`/dashboard/tickety/${ticket.id}`}
className="block text-white/85 transition-colors hover:text-brand-200"
>
{ticket.subject}
</Link>
<span className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 font-mono text-xs text-white/35">
{ticket.id}
{ticket.externalId && <span>externí {ticket.externalId}</span>}
{ticket.reopenCount > 0 && (
<span className="flex items-center gap-1 font-sans text-warn-400">
<RotateCcw className="size-3" />
{ticket.reopenCount}x vráceno
</span>
)}
</span>
</td>
{!skip.has('customer') && (
<td className="px-3 py-3">
{ticket.customer.id ? (
<span className="text-white/55">{ticket.customer.company}</span>
) : (
<span className="text-warn-400">nedohledáno</span>
)}
{ticket.customer.contact && (
<span className="block text-xs text-white/30">{ticket.customer.contact}</span>
)}
</td>
)}
{!skip.has('channel') && (
<td className="px-3 py-3">
<TicketChannelBadge channel={ticket.channel} />
</td>
)}
<td className="px-3 py-3">
<TicketStatusBadge status={ticket.status} closed={ticket.closed} />
</div>
<p className="mt-1 text-sm text-white/85">{ticket.subject}</p>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
{!skip.has('channel') && <TicketChannelBadge channel={ticket.channel} />}
</td>
<td className="px-3 py-3">
<TicketPriorityBadge priority={ticket.priority} />
{ticket.externalId && <Badge tone="neutral">externí {ticket.externalId}</Badge>}
{ticket.reopenCount > 0 && <Badge tone="warn">{ticket.reopenCount}x vráceno</Badge>}
</div>
</td>
<div className="mt-2 flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-xs text-white/40">
{!skip.has('customer') && (
<span className="truncate">
{ticket.customer.id ? (
ticket.customer.company
) : (
<span className="text-warn-400">zákazník nedohledán</span>
)}
</span>
)}
{!skip.has('assignee') && (
<span className={cn(ticket.assignee?.id === meId && 'text-brand-200')}>
{ticket.assignee?.name ?? 've frontě'}
</span>
)}
<span className="flex items-center gap-1">
<Clock className="size-3" />
{!skip.has('assignee') && (
<td className="px-3 py-3">
{ticket.assignee ? (
<Link
to={`/dashboard/lide/${ticket.assignee.id}`}
className={cn(
'transition-colors hover:text-brand-200',
ticket.assignee.id === meId ? 'text-brand-200' : 'text-white/55',
)}
>
{ticket.assignee.name}
</Link>
) : (
<span className="text-white/30">ve frontě</span>
)}
</td>
)}
<td className="px-3 py-3 text-right whitespace-nowrap text-white/45">
<span title={formatDateTime(ticket.updatedAt)}>
{formatRelative(ticket.updatedAt)}
</span>
</div>
</Link>
</li>
))}
</ul>
</>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
@@ -5,16 +5,7 @@
* jako role: patri to pod zalozku Firma.
*/
import { EntityAdmin, parseJsonField } from '@/components/dashboard/EntityAdmin';
/** Zaznam ciselniku tak, jak ho vraci EntityAdmin. Stejny tvar jako v nastaveni. */
interface NamedRecord {
id: string;
name?: string;
label?: string;
[key: string]: unknown;
}
import { EntityAdmin, parseJsonField, type NamedRecord } from '@/components/dashboard/EntityAdmin';
export function TicketTypesAdmin() {
return (
@@ -0,0 +1,159 @@
import { ChevronDown } from 'lucide-react';
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 { cn } from '@/lib/cn';
import { resolveOperation } from '@/lib/flow';
import { serviceIcon } from '@/lib/serviceIcons';
import type { FlowStep, Service, TriggerField } from '@/types/dashboard';
/**
* Karta akce nad konektorem.
*
* `memo` a callbacky podle ID: psani do pole jedne akce meni jen jeji krok,
* ostatni karty dostanou stejne props a nekresli se znovu.
*/
export const ActionCard = memo(function ActionCard({
step,
connectors,
available,
context,
canMoveUp,
canMoveDown,
onRemoveStep,
onMoveStep,
onUpdateInputs,
}: {
step: Extract<FlowStep, { kind: 'action' }>;
connectors: Service[];
/** Parametry viditelne prave v tomto miste stromu. */
available: TriggerField[];
context: CanvasContext;
canMoveUp: boolean;
canMoveDown: boolean;
onRemoveStep: (stepId: string) => void;
onMoveStep: (stepId: string, offset: number) => void;
onUpdateInputs: (stepId: string, inputs: Record<string, string>) => void;
}) {
const [open, setOpen] = useState(false);
// Hledani v katalogu se nedela pri kazdem prekresleni, jen kdyz se zmeni krok.
const resolved = useMemo(
() => resolveOperation(connectors, step.serviceId, step.operationId, 'action'),
[connectors, step.serviceId, step.operationId],
);
if (!resolved) {
return (
<BrokenCard
label="Krok odkazuje na neznámou akci"
detail={`${step.serviceId} / ${step.operationId}`}
onRemove={() => onRemoveStep(step.id)}
/>
);
}
const Icon = serviceIcon(resolved.connector.icon);
const inputs = resolved.operation.inputs;
/*
* Sbalene kroky.
*
* Strom o peti krocich s vyplnenymi poli je pres celou obrazovku a nejde
* v nem najit, co kde je. Sbaleny krok ukazuje jen to podstatne: co dela
* a co ma vyplneno. Vychozi je **sbaleno**, protoze vetsinou clovek strom
* cte, ne prenastavuje.
*/
const filled = Object.entries(step.inputs ?? {}).filter(([, value]) => value.trim() !== '');
return (
<div className="@container rounded-card border border-ink-600/60 bg-ink-800/60 p-3 transition-colors hover:border-ink-600 sm:p-4">
<div className="flex items-start gap-2.5 sm:gap-3">
<button
type="button"
onClick={() => setOpen(!open)}
aria-expanded={open}
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" />
</button>
<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}
<ChevronDown
className={cn(
'size-4 shrink-0 text-white/30 transition-transform',
open && 'rotate-180',
)}
/>
</span>
{open ? (
/* Popis je jen doplnek. V uzkem sloupci by se lamal po slovech. */
<span className="mt-0.5 hidden text-sm text-white/50 @xs:block">
{resolved.operation.description}
</span>
) : (
/*
* Sbalene: co ma krok vyplneno. Tohle clovek hleda, kdyz cte strom -
* ne popis operace, ktery zna.
*/
<span className="mt-1 flex flex-wrap gap-1.5">
{filled.length === 0 ? (
<span className="text-xs text-white/30">nic nevyplněno</span>
) : (
filled.slice(0, 4).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"
title={`${key}: ${value}`}
>
{key}: {value}
</span>
))
)}
{filled.length > 4 && (
<span className="text-xs text-white/30">a {filled.length - 4} dalších</span>
)}
</span>
)}
</button>
<StepControls
canMoveUp={canMoveUp}
canMoveDown={canMoveDown}
onMove={(offset) => onMoveStep(step.id, offset)}
onRemove={() => onRemoveStep(step.id)}
/>
</div>
{open &&
(inputs && inputs.length > 0 ? (
<StepInputs
fields={inputs}
values={step.inputs ?? {}}
available={available}
model={context.model}
extraRoots={context.extraRoots}
onChange={(next) => onUpdateInputs(step.id, next)}
/>
) : (
// Akce, ktere zatim nemaji `inputs`. Radeji to rekneme nahlas,
// nez aby uzivatel hledal nastaveni, ktere neexistuje.
<p className="mt-3 border-t border-ink-600/50 pt-3 text-xs text-white/35">
Tuhle akci zatím nejde nastavit.
{resolved.operation.fields && resolved.operation.fields.length > 0 && (
<> Až půjde, budou to pole: {resolved.operation.fields.join(', ')}.</>
)}
</p>
))}
</div>
);
});
@@ -0,0 +1,292 @@
import { GitBranch, Plus, Trash2 } from 'lucide-react';
import { memo } from 'react';
import type { CanvasCallbacks, RenderSteps } from '@/components/dashboard/flow/canvasTypes';
import { StepControls } from '@/components/dashboard/flow/StepControls';
import { Badge } from '@/components/ui/Badge';
import { Input } from '@/components/ui/form/Input';
import { Select } from '@/components/ui/form/Select';
import { cn } from '@/lib/cn';
import {
defaultOperatorFor,
describeRules,
isUnaryOperator,
matchOf,
operatorLabel,
operatorsByType,
rulesOf,
type FlowPath,
} from '@/lib/flow';
import type {
ConditionMatch,
ConditionOperator,
ConditionRule,
FlowStep,
TriggerField,
} from '@/types/dashboard';
/** Podminka: otazky nad parametry a dve vetve. */
export const ConditionCard = memo(function ConditionCard({
step,
path,
available: fields,
canMoveUp,
canMoveDown,
onRemoveStep,
onMoveStep,
onUpdateCondition,
renderSteps,
}: {
step: Extract<FlowStep, { kind: 'condition' }>;
path: FlowPath;
available: TriggerField[];
canMoveUp: boolean;
canMoveDown: boolean;
onRemoveStep: CanvasCallbacks['onRemoveStep'];
onMoveStep: CanvasCallbacks['onMoveStep'];
onUpdateCondition: CanvasCallbacks['onUpdateCondition'];
renderSteps: RenderSteps;
}) {
/*
* Podminka se muze ptat na vic veci naraz.
*
* Zapisuje se vzdycky `rules`, i kdyz je otazka jedna - stara podoba se tim
* pri prvni uprave prevede a dal uz existuje jen jeden tvar.
*/
const rules = rulesOf(step);
const match = matchOf(step);
const missing = rules.some((rule) => {
const field = fields.find((f) => f.id === rule.fieldId);
return field && !isUnaryOperator(rule.operator) && (rule.value ?? '').trim().length === 0;
});
function writeRules(next: ConditionRule[], nextMatch: ConditionMatch = match) {
onUpdateCondition(step.id, { rules: next, match: nextMatch });
}
function changeRule(index: number, patch: Partial<ConditionRule>) {
writeRules(rules.map((rule, i) => (i === index ? { ...rule, ...patch } : rule)));
}
/** Zmena parametru muze zneplatnit operator - v tom pripade ho prepneme. */
function changeField(index: number, fieldId: string) {
const next = fields.find((f) => f.id === fieldId);
if (!next) {
console.warn(`[flow] podminka: neznamy parametr ${fieldId}`);
return;
}
const rule = rules[index];
const stillValid = operatorsByType[next.type].includes(rule.operator);
changeRule(index, {
fieldId,
operator: stillValid ? rule.operator : defaultOperatorFor(next.type),
value: stillValid ? rule.value : '',
});
}
/** Nova otazka se pta na tentyz parametr jako ta prvni, aby nebyla prazdna. */
function addRule() {
const first = fields[0];
if (!first) return;
const base = fields.find((f) => f.id === rules[0]?.fieldId) ?? first;
writeRules([
...rules,
{ fieldId: base.id, operator: defaultOperatorFor(base.type), value: '' },
]);
}
function removeRule(index: number) {
// Posledni otazka se nemaze: podminka bez otazky by tise nevetvila.
if (rules.length <= 1) return;
writeRules(rules.filter((_, i) => i !== index));
}
return (
<div className="@container rounded-card border border-accent-400/30 bg-accent-500/6 p-3 sm:p-4">
<div className="flex items-start gap-2.5 sm:gap-3">
<span className="grid size-8 shrink-0 place-items-center rounded-xl bg-accent-500/20 text-accent-300 @xs:size-10">
<GitBranch className="size-4 @xs:size-4.5" />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-xs text-white/40">Podmínka</p>
{missing && <Badge tone="warn">Doplňte hodnotu</Badge>}
</div>
<p className="mt-0.5 font-semibold break-words text-white">
{describeRules(step, fields)}
</p>
{/*
Spojka se nabizi az od druhe otazky. U jedne nema co spojovat
a prepinac, ktery nic nedela, jen mate.
*/}
{rules.length > 1 && (
<div className="mt-2.5 flex flex-wrap items-center gap-2">
<span className="text-xs text-white/40">Platí, když</span>
{(['all', 'any'] as const).map((option) => (
<button
key={option}
type="button"
onClick={() => writeRules(rules, option)}
className={cn(
'rounded-lg px-2.5 py-1 text-xs font-medium transition-colors',
match === option
? 'bg-accent-500/20 text-accent-200'
: 'text-white/45 hover:text-white',
)}
>
{option === 'all' ? 'sedí všechny' : 'sedí aspoň jedna'}
</button>
))}
</div>
)}
{/* Radek na otazku: parametr - operator - hodnota */}
<div className="mt-2.5 space-y-2">
{rules.map((rule, index) => {
const field = fields.find((f) => f.id === rule.fieldId);
const allowed = field ? operatorsByType[field.type] : [];
const needsValue = !isUnaryOperator(rule.operator);
const valueMissing = needsValue && (rule.value ?? '').trim().length === 0;
return (
/*
Klic je poradi zamerne: otazka nema vlastni ID a jeji obsah se
meni pri psani. Radky se pridavaji jen na konec a mazou jednotlive,
takze poradi je tu stabilni identita.
*/
<div key={`rule-${index}`} className="flex flex-wrap items-center gap-2">
{index > 0 && (
<span className="w-16 shrink-0 text-xs text-white/35">
{match === 'any' ? 'nebo' : 'a zároveň'}
</span>
)}
<Select
value={field ? rule.fieldId : ''}
onChange={(event) => changeField(index, event.target.value)}
aria-label="Parametr"
size="compact"
tone="card"
fullWidth={false}
className="font-mono"
>
{!field && (
<option value="" className="bg-ink-850">
- vyberte parametr -
</option>
)}
{fields.map((option) => (
<option key={option.id} value={option.id} className="bg-ink-850">
{option.name || '(bez názvu)'}
</option>
))}
</Select>
<Select
value={rule.operator}
onChange={(event) =>
changeRule(index, { operator: event.target.value as ConditionOperator })
}
disabled={!field}
aria-label="Operátor"
size="compact"
tone="card"
fullWidth={false}
options={allowed.map((operator) => ({
value: operator,
label: field ? operatorLabel(operator, field.type) : operator,
}))}
/>
{needsValue && field && (
<Input
value={rule.value ?? ''}
onChange={(event) => changeRule(index, { value: event.target.value })}
type={
field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : 'text'
}
placeholder={field.type === 'number' ? '15' : 'hodnota'}
aria-label="Hodnota"
size="compact"
tone="card"
invalid={valueMissing}
fullWidth={false}
className="w-32"
/>
)}
{!field && (
<span className="text-xs text-danger-400">parametr už neexistuje</span>
)}
{rules.length > 1 && (
<button
type="button"
onClick={() => removeRule(index)}
aria-label="Odebrat otázku"
className="rounded-lg px-2 py-1.5 text-white/35 transition-colors hover:text-danger-400"
>
<Trash2 className="size-3.5" />
</button>
)}
</div>
);
})}
<button
type="button"
onClick={addRule}
className="inline-flex items-center gap-1.5 text-xs font-medium text-accent-300 transition-colors hover:text-accent-200"
>
<Plus className="size-3.5" />
Přidat otázku
</button>
</div>
</div>
<StepControls
canMoveUp={canMoveUp}
canMoveDown={canMoveDown}
onMove={(offset) => onMoveStep(step.id, offset)}
onRemove={() => onRemoveStep(step.id)}
/>
</div>
{/*
Vetve vedle sebe jen tehdy, kdyz je na to v TETO karte misto.
Rozhoduje sirka karty, ne okna - kazde zanoreni ji puli, takze
od urcite hloubky se vetve samy prepnou pod sebe misto toho,
aby se karty zmackly na par pixelu.
*/}
<div className="mt-4 grid gap-4 @2xl:grid-cols-2">
{(['yes', 'no'] as const).map((branch) => (
<div
key={branch}
className={cn(
'min-w-0 rounded-xl border p-3',
branch === 'yes'
? 'border-ok-400/25 bg-ok-500/5'
: 'border-danger-400/25 bg-danger-500/5',
)}
>
<p
className={cn(
'mb-1 text-xs font-semibold tracking-wide uppercase',
branch === 'yes' ? 'text-ok-400' : 'text-danger-400',
)}
>
{branch === 'yes' ? 'Ano' : 'Ne'}
</p>
{step[branch].length === 0 && (
<p className="mb-1 text-xs text-white/35">
Zatím prázdná větev - přidejte krok tlačítkem níž.
</p>
)}
{renderSteps(step[branch], [...path, { stepId: step.id, branch }])}
</div>
))}
</div>
</div>
);
});
+136 -733
View File
@@ -1,36 +1,16 @@
import {
ChevronDown,
ChevronUp,
GitBranch,
MousePointerClick,
Plus,
Repeat,
Trash2,
Zap,
} from 'lucide-react';
import { useMemo, useState } from 'react';
import { StepInputs } from '@/components/dashboard/flow/StepInputs';
import { MousePointerClick, Zap } from 'lucide-react';
import { useCallback, useMemo, useRef } 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';
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 { cn } from '@/lib/cn';
import { serviceIcon } from '@/lib/serviceIcons';
import {
collectScopes,
defaultOperatorFor,
describeRules,
isUnaryOperator,
matchOf,
operatorLabel,
operatorsByType,
resolveOperation,
rulesOf,
type FlowPath,
} from '@/lib/flow';
import { collectScopes, resolveOperation, type FlowPath } from '@/lib/flow';
import type {
AutomationFlow,
ConditionMatch,
ConditionOperator,
ConditionRule,
ModelNode,
Service,
FlowStep,
@@ -43,37 +23,51 @@ import type {
*
* Struktura: karta spoustece -> sekvence kroku. Podminka rozdeluje beh
* na vetev ANO a NE, kazda ma vlastni sekvenci a vlastni "+".
*/
/**
* Co kroky potrebuji vedet krome svych vlastnich dat.
*
* Chodi to skrz celý strom, proto pohromade - jinak by kazde zanoreni pridalo
* dalsi dva parametry a signatury by prestaly byt citelne.
* Karty kroku jsou v `ActionCard`, `ConditionCard` a `ForeachCard`, spolecne
* ovladani ve `StepControls`. Tady zustava jen spoustec a sekvence.
*/
interface CanvasContext {
/** Cesty z ukazky tela u spoustece. Daji se kliknout do sablony. */
model: ModelNode[];
/** Koreny odkazu, ktere jsou v poradku i mimo deklarovane parametry. */
extraRoots: Set<string>;
/** ID vsech kroku vcetne vnorenych, v poradi stromu. */
function collectIds(steps: FlowStep[], into: string[] = []): string[] {
for (const step of steps) {
into.push(step.id);
if (step.kind === 'condition') {
collectIds(step.yes, into);
collectIds(step.no, into);
} else if (step.kind === 'foreach') {
collectIds(step.steps, into);
}
}
return into;
}
interface CanvasCallbacks {
onAddStep: (path: FlowPath, index: number) => void;
onRemoveStep: (stepId: string) => void;
onUpdateCondition: (
stepId: string,
patch: {
rules?: ConditionRule[];
match?: ConditionMatch;
fieldId?: string;
operator?: ConditionOperator;
value?: string;
},
) => void;
onUpdateInputs: (stepId: string, inputs: Record<string, string>) => void;
/** Zmena cesty k seznamu u smycky. */
onUpdateForeach: (stepId: string, path: string) => void;
onMoveStep: (stepId: string, offset: number) => void;
/**
* Scopes se stabilni identitou.
*
* `collectScopes` vraci pri kazdem prepocitani nove seznamy, i kdyz se pro
* 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.
*/
function useStableScopes(
flow: AutomationFlow,
connectors: Service[],
): Map<string, TriggerField[]> {
const stable = useRef(new Map<string, TriggerField[]>());
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;
}
export function FlowCanvas({
@@ -116,26 +110,30 @@ export function FlowCanvas({
* Co smi stat na zacatku odkazu krome deklarovanych parametru: klice
* z ukazky tela, ID kroku (`{{st_faktura.invoiceId}}`) a to, co pridava
* smycka. Bez toho by builder hlasil kazdou cestu jako rozbitou.
*
* Zavisi jen na ID kroku, ne na jejich obsahu - psani do pole ho nemeni.
*/
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*\]$/, ''));
const collect = (steps: FlowStep[]): void => {
for (const step of steps) {
roots.add(step.id);
if (step.kind === 'condition') {
collect(step.yes);
collect(step.no);
} else if (step.kind === 'foreach') {
collect(step.steps);
}
}
};
collect(flow.steps);
for (const id of idsKey.split(',')) if (id) roots.add(id);
return { model, extraRoots: roots };
}, [model, flow.steps]);
}, [model, idsKey]);
/*
* Aby `collectScopes` videlo parametry i bez spoustece, podstrci se mu
* nahradni. Do ulozeneho stromu se to nikdy nedostane, je to jen pro vypocet,
* na co se da v krocich odkazat.
*/
const scopedFlow = useMemo<AutomationFlow>(
() =>
start
? { ...flow, trigger: { serviceId: 'ticket', operationId: 'action', fields: start.fields } }
: flow,
[flow, start],
);
const scopes = useStableScopes(scopedFlow, connectors);
return (
<div className="flex flex-col items-stretch">
@@ -160,7 +158,7 @@ export function FlowCanvas({
steps={flow.steps}
path={[]}
connectors={connectors}
scopes={collectScopes({ ...flow, trigger: startTrigger(flow, start) }, connectors)}
scopes={scopes}
fallbackFields={fallbackFields}
context={context}
{...callbacks}
@@ -170,19 +168,6 @@ export function FlowCanvas({
);
}
/**
* Aby `collectScopes` videlo parametry i bez spoustece, podstrci se mu
* nahradni. Do ulozeneho stromu se to nikdy nedostane, je to jen pro vypocet,
* na co se da v krocich odkazat.
*/
function startTrigger(
flow: AutomationFlow,
start?: { fields: TriggerField[] },
): AutomationFlow['trigger'] {
if (!start) return flow.trigger;
return { serviceId: 'ticket', operationId: 'action', fields: start.fields };
}
/** Prvni karta u akce: spousti to clovek, ne sluzba. */
function StartCard({ start }: { start: { label: string; hint: string; fields: TriggerField[] } }) {
return (
@@ -241,7 +226,16 @@ function TriggerCard({
onChangeSample?: (sample: unknown) => void;
onRegenerateToken: () => void;
}) {
if (!flow.trigger) {
const trigger = flow.trigger;
const resolved = useMemo(
() =>
trigger
? resolveOperation(connectors, trigger.serviceId, trigger.operationId, 'trigger')
: null,
[connectors, trigger],
);
if (!trigger) {
return (
<button
type="button"
@@ -261,18 +255,11 @@ function TriggerCard({
);
}
const resolved = resolveOperation(
connectors,
flow.trigger.serviceId,
flow.trigger.operationId,
'trigger',
);
if (!resolved) {
return (
<BrokenCard
label="Spouštěč odkazuje na neznámý konektor"
detail={`${flow.trigger.serviceId} / ${flow.trigger.operationId}`}
detail={`${trigger.serviceId} / ${trigger.operationId}`}
onFix={onPick}
/>
);
@@ -307,7 +294,7 @@ function TriggerCard({
</div>
<TriggerConfig
trigger={flow.trigger}
trigger={trigger}
connector={resolved.connector}
webhookBaseUrl={webhookBaseUrl}
model={model}
@@ -341,7 +328,50 @@ function StepSequence({
context: CanvasContext;
compact?: boolean;
}) {
const { onAddStep, onRemoveStep, onMoveStep, onUpdateInputs } = callbacks;
const {
onAddStep,
onRemoveStep,
onMoveStep,
onUpdateInputs,
onUpdateCondition,
onUpdateForeach,
} = callbacks;
/*
* Vnorena sekvence jako funkce. Stabilni diky useCallback, takze karta
* podminky se neprekresli jen proto, ze se psalo do jine karty.
*/
const renderSteps = useCallback(
(nested: FlowStep[], nestedPath: FlowPath) => (
<StepSequence
steps={nested}
path={nestedPath}
connectors={connectors}
scopes={scopes}
fallbackFields={fallbackFields}
context={context}
compact
onAddStep={onAddStep}
onRemoveStep={onRemoveStep}
onMoveStep={onMoveStep}
onUpdateInputs={onUpdateInputs}
onUpdateCondition={onUpdateCondition}
onUpdateForeach={onUpdateForeach}
/>
),
[
connectors,
scopes,
fallbackFields,
context,
onAddStep,
onRemoveStep,
onMoveStep,
onUpdateInputs,
onUpdateCondition,
onUpdateForeach,
],
);
return (
<div className="flex flex-col items-stretch">
@@ -357,34 +387,32 @@ function StepSequence({
context={context}
canMoveUp={index > 0}
canMoveDown={index < steps.length - 1}
onRemove={() => onRemoveStep(step.id)}
onMove={(offset) => onMoveStep(step.id, offset)}
onUpdateInputs={(inputs) => onUpdateInputs(step.id, inputs)}
onRemoveStep={onRemoveStep}
onMoveStep={onMoveStep}
onUpdateInputs={onUpdateInputs}
/>
) : step.kind === 'foreach' ? (
<ForeachCard
step={step}
path={path}
connectors={connectors}
scopes={scopes}
fallbackFields={fallbackFields}
context={context}
canMoveUp={index > 0}
canMoveDown={index < steps.length - 1}
{...callbacks}
onRemoveStep={onRemoveStep}
onMoveStep={onMoveStep}
onUpdateForeach={onUpdateForeach}
renderSteps={renderSteps}
/>
) : (
<ConditionCard
step={step}
path={path}
connectors={connectors}
scopes={scopes}
fallbackFields={fallbackFields}
context={context}
available={scopes.get(step.id) ?? fallbackFields}
canMoveUp={index > 0}
canMoveDown={index < steps.length - 1}
{...callbacks}
onRemoveStep={onRemoveStep}
onMoveStep={onMoveStep}
onUpdateCondition={onUpdateCondition}
renderSteps={renderSteps}
/>
)}
</div>
@@ -394,628 +422,3 @@ function StepSequence({
</div>
);
}
/** Spojnice + tlacitko "+". Tohle je to misto, kde se strom rozsiruje. */
function AddStepButton({
onClick,
compact = false,
last = false,
}: {
onClick: () => void;
compact?: boolean;
last?: boolean;
}) {
return (
<div className="flex flex-col items-center">
<span className={cn('w-px bg-ink-600', compact ? 'h-3' : 'h-4')} aria-hidden />
<button
type="button"
onClick={onClick}
title="Přidat krok"
className={cn(
'group grid place-items-center rounded-full border border-dashed border-ink-600 bg-ink-850 text-white/40 transition-all hover:scale-110 hover:border-brand-400 hover:bg-brand-500/15 hover:text-brand-300',
compact ? 'size-7' : 'size-8',
)}
>
<Plus className={compact ? 'size-3.5' : 'size-4'} />
<span className="sr-only">Přidat krok</span>
</button>
{!last && <span className={cn('w-px bg-ink-600', compact ? 'h-3' : 'h-4')} aria-hidden />}
</div>
);
}
function ActionCard({
step,
connectors,
available,
context,
canMoveUp,
canMoveDown,
onRemove,
onMove,
onUpdateInputs,
}: {
step: Extract<FlowStep, { kind: 'action' }>;
connectors: Service[];
/** Parametry viditelne prave v tomto miste stromu. */
available: TriggerField[];
context: CanvasContext;
canMoveUp: boolean;
canMoveDown: boolean;
onRemove: () => void;
onMove: (offset: number) => void;
onUpdateInputs: (inputs: Record<string, string>) => void;
}) {
const [open, setOpen] = useState(false);
const resolved = resolveOperation(connectors, step.serviceId, step.operationId, 'action');
if (!resolved) {
return (
<BrokenCard
label="Krok odkazuje na neznámou akci"
detail={`${step.serviceId} / ${step.operationId}`}
onRemove={onRemove}
/>
);
}
const Icon = serviceIcon(resolved.connector.icon);
const inputs = resolved.operation.inputs;
/*
* Sbalene kroky.
*
* Strom o peti krocich s vyplnenymi poli je pres celou obrazovku a nejde
* v nem najit, co kde je. Sbaleny krok ukazuje jen to podstatne: co dela
* a co ma vyplneno. Vychozi je **sbaleno**, protoze vetsinou clovek strom
* cte, ne prenastavuje.
*/
const filled = Object.entries(step.inputs ?? {}).filter(([, value]) => value.trim() !== '');
return (
<div className="@container rounded-card border border-ink-600/60 bg-ink-800/60 p-3 transition-colors hover:border-ink-600 sm:p-4">
<div className="flex items-start gap-2.5 sm:gap-3">
<button
type="button"
onClick={() => setOpen(!open)}
aria-expanded={open}
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" />
</button>
<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}
<ChevronDown
className={cn(
'size-4 shrink-0 text-white/30 transition-transform',
open && 'rotate-180',
)}
/>
</span>
{open ? (
/* Popis je jen doplnek. V uzkem sloupci by se lamal po slovech. */
<span className="mt-0.5 hidden text-sm text-white/50 @xs:block">
{resolved.operation.description}
</span>
) : (
/*
* Sbalene: co ma krok vyplneno. Tohle clovek hleda, kdyz cte strom -
* ne popis operace, ktery zna.
*/
<span className="mt-1 flex flex-wrap gap-1.5">
{filled.length === 0 ? (
<span className="text-xs text-white/30">nic nevyplněno</span>
) : (
filled.slice(0, 4).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"
title={`${key}: ${value}`}
>
{key}: {value}
</span>
))
)}
{filled.length > 4 && (
<span className="text-xs text-white/30">a {filled.length - 4} dalších</span>
)}
</span>
)}
</button>
<StepControls
canMoveUp={canMoveUp}
canMoveDown={canMoveDown}
onMove={onMove}
onRemove={onRemove}
/>
</div>
{open &&
(inputs && inputs.length > 0 ? (
<StepInputs
fields={inputs}
values={step.inputs ?? {}}
available={available}
model={context.model}
extraRoots={context.extraRoots}
onChange={onUpdateInputs}
/>
) : (
// Akce, ktere zatim nemaji `inputs`. Radeji to rekneme nahlas,
// nez aby uzivatel hledal nastaveni, ktere neexistuje.
<p className="mt-3 border-t border-ink-600/50 pt-3 text-xs text-white/35">
Tuhle akci zatím nejde nastavit.
{resolved.operation.fields && resolved.operation.fields.length > 0 && (
<> Až půjde, budou to pole: {resolved.operation.fields.join(', ')}.</>
)}
</p>
))}
</div>
);
}
function ConditionCard({
step,
path,
connectors,
scopes,
fallbackFields,
context,
available: fields,
canMoveUp,
canMoveDown,
...callbacks
}: CanvasCallbacks & {
step: Extract<FlowStep, { kind: 'condition' }>;
path: FlowPath;
connectors: Service[];
scopes: Map<string, TriggerField[]>;
fallbackFields: TriggerField[];
context: CanvasContext;
available: TriggerField[];
canMoveUp: boolean;
canMoveDown: boolean;
}) {
const { onRemoveStep, onMoveStep, onUpdateCondition } = callbacks;
/*
* Podminka se muze ptat na vic veci naraz.
*
* Zapisuje se vzdycky `rules`, i kdyz je otazka jedna - stara podoba se tim
* pri prvni uprave prevede a dal uz existuje jen jeden tvar.
*/
const rules = rulesOf(step);
const match = matchOf(step);
const missing = rules.some((rule) => {
const field = fields.find((f) => f.id === rule.fieldId);
return field && !isUnaryOperator(rule.operator) && (rule.value ?? '').trim().length === 0;
});
function writeRules(next: ConditionRule[], nextMatch: ConditionMatch = match) {
onUpdateCondition(step.id, { rules: next, match: nextMatch });
}
function changeRule(index: number, patch: Partial<ConditionRule>) {
writeRules(rules.map((rule, i) => (i === index ? { ...rule, ...patch } : rule)));
}
/** Zmena parametru muze zneplatnit operator - v tom pripade ho prepneme. */
function changeField(index: number, fieldId: string) {
const next = fields.find((f) => f.id === fieldId);
if (!next) {
console.warn(`[flow] podminka: neznamy parametr ${fieldId}`);
return;
}
const rule = rules[index];
const stillValid = operatorsByType[next.type].includes(rule.operator);
changeRule(index, {
fieldId,
operator: stillValid ? rule.operator : defaultOperatorFor(next.type),
value: stillValid ? rule.value : '',
});
}
/** Nova otazka se pta na tentyz parametr jako ta prvni, aby nebyla prazdna. */
function addRule() {
const first = fields[0];
if (!first) return;
const base = fields.find((f) => f.id === rules[0]?.fieldId) ?? first;
writeRules([
...rules,
{ fieldId: base.id, operator: defaultOperatorFor(base.type), value: '' },
]);
}
function removeRule(index: number) {
// Posledni otazka se nemaze: podminka bez otazky by tise nevetvila.
if (rules.length <= 1) return;
writeRules(rules.filter((_, i) => i !== index));
}
return (
<div className="@container rounded-card border border-accent-400/30 bg-accent-500/6 p-3 sm:p-4">
<div className="flex items-start gap-2.5 sm:gap-3">
<span className="grid size-8 shrink-0 place-items-center rounded-xl bg-accent-500/20 text-accent-300 @xs:size-10">
<GitBranch className="size-4 @xs:size-4.5" />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-xs text-white/40">Podmínka</p>
{missing && <Badge tone="warn">Doplňte hodnotu</Badge>}
</div>
<p className="mt-0.5 font-semibold break-words text-white">
{describeRules(step, fields)}
</p>
{/*
Spojka se nabizi az od druhe otazky. U jedne nema co spojovat
a prepinac, ktery nic nedela, jen mate.
*/}
{rules.length > 1 && (
<div className="mt-2.5 flex flex-wrap items-center gap-2">
<span className="text-xs text-white/40">Platí, když</span>
{(['all', 'any'] as const).map((option) => (
<button
key={option}
type="button"
onClick={() => writeRules(rules, option)}
className={cn(
'rounded-lg px-2.5 py-1 text-xs font-medium transition-colors',
match === option
? 'bg-accent-500/20 text-accent-200'
: 'text-white/45 hover:text-white',
)}
>
{option === 'all' ? 'sedí všechny' : 'sedí aspoň jedna'}
</button>
))}
</div>
)}
{/* Radek na otazku: parametr - operator - hodnota */}
<div className="mt-2.5 space-y-2">
{rules.map((rule, index) => {
const field = fields.find((f) => f.id === rule.fieldId);
const allowed = field ? operatorsByType[field.type] : [];
const needsValue = !isUnaryOperator(rule.operator);
const valueMissing = needsValue && (rule.value ?? '').trim().length === 0;
return (
<div key={index} className="flex flex-wrap items-center gap-2">
{index > 0 && (
<span className="w-16 shrink-0 text-xs text-white/35">
{match === 'any' ? 'nebo' : 'a zároveň'}
</span>
)}
<select
value={field ? rule.fieldId : ''}
onChange={(event) => changeField(index, event.target.value)}
aria-label="Parametr"
className="rounded-lg border border-ink-600/70 bg-ink-900/70 px-3 py-1.5 font-mono text-sm text-white focus:border-accent-400/70 focus:outline-none"
>
{!field && (
<option value="" className="bg-ink-850">
- vyberte parametr -
</option>
)}
{fields.map((option) => (
<option key={option.id} value={option.id} className="bg-ink-850">
{option.name || '(bez názvu)'}
</option>
))}
</select>
<select
value={rule.operator}
onChange={(event) =>
changeRule(index, { operator: event.target.value as ConditionOperator })
}
disabled={!field}
aria-label="Operátor"
className="rounded-lg border border-ink-600/70 bg-ink-900/70 px-3 py-1.5 text-sm text-white focus:border-accent-400/70 focus:outline-none disabled:opacity-50"
>
{allowed.map((operator) => (
<option key={operator} value={operator} className="bg-ink-850">
{field ? operatorLabel(operator, field.type) : operator}
</option>
))}
</select>
{needsValue && field && (
<input
value={rule.value ?? ''}
onChange={(event) => changeRule(index, { value: event.target.value })}
type={
field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : 'text'
}
placeholder={field.type === 'number' ? '15' : 'hodnota'}
aria-label="Hodnota"
className={cn(
'w-32 rounded-lg border bg-ink-900/70 px-3 py-1.5 text-sm text-white placeholder:text-white/25 focus:outline-none',
valueMissing
? 'border-warn-400/60'
: 'border-ink-600/70 focus:border-accent-400/70',
)}
/>
)}
{!field && (
<span className="text-xs text-danger-400">parametr už neexistuje</span>
)}
{rules.length > 1 && (
<button
type="button"
onClick={() => removeRule(index)}
aria-label="Odebrat otázku"
className="rounded-lg px-2 py-1.5 text-white/35 transition-colors hover:text-danger-400"
>
<Trash2 className="size-3.5" />
</button>
)}
</div>
);
})}
<button
type="button"
onClick={addRule}
className="inline-flex items-center gap-1.5 text-xs font-medium text-accent-300 transition-colors hover:text-accent-200"
>
<Plus className="size-3.5" />
Přidat otázku
</button>
</div>
</div>
<StepControls
canMoveUp={canMoveUp}
canMoveDown={canMoveDown}
onMove={(offset) => onMoveStep(step.id, offset)}
onRemove={() => onRemoveStep(step.id)}
/>
</div>
{/*
Vetve vedle sebe jen tehdy, kdyz je na to v TETO karte misto.
Rozhoduje sirka karty, ne okna - kazde zanoreni ji pulí, takze
od urcite hloubky se vetve samy prepnou pod sebe misto toho,
aby se karty zmackly na par pixelu.
*/}
<div className="mt-4 grid gap-4 @2xl:grid-cols-2">
{(['yes', 'no'] as const).map((branch) => (
<div
key={branch}
className={cn(
'min-w-0 rounded-xl border p-3',
branch === 'yes'
? 'border-ok-400/25 bg-ok-500/5'
: 'border-danger-400/25 bg-danger-500/5',
)}
>
<p
className={cn(
'mb-1 text-xs font-semibold tracking-wide uppercase',
branch === 'yes' ? 'text-ok-400' : 'text-danger-400',
)}
>
{branch === 'yes' ? 'Ano' : 'Ne'}
</p>
{step[branch].length === 0 && (
<p className="mb-1 text-xs text-white/35">
Zatím prázdná větev - přidejte krok tlačítkem níž.
</p>
)}
<StepSequence
steps={step[branch]}
path={[...path, { stepId: step.id, branch }]}
connectors={connectors}
scopes={scopes}
fallbackFields={fallbackFields}
context={context}
compact
{...callbacks}
/>
</div>
))}
</div>
</div>
);
}
/**
* Smycka nad seznamem.
*
* Uvnitr je podstrom, ktery se vykona za kazdou polozku. Vypada jako vetev
* podminky zamerne - je to totez z pohledu ovladani: sekvence kroku, ktera
* patri dovnitr neceho.
*/
function ForeachCard({
step,
path,
connectors,
scopes,
fallbackFields,
context,
canMoveUp,
canMoveDown,
...callbacks
}: CanvasCallbacks & {
step: Extract<FlowStep, { kind: 'foreach' }>;
path: FlowPath;
connectors: Service[];
scopes: Map<string, TriggerField[]>;
fallbackFields: TriggerField[];
context: CanvasContext;
canMoveUp: boolean;
canMoveDown: boolean;
}) {
const { onRemoveStep, onMoveStep, onUpdateForeach } = callbacks;
const missing = step.path.trim().length === 0;
return (
<div className="@container rounded-card border border-brand-400/30 bg-brand-500/6 p-3 sm:p-4">
<div className="flex items-start gap-2.5 sm:gap-3">
<span className="grid size-8 shrink-0 place-items-center rounded-xl bg-brand-500/20 text-brand-300 @xs:size-10">
<Repeat className="size-4 @xs:size-4.5" />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-xs text-white/40">Pro každou položku</p>
{missing && <Badge tone="warn">Doplňte cestu</Badge>}
</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
<input
value={step.path}
onChange={(event) => onUpdateForeach(step.id, event.target.value)}
placeholder="data.order.items"
aria-label="Cesta k seznamu"
className={cn(
'min-w-56 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',
missing ? 'border-warn-400/60' : 'border-ink-600/70 focus:border-brand-400/70',
)}
/>
</div>
<p className="mt-1.5 text-xs text-white/35">
Uvnitř je k dispozici <code className="text-brand-300/80">{'{{item}}'}</code> s celou
položkou, například <code className="text-brand-300/80">{'{{item.name}}'}</code>, a{' '}
<code className="text-brand-300/80">{'{{index}}'}</code> s pořadím. Po smyčce je{' '}
<code className="text-brand-300/80">{`{{${step.id}.results}}`}</code> se seznamem
výsledků za všechny položky.
</p>
</div>
<StepControls
canMoveUp={canMoveUp}
canMoveDown={canMoveDown}
onMove={(offset) => onMoveStep(step.id, offset)}
onRemove={() => onRemoveStep(step.id)}
/>
</div>
<div className="mt-4 min-w-0 rounded-xl border border-brand-400/25 bg-brand-500/5 p-3">
<p className="mb-1 text-xs font-semibold tracking-wide text-brand-300 uppercase">
Za každou položku
</p>
{step.steps.length === 0 && (
<p className="mb-1 text-xs text-white/35">
Zatím prázdná smyčka - přidejte krok tlačítkem níž.
</p>
)}
<StepSequence
steps={step.steps}
path={[...path, { stepId: step.id, branch: 'loop' }]}
connectors={connectors}
scopes={scopes}
fallbackFields={fallbackFields}
context={context}
compact
{...callbacks}
/>
</div>
</div>
);
}
function StepControls({
canMoveUp,
canMoveDown,
onMove,
onRemove,
}: {
canMoveUp: boolean;
canMoveDown: boolean;
onMove: (offset: number) => void;
onRemove: () => void;
}) {
// V uzke karte se ovladani skryva do sloupce, aby nebralo sirku textu.
return (
<div className="flex shrink-0 flex-col items-center gap-0.5 @xs:flex-row">
<button
type="button"
onClick={() => onMove(-1)}
disabled={!canMoveUp}
title="Posunout výš"
className="grid size-7 place-items-center rounded-lg text-white/35 transition-colors hover:bg-white/5 hover:text-white disabled:pointer-events-none disabled:opacity-25 @xs:size-8"
>
<ChevronUp className="size-4" />
<span className="sr-only">Posunout výš</span>
</button>
<button
type="button"
onClick={() => onMove(1)}
disabled={!canMoveDown}
title="Posunout níž"
className="grid size-7 place-items-center rounded-lg text-white/35 transition-colors hover:bg-white/5 hover:text-white disabled:pointer-events-none disabled:opacity-25 @xs:size-8"
>
<ChevronDown className="size-4" />
<span className="sr-only">Posunout níž</span>
</button>
<button
type="button"
onClick={onRemove}
title="Odebrat krok"
className="grid size-7 place-items-center rounded-lg text-white/35 transition-colors hover:bg-danger-500/12 hover:text-danger-400 @xs:size-8"
>
<Trash2 className="size-3.5" />
<span className="sr-only">Odebrat krok</span>
</button>
</div>
);
}
/** Krok odkazuje na neco, co v katalogu neexistuje - nesmi to tise zmizet. */
function BrokenCard({
label,
detail,
onFix,
onRemove,
}: {
label: string;
detail: string;
onFix?: () => void;
onRemove?: () => void;
}) {
return (
<div className="flex items-start gap-3 rounded-card border border-danger-400/40 bg-danger-500/8 p-4">
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-danger-400">{label}</p>
<p className="mt-0.5 font-mono text-xs text-white/45">{detail}</p>
</div>
{onFix && (
<button
type="button"
onClick={onFix}
className="shrink-0 rounded-lg px-3 py-1.5 text-xs font-medium text-white/60 hover:bg-white/5 hover:text-white"
>
Vybrat znovu
</button>
)}
{onRemove && (
<button
type="button"
onClick={onRemove}
className="grid size-8 shrink-0 place-items-center rounded-lg text-white/40 hover:bg-danger-500/12 hover:text-danger-400"
title="Odebrat krok"
>
<Trash2 className="size-3.5" />
<span className="sr-only">Odebrat krok</span>
</button>
)}
</div>
);
}
@@ -0,0 +1,94 @@
import { Repeat } from 'lucide-react';
import { memo } from 'react';
import type { CanvasCallbacks, RenderSteps } from '@/components/dashboard/flow/canvasTypes';
import { StepControls } from '@/components/dashboard/flow/StepControls';
import { Badge } from '@/components/ui/Badge';
import { Input } from '@/components/ui/form/Input';
import type { FlowPath } from '@/lib/flow';
import type { FlowStep } from '@/types/dashboard';
/**
* Smycka nad seznamem.
*
* Uvnitr je podstrom, ktery se vykona za kazdou polozku. Vypada jako vetev
* podminky zamerne - je to totez z pohledu ovladani: sekvence kroku, ktera
* patri dovnitr neceho.
*/
export const ForeachCard = memo(function ForeachCard({
step,
path,
canMoveUp,
canMoveDown,
onRemoveStep,
onMoveStep,
onUpdateForeach,
renderSteps,
}: {
step: Extract<FlowStep, { kind: 'foreach' }>;
path: FlowPath;
canMoveUp: boolean;
canMoveDown: boolean;
onRemoveStep: CanvasCallbacks['onRemoveStep'];
onMoveStep: CanvasCallbacks['onMoveStep'];
onUpdateForeach: CanvasCallbacks['onUpdateForeach'];
renderSteps: RenderSteps;
}) {
const missing = step.path.trim().length === 0;
return (
<div className="@container rounded-card border border-brand-400/30 bg-brand-500/6 p-3 sm:p-4">
<div className="flex items-start gap-2.5 sm:gap-3">
<span className="grid size-8 shrink-0 place-items-center rounded-xl bg-brand-500/20 text-brand-300 @xs:size-10">
<Repeat className="size-4 @xs:size-4.5" />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-xs text-white/40">Pro každou položku</p>
{missing && <Badge tone="warn">Doplňte cestu</Badge>}
</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
<Input
value={step.path}
onChange={(event) => onUpdateForeach(step.id, event.target.value)}
placeholder="data.order.items"
aria-label="Cesta k seznamu"
size="compact"
tone="card"
invalid={missing}
fullWidth={false}
className="min-w-56 flex-1 font-mono"
/>
</div>
<p className="mt-1.5 text-xs text-white/35">
Uvnitř je k dispozici <code className="text-brand-300/80">{'{{item}}'}</code> s celou
položkou, například <code className="text-brand-300/80">{'{{item.name}}'}</code>, a{' '}
<code className="text-brand-300/80">{'{{index}}'}</code> s pořadím. Po smyčce je{' '}
<code className="text-brand-300/80">{`{{${step.id}.results}}`}</code> se seznamem
výsledků za všechny položky.
</p>
</div>
<StepControls
canMoveUp={canMoveUp}
canMoveDown={canMoveDown}
onMove={(offset) => onMoveStep(step.id, offset)}
onRemove={() => onRemoveStep(step.id)}
/>
</div>
<div className="mt-4 min-w-0 rounded-xl border border-brand-400/25 bg-brand-500/5 p-3">
<p className="mb-1 text-xs font-semibold tracking-wide text-brand-300 uppercase">
Za každou položku
</p>
{step.steps.length === 0 && (
<p className="mb-1 text-xs text-white/35">
Zatím prázdná smyčka - přidejte krok tlačítkem níž.
</p>
)}
{renderSteps(step.steps, [...path, { stepId: step.id, branch: 'loop' }])}
</div>
</div>
);
});
@@ -1,7 +1,33 @@
import { ArrowRight, Braces, Plus, Trash2 } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useId, useMemo, useRef, 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 { Textarea } from '@/components/ui/form/Textarea';
let keyCounter = 0;
/**
* Stabilni klice pro radky bez vlastniho ID.
*
* Pravidla i prevody jsou holy JSON bez identifikatoru. `key={index}` znamenal,
* ze po smazani prvniho radku React prevzal jeho stav (rozepsany vyber prevodu)
* 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.
*/
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;
return {
keys: keys.current,
remove: (index) => {
keys.current.splice(index, 1);
},
};
}
/**
* Editor pravidel transformace dat.
@@ -147,6 +173,12 @@ export function MappingEditor({
}) {
const { rules, broken } = useMemo(() => parseRules(value), [value]);
const [rawMode, setRawMode] = useState(broken);
/*
* Jeden seznam napoved na cely editor. Driv ho kreslil kazdy radek se
* stejnym `id`, takze v dokumentu bylo deset stejnych datalistu a prohlizec
* bral prvni - u druheho a dalsiho radku tedy napoveda patrila jinam.
*/
const hintsId = useId();
function write(next: MappingRule[]) {
onChange(JSON.stringify(next, null, 2));
@@ -155,16 +187,15 @@ export function MappingEditor({
if (rawMode) {
return (
<div className="mt-1 space-y-2">
<textarea
<Textarea
value={value}
rows={10}
onChange={(event) => onChange(event.target.value)}
spellCheck={false}
placeholder='[{ "to": "partnerId", "from": "customer.id" }]'
className={cn(
'w-full resize-y rounded-lg border bg-ink-950/70 px-3 py-2 font-mono text-xs text-white/85 focus:outline-none',
broken ? 'border-warn-400/60' : 'border-ink-600/70 focus:border-brand-400/70',
)}
mono
tone="deep"
invalid={broken}
/>
{broken && <p className="text-xs text-warn-400">Tohle není platný JSON se seznamem pravidel.</p>}
<button
@@ -181,7 +212,13 @@ export function MappingEditor({
return (
<div className="mt-1 space-y-2">
<RuleList rules={rules} sourceHints={sourceHints} onChange={write} />
<datalist id={hintsId}>
{sourceHints.map((hint) => (
<option key={hint} value={hint} />
))}
</datalist>
<RuleList rules={rules} hintsId={hintsId} onChange={write} />
<button
type="button"
@@ -203,13 +240,16 @@ export function MappingEditor({
*/
function RuleList({
rules,
sourceHints,
hintsId,
onChange,
}: {
rules: MappingRule[];
sourceHints: string[];
/** ID datalistu s napovedou zdroju. Chybi u vnorenych pravidel. */
hintsId?: string;
onChange: (rules: MappingRule[]) => void;
}) {
const rowKeys = useRowKeys(rules.length);
return (
<div className="space-y-2">
{rules.length === 0 && (
@@ -220,13 +260,16 @@ function RuleList({
{rules.map((rule, index) => (
<RuleRow
key={index}
key={rowKeys.keys[index]}
rule={rule}
sourceHints={sourceHints}
hintsId={hintsId}
onChange={(patch) =>
onChange(rules.map((item, at) => (at === index ? { ...item, ...patch } : item)))
}
onRemove={() => onChange(rules.filter((_item, at) => at !== index))}
onRemove={() => {
rowKeys.remove(index);
onChange(rules.filter((_item, at) => at !== index));
}}
/>
))}
@@ -244,20 +287,18 @@ function RuleList({
function RuleRow({
rule,
sourceHints,
hintsId,
onChange,
onRemove,
}: {
rule: MappingRule;
sourceHints: string[];
hintsId?: string;
onChange: (patch: Partial<MappingRule>) => void;
onRemove: () => void;
}) {
const fixed = rule.from === undefined;
const [adding, setAdding] = useState('');
const inputClass =
'w-full rounded-md border border-ink-600/70 bg-ink-900/70 px-2 py-1 text-xs text-white placeholder:text-white/25 focus:border-brand-400/70 focus:outline-none';
const opKeys = useRowKeys((rule.transforms ?? []).length);
function addOp(op: string) {
if (op === '') return;
@@ -274,6 +315,7 @@ function RuleRow({
}
function removeOp(index: number) {
opKeys.remove(index);
onChange({ transforms: (rule.transforms ?? []).filter((_op, at) => at !== index) });
}
@@ -281,51 +323,56 @@ function RuleRow({
<div className="rounded-lg border border-ink-600/50 bg-ink-900/40 p-2.5">
<div className="flex flex-wrap items-center gap-2">
{/* Zdroj: cesta ve vstupu, nebo pevna hodnota. */}
<select
<Select
value={fixed ? 'value' : 'from'}
onChange={(event) =>
event.target.value === 'from'
? onChange({ from: '', value: undefined })
: onChange({ from: undefined, value: '' })
}
className={cn(inputClass, 'w-auto')}
>
<option value="from" className="bg-ink-850">
cesta
</option>
<option value="value" className="bg-ink-850">
pevná hodnota
</option>
</select>
size="xs"
tone="card"
fullWidth={false}
options={[
{ value: 'from', label: 'cesta' },
{ value: 'value', label: 'pevná hodnota' },
]}
/>
{fixed ? (
<input
<Input
value={String(rule.value ?? '')}
onChange={(event) => onChange({ value: event.target.value })}
placeholder="hodnota"
className={cn(inputClass, 'flex-1 min-w-32')}
size="xs"
tone="card"
fullWidth={false}
className="min-w-32 flex-1"
/>
) : (
<input
<Input
value={rule.from ?? ''}
onChange={(event) => onChange({ from: event.target.value })}
placeholder="customer.email"
list="mapping-source-hints"
className={cn(inputClass, 'flex-1 min-w-32 font-mono')}
list={hintsId}
size="xs"
tone="card"
fullWidth={false}
className="min-w-32 flex-1 font-mono"
/>
)}
<ArrowRight className="size-3 shrink-0 text-white/30" />
<input
<Input
value={rule.to ?? ''}
onChange={(event) => onChange({ to: event.target.value })}
placeholder="partnerId"
className={cn(
inputClass,
'flex-1 min-w-32 font-mono',
(rule.to ?? '').trim() === '' && 'border-warn-400/60',
)}
size="xs"
tone="card"
fullWidth={false}
invalid={(rule.to ?? '').trim() === ''}
className="min-w-32 flex-1 font-mono"
/>
<button
@@ -338,17 +385,11 @@ function RuleRow({
</button>
</div>
<datalist id="mapping-source-hints">
{sourceHints.map((hint) => (
<option key={hint} value={hint} />
))}
</datalist>
{(rule.transforms ?? []).length > 0 && (
<div className="mt-2 space-y-1.5 border-t border-ink-600/40 pt-2">
{(rule.transforms ?? []).map((operation, index) => (
<OpRow
key={index}
key={opKeys.keys[index]}
operation={operation}
onChange={(patch) => updateOp(index, patch)}
onRemove={() => removeOp(index)}
@@ -358,20 +399,15 @@ function RuleRow({
)}
<div className="mt-2 flex flex-wrap items-center gap-2">
<select
<Select
value={adding}
onChange={(event) => addOp(event.target.value)}
className={cn(inputClass, 'w-auto')}
>
<option value="" className="bg-ink-850">
Přidat převod
</option>
{opCatalog.map((item) => (
<option key={item.op} value={item.op} className="bg-ink-850">
{item.label}
</option>
))}
</select>
size="xs"
tone="card"
fullWidth={false}
placeholder="Přidat převod"
options={opCatalog.map((item) => ({ value: item.op, label: item.label }))}
/>
<label className="flex items-center gap-1.5 text-xs text-white/45">
<input
@@ -406,8 +442,6 @@ function OpRow({
onRemove: () => void;
}) {
const meta = opCatalog.find((item) => item.op === operation.op);
const inputClass =
'rounded-md border border-ink-600/70 bg-ink-900/70 px-2 py-0.5 text-xs text-white placeholder:text-white/25 focus:border-brand-400/70 focus:outline-none';
/*
* Prevod nad seznamem ma misto parametru vlastni pravidla, tedy dalsi editor
@@ -432,11 +466,7 @@ function OpRow({
</div>
<div className="mt-2">
<RuleList
rules={inner}
sourceHints={[]}
onChange={(rules) => onChange({ rules })}
/>
<RuleList rules={inner} onChange={(rules) => onChange({ rules })} />
</div>
</div>
);
@@ -448,20 +478,17 @@ function OpRow({
{(meta?.params ?? []).map((param) =>
param.kind === 'choice' ? (
<select
<Select
key={param.key}
value={String(operation[param.key] ?? '')}
onChange={(event) => onChange({ [param.key]: event.target.value })}
className={cn(inputClass, 'w-auto')}
>
{(param.options ?? []).map((option) => (
<option key={option} value={option} className="bg-ink-850">
{option}
</option>
))}
</select>
size="xs"
tone="card"
fullWidth={false}
options={(param.options ?? []).map((option) => ({ value: option, label: option }))}
/>
) : (
<input
<Input
key={param.key}
type={param.kind === 'number' ? 'number' : 'text'}
value={String(operation[param.key] ?? '')}
@@ -477,7 +504,10 @@ function OpRow({
})
}
title={param.label}
className={cn(inputClass, 'w-24')}
size="xs"
tone="card"
fullWidth={false}
className="w-24"
/>
),
)}
@@ -0,0 +1,122 @@
import { ChevronDown, ChevronUp, Plus, Trash2 } from 'lucide-react';
import { cn } from '@/lib/cn';
/** Sipky a kos u kroku. Spolecne pro akci, podminku i smycku. */
export function StepControls({
canMoveUp,
canMoveDown,
onMove,
onRemove,
}: {
canMoveUp: boolean;
canMoveDown: boolean;
onMove: (offset: number) => void;
onRemove: () => void;
}) {
// V uzke karte se ovladani skryva do sloupce, aby nebralo sirku textu.
return (
<div className="flex shrink-0 flex-col items-center gap-0.5 @xs:flex-row">
<button
type="button"
onClick={() => onMove(-1)}
disabled={!canMoveUp}
title="Posunout výš"
className="grid size-7 place-items-center rounded-lg text-white/35 transition-colors hover:bg-white/5 hover:text-white disabled:pointer-events-none disabled:opacity-25 @xs:size-8"
>
<ChevronUp className="size-4" />
<span className="sr-only">Posunout výš</span>
</button>
<button
type="button"
onClick={() => onMove(1)}
disabled={!canMoveDown}
title="Posunout níž"
className="grid size-7 place-items-center rounded-lg text-white/35 transition-colors hover:bg-white/5 hover:text-white disabled:pointer-events-none disabled:opacity-25 @xs:size-8"
>
<ChevronDown className="size-4" />
<span className="sr-only">Posunout níž</span>
</button>
<button
type="button"
onClick={onRemove}
title="Odebrat krok"
className="grid size-7 place-items-center rounded-lg text-white/35 transition-colors hover:bg-danger-500/12 hover:text-danger-400 @xs:size-8"
>
<Trash2 className="size-3.5" />
<span className="sr-only">Odebrat krok</span>
</button>
</div>
);
}
/** Spojnice + tlacitko "+". Tohle je to misto, kde se strom rozsiruje. */
export function AddStepButton({
onClick,
compact = false,
last = false,
}: {
onClick: () => void;
compact?: boolean;
last?: boolean;
}) {
return (
<div className="flex flex-col items-center">
<span className={cn('w-px bg-ink-600', compact ? 'h-3' : 'h-4')} aria-hidden />
<button
type="button"
onClick={onClick}
title="Přidat krok"
className={cn(
'group grid place-items-center rounded-full border border-dashed border-ink-600 bg-ink-850 text-white/40 transition-all hover:scale-110 hover:border-brand-400 hover:bg-brand-500/15 hover:text-brand-300',
compact ? 'size-7' : 'size-8',
)}
>
<Plus className={compact ? 'size-3.5' : 'size-4'} />
<span className="sr-only">Přidat krok</span>
</button>
{!last && <span className={cn('w-px bg-ink-600', compact ? 'h-3' : 'h-4')} aria-hidden />}
</div>
);
}
/** Krok odkazuje na neco, co v katalogu neexistuje - nesmi to tise zmizet. */
export function BrokenCard({
label,
detail,
onFix,
onRemove,
}: {
label: string;
detail: string;
onFix?: () => void;
onRemove?: () => void;
}) {
return (
<div className="flex items-start gap-3 rounded-card border border-danger-400/40 bg-danger-500/8 p-4">
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-danger-400">{label}</p>
<p className="mt-0.5 font-mono text-xs text-white/45">{detail}</p>
</div>
{onFix && (
<button
type="button"
onClick={onFix}
className="shrink-0 rounded-lg px-3 py-1.5 text-xs font-medium text-white/60 hover:bg-white/5 hover:text-white"
>
Vybrat znovu
</button>
)}
{onRemove && (
<button
type="button"
onClick={onRemove}
className="grid size-8 shrink-0 place-items-center rounded-lg text-white/40 hover:bg-danger-500/12 hover:text-danger-400"
title="Odebrat krok"
>
<Trash2 className="size-3.5" />
<span className="sr-only">Odebrat krok</span>
</button>
)}
</div>
);
}
@@ -1,11 +1,24 @@
import { AlertTriangle, Braces, ChevronRight } from 'lucide-react';
import { useRef, useState } from 'react';
import { useMemo, useRef, useState } from 'react';
import { MappingEditor } from '@/components/dashboard/flow/MappingEditor';
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';
import { brokenReferences } from '@/lib/flow';
import { cn } from '@/lib/cn';
import type { ModelNode, OperationField, TriggerField } from '@/types/dashboard';
/** Pole, ktera nesou sablonu s `{{odkazy}}` a daji se u nich hlidat. */
function isPlainTemplate(field: OperationField): boolean {
return (
field.kind !== 'choice' &&
field.kind !== 'mapping' &&
field.kind !== 'json' &&
field.kind !== 'object'
);
}
/** Parametry, ktere nesou celou strukturu. Jen ty jde predat do pole typu objekt. */
function structuredFields(available: TriggerField[]): TriggerField[] {
return available.filter((field) => field.type === 'object' || field.type === 'list');
@@ -88,8 +101,20 @@ export function StepInputs({
});
}
const inputClass =
'w-full rounded-lg border bg-ink-900/70 px-3 py-1.5 text-sm text-white placeholder:text-white/25 focus:outline-none';
/*
* Rozbite odkazy pro vsechna pole najednou. Kontrola parsuje sablonu
* regularnim vyrazem a driv bezela pro kazde pole pri kazdem stisku klavesy;
* ted jen kdyz se zmeni hodnoty nebo dostupne parametry.
*/
const brokenByField = useMemo(() => {
const result = new Map<string, string[]>();
for (const field of fields) {
if (!isPlainTemplate(field)) continue;
const broken = brokenReferences(values[field.id] ?? '', available, extraRoots);
if (broken.length > 0) result.set(field.id, broken);
}
return result;
}, [fields, values, available, extraRoots]);
return (
<div className="mt-4 space-y-3 border-t border-ink-600/50 pt-4">
@@ -100,12 +125,7 @@ export function StepInputs({
{fields.map((field) => {
const value = values[field.id] ?? '';
const missing = field.required && value.trim().length === 0;
const plainTemplate =
field.kind !== 'choice' &&
field.kind !== 'mapping' &&
field.kind !== 'json' &&
field.kind !== 'object';
const broken = plainTemplate ? brokenReferences(value, available, extraRoots) : [];
const broken = brokenByField.get(field.id) ?? [];
return (
<div key={field.id}>
@@ -143,15 +163,14 @@ export function StepInputs({
onChange={(next) => set(field.id, next)}
/>
) : field.kind === 'choice' ? (
<select
<Select
id={`input-${field.id}`}
value={value}
onChange={(event) => set(field.id, event.target.value)}
className={cn(
inputClass,
'mt-1',
missing ? 'border-warn-400/60' : 'border-ink-600/70 focus:border-brand-400/70',
)}
size="compact"
tone="card"
invalid={missing}
className="mt-1"
>
{/* Prazdna volba jen dokud si uzivatel nevybral. */}
{value === '' && !field.options?.some((option) => option.value === '') && (
@@ -164,7 +183,7 @@ export function StepInputs({
{option.label}
</option>
))}
</select>
</Select>
) : field.kind === 'lookup' ? (
/*
Ciselnik a volny text zaroven.
@@ -176,7 +195,7 @@ export function StepInputs({
zustava jedna.
*/
<div className="mt-1 flex gap-2">
<input
<Input
id={`input-${field.id}`}
ref={(element) => {
if (element) elements.current.set(field.id, element);
@@ -186,36 +205,28 @@ export function StepInputs({
onFocus={() => setFocused(field.id)}
onChange={(event) => set(field.id, event.target.value)}
placeholder="Vyberte vpravo, nebo dosaďte {{parametr}}"
className={cn(
inputClass,
'flex-1',
missing || broken.length > 0
? 'border-warn-400/60'
: 'border-ink-600/70 focus:border-brand-400/70',
)}
size="compact"
tone="card"
invalid={missing || broken.length > 0}
fullWidth={false}
className="flex-1"
/>
<select
<Select
aria-label={`${field.label}: vybrat ze seznamu`}
value={
field.options?.some((option) => option.value === value) ? value : ''
}
onChange={(event) => set(field.id, event.target.value)}
className={cn(inputClass, 'w-40 shrink-0 border-ink-600/70')}
>
<option value="" className="bg-ink-850">
ze seznamu
</option>
{field.options
?.filter((option) => option.value !== '')
.map((option) => (
<option key={option.value} value={option.value} className="bg-ink-850">
{option.label}
</option>
))}
</select>
size="compact"
tone="card"
fullWidth={false}
className="w-40 shrink-0"
placeholder="ze seznamu"
options={field.options?.filter((option) => option.value !== '') ?? []}
/>
</div>
) : field.kind === 'longtext' || field.kind === 'html' ? (
<textarea
<Textarea
id={`input-${field.id}`}
ref={(element) => {
if (element) elements.current.set(field.id, element);
@@ -228,17 +239,14 @@ export function StepInputs({
onFocus={() => setFocused(field.id)}
onChange={(event) => set(field.id, event.target.value)}
placeholder={field.hint ?? 'Text, nebo {{parametr}} ze spouštěče'}
className={cn(
inputClass,
'mt-1 resize-y',
field.kind === 'html' && 'font-mono text-xs',
missing || broken.length > 0
? 'border-warn-400/60'
: 'border-ink-600/70 focus:border-brand-400/70',
)}
size="compact"
tone="card"
mono={field.kind === 'html'}
invalid={missing || broken.length > 0}
className="mt-1"
/>
) : (
<input
<Input
id={`input-${field.id}`}
ref={(element) => {
if (element) elements.current.set(field.id, element);
@@ -248,13 +256,10 @@ export function StepInputs({
onFocus={() => setFocused(field.id)}
onChange={(event) => set(field.id, event.target.value)}
placeholder={field.hint ?? 'Text, nebo {{parametr}} ze spouštěče'}
className={cn(
inputClass,
'mt-1',
missing || broken.length > 0
? 'border-warn-400/60'
: 'border-ink-600/70 focus:border-brand-400/70',
)}
size="compact"
tone="card"
invalid={missing || broken.length > 0}
className="mt-1"
/>
)}
@@ -426,17 +431,16 @@ function JsonField({
return (
<div className="mt-1">
<textarea
<Textarea
id={id}
value={value}
rows={8}
spellCheck={false}
onChange={(event) => onChange(event.target.value)}
placeholder={hint ?? '{ "pole": "${cesta.ve.zdroji}" }'}
className={cn(
'w-full resize-y rounded-lg border bg-ink-950/70 px-3 py-2 font-mono text-xs text-white/85 focus:outline-none',
missing || problem ? 'border-warn-400/60' : 'border-ink-600/70 focus:border-brand-400/70',
)}
mono
tone="deep"
invalid={missing || problem !== null}
/>
{problem && (
<p className="mt-1 flex items-start gap-1.5 text-xs text-warn-400">
@@ -483,14 +487,14 @@ function ObjectField({
}
return (
<select
<Select
id={id}
value={value}
onChange={(event) => onChange(event.target.value)}
className={cn(
'mt-1 w-full rounded-lg border bg-ink-900/70 px-3 py-1.5 text-sm text-white focus:outline-none',
missing ? 'border-warn-400/60' : 'border-ink-600/70 focus:border-brand-400/70',
)}
size="compact"
tone="card"
invalid={missing}
className="mt-1"
>
{value === '' && (
<option value="" className="bg-ink-850">
@@ -502,6 +506,6 @@ function ObjectField({
{option.name} ({option.type === 'list' ? 'seznam' : 'objekt'})
</option>
))}
</select>
</Select>
);
}
@@ -1,11 +1,13 @@
import { ArrowLeft, ChevronRight, GitBranch, Repeat, Search } from 'lucide-react';
import { useMemo, useState } from 'react';
import type { ReactNode } from 'react';
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 { cn } from '@/lib/cn';
import { plural } from '@/lib/format';
import { serviceIcon } from '@/lib/serviceIcons';
import type { Service, ServiceCategory, ServiceOperation } from '@/types/dashboard';
import type { CatalogService, ServiceCategory, ServiceOperation } from '@/types/dashboard';
/**
* Vyber toho, co se ma pridat do stromu.
@@ -29,7 +31,7 @@ export function StepPicker({
}: {
open: boolean;
mode: 'trigger' | 'action';
connectors: Service[];
connectors: CatalogService[];
categories: Array<{ id: ServiceCategory; label: string }>;
onClose: () => void;
onPickOperation: (serviceId: string, operationId: string) => void;
@@ -39,11 +41,11 @@ export function StepPicker({
/** false = spoustec nema zadne parametry, nebylo by podle ceho se rozhodovat */
canAddCondition?: boolean;
}) {
const [selected, setSelected] = useState<Service | null>(null);
const [selected, setSelected] = useState<CatalogService | null>(null);
const [query, setQuery] = useState('');
const [category, setCategory] = useState<ServiceCategory | 'all'>('all');
const operationsOf = (connector: Service) =>
const operationsOf = (connector: CatalogService) =>
mode === 'trigger' ? connector.triggers : connector.actions;
/*
@@ -89,7 +91,7 @@ export function StepPicker({
// Ploche vysledky hledani - hleda v nazvu konektoru i v nazvech operaci.
const searchResults = useMemo(() => {
if (!searching) return [];
const results: Array<{ connector: Service; operation: ServiceOperation }> = [];
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();
@@ -106,7 +108,7 @@ export function StepPicker({
onClose();
}
function pick(connector: Service, operation: ServiceOperation) {
function pick(connector: CatalogService, operation: ServiceOperation) {
if (connector.status === 'planned') {
console.warn(`[picker] konektor ${connector.id} je na roadmape, nelze pouzit`);
return;
@@ -130,28 +132,28 @@ export function StepPicker({
<div className="border-b border-ink-600/60 p-5">
<div className="relative">
<Search className="pointer-events-none absolute top-1/2 left-3.5 size-4 -translate-y-1/2 text-white/35" />
<input
<Input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={mode === 'trigger' ? 'Hledat spouštěč...' : 'Hledat akci nebo službu...'}
className="w-full rounded-xl border border-ink-600/70 bg-ink-850/70 py-2.5 pr-4 pl-10 text-sm text-white placeholder:text-white/30 focus:border-brand-400/70 focus:outline-none"
size="search"
/>
</div>
{!searching && !selected && visibleCategories.length > 1 && (
<div className="mt-4 flex flex-wrap gap-2">
<CategoryChip active={category === 'all'} onClick={() => setCategory('all')}>
<Chip active={category === 'all'} onClick={() => setCategory('all')}>
Vše
</CategoryChip>
</Chip>
{visibleCategories.map((cat) => (
<CategoryChip
<Chip
key={cat.id}
active={category === cat.id}
onClick={() => setCategory(cat.id)}
>
{cat.label}
</CategoryChip>
</Chip>
))}
</div>
)}
@@ -301,32 +303,7 @@ export function StepPicker({
);
}
function CategoryChip({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'rounded-full border px-3 py-1.5 text-xs font-medium transition-colors',
active
? 'border-brand-400/50 bg-brand-500/15 text-brand-200'
: 'border-ink-600/70 text-white/50 hover:border-white/20 hover:text-white',
)}
>
{children}
</button>
);
}
function ConnectorGlyph({ connector }: { connector: Service }) {
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">
@@ -340,7 +317,7 @@ function ConnectorCard({
operationCount,
onClick,
}: {
connector: Service;
connector: CatalogService;
operationCount: number;
onClick: () => void;
}) {
@@ -369,7 +346,7 @@ function ConnectorCard({
{connector.description}
</span>
<span className="mt-1.5 block text-xs text-white/30">
{operationCount} {operationCount === 1 ? 'možnost' : 'možnosti'}
{operationCount} {plural(operationCount, ['možnost', 'možnosti', 'možností'])}
</span>
</span>
<ChevronRight className="mt-2.5 size-4 shrink-0 text-white/25" />
@@ -383,7 +360,7 @@ function OperationRow({
showConnectorName = false,
onClick,
}: {
connector: Service;
connector: CatalogService;
operation: ServiceOperation;
showConnectorName?: boolean;
onClick: () => void;
@@ -0,0 +1,54 @@
import type { ReactNode } from 'react';
import type { FlowPath } from '@/lib/flow';
import type {
ConditionMatch,
ConditionOperator,
ConditionRule,
FlowStep,
ModelNode,
} from '@/types/dashboard';
/**
* Co kroky potrebuji vedet krome svych vlastnich dat.
*
* Chodi to skrz cely strom, proto pohromade - jinak by kazde zanoreni pridalo
* dalsi dva parametry a signatury by prestaly byt citelne.
*/
export interface CanvasContext {
/** Cesty z ukazky tela u spoustece. Daji se kliknout do sablony. */
model: ModelNode[];
/** Koreny odkazu, ktere jsou v poradku i mimo deklarovane parametry. */
extraRoots: Set<string>;
}
/**
* Callbacky stromu. Vsechny berou ID kroku, ne closure na konkretni krok:
* diky tomu jsou stabilni a karty kroku se daji obalit do `memo`, takze
* psani do jednoho pole neprekresluje cely strom.
*/
export interface CanvasCallbacks {
onAddStep: (path: FlowPath, index: number) => void;
onRemoveStep: (stepId: string) => void;
onUpdateCondition: (
stepId: string,
patch: {
rules?: ConditionRule[];
match?: ConditionMatch;
fieldId?: string;
operator?: ConditionOperator;
value?: string;
},
) => void;
onUpdateInputs: (stepId: string, inputs: Record<string, string>) => void;
/** Zmena cesty k seznamu u smycky. */
onUpdateForeach: (stepId: string, path: string) => void;
onMoveStep: (stepId: string, offset: number) => void;
}
/**
* Vykresli vnorenou sekvenci (vetev podminky, telo smycky).
*
* Karta vetve nezna sekvenci primo, dostane ji jako funkci - jinak by se
* soubory importovaly do kruhu (sekvence kresli kartu, karta sekvenci).
*/
export type RenderSteps = (steps: FlowStep[], path: FlowPath) => ReactNode;
@@ -1,10 +1,10 @@
import { AlertTriangle, ArrowDownRight, ArrowUpRight, Clock, RefreshCw, RotateCcw } from 'lucide-react';
import { Link } from 'react-router-dom';
import { TicketStatusBadge } from '@/components/dashboard/StatusBadge';
import { TicketCard } from '@/components/dashboard/TicketCard';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/cn';
import { formatDuration, formatNumber, formatRelative } from '@/lib/format';
import type { AgentStatsRow, WidgetDefinition, WidgetValue } from '@/types/dashboard';
import type { AgentStatsRow, CatalogWidget, WidgetValue } from '@/types/dashboard';
/**
* Vykresleni widgetu, jehoz data pocita server (`/api/dashboard/widget-data`).
@@ -23,7 +23,7 @@ export function CustomWidgetCard({
loading,
onRefresh,
}: {
definition: WidgetDefinition;
definition: CatalogWidget;
value: WidgetValue | undefined;
error: string | undefined;
loading: boolean;
@@ -110,19 +110,7 @@ export function CustomWidgetCard({
) : (
<ul className="divide-y divide-ink-600/50">
{value.items.map((ticket) => (
<li key={ticket.id} className="flex items-center gap-3 py-2.5 first:pt-0 last:pb-0">
<span className="font-mono text-xs text-white/35">{ticket.id}</span>
<Link
to={`/dashboard/tickety/${ticket.id}`}
className="min-w-0 flex-1 truncate text-sm text-white/80 transition-colors hover:text-brand-200"
>
{ticket.subject}
</Link>
<span className="hidden text-xs text-white/40 sm:block">
{ticket.assignee ?? 've frontě'}
</span>
<TicketStatusBadge status={ticket.status} />
</li>
<TicketCard key={ticket.id} ticket={ticket} compact className="py-2.5" />
))}
</ul>
)}
@@ -303,7 +291,7 @@ function Number({
onRefresh,
href,
}: {
definition: WidgetDefinition;
definition: CatalogWidget;
value: number | null;
at?: string;
stale?: boolean;
@@ -409,7 +397,7 @@ function Shell({
href,
children,
}: {
definition: WidgetDefinition;
definition: CatalogWidget;
at?: string;
stale?: boolean;
ttlSec?: number;
@@ -13,7 +13,8 @@ import { Link } from 'react-router-dom';
import { DataState } from '@/components/dashboard/DataState';
import { RunsChart } from '@/components/dashboard/RunsChart';
import { StatTile } from '@/components/dashboard/StatTile';
import { IncidentStatusBadge, TicketStatusBadge } from '@/components/dashboard/StatusBadge';
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';
@@ -22,7 +23,7 @@ import type {
Incident,
ListResponse,
TicketListResponse,
WidgetDefinition,
CatalogWidget,
WidgetMetric,
Workload,
} from '@/types/dashboard';
@@ -32,6 +33,9 @@ import type {
* z prehledu. Jinak by deset dlazdic znamenalo deset stejnych dotazu.
*/
/** Kolik ticketu widget ukazuje. Prehled si podle toho rekne serveru o `limit`. */
export const TICKET_WIDGET_ROWS = 5;
export interface WidgetData {
summary: QueryState<DashboardSummary>;
tickets: QueryState<TicketListResponse>;
@@ -71,7 +75,7 @@ export function WidgetCard({
definition,
data,
}: {
definition: WidgetDefinition;
definition: CatalogWidget;
data: WidgetData;
}) {
if (definition.kind === 'stat') {
@@ -82,7 +86,12 @@ export function WidgetCard({
}
return (
<DataState loading={data.summary.loading} error={data.summary.error} onRetry={data.summary.reload}>
<DataState
loading={data.summary.loading}
refreshing={data.summary.refreshing}
error={data.summary.error}
onRetry={data.summary.reload}
>
{data.summary.data && (
<StatTile
icon={metric.icon}
@@ -114,29 +123,15 @@ export function WidgetCard({
<Panel title="Poslední tickety" to="/dashboard/tickety">
<DataState
loading={data.tickets.loading}
refreshing={data.tickets.refreshing}
error={data.tickets.error}
empty={data.tickets.data?.items.length === 0}
onRetry={data.tickets.reload}
emptyLabel="Žádné tickety, dobrá zpráva."
>
<ul className="divide-y divide-ink-600/50">
{data.tickets.data?.items.slice(0, 5).map((ticket) => (
<li key={ticket.id} className="flex items-start gap-3 py-3.5 first:pt-0 last:pb-0">
<span className="mt-0.5 font-mono text-xs text-white/35">{ticket.id}</span>
<div className="min-w-0 flex-1">
<Link
to={`/dashboard/tickety/${ticket.id}`}
className="block truncate text-sm text-white/80 transition-colors hover:text-brand-200"
>
{ticket.subject}
</Link>
<p className="mt-0.5 truncate text-xs text-white/40">
{ticket.customer.company} · {ticket.assignee?.name ?? 've frontě'} ·{' '}
{formatRelative(ticket.updatedAt)}
</p>
</div>
<TicketStatusBadge status={ticket.status} />
</li>
{data.tickets.data?.items.slice(0, TICKET_WIDGET_ROWS).map((ticket) => (
<TicketCard key={ticket.id} ticket={ticket} compact />
))}
</ul>
</DataState>
@@ -149,6 +144,7 @@ export function WidgetCard({
<Panel title="Incidenty" to="/dashboard/incidenty">
<DataState
loading={data.incidents.loading}
refreshing={data.incidents.refreshing}
error={data.incidents.error}
empty={data.incidents.data?.items.length === 0}
onRetry={data.incidents.reload}
@@ -178,6 +174,7 @@ export function WidgetCard({
return (
<DataState
loading={data.workload.loading}
refreshing={data.workload.refreshing}
error={data.workload.error}
onRetry={data.workload.reload}
>
@@ -1,6 +1,6 @@
import { Plus } from 'lucide-react';
import { Modal } from '@/components/ui/Modal';
import type { WidgetDefinition } from '@/types/dashboard';
import type { CatalogWidget } from '@/types/dashboard';
/** Nabidka widgetu, ktere jde na dashboard pridat. */
export function WidgetPicker({
@@ -10,9 +10,9 @@ export function WidgetPicker({
onPick,
}: {
open: boolean;
widgets: WidgetDefinition[];
widgets: CatalogWidget[];
onClose: () => void;
onPick: (widget: WidgetDefinition) => void;
onPick: (widget: CatalogWidget) => void;
}) {
return (
<Modal
+22 -19
View File
@@ -1,44 +1,47 @@
import { Section, SectionHeading } from '@/components/ui/Section';
import { useT, type MessageKey } from '@/i18n';
const steps = [
const steps: Array<{ number: string; title: MessageKey; text: MessageKey; duration: MessageKey }> = [
{
number: '01',
title: 'Konzultace a mapování',
text: 'Projdeme s vámi proces tak, jak reálně běží. Najdeme místa, kde se ztrácí čas a data.',
duration: '1-2 týdny',
title: 'process.step1.title',
text: 'process.step1.text',
duration: 'process.step1.duration',
},
{
number: '02',
title: 'Návrh a prototyp',
text: 'Postavíme funkční prototyp na vašich datech. Uvidíte, jak to bude fungovat, než se doplatí zbytek.',
duration: '2-3 týdny',
title: 'process.step2.title',
text: 'process.step2.text',
duration: 'process.step2.duration',
},
{
number: '03',
title: 'Nasazení do provozu',
text: 'Napojíme reálné systémy, zapneme monitoring a zaškolíme lidi. Přepínáme postupně, ne přes noc.',
duration: '2-4 týdny',
title: 'process.step3.title',
text: 'process.step3.text',
duration: 'process.step3.duration',
},
{
number: '04',
title: 'Provoz a rozvoj',
text: 'Držíme SLA, řešíme tickety a incidenty, průběžně přidáváme, co se v provozu ukáže jako potřebné.',
duration: 'trvale',
title: 'process.step4.title',
text: 'process.step4.text',
duration: 'process.step4.duration',
},
];
/** Jak spoluprace probiha - casova osa ve 4 krocich. */
export function Process() {
const t = useT();
return (
<Section id="postup">
<SectionHeading
eyebrow="Jak to probíhá"
eyebrow={t('process.eyebrow')}
title={
<>
Od prvního hovoru <span className="text-gradient">do provozu za pár týdnů</span>
{t('process.titleFirst')} <span className="text-gradient">{t('process.titleAccent')}</span>
</>
}
subtitle="Žádné půlroční analýzy. Nejdřív malý funkční celek, který přinese úsporu, potom rozšiřování."
subtitle={t('process.subtitle')}
/>
<ol className="grid gap-5 md:grid-cols-2 lg:grid-cols-4">
@@ -50,10 +53,10 @@ export function Process() {
<span className="font-mono text-3xl font-bold text-brand-400/25 transition-colors group-hover:text-brand-400/45">
{step.number}
</span>
<h3 className="mt-3 font-semibold text-white">{step.title}</h3>
<p className="mt-2 text-sm leading-relaxed text-white/55">{step.text}</p>
<h3 className="mt-3 font-semibold text-white">{t(step.title)}</h3>
<p className="mt-2 text-sm leading-relaxed text-white/55">{t(step.text)}</p>
<p className="mt-4 text-xs font-medium tracking-wide text-brand-300/70 uppercase">
{step.duration}
{t(step.duration)}
</p>
</li>
))}
+14 -9
View File
@@ -3,20 +3,24 @@ import { Link } from 'react-router-dom';
import { Card } from '@/components/ui/Card';
import { Section, SectionHeading } from '@/components/ui/Section';
import { products, type Product } from '@/data/products';
import { useT } from '@/i18n';
import { cn } from '@/lib/cn';
/** Mockup produktu / sluzeb - grid karet. */
export function Products() {
const t = useT();
return (
<Section id="produkty">
<SectionHeading
eyebrow="Produkty"
eyebrow={t('products.eyebrow')}
title={
<>
Šest věcí, které <span className="text-gradient">umíme dodat a udržet</span>
{t('products.titleFirst')}{' '}
<span className="text-gradient">{t('products.titleAccent')}</span>
</>
}
subtitle="Každou z nich umíme nasadit samostatně, nebo je pospojovat do jednoho celku. Ceny a rozsah vždy podle skutečného provozu."
subtitle={t('products.subtitle')}
/>
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
@@ -29,6 +33,7 @@ export function Products() {
}
export function ProductCard({ product }: { product: Product }) {
const t = useT();
const Icon = product.icon;
return (
@@ -45,19 +50,19 @@ export function ProductCard({ product }: { product: Product }) {
</span>
<div className="text-right">
<p className="text-lg font-bold text-white">{product.metric.value}</p>
<p className="text-[0.7rem] text-white/40">{product.metric.label}</p>
<p className="text-[0.7rem] text-white/40">{t(product.metric.label)}</p>
</div>
</div>
<h3 className="mt-5 text-lg font-semibold text-white">{product.name}</h3>
<p className="mt-1 text-sm font-medium text-brand-300/80">{product.tagline}</p>
<p className="mt-3 text-sm leading-relaxed text-white/60">{product.description}</p>
<h3 className="mt-5 text-lg font-semibold text-white">{t(product.name)}</h3>
<p className="mt-1 text-sm font-medium text-brand-300/80">{t(product.tagline)}</p>
<p className="mt-3 text-sm leading-relaxed text-white/60">{t(product.description)}</p>
<ul className="mt-5 space-y-2">
{product.features.map((feature) => (
<li key={feature} className="flex items-start gap-2 text-sm text-white/55">
<Check className="mt-0.5 size-3.5 shrink-0 text-ok-400" />
{feature}
{t(feature)}
</li>
))}
</ul>
@@ -66,7 +71,7 @@ export function ProductCard({ product }: { product: Product }) {
to={`/sluzby#${product.slug}`}
className="mt-6 inline-flex items-center gap-1.5 text-sm font-semibold text-brand-300 transition-colors hover:text-brand-200"
>
Detail služby
{t('products.detail')}
<ArrowRight className="size-3.5" />
</Link>
</Card>
+14 -10
View File
@@ -2,19 +2,23 @@ import { Quote, TrendingUp } from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Section, SectionHeading } from '@/components/ui/Section';
import { references, type Reference } from '@/data/references';
import { useT } from '@/i18n';
/** Mockup referenci - citaty klientu + merutelny vysledek. */
export function References() {
const t = useT();
return (
<Section id="reference" className="bg-ink-950/40">
<SectionHeading
eyebrow="Reference"
eyebrow={t('references.eyebrow')}
title={
<>
Co říkají firmy, <span className="text-gradient">se kterými to běží</span>
{t('references.titleFirst')}{' '}
<span className="text-gradient">{t('references.titleAccent')}</span>
</>
}
subtitle="Ukázka spoluprací napříč obory. Čísla vždy měříme před nasazením a po něm - jinak by to byl jen marketing."
subtitle={t('references.subtitle')}
/>
<div className="grid gap-5 md:grid-cols-2">
@@ -23,14 +27,14 @@ export function References() {
))}
</div>
<p className="mt-8 text-center text-xs text-white/30">
Reference jsou v této verzi webu ukázkové (mockup) a slouží k náhledu layoutu.
</p>
<p className="mt-8 text-center text-xs text-white/30">{t('references.mockup')}</p>
</Section>
);
}
function ReferenceCard({ reference }: { reference: Reference }) {
const t = useT();
return (
<Card interactive className="flex h-full flex-col">
<div className="flex items-center justify-between gap-4">
@@ -40,25 +44,25 @@ function ReferenceCard({ reference }: { reference: Reference }) {
</span>
<div>
<p className="font-semibold text-white">{reference.company}</p>
<p className="text-xs text-white/40">{reference.industry}</p>
<p className="text-xs text-white/40">{t(reference.industry)}</p>
</div>
</div>
<Quote className="size-6 shrink-0 text-brand-400/25" />
</div>
<blockquote className="mt-5 flex-1 text-[0.975rem] leading-relaxed text-white/70">
"{reference.quote}"
"{t(reference.quote)}"
</blockquote>
<div className="mt-6 flex flex-wrap items-end justify-between gap-4 border-t border-ink-600/60 pt-4">
<div>
<p className="text-sm font-medium text-white">{reference.author}</p>
<p className="text-xs text-white/40">{reference.role}</p>
<p className="text-xs text-white/40">{t(reference.role)}</p>
</div>
<div className="flex items-center gap-2 rounded-lg border border-ok-400/25 bg-ok-500/10 px-3 py-1.5">
<TrendingUp className="size-4 text-ok-400" />
<span className="text-sm font-bold text-ok-400">{reference.result.value}</span>
<span className="text-xs text-white/45">{reference.result.label}</span>
<span className="text-xs text-white/45">{t(reference.result.label)}</span>
</div>
</div>
</Card>
+3 -5
View File
@@ -21,7 +21,7 @@ export function Footer() {
{footerNav.map((column) => (
<div key={column.title}>
<h3 className="mb-4 text-sm font-semibold text-white">{column.title}</h3>
<h3 className="mb-4 text-sm font-semibold text-white">{t(column.title)}</h3>
<ul className="space-y-2.5">
{column.items.map((item) => (
<li key={item.to + item.label}>
@@ -29,7 +29,7 @@ export function Footer() {
to={item.to}
className="text-sm text-white/55 transition-colors hover:text-brand-300"
>
{item.label}
{t(item.label)}
</Link>
</li>
))}
@@ -74,9 +74,7 @@ export function Footer() {
<p>
© {year} {brand.legalName} · IČO {brand.ico}
</p>
<p className="font-mono">
Prototyp webu - obsah a reference jsou ukázkové (mockup).
</p>
<p className="font-mono">{t('footer.prototype')}</p>
</div>
</Container>
</footer>
+44
View File
@@ -0,0 +1,44 @@
import type { ReactNode } from 'react';
import { cn } from '@/lib/cn';
/**
* Prepinaci stitek: filtr, zalozka, kategorie.
*
* Byl napsany ctyrikrat (tickety, sluzby, vyber kroku, detail cloveka)
* a pokazde trochu jinak. Jedna komponenta, jeden vzhled.
*/
export function Chip({
active,
onClick,
disabled,
title,
className,
children,
}: {
active: boolean;
onClick: () => void;
disabled?: boolean;
title?: string;
className?: string;
children: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={title}
aria-pressed={active}
className={cn(
'rounded-full border px-3 py-1.5 text-xs font-medium transition-colors',
active
? 'border-brand-400/50 bg-brand-500/15 text-brand-200'
: 'border-ink-600/70 text-white/50 hover:border-white/20 hover:text-white',
disabled && 'cursor-not-allowed opacity-40 hover:border-ink-600/70 hover:text-white/50',
className,
)}
>
{children}
</button>
);
}
+24 -9
View File
@@ -29,13 +29,24 @@ export function Modal({
children: ReactNode;
className?: string;
}) {
const panelRef = useRef<HTMLDivElement>(null);
const bodyRef = useRef<HTMLDivElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
/*
* `onClose` drzime v refu a effect zavisi jen na `open`.
*
* Stranky ho predavaji jako inline lambdu, takze ma pri kazdem vykresleni
* 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;
useEffect(() => {
if (!open) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose();
if (event.key === 'Escape') onCloseRef.current();
};
document.addEventListener('keydown', onKeyDown);
@@ -43,17 +54,21 @@ export function Modal({
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
// Focus do dialogu - jinak by tabulator pokracoval na strance za nim.
const firstFocusable = panelRef.current?.querySelector<HTMLElement>(
'input, button, [tabindex]:not([tabindex="-1"])',
/*
* Focus do obsahu dialogu, ne na krizek. Prvni pole formulare je to,
* kam clovek chce psat; krizek je zaloha pro dialog bez ovladacich prvku,
* aby tabulator nepokracoval na strance za nim.
*/
const firstFocusable = bodyRef.current?.querySelector<HTMLElement>(
'input:not([type="hidden"]), select, textarea, button, [tabindex]:not([tabindex="-1"])',
);
firstFocusable?.focus();
(firstFocusable ?? closeButtonRef.current)?.focus();
return () => {
document.removeEventListener('keydown', onKeyDown);
document.body.style.overflow = previousOverflow;
};
}, [open, onClose]);
}, [open]);
if (!open) return null;
@@ -66,7 +81,6 @@ export function Modal({
/>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label={title}
@@ -81,6 +95,7 @@ export function Modal({
{description && <p className="mt-1 text-sm text-white/50">{description}</p>}
</div>
<button
ref={closeButtonRef}
type="button"
onClick={onClose}
className="grid size-9 shrink-0 place-items-center rounded-lg text-white/50 transition-colors hover:bg-white/5 hover:text-white"
@@ -90,7 +105,7 @@ export function Modal({
</button>
</div>
{children}
<div ref={bodyRef}>{children}</div>
</div>
</div>,
document.body,
+57
View File
@@ -0,0 +1,57 @@
import type { ReactNode } from 'react';
import { cn } from '@/lib/cn';
/**
* Popisek, napoveda a chyba kolem jednoho pole.
*
* Vykresluje se jako `<label>`, takze klik na popisek prenese focus do pole
* uvnitr bez `htmlFor`. Kdyz je uvnitr vic ovladacich prvku (pole plus
* tlacitko), predat `as="div"` a `htmlFor` rucne.
*/
export function Field({
label,
hint,
error,
required,
optional,
htmlFor,
as = 'label',
size = 'sm',
className,
children,
}: {
label: ReactNode;
hint?: ReactNode;
/** Chyba u pole. Zobrazi se pod nim cervene. */
error?: string | null;
/** Hvezdicka za popiskem. */
required?: boolean;
/** Slovo "nepovinne" za popiskem. Hodi se tam, kde je vetsina poli povinna. */
optional?: boolean;
htmlFor?: string;
as?: 'label' | 'div';
/** `md` je pro verejny web: vetsi popisek s vetsi mezerou. */
size?: 'sm' | 'md';
className?: string;
children: ReactNode;
}) {
const Tag = as;
return (
<Tag htmlFor={as === 'label' ? htmlFor : undefined} className={cn('block', className)}>
<span
className={cn(
'flex flex-wrap items-center gap-1.5 text-white/70',
size === 'md' ? 'mb-1.5 text-sm font-medium' : 'mb-1 text-sm',
)}
>
{label}
{required && <span className="text-warn-400">*</span>}
{optional && <span className="text-xs text-white/30">nepovinné</span>}
</span>
{children}
{error && <span className="mt-1 block text-xs text-danger-400">{error}</span>}
{hint && !error && <span className="mt-1 block text-xs text-white/35">{hint}</span>}
</Tag>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { forwardRef } from 'react';
import type { InputHTMLAttributes } from 'react';
import { controlClass, type ControlStyle } from '@/components/ui/form/controlClass';
/**
* Textove pole. Vzhled bere z `controlClass`, zbytek je obycejny `<input>`.
*
* `forwardRef` kvuli editoru kroku, ktery si drzi odkaz na pole, aby do nej
* umel vlozit parametr na pozici kurzoru.
*/
// `size` je tady velikost vzhledu, ne HTML atribut s poctem znaku.
export const Input = forwardRef<
HTMLInputElement,
Omit<InputHTMLAttributes<HTMLInputElement>, 'size'> & ControlStyle
>(function Input({ size, tone, invalid, fullWidth, className, ...rest }, ref) {
return (
<input
ref={ref}
className={controlClass({ size, tone, invalid, fullWidth }, className)}
{...rest}
/>
);
});
+48
View File
@@ -0,0 +1,48 @@
import type { ReactNode, SelectHTMLAttributes } from 'react';
import { controlClass, type ControlStyle } from '@/components/ui/form/controlClass';
export interface SelectOption {
value: string;
label: string;
}
/**
* Roletka. Volby jdou predat jako `options`, nebo jako `<option>` v detech.
*
* `placeholder` je prvni volba s prazdnou hodnotou ("Vyberte", "Bez typu").
* Kazda volba dostava tmave pozadi, protoze prohlizec jinak vykresli
* nabidku bilou na nasem tmavem podkladu.
*/
export function Select({
size,
tone,
invalid,
fullWidth,
className,
options,
placeholder,
children,
...rest
}: Omit<SelectHTMLAttributes<HTMLSelectElement>, 'size'> &
ControlStyle & {
options?: SelectOption[];
/** Text prazdne volby. Bez nej prazdna volba neni. */
placeholder?: string;
children?: ReactNode;
}) {
return (
<select className={controlClass({ size, tone, invalid, fullWidth }, className)} {...rest}>
{placeholder !== undefined && (
<option value="" className="bg-ink-850">
{placeholder}
</option>
)}
{options?.map((option) => (
<option key={option.value} value={option.value} className="bg-ink-850">
{option.label}
</option>
))}
{children}
</select>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { forwardRef } from 'react';
import type { TextareaHTMLAttributes } from 'react';
import { controlClass, type ControlStyle } from '@/components/ui/form/controlClass';
import { cn } from '@/lib/cn';
/** Viceradkove pole. Vzdy jde roztahnout svisle, nikdy do sirky. */
export const Textarea = forwardRef<
HTMLTextAreaElement,
Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'size'> & ControlStyle & { mono?: boolean }
>(function Textarea({ size, tone, invalid, fullWidth, mono, className, ...rest }, ref) {
return (
<textarea
ref={ref}
className={controlClass(
// Kod se pise v mensim pismu, velikost `xs` ma stejne pismo bez konfliktu trid.
{ size: mono ? 'xs' : size, tone, invalid, fullWidth },
cn('resize-y', mono && 'font-mono leading-relaxed', className),
)}
{...rest}
/>
);
});
@@ -0,0 +1,71 @@
import { cn } from '@/lib/cn';
/**
* Tridy vstupnich poli na jednom miste.
*
* Driv byl `inputClass` nadefinovany na patnacti mistech a rozesel se do peti
* vzhledu: stejne pole vypadalo jinak podle toho, kde v aplikaci stalo.
*
* Dve skutecne velikosti, ne jedna: huste formulare v portalu (`sm`) a vzdusne
* na verejnem webu (`md`). `xs` je pro radkove editory ve stromu, kde se pole
* tisknou vedle sebe. Sjednotit vse na jednu velikost by Login zhorsilo.
*/
/**
* Velikosti jsou vyjmenovane, ne skladane z trid. `cn` neumi slucovat
* konfliktni tridy (zadny twMerge), takze `py-2` ze zakladu a `py-1.5`
* z volajiciho by se praly a vyhral by ten, kdo je v CSS pozdeji.
*
* - `xs` radkove editory ve stromu (male, pod sebou)
* - `compact` pole vedle sebe v kartach kroku a u typu ticketu
* - `sm` bezne formulare portalu
* - `lg` postranni panel detailu, pozvanka
* - `md` verejny web (vzdusne)
* - `pill` filtr ve tvaru stitku
* - `search` hledani s ikonou vlevo
*/
export type ControlSize = 'xs' | 'compact' | 'sm' | 'lg' | 'md' | 'pill' | 'search';
/**
* Pozadi podle podkladu: na karte je tmavsi, aby se pole od karty oddelilo,
* `deep` je pro kod a JSON.
*/
export type ControlTone = 'page' | 'card' | 'deep';
export interface ControlStyle {
size?: ControlSize;
tone?: ControlTone;
/** true = zvyraznit jako chybne nebo nevyplnene. */
invalid?: boolean;
/** false = sirka podle obsahu (roletka vedle textu). Vychozi je cela sirka. */
fullWidth?: boolean;
}
const sizes: Record<ControlSize, string> = {
xs: 'rounded-md px-2 py-1 text-xs',
compact: 'rounded-lg px-3 py-1.5 text-sm',
sm: 'rounded-lg px-3 py-2 text-sm',
lg: 'rounded-xl px-3.5 py-2.5 text-sm',
md: 'rounded-xl px-4 py-3 text-sm',
pill: 'rounded-full px-3 py-1.5 text-xs',
search: 'rounded-xl py-2.5 pr-4 pl-10 text-sm',
};
const tones: Record<ControlTone, string> = {
page: 'bg-ink-850/70',
card: 'bg-ink-900/70',
deep: 'bg-ink-950/70',
};
export function controlClass(
{ size = 'sm', tone = 'page', invalid = false, fullWidth = true }: ControlStyle,
className?: string,
): string {
return cn(
'border text-white transition-colors placeholder:text-white/30 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50',
fullWidth && 'w-full',
sizes[size],
tones[tone],
invalid ? 'border-warn-400/60' : 'border-ink-600/70 focus:border-brand-400/70',
className,
);
}
+11 -11
View File
@@ -14,23 +14,23 @@ export const mainNav: NavItem[] = [
{ label: 'nav.contact', to: '/kontakt' },
];
/** Odkazy v paticce, rozdelene do sloupcu. */
export const footerNav: Array<{ title: string; items: Array<{ label: string; to: string }> }> = [
/** Odkazy v paticce, rozdelene do sloupcu. Nadpisy i polozky jsou klice prekladu. */
export const footerNav: Array<{ title: MessageKey; items: NavItem[] }> = [
{
title: 'Služby',
title: 'footer.services',
items: [
{ label: 'Automatizace procesů', to: '/sluzby#automatizace' },
{ label: 'Voiceboti', to: '/sluzby#voiceboti' },
{ label: 'Integrace systémů', to: '/sluzby#integrace' },
{ label: 'Dashboardy', to: '/sluzby#dashboardy' },
{ label: 'footer.link.automation', to: '/sluzby#automatizace' },
{ label: 'footer.link.voicebots', to: '/sluzby#voiceboti' },
{ label: 'footer.link.integrations', to: '/sluzby#integrace' },
{ label: 'footer.link.dashboards', to: '/sluzby#dashboardy' },
],
},
{
title: 'Firma',
title: 'footer.company',
items: [
{ label: 'O nás', to: '/o-nas' },
{ label: 'Kontakt', to: '/kontakt' },
{ label: 'Klientský portál', to: '/prihlaseni' },
{ label: 'footer.link.about', to: '/o-nas' },
{ label: 'footer.link.contact', to: '/kontakt' },
{ label: 'footer.link.portal', to: '/prihlaseni' },
],
},
];
+61 -42
View File
@@ -7,16 +7,21 @@ import {
Workflow,
type LucideIcon,
} from 'lucide-react';
import type { MessageKey } from '@/i18n';
/**
* Texty jsou klice prekladu, ne hotove retezce - stejne jako u navigace.
* Produkt se tim da ukazat cesky i anglicky z jednoho zaznamu.
*/
export interface Product {
slug: string;
name: string;
tagline: string;
description: string;
name: MessageKey;
tagline: MessageKey;
description: MessageKey;
icon: LucideIcon;
features: string[];
/** Ukazkova metrika na karte produktu. */
metric: { value: string; label: string };
features: MessageKey[];
/** Ukazkova metrika na karte produktu. Hodnota je cislo, popisek se preklada. */
metric: { value: string; label: MessageKey };
/** Zvyrazneni jedne karty v gridu. */
featured?: boolean;
}
@@ -28,63 +33,77 @@ export interface Product {
export const products: Product[] = [
{
slug: 'automatizace',
name: 'Automatizace procesů',
tagline: 'Od objednávky po fakturu bez ručního přepisování',
description:
'Zmapujeme proces, najdeme ruční kroky a nahradíme je workflow, které běží samo. Včetně kontrol, notifikací a auditní stopy.',
name: 'product.automation.name',
tagline: 'product.automation.tagline',
description: 'product.automation.description',
icon: Workflow,
features: ['Návrh procesu a analýza', 'Workflow engine s retry logikou', 'Auditní log každého kroku'],
metric: { value: '312 h', label: 'ušetřeno měsíčně' },
features: [
'product.automation.feature1',
'product.automation.feature2',
'product.automation.feature3',
],
metric: { value: '312 h', label: 'product.automation.metric' },
featured: true,
},
{
slug: 'voiceboti',
name: 'Voiceboti a hlasové linky',
tagline: 'Telefon, který obsluhuje i ve tři ráno',
description:
'Hlasový asistent přijme volání, rozpozná záměr, založí poptávku nebo ticket a předá člověku jen to, co má cenu řešit.',
name: 'product.voicebots.name',
tagline: 'product.voicebots.tagline',
description: 'product.voicebots.description',
icon: PhoneCall,
features: ['Čeština i angličtina', 'Předání na operátora se souhrnem', 'Přepisy a analytika hovorů'],
metric: { value: '24/7', label: 'dostupnost linky' },
features: [
'product.voicebots.feature1',
'product.voicebots.feature2',
'product.voicebots.feature3',
],
metric: { value: '24/7', label: 'product.voicebots.metric' },
},
{
slug: 'integrace',
name: 'Integrace systémů',
tagline: 'CRM, účetnictví, e-shop i sklad mluví stejným jazykem',
description:
'Propojíme systémy, které spolu nikdy neměly mluvit. Obousměrná synchronizace, mapování polí, řešení konfliktů.',
name: 'product.integrations.name',
tagline: 'product.integrations.tagline',
description: 'product.integrations.description',
icon: Network,
features: ['API i webhooky', 'Mapování a transformace dat', 'Odolnost proti výpadkům'],
metric: { value: '40+', label: 'napojených služeb' },
features: [
'product.integrations.feature1',
'product.integrations.feature2',
'product.integrations.feature3',
],
metric: { value: '40+', label: 'product.integrations.metric' },
},
{
slug: 'dashboardy',
name: 'Dashboardy a reporting',
tagline: 'Jedna obrazovka místo pěti exportů',
description:
'Data z různých zdrojů na jednom místě, v reálném čase, s historií a s alerty, když se něco vymkne.',
name: 'product.dashboards.name',
tagline: 'product.dashboards.tagline',
description: 'product.dashboards.description',
icon: BarChart3,
features: ['Metriky v reálném čase', 'Automatické reporty e-mailem', 'Přístupy podle rolí'],
metric: { value: '5 s', label: 'aktualizace dat' },
features: [
'product.dashboards.feature1',
'product.dashboards.feature2',
'product.dashboards.feature3',
],
metric: { value: '5 s', label: 'product.dashboards.metric' },
},
{
slug: 'tickety',
name: 'Tickety a servicedesk',
tagline: 'Požadavky, které se neztratí v e-mailu',
description:
'Sběr požadavků z e-mailu, webu i telefonu do jedné fronty. Kategorizace, SLA, eskalace a přehled pro management.',
name: 'product.tickets.name',
tagline: 'product.tickets.tagline',
description: 'product.tickets.description',
icon: LifeBuoy,
features: ['Sběr z více kanálů', 'SLA a eskalace', 'Znalostní báze'],
metric: { value: '11 min', label: 'medián první reakce' },
features: ['product.tickets.feature1', 'product.tickets.feature2', 'product.tickets.feature3'],
metric: { value: '11 min', label: 'product.tickets.metric' },
},
{
slug: 'incidenty',
name: 'Monitoring a incidenty',
tagline: 'O výpadku víme dřív než váš zákazník',
description:
'Healthchecky, alerting a incident management s jasnými rolemi. Po vyřešení dostanete post-mortem, ne výmluvu.',
name: 'product.incidents.name',
tagline: 'product.incidents.tagline',
description: 'product.incidents.description',
icon: AlarmClock,
features: ['Healthchecky a alerty', 'On-call rozpis', 'Post-mortem po každém incidentu'],
metric: { value: '99,98 %', label: 'dostupnost 2025' },
features: [
'product.incidents.feature1',
'product.incidents.feature2',
'product.incidents.feature3',
],
metric: { value: '99,98 %', label: 'product.incidents.metric' },
},
];
+24 -26
View File
@@ -1,14 +1,16 @@
import type { MessageKey } from '@/i18n';
export interface Reference {
id: string;
company: string;
/** Obor - zobrazuje se pod nazvem firmy. */
industry: string;
/** Kratky iniciálový "logotyp" - misto obrazku, dokud nejsou realna loga. */
/** Obor - zobrazuje se pod nazvem firmy. Klic prekladu. */
industry: MessageKey;
/** Kratky inicialovy "logotyp" - misto obrazku, dokud nejsou realna loga. */
initials: string;
quote: string;
quote: MessageKey;
author: string;
role: string;
result: { value: string; label: string };
role: MessageKey;
result: { value: string; label: MessageKey };
}
/**
@@ -19,46 +21,42 @@ export const references: Reference[] = [
{
id: 'ref-nordis',
company: 'Nordis a.s.',
industry: 'Velkoobchod',
industry: 'reference.nordis.industry',
initials: 'NO',
quote:
'Přepisování objednávek mezi e-shopem a účetnictvím nám žralo dva lidi na plný úvazek. Dnes to běží samo a my řešíme jen výjimky.',
quote: 'reference.nordis.quote',
author: 'Tomáš Vrána',
role: 'provozní ředitel',
result: { value: '−78 %', label: 'ruční práce v administraci' },
role: 'reference.nordis.role',
result: { value: '-78 %', label: 'reference.nordis.result' },
},
{
id: 'ref-logitrans',
company: 'LogiTrans',
industry: 'Doprava a logistika',
industry: 'reference.logitrans.industry',
initials: 'LT',
quote:
'Voicebot bere objednávky svozů i v noci a o víkendu. Dispečeři ráno vidí hotový seznam, ne dvacet hlasových zpráv.',
quote: 'reference.logitrans.quote',
author: 'Klára Doubravová',
role: 'vedoucí dispečinku',
result: { value: '1 400', label: 'hovorů zpracovaných měsíčně' },
role: 'reference.logitrans.role',
result: { value: '1 400', label: 'reference.logitrans.result' },
},
{
id: 'ref-medipoint',
company: 'MediPoint',
industry: 'Zdravotnictví',
industry: 'reference.medipoint.industry',
initials: 'MP',
quote:
'Potřebovali jsme přehled napříč šesti ambulancemi. Dashboard postavili za tři týdny a od té doby ho používáme každé ráno.',
quote: 'reference.medipoint.quote',
author: 'MUDr. Jan Sedlák',
role: 'jednatel',
result: { value: '6', label: 'provozoven v jednom přehledu' },
role: 'reference.medipoint.role',
result: { value: '6', label: 'reference.medipoint.result' },
},
{
id: 'ref-bistro',
company: 'Bistro Kolektiv',
industry: 'Gastro',
industry: 'reference.bistro.industry',
initials: 'BK',
quote:
'Rezervace, sklad a docházka konečně mluví dohromady. A když se něco rozbije, víme to od nich, ne od zákazníků.',
quote: 'reference.bistro.quote',
author: 'Eliška Marešová',
role: 'majitelka',
result: { value: '99,9 %', label: 'dostupnost systémů' },
role: 'reference.bistro.role',
result: { value: '99,9 %', label: 'reference.bistro.result' },
},
];
+264
View File
@@ -98,9 +98,273 @@ export const cs = {
// ---------------------------------------------------------------- paticka
'footer.contact': 'Kontakt',
'footer.rights': 'Všechna práva vyhrazena.',
'footer.services': 'Služby',
'footer.company': 'Firma',
'footer.link.automation': 'Automatizace procesů',
'footer.link.voicebots': 'Voiceboti',
'footer.link.integrations': 'Integrace systémů',
'footer.link.dashboards': 'Dashboardy',
'footer.link.about': 'O nás',
'footer.link.contact': 'Kontakt',
'footer.link.portal': 'Klientský portál',
'footer.prototype': 'Prototyp webu - obsah a reference jsou ukázkové (mockup).',
// ----------------------------------------------------------------- claim
'brand.claim': 'Konec ruční práce.',
// -------------------------------------------------------------- produkty
'products.eyebrow': 'Produkty',
'products.titleFirst': 'Šest věcí, které',
'products.titleAccent': 'umíme dodat a udržet',
'products.subtitle':
'Každou z nich umíme nasadit samostatně, nebo je pospojovat do jednoho celku. Ceny a rozsah vždy podle skutečného provozu.',
'products.detail': 'Detail služby',
'product.automation.name': 'Automatizace procesů',
'product.automation.tagline': 'Od objednávky po fakturu bez ručního přepisování',
'product.automation.description':
'Zmapujeme proces, najdeme ruční kroky a nahradíme je workflow, které běží samo. Včetně kontrol, notifikací a auditní stopy.',
'product.automation.feature1': 'Návrh procesu a analýza',
'product.automation.feature2': 'Workflow engine s retry logikou',
'product.automation.feature3': 'Auditní log každého kroku',
'product.automation.metric': 'ušetřeno měsíčně',
'product.voicebots.name': 'Voiceboti a hlasové linky',
'product.voicebots.tagline': 'Telefon, který obsluhuje i ve tři ráno',
'product.voicebots.description':
'Hlasový asistent přijme volání, rozpozná záměr, založí poptávku nebo ticket a předá člověku jen to, co má cenu řešit.',
'product.voicebots.feature1': 'Čeština i angličtina',
'product.voicebots.feature2': 'Předání na operátora se souhrnem',
'product.voicebots.feature3': 'Přepisy a analytika hovorů',
'product.voicebots.metric': 'dostupnost linky',
'product.integrations.name': 'Integrace systémů',
'product.integrations.tagline': 'CRM, účetnictví, e-shop i sklad mluví stejným jazykem',
'product.integrations.description':
'Propojíme systémy, které spolu nikdy neměly mluvit. Obousměrná synchronizace, mapování polí, řešení konfliktů.',
'product.integrations.feature1': 'API i webhooky',
'product.integrations.feature2': 'Mapování a transformace dat',
'product.integrations.feature3': 'Odolnost proti výpadkům',
'product.integrations.metric': 'napojených služeb',
'product.dashboards.name': 'Dashboardy a reporting',
'product.dashboards.tagline': 'Jedna obrazovka místo pěti exportů',
'product.dashboards.description':
'Data z různých zdrojů na jednom místě, v reálném čase, s historií a s alerty, když se něco vymkne.',
'product.dashboards.feature1': 'Metriky v reálném čase',
'product.dashboards.feature2': 'Automatické reporty e-mailem',
'product.dashboards.feature3': 'Přístupy podle rolí',
'product.dashboards.metric': 'aktualizace dat',
'product.tickets.name': 'Tickety a servicedesk',
'product.tickets.tagline': 'Požadavky, které se neztratí v e-mailu',
'product.tickets.description':
'Sběr požadavků z e-mailu, webu i telefonu do jedné fronty. Kategorizace, SLA, eskalace a přehled pro management.',
'product.tickets.feature1': 'Sběr z více kanálů',
'product.tickets.feature2': 'SLA a eskalace',
'product.tickets.feature3': 'Znalostní báze',
'product.tickets.metric': 'medián první reakce',
'product.incidents.name': 'Monitoring a incidenty',
'product.incidents.tagline': 'O výpadku víme dřív než váš zákazník',
'product.incidents.description':
'Healthchecky, alerting a incident management s jasnými rolemi. Po vyřešení dostanete post-mortem, ne výmluvu.',
'product.incidents.feature1': 'Healthchecky a alerty',
'product.incidents.feature2': 'On-call rozpis',
'product.incidents.feature3': 'Post-mortem po každém incidentu',
'product.incidents.metric': 'dostupnost 2025',
// ------------------------------------------------------------- reference
'references.eyebrow': 'Reference',
'references.titleFirst': 'Co říkají firmy,',
'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.',
'reference.nordis.industry': 'Velkoobchod',
'reference.nordis.quote':
'Přepisování objednávek mezi e-shopem a účetnictvím nám žralo dva lidi na plný úvazek. Dnes to běží samo a my řešíme jen výjimky.',
'reference.nordis.role': 'provozní ředitel',
'reference.nordis.result': 'ruční práce v administraci',
'reference.logitrans.industry': 'Doprava a logistika',
'reference.logitrans.quote':
'Voicebot bere objednávky svozů i v noci a o víkendu. Dispečeři ráno vidí hotový seznam, ne dvacet hlasových zpráv.',
'reference.logitrans.role': 'vedoucí dispečinku',
'reference.logitrans.result': 'hovorů zpracovaných měsíčně',
'reference.medipoint.industry': 'Zdravotnictví',
'reference.medipoint.quote':
'Potřebovali jsme přehled napříč šesti ambulancemi. Dashboard postavili za tři týdny a od té doby ho používáme každé ráno.',
'reference.medipoint.role': 'jednatel',
'reference.medipoint.result': 'provozoven v jednom přehledu',
'reference.bistro.industry': 'Gastro',
'reference.bistro.quote':
'Rezervace, sklad a docházka konečně mluví dohromady. A když se něco rozbije, víme to od nich, ne od zákazníků.',
'reference.bistro.role': 'majitelka',
'reference.bistro.result': 'dostupnost systémů',
// ---------------------------------------------------------------- postup
'process.eyebrow': 'Jak to probíhá',
'process.titleFirst': 'Od prvního hovoru',
'process.titleAccent': 'do provozu za pár týdnů',
'process.subtitle':
'Žádné půlroční analýzy. Nejdřív malý funkční celek, který přinese úsporu, potom rozšiřování.',
'process.step1.title': 'Konzultace a mapování',
'process.step1.text':
'Projdeme s vámi proces tak, jak reálně běží. Najdeme místa, kde se ztrácí čas a data.',
'process.step1.duration': '1-2 týdny',
'process.step2.title': 'Návrh a prototyp',
'process.step2.text':
'Postavíme funkční prototyp na vašich datech. Uvidíte, jak to bude fungovat, než se doplatí zbytek.',
'process.step2.duration': '2-3 týdny',
'process.step3.title': 'Nasazení do provozu',
'process.step3.text':
'Napojíme reálné systémy, zapneme monitoring a zaškolíme lidi. Přepínáme postupně, ne přes noc.',
'process.step3.duration': '2-4 týdny',
'process.step4.title': 'Provoz a rozvoj',
'process.step4.text':
'Držíme SLA, řešíme tickety a incidenty, průběžně přidáváme, co se v provozu ukáže jako potřebné.',
'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.',
// ---------------------------------------------------------------- sluzby
'services.title': 'Služby',
'services.meta':
'Automatizace procesů, voiceboti, integrace systémů, dashboardy, tickety a incident management.',
'services.eyebrow': 'Služby',
'services.titleFirst': 'Co pro vás',
'services.titleAccent': 'umíme postavit',
'services.subtitle':
'Nabídku skládáme podle toho, co firmě reálně chybí. Nejčastěji ale řešíme těchto šest oblastí.',
'services.sampleMetric': 'ukázková metrika',
// ----------------------------------------------------------------- o nas
'about.title': 'O nás',
'about.meta':
'Malý tým, který firmám staví automatizace, voiceboty a integrace - a pak je i provozuje.',
'about.eyebrow': 'O nás',
'about.titleFirst': 'Malý tým,',
'about.titleAccent': 'který za svou práci ručí',
'about.subtitle':
'{brand} funguje od roku {founded}. Nejsme softwarový dům na sto lidí - jsme parta, která staví věci, co musí fungovat v pondělí ráno.',
'about.storyTitle': 'Jak jsme se k tomu dostali',
'about.story1':
'Začínali jsme tím, že jsme jedné velkoobchodní firmě přestali přepisovat objednávky z e-mailu do skladu. Ukázalo se, že tenhle problém má skoro každý - jen mu každý říká jinak. Od té doby jsme postavili automatizace pro firmy od pěti do pěti set lidí.',
'about.story2':
'Postupně se k automatizacím přidaly hlasové linky, protože telefon je pořád kanál, kterým přichází nejvíc poptávek. A když jsme začali provozovat věci nonstop, přišly nutně i dashboardy, tickety a incident management - abychom o problémech věděli dřív než klient.',
'about.story3':
'Dnes je náš přístup jednoduchý: dodáme malý funkční celek, změříme, co ušetřil, a teprve potom stavíme dál.',
'about.milestone2018': 'Vznik firmy - první integrace e-shopů s účetnictvím.',
'about.milestone2020': 'První hlasová linka v produkci, tým se rozrůstá na čtyři lidi.',
'about.milestone2023': 'Spouštíme vlastní klientský portál s tickety a incidenty.',
'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.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.',
'about.value2.title': 'Bez vendor lock-inu',
'about.value2.text':
'Stavíme na standardních technologiích. Kód i dokumentace jsou vaše, kdykoliv můžete odejít.',
'about.value3.title': 'Provoz bereme jako součást dodávky',
'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.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í.',
'about.team1.role': 'zakladatel, architektura řešení',
'about.team2.role': 'integrace a backend',
'about.team3.role': 'voiceboti a konverzační design',
'about.team4.role': 'provoz, monitoring, podpora',
'about.teamMockup': 'Složení týmu je v této verzi webu ukázkové (mockup).',
// --------------------------------------------------------------- kontakt
'contact.title': 'Kontakt',
'contact.meta': 'Napište nám nebo zavolejte. Ozveme se do jednoho pracovního dne.',
'contact.eyebrow': 'Kontakt',
'contact.titleFirst': 'Ozvěte se.',
'contact.titleAccent': 'Odpovídáme rychle.',
'contact.subtitle':
'Napište, co řešíte - i když ještě nevíte, jak by to mělo fungovat. Na první hovor stačí popis problému.',
'contact.formTitle': 'Poptávka nebo dotaz',
'contact.formLead': 'Vyplnění zabere minutu.',
'contact.name': 'Jméno a příjmení',
'contact.namePlaceholder': 'Jan Novák',
'contact.email': 'E-mail',
'contact.emailPlaceholder': 'jan@firma.cz',
'contact.company': 'Firma',
'contact.companyPlaceholder': 'Firma s.r.o.',
'contact.phone': 'Telefon',
'contact.phonePlaceholder': '+420 ...',
'contact.topic': 'Téma',
'contact.topic.automation': 'Automatizace procesů',
'contact.topic.voicebot': 'Voicebot / hlasová linka',
'contact.topic.integration': 'Integrace systémů',
'contact.topic.dashboard': 'Dashboard a reporting',
'contact.topic.support': 'Podpora / incident',
'contact.topic.other': 'Něco jiného',
'contact.message': 'Co řešíte',
'contact.messagePlaceholder':
'Např.: Objednávky z e-shopu přepisujeme ručně do skladu, denně asi 60 kusů...',
'contact.submit': 'Odeslat poptávku',
'contact.submitting': 'Odesílám...',
'contact.consent': 'Odesláním souhlasíte se zpracováním údajů pro účely odpovědi.',
'contact.failed': 'Zprávu se nepodařilo odeslat.',
'contact.directTitle': 'Přímé kontakty',
'contact.availability': 'Dostupnost',
'contact.address': 'Adresa',
'contact.billingTitle': 'Fakturační údaje',
'contact.billingCompany': 'Firma',
'contact.billingIco': 'IČO',
'contact.billingDic': 'DIČ',
'contact.clientTitle': 'Jste náš klient?',
'contact.clientText':
'Požadavky a incidenty zadávejte přímo v klientském portálu - mají tam SLA a vidíte jejich stav.',
'contact.clientLink': 'Přejít do portálu',
// ------------------------------------------------------------------- 404
'notFound.title': 'Stránka nenalezena',
'notFound.heading': 'Tuhle stránku nemáme',
'notFound.text': 'Odkaz je nefunkční nebo se stránka přesunula. Zkuste to z rozcestí níž.',
'notFound.home': 'Zpět na homepage',
'notFound.contact': 'Napsat nám',
// ----------------------------------------------------------- prihlaseni
'login.title': 'Přihlášení',
'login.meta': 'Přihlášení do klientského portálu {brand}.',
'login.back': 'Zpět na web',
'login.heading': 'Klientský portál',
'login.lead': 'Přihlaste se a uvidíte stav automatizací, ticketů i incidentů.',
'login.email': 'E-mail',
'login.emailPlaceholder': 'vas@email.cz',
'login.password': 'Heslo',
'login.forgot': 'Zapomenuté heslo? Napište nám.',
'login.submit': 'Přihlásit se',
'login.submitting': 'Přihlašuji...',
'login.failed': 'Přihlášení se nepodařilo.',
'login.expired': 'Přihlášení vypršelo. Přihlaste se prosím znovu.',
'login.demoTitle': 'Demo přístupy (prototyp)',
'login.demoText': 'Formulář je předvyplněný správcem platformy. Kliknutím se přepne účet.',
'login.demo.admin': 'správce platformy, vidí všechny firmy',
'login.demo.multi': 'dvě firmy, jiná role v každé',
'login.demo.agent': 'řešitel jedné firmy',
'login.noAccount': 'Nemáte účet?',
'login.contactUs': 'Ozvěte se nám',
'login.noAccountTail': '- portál zřizujeme klientům {brand}.',
// ------------------------------------------------- stavy dat a pojistka
'state.loading': 'Načítám data...',
'state.refreshing': 'Obnovuji data',
'state.retry': 'Zkusit znovu',
'state.empty': 'Žádná data k zobrazení.',
'error.title': 'Tuhle část se nepodařilo vykreslit',
'error.text':
'Zbytek portálu funguje dál. Zkuste to znovu, a když to bude přetrvávat, pošlete nám prosím tuhle hlášku.',
} as const;
export type MessageKey = keyof typeof cs;
+265
View File
@@ -98,7 +98,272 @@ export const en: Partial<Record<MessageKey, string>> = {
// ---------------------------------------------------------------- paticka
'footer.contact': 'Contact',
'footer.rights': 'All rights reserved.',
'footer.services': 'Services',
'footer.company': 'Company',
'footer.link.automation': 'Process automation',
'footer.link.voicebots': 'Voicebots',
'footer.link.integrations': 'System integrations',
'footer.link.dashboards': 'Dashboards',
'footer.link.about': 'About us',
'footer.link.contact': 'Contact',
'footer.link.portal': 'Client portal',
'footer.prototype': 'Website prototype - content and references are sample data (mockup).',
// ----------------------------------------------------------------- claim
'brand.claim': 'Nuke the busywork.',
// -------------------------------------------------------------- produkty
'products.eyebrow': 'Products',
'products.titleFirst': 'Six things we',
'products.titleAccent': 'build and keep running',
'products.subtitle':
'Each one works on its own or as part of a bigger whole. Pricing and scope always follow how your business actually runs.',
'products.detail': 'Service details',
'product.automation.name': 'Process automation',
'product.automation.tagline': 'From order to invoice without retyping a thing',
'product.automation.description':
'We map the process, find the manual steps and replace them with a workflow that runs on its own. Checks, notifications and an audit trail included.',
'product.automation.feature1': 'Process design and analysis',
'product.automation.feature2': 'Workflow engine with retry logic',
'product.automation.feature3': 'Audit log of every step',
'product.automation.metric': 'saved per month',
'product.voicebots.name': 'Voicebots and phone lines',
'product.voicebots.tagline': 'A phone line that answers at three in the morning',
'product.voicebots.description':
'A voice assistant takes the call, understands the intent, creates a lead or a ticket and hands over to a person only what is worth their time.',
'product.voicebots.feature1': 'Czech and English',
'product.voicebots.feature2': 'Handover to an operator with a summary',
'product.voicebots.feature3': 'Transcripts and call analytics',
'product.voicebots.metric': 'line availability',
'product.integrations.name': 'System integrations',
'product.integrations.tagline': 'CRM, accounting, e-shop and warehouse speaking one language',
'product.integrations.description':
'We connect systems that were never meant to talk to each other. Two-way sync, field mapping, conflict resolution.',
'product.integrations.feature1': 'APIs and webhooks',
'product.integrations.feature2': 'Data mapping and transformation',
'product.integrations.feature3': 'Resilient to outages',
'product.integrations.metric': 'connected services',
'product.dashboards.name': 'Dashboards and reporting',
'product.dashboards.tagline': 'One screen instead of five exports',
'product.dashboards.description':
'Data from different sources in one place, in real time, with history and alerts when something goes off the rails.',
'product.dashboards.feature1': 'Real-time metrics',
'product.dashboards.feature2': 'Automatic reports by e-mail',
'product.dashboards.feature3': 'Role-based access',
'product.dashboards.metric': 'data refresh',
'product.tickets.name': 'Tickets and service desk',
'product.tickets.tagline': 'Requests that never get lost in e-mail',
'product.tickets.description':
'Requests from e-mail, web and phone collected into one queue. Categories, SLAs, escalation and an overview for management.',
'product.tickets.feature1': 'Multi-channel intake',
'product.tickets.feature2': 'SLAs and escalation',
'product.tickets.feature3': 'Knowledge base',
'product.tickets.metric': 'median first response',
'product.incidents.name': 'Monitoring and incidents',
'product.incidents.tagline': 'We know about the outage before your customer does',
'product.incidents.description':
'Health checks, alerting and incident management with clear roles. After resolution you get a post-mortem, not an excuse.',
'product.incidents.feature1': 'Health checks and alerts',
'product.incidents.feature2': 'On-call rota',
'product.incidents.feature3': 'Post-mortem after every incident',
'product.incidents.metric': 'uptime in 2025',
// ------------------------------------------------------------- reference
'references.eyebrow': 'References',
'references.titleFirst': 'What companies say',
'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.',
'reference.nordis.industry': 'Wholesale',
'reference.nordis.quote':
'Retyping orders between the e-shop and accounting ate up two full-time people. Today it runs by itself and we only handle the exceptions.',
'reference.nordis.role': 'Chief Operating Officer',
'reference.nordis.result': 'manual admin work',
'reference.logitrans.industry': 'Transport and logistics',
'reference.logitrans.quote':
'The voicebot takes pickup orders at night and on weekends. In the morning the dispatchers see a finished list, not twenty voicemails.',
'reference.logitrans.role': 'Head of dispatch',
'reference.logitrans.result': 'calls handled per month',
'reference.medipoint.industry': 'Healthcare',
'reference.medipoint.quote':
'We needed an overview across six clinics. They built the dashboard in three weeks and we have used it every morning since.',
'reference.medipoint.role': 'Managing director',
'reference.medipoint.result': 'sites in one overview',
'reference.bistro.industry': 'Hospitality',
'reference.bistro.quote':
'Reservations, stock and attendance finally talk to each other. And when something breaks, we hear it from them, not from our customers.',
'reference.bistro.role': 'Owner',
'reference.bistro.result': 'system uptime',
// ---------------------------------------------------------------- postup
'process.eyebrow': 'How it works',
'process.titleFirst': 'From the first call',
'process.titleAccent': 'to production in a few weeks',
'process.subtitle':
'No six-month analyses. First a small working piece that saves money, then we expand.',
'process.step1.title': 'Consultation and mapping',
'process.step1.text':
'We walk through the process with you as it really runs and find where time and data get lost.',
'process.step1.duration': '1-2 weeks',
'process.step2.title': 'Design and prototype',
'process.step2.text':
'We build a working prototype on your data. You see how it will work before paying for the rest.',
'process.step2.duration': '2-3 weeks',
'process.step3.title': 'Go-live',
'process.step3.text':
'We connect the real systems, switch on monitoring and train your people. We switch over gradually, not overnight.',
'process.step3.duration': '2-4 weeks',
'process.step4.title': 'Operations and growth',
'process.step4.text':
'We keep the SLA, handle tickets and incidents, and keep adding what daily operations show is needed.',
'process.step4.duration': 'ongoing',
// ------------------------------------------------------------ meta popisy
'meta.home':
'WorkNuke collects events from all your tools, automates what can be done, connects the next steps and gets the right people involved. So your work just flows.',
// ---------------------------------------------------------------- sluzby
'services.title': 'Services',
'services.meta':
'Process automation, voicebots, system integrations, dashboards, tickets and incident management.',
'services.eyebrow': 'Services',
'services.titleFirst': 'What we can',
'services.titleAccent': 'build for you',
'services.subtitle':
'We put the offer together around what your company is actually missing. Most often it comes down to these six areas.',
'services.sampleMetric': 'sample metric',
// ----------------------------------------------------------------- o nas
'about.title': 'About us',
'about.meta':
'A small team that builds automations, voicebots and integrations for companies - and then keeps them running.',
'about.eyebrow': 'About us',
'about.titleFirst': 'A small team',
'about.titleAccent': 'that stands behind its work',
'about.subtitle':
'{brand} has been around since {founded}. We are not a hundred-person software house - we are a crew that builds things that have to work on Monday morning.',
'about.storyTitle': 'How we got here',
'about.story1':
'We started by helping one wholesale company stop retyping orders from e-mail into the warehouse system. It turned out almost everyone has this problem - they just call it something different. Since then we have built automations for companies from five to five hundred people.',
'about.story2':
'Voice lines followed, because the phone is still the channel where most enquiries come in. And once we started running things around the clock, dashboards, tickets and incident management became a necessity - so that we know about problems before the client does.',
'about.story3':
'Today our approach is simple: deliver a small working piece, measure what it saved, and only then build further.',
'about.milestone2018': 'Company founded - first e-shop to accounting integrations.',
'about.milestone2020': 'First voice line in production, the team grows to four people.',
'about.milestone2023': 'We launch our own client portal with tickets and incidents.',
'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.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.',
'about.value2.title': 'No vendor lock-in',
'about.value2.text':
'We build on standard technologies. The code and documentation are yours and you can leave at any time.',
'about.value3.title': 'Operations are part of the delivery',
'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.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.team1.role': 'founder, solution architecture',
'about.team2.role': 'integrations and backend',
'about.team3.role': 'voicebots and conversation design',
'about.team4.role': 'operations, monitoring, support',
'about.teamMockup': 'The team shown in this version of the site is sample data (mockup).',
// --------------------------------------------------------------- kontakt
'contact.title': 'Contact',
'contact.meta': 'Write or call us. We get back to you within one business day.',
'contact.eyebrow': 'Contact',
'contact.titleFirst': 'Get in touch.',
'contact.titleAccent': 'We answer fast.',
'contact.subtitle':
'Tell us what you are dealing with - even if you do not yet know how it should work. A description of the problem is enough for a first call.',
'contact.formTitle': 'Enquiry or question',
'contact.formLead': 'Takes a minute to fill in.',
'contact.name': 'Full name',
'contact.namePlaceholder': 'Jane Smith',
'contact.email': 'E-mail',
'contact.emailPlaceholder': 'jane@company.com',
'contact.company': 'Company',
'contact.companyPlaceholder': 'Company Ltd.',
'contact.phone': 'Phone',
'contact.phonePlaceholder': '+420 ...',
'contact.topic': 'Topic',
'contact.topic.automation': 'Process automation',
'contact.topic.voicebot': 'Voicebot / phone line',
'contact.topic.integration': 'System integrations',
'contact.topic.dashboard': 'Dashboard and reporting',
'contact.topic.support': 'Support / incident',
'contact.topic.other': 'Something else',
'contact.message': 'What are you dealing with',
'contact.messagePlaceholder':
'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.failed': 'The message could not be sent.',
'contact.directTitle': 'Direct contacts',
'contact.availability': 'Availability',
'contact.address': 'Address',
'contact.billingTitle': 'Billing details',
'contact.billingCompany': 'Company',
'contact.billingIco': 'Company ID',
'contact.billingDic': 'VAT ID',
'contact.clientTitle': 'Already a client?',
'contact.clientText':
'Submit requests and incidents directly in the client portal - they have an SLA there and you can see their status.',
'contact.clientLink': 'Go to the portal',
// ------------------------------------------------------------------- 404
'notFound.title': 'Page not found',
'notFound.heading': 'We do not have this page',
'notFound.text': 'The link is broken or the page has moved. Try one of the links below.',
'notFound.home': 'Back to homepage',
'notFound.contact': 'Write to us',
// ----------------------------------------------------------- prihlaseni
'login.title': 'Sign in',
'login.meta': 'Sign in to the {brand} client portal.',
'login.back': 'Back to the website',
'login.heading': 'Client portal',
'login.lead': 'Sign in to see the status of your automations, tickets and incidents.',
'login.email': 'E-mail',
'login.emailPlaceholder': 'you@email.com',
'login.password': 'Password',
'login.forgot': 'Forgot your password? Write to us.',
'login.submit': 'Sign in',
'login.submitting': 'Signing in...',
'login.failed': 'Sign-in failed.',
'login.expired': 'Your session has expired. Please sign in again.',
'login.demoTitle': 'Demo accounts (prototype)',
'login.demoText': 'The form is pre-filled with the platform admin. Click to switch accounts.',
'login.demo.admin': 'platform admin, sees all companies',
'login.demo.multi': 'two companies, a different role in each',
'login.demo.agent': 'agent of a single company',
'login.noAccount': 'No account yet?',
'login.contactUs': 'Get in touch',
'login.noAccountTail': '- the portal is set up for {brand} clients.',
// ------------------------------------------------- stavy dat a pojistka
'state.loading': 'Loading data...',
'state.refreshing': 'Refreshing data',
'state.retry': 'Try again',
'state.empty': 'No data to show.',
'error.title': 'This part could not be rendered',
'error.text':
'The rest of the portal keeps working. Try again, and if it keeps happening, please send us this message.',
};
+54 -3
View File
@@ -7,6 +7,15 @@ import { withTenant } from '@/lib/tenant';
const TOKEN_KEY = 'automia.token';
/**
* Udalost okna, kterou vyvola vyprsely token.
*
* `apiFetch` neni komponenta a k `AuthProvider` se nedostane. Udalost je
* jediny kanal, kterym muze rict "server te uz nezna" - a provider pak
* uzivatele odhlasi a posle na prihlaseni s vysvetlenim.
*/
export const AUTH_EXPIRED_EVENT = 'auth:expired';
/**
* Prefix reverse proxy, napr. "/apps/csbot-prototype".
* Server ho vklada do index.html podle ROOT_PATH, viz src/index.ts.
@@ -71,13 +80,41 @@ export function setToken(token: string | null) {
}
}
/**
* Token uz neplati: zahodi se a rekne se to zbytku aplikace.
*
* Vola se z `apiFetch` i ze SSE streamu. Bez toho uzivatel zustal "prihlaseny"
* s mrtvym tokenem: kazdy dotaz koncil chybou a stream se pripojoval donekonecna.
*/
export function notifyAuthExpired(): void {
if (!getToken()) return;
setToken(null);
window.dispatchEvent(new CustomEvent(AUTH_EXPIRED_EVENT));
}
interface RequestOptions extends Omit<RequestInit, 'body'> {
body?: unknown;
/** true = pridat Authorization hlavicku (default pro vse krome loginu) */
auth?: boolean;
}
export async function apiFetch<T>(path: string, options: RequestOptions = {}): Promise<T> {
/** Odpoved vcetne udaju z hlavicek, ktere v tele nejsou. */
export interface ApiResult<T> {
data: T;
/** Celkovy pocet zaznamu z `X-Total-Count`. null = server ho neposlal. */
total: number | null;
}
/**
* Totez co `apiFetch`, ale vraci i to, co je jen v hlavickach.
*
* Seznamy se strankuji pres `limit` a `offset` a telo zustava pole - celkovy
* pocet chodi v `X-Total-Count`. Bez teto varianty by se k nemu stranka nedostala.
*/
export async function apiFetchWithMeta<T>(
path: string,
options: RequestOptions = {},
): Promise<ApiResult<T>> {
const { body, auth = true, headers, ...rest } = options;
const token = auth ? getToken() : null;
@@ -100,7 +137,11 @@ export async function apiFetch<T>(path: string, options: RequestOptions = {}): P
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (response.status === 204) return undefined as T;
const totalHeader = response.headers.get('x-total-count');
const parsedTotal = totalHeader === null || totalHeader.trim() === '' ? NaN : Number(totalHeader);
const total = Number.isFinite(parsedTotal) ? parsedTotal : null;
if (response.status === 204) return { data: undefined as T, total };
const isJson = response.headers.get('content-type')?.includes('application/json');
const payload = isJson ? await response.json().catch(() => null) : await response.text();
@@ -115,8 +156,18 @@ export async function apiFetch<T>(path: string, options: RequestOptions = {}): P
? String((payload as { error: unknown }).error)
: undefined;
console.error(`[api] ${path} -> ${response.status} ${code ?? ''} ${message}`);
// 401 s tokenem znamena vyprsely nebo neplatny token, ne spatne heslo -
// to prichazi bez tokenu (auth: false) a resi si ho prihlasovaci formular.
if (response.status === 401 && token) notifyAuthExpired();
throw new ApiError(message, response.status, code, isJson ? payload : undefined);
}
return payload as T;
return { data: payload as T, total };
}
export async function apiFetch<T>(path: string, options: RequestOptions = {}): Promise<T> {
const result = await apiFetchWithMeta<T>(path, options);
return result.data;
}
+393
View File
@@ -0,0 +1,393 @@
import { createContext, useContext, useEffect, useMemo, useSyncExternalStore } from 'react';
import type { ReactNode } from 'react';
import { useEventStream } from '@/components/dashboard/EventStreamProvider';
import { apiFetch } from '@/lib/api';
import { getActiveTenant, onTenantChange, useActiveTenant } from '@/lib/tenant';
import type {
Access,
Connector,
GroupRef,
PeopleResponse,
Person,
CatalogService,
ServiceCatalog,
TicketType,
} from '@/types/dashboard';
import type { DashboardEvent } from '@/types/events';
/**
* Klientske uloziste ciselniku.
*
* Lide, skupiny, typy ticketu, katalog sluzeb, konektory a prava jsou mala
* data, ktera se meni zridka a pta se na ne kdeco: detail ticketu, dialog
* noveho ticketu, fronta, builder. Kazdy si je nacital sam, takze otevreni
* detailu znamenalo pet dotazu na totez.
*
* Tady se kazdy ciselnik nacte **jednou za firmu** (pri prvnim pouziti, po
* prepnuti firmy znovu) a drzi se v modulu mimo React - stejny vzorec jako
* `lib/tenant.ts`. Udalosti ze streamu ho opravuji na miste, kdyz nesou cely
* zaznam, jinak se ten jeden ciselnik nacte znovu.
*
* **Tickety, behy a statistiky sem nepatri.** Ty filtruje a strankuje server
* a zustavaji v `useApiQuery`.
*/
// ------------------------------------------------------------- definice
export interface CollectionMap {
people: Person;
groups: GroupRef;
ticketTypes: TicketType;
services: CatalogService;
connectors: Connector;
/** Prava jsou jeden zaznam, ne seznam. `useAccess` je vraci primo. */
access: Access;
}
/** Co jeste endpoint vraci vedle seznamu a co stranky potrebuji. */
export interface CollectionExtraMap {
people: { meId: string | null };
groups: null;
ticketTypes: null;
services: Pick<ServiceCatalog, 'categories' | 'operatorsByType' | 'webhookBaseUrl'>;
connectors: null;
access: null;
}
export type CollectionKey = keyof CollectionMap;
interface Loaded<K extends CollectionKey> {
items: Array<CollectionMap[K]>;
extra: CollectionExtraMap[K];
}
/**
* Odkud se ktery ciselnik bere a jak se z odpovedi vytahne seznam.
*
* Lide a skupiny chodi z jednoho endpointu: skupiny s cleny jsou pod pravem
* `group.manage`, kdezto `/people` vraci jmena skupin kazdemu, kdo vidi lidi.
* Proto se skupiny berou odtud a ne z nastaveni.
*/
const specs: { [K in CollectionKey]: { path: string; parse: (raw: unknown) => Loaded<K> } } = {
people: {
path: '/api/dashboard/people',
parse: (raw) => {
const data = raw as PeopleResponse;
return { items: data.items, extra: { meId: data.meId } };
},
},
groups: {
path: '/api/dashboard/people',
parse: (raw) => ({ items: (raw as PeopleResponse).groups, extra: null }),
},
ticketTypes: {
path: '/api/dashboard/settings/ticket-types',
parse: (raw) => ({ items: (raw as { items: TicketType[] }).items, extra: null }),
},
services: {
path: '/api/dashboard/services',
parse: (raw) => {
const data = raw as ServiceCatalog;
return {
items: data.items,
extra: {
categories: data.categories,
operatorsByType: data.operatorsByType,
webhookBaseUrl: data.webhookBaseUrl,
},
};
},
},
connectors: {
path: '/api/dashboard/connectors',
parse: (raw) => ({ items: (raw as { items: Connector[] }).items, extra: null }),
},
access: {
path: '/api/dashboard/access',
parse: (raw) => ({ items: [raw as Access], extra: null }),
},
};
const keys = Object.keys(specs) as CollectionKey[];
// ----------------------------------------------------------------- stav
export interface CollectionState<K extends CollectionKey> extends Loaded<K> {
/** true dokud neni nic nacteno. Obnova na pozadi ho nezvedne. */
loading: boolean;
error: string | null;
/** true = uz aspon jednou doslo k nacteni pro tuhle firmu. */
loaded: boolean;
}
type AnyState = CollectionState<CollectionKey>;
function emptyState<K extends CollectionKey>(): CollectionState<K> {
return {
items: [],
extra: null as CollectionExtraMap[K],
loading: false,
error: null,
loaded: false,
};
}
const states = new Map<CollectionKey, AnyState>();
const inflight = new Map<CollectionKey, Promise<void>>();
const listeners = new Set<() => void>();
function getState<K extends CollectionKey>(key: K): CollectionState<K> {
let state = states.get(key);
if (!state) {
state = emptyState();
states.set(key, state);
}
return state as CollectionState<K>;
}
function setState<K extends CollectionKey>(key: K, patch: Partial<CollectionState<K>>): void {
states.set(key, { ...getState(key), ...patch });
for (const listener of listeners) listener();
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
/**
* Zahodi vsechno. Vola se pri prepnuti firmy a pri odchodu z portalu -
* ciselniky druhe firmy nebo predchoziho uzivatele tu nemaji co delat.
*/
export function resetCollections(): void {
states.clear();
inflight.clear();
for (const listener of listeners) listener();
}
onTenantChange(resetCollections);
/** Nacte ciselnik. Bezici nacitani se sdili, druhe volani nic nespousti. */
export function loadCollection<K extends CollectionKey>(key: K): Promise<void> {
const running = inflight.get(key);
if (running) return running;
const current = getState(key);
setState(key, { loading: !current.loaded, error: null });
const tenantAtStart = getActiveTenant();
const promise = apiFetch<unknown>(specs[key].path)
.then((raw) => {
// Mezitim prepnuta firma: odpoved patri te stare a zahazuje se.
if (getActiveTenant() !== tenantAtStart) return;
const parsed = specs[key].parse(raw);
setState(key, { ...parsed, loading: false, error: null, loaded: true });
})
.catch((err: unknown) => {
if (getActiveTenant() !== tenantAtStart) return;
const message = err instanceof Error ? err.message : 'Data se nepodařilo načíst.';
console.error(`[collections] ${key} selhalo:`, err);
setState(key, { loading: false, error: message });
})
.finally(() => {
inflight.delete(key);
});
inflight.set(key, promise);
return promise;
}
function ensureLoaded(key: CollectionKey): void {
const state = getState(key);
if (state.loaded || state.loading || inflight.has(key)) return;
void loadCollection(key);
}
// ------------------------------------------------- opravy podle udalosti
/**
* Ktere ciselniky se kterou udalosti meni. Klic je prvni cast typu udalosti
* (`person.created` -> `person`), aby se nove udalosti serveru chytly bez
* zasahu tady. Konektor meni i katalog sluzeb - ten nese priznak `connected`.
*/
const targetsByPrefix: Record<string, CollectionKey[]> = {
person: ['people'],
people: ['people'],
group: ['groups', 'people'],
connector: ['connectors', 'services'],
tickettype: ['ticketTypes'],
'ticket-type': ['ticketTypes'],
access: ['access'],
role: ['access'],
membership: ['access'],
settings: ['access'],
feature: ['access'],
};
/** Klic zaznamu v payloadu, pod kterym server muze poslat cely objekt. */
const recordKeys: Record<CollectionKey, string> = {
people: 'person',
groups: 'group',
ticketTypes: 'ticketType',
services: 'service',
connectors: 'connector',
access: 'access',
};
function isRecord(value: unknown): value is { id: string } & Record<string, unknown> {
return (
value !== null &&
typeof value === 'object' &&
typeof (value as { id?: unknown }).id === 'string'
);
}
/** ID zaznamu. Prava zadne nemaji, tam se vraci prazdny retezec. */
function idOf(item: unknown): string {
return isRecord(item) ? item.id : '';
}
/**
* Zpracuje udalost: oprava na miste, kdyz nese cely zaznam, jinak nove nacteni
* jen toho ciselniku, kterého se tyka. Vraci true, kdyz se neco delo.
*/
export function applyCollectionEvent(event: DashboardEvent): boolean {
const [prefix, verb] = event.type.toLowerCase().split('.');
const targets = targetsByPrefix[prefix ?? ''];
if (!targets) return false;
for (const key of targets) {
const state = getState(key);
// Co jeste nikdo nepotreboval, se kvuli udalosti nenacita.
if (!state.loaded) continue;
// Prava nejsou seznam, ty se vzdy nactou znovu.
if (key === 'access') {
void loadCollection(key);
continue;
}
const payload = event.payload ?? {};
const record = payload[recordKeys[key]];
const items = state.items as unknown[];
if (verb === 'deleted') {
const id = typeof payload.id === 'string' ? payload.id : isRecord(record) ? record.id : null;
if (id) {
setState(key, { items: items.filter((item) => idOf(item) !== id) } as Partial<AnyState>);
continue;
}
}
if (isRecord(record)) {
const exists = items.some((item) => idOf(item) === record.id);
const next = exists
? items.map((item) => (idOf(item) === record.id ? record : item))
: [...items, record];
setState(key, { items: next } as Partial<AnyState>);
continue;
}
void loadCollection(key);
}
return true;
}
// ---------------------------------------------------------------- React
const CollectionsContext = createContext(false);
/**
* Napoji uloziste na stream udalosti a uklidi ho pri odchodu z portalu.
* Musi stat uvnitr `EventStreamProvider`.
*/
export function CollectionsProvider({ children }: { children: ReactNode }) {
const { subscribe: subscribeEvents } = useEventStream();
useEffect(() => subscribeEvents(applyCollectionEvent), [subscribeEvents]);
// Odchod z portalu (odhlaseni, vyprsely token) = konec platnosti dat.
useEffect(() => resetCollections, []);
return <CollectionsContext.Provider value={true}>{children}</CollectionsContext.Provider>;
}
export interface CollectionResult<K extends CollectionKey> extends CollectionState<K> {
/** Zaznamy podle ID. Pocita se jen kdyz se seznam zmeni. */
byId: Map<string, CollectionMap[K]>;
reload: () => void;
}
/**
* Jeden ciselnik jako stav komponenty.
*
* Prvni pouziti ho nacte, dalsi uz jen ctou. Prekresluje se pri kazde zmene
* kterehokoliv ciselniku - jsou male a meni se zridka, takze to nevadi.
*/
export function useCollection<K extends CollectionKey>(key: K): CollectionResult<K> {
const inProvider = useContext(CollectionsContext);
// Bez providera by udalosti nechodily a data by zustala stara. Radsi nahlas.
if (!inProvider) console.warn('[collections] useCollection mimo CollectionsProvider');
const tenantId = useActiveTenant();
const state = useSyncExternalStore(subscribe, () => getState(key));
useEffect(() => {
ensureLoaded(key);
}, [key, tenantId]);
const byId = useMemo(
() => new Map(state.items.map((item) => [idOf(item), item] as const)),
[state.items],
);
return useMemo(
() => ({ ...state, byId, reload: () => void loadCollection(key) }),
[state, byId, key],
);
}
/**
* Cast ciselniku. Prekresli se jen kdyz se zmeni vysledek selektoru,
* ne pri kazde zmene ulozist - pro komponenty, ktere chteji jeden zaznam.
*
* Selektor musi vracet stabilni hodnotu (zaznam z pole, retezec, cislo).
* Novy objekt pri kazdem volani by React donutil kreslit porad dokola.
*/
export function useCollectionSelector<K extends CollectionKey, R>(
key: K,
selector: (state: CollectionState<K>) => R,
): R {
const tenantId = useActiveTenant();
useEffect(() => {
ensureLoaded(key);
}, [key, tenantId]);
return useSyncExternalStore(subscribe, () => selector(getState(key)));
}
/** Prava prihlaseneho v aktualni firme. Jeden zaznam, ne seznam. */
export function useAccess(): {
data: Access | null;
loading: boolean;
error: string | null;
reload: () => void;
} {
const access = useCollection('access');
return useMemo(
() => ({
data: access.items[0] ?? null,
loading: access.loading,
error: access.error,
reload: access.reload,
}),
[access],
);
}
/** Kterou firmu maji ciselniky nactenou. Jen pro ladeni. */
export function loadedCollections(): CollectionKey[] {
return keys.filter((key) => getState(key).loaded);
}
+14 -1
View File
@@ -1,4 +1,4 @@
import { apiUrl, getToken } from '@/lib/api';
import { apiUrl, getToken, notifyAuthExpired } from '@/lib/api';
import type { DashboardEvent } from '@/types/events';
/**
@@ -39,6 +39,19 @@ export function connectEventStream(path: string, handlers: StreamHandlers): () =
signal: controller.signal,
});
/*
* 401 a 403 nejsou vypadek, ale odmitnuti. Znovu se pripojovat nema
* smysl - se stejnym tokenem dopadne kazdy pokus stejne a jen by
* se plnil log serveru. U 401 se navic ohlasi vyprsele prihlaseni.
*/
if (response.status === 401 || response.status === 403) {
console.warn(`[stream] server odmitl pripojeni (HTTP ${response.status}), koncim`);
closed = true;
handlers.onStatus('closed');
if (response.status === 401) notifyAuthExpired();
return;
}
if (!response.ok || !response.body) {
throw new Error(`Stream se nepodařilo otevřít (HTTP ${response.status}).`);
}
+2 -1
View File
@@ -72,7 +72,8 @@ export const operatorsByType: Record<FieldType, ConditionOperator[]> = {
date: ['eq', 'gt', 'lt'],
// Nad strukturou ma smysl jen to, jestli vubec neco prisla.
object: ['isEmpty', 'isNotEmpty'],
list: ['isEmpty', 'isNotEmpty'],
// contains kvuli stitkum ticketu, stejne jako na serveru.
list: ['contains', 'isEmpty', 'isNotEmpty'],
};
/** Operatory, ktere nepotrebuji hodnotu k porovnani. */
+13
View File
@@ -51,6 +51,19 @@ export function formatRelative(iso: string): string {
return diffDays === 1 ? 'včera' : `před ${diffDays} dny`;
}
/**
* Cesky tvar podle poctu: `plural(3, ['krok', 'kroky', 'kroků'])` da "kroky".
*
* Bylo to napsane na ctyrech mistech a jedno z nich melo jen dva tvary,
* takze "3 kroků". Tady je to jednou a spravne.
*/
export function plural(count: number, forms: [one: string, few: string, many: string]): string {
const abs = Math.abs(count);
if (abs === 1) return forms[0];
if (abs >= 2 && abs <= 4) return forms[1];
return forms[2];
}
/** 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`;
+18
View File
@@ -0,0 +1,18 @@
import type { TicketPriority } from '@/types/dashboard';
/**
* Pevne ciselniky pro formulare.
*
* Priorita je pevny vycet a byla opsana ve dvou formularich. Stavy a kanaly
* tady **nejsou**: ty nabizi server podle dat, viz `/widget-data/options`.
*/
export const priorities: Array<{ value: TicketPriority; label: string }> = [
{ value: 'low', label: 'Nízká' },
{ value: 'normal', label: 'Normální' },
{ value: 'high', label: 'Vysoká' },
{ value: 'critical', label: 'Kritická' },
];
export function priorityLabel(value: TicketPriority): string {
return priorities.find((item) => item.value === value)?.label ?? value;
}
+31
View File
@@ -0,0 +1,31 @@
import type { Ticket } from '@/types/dashboard';
import type { DashboardEvent } from '@/types/events';
function isTicket(value: unknown): value is Ticket {
return (
value !== null &&
typeof value === 'object' &&
typeof (value as { id?: unknown }).id === 'string' &&
typeof (value as { subject?: unknown }).subject === 'string' &&
typeof (value as { status?: unknown }).status === 'string'
);
}
/**
* Oprava nacteneho seznamu ticketu podle udalosti, bez dotazu na server.
*
* Jde jen kdyz udalost nese **cely ticket** (`payload.ticket`) a ten uz
* v seznamu je. Novy ticket se na miste nedoplnuje: seznam je vyfiltrovany
* a serazeny serverem a klient nevi, jestli by do nej patril. Vraci null,
* kdyz se opravit neda - volajici pak necha probehnout bezny refetch.
*/
export function patchTicketList<T extends { items: Ticket[] }>(
data: T,
event: DashboardEvent,
): T | null {
if (event.type !== 'ticket.updated' && event.type !== 'ticket.assigned') return null;
const ticket = event.payload?.ticket;
if (!isTicket(ticket)) return null;
if (!data.items.some((item) => item.id === ticket.id)) return null;
return { ...data, items: data.items.map((item) => (item.id === ticket.id ? ticket : item)) };
}
+203 -43
View File
@@ -1,17 +1,28 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useEventStream } from '@/components/dashboard/EventStreamProvider';
import { apiFetch } from '@/lib/api';
import { apiFetchWithMeta } from '@/lib/api';
import { getActiveTenant, onTenantChange } from '@/lib/tenant';
import type { DashboardEventType } from '@/types/events';
import type { DashboardEvent, DashboardEventType } from '@/types/events';
export interface QueryState<T> {
data: T | null;
/**
* true jen dokud nejsou zadna data, tedy pri prvnim nacteni.
*
* Obnova po udalosti ze streamu ho **nezvedne**: stranka drzi stara data
* a jen se na pozadi doplni nova. Driv se pri kazde udalosti cela stranka
* odmontovala kvuli spinneru a znovu namontovala, vcetne vsech dotazu uvnitr.
*/
loading: boolean;
/** Bezi obnova na pozadi. Data zustavaji, jen jsou mozna o chvilku stara. */
refreshing: boolean;
error: string | null;
/** Celkovy pocet zaznamu z `X-Total-Count`, kdyz ho server posila. */
total: number | null;
reload: () => void;
}
interface QueryOptions {
interface QueryOptions<T> {
/**
* Typy udalosti, po kterych se maji data znovu nacist.
* Diky tomu je dashboard zivy bez pravidelneho dotazovani serveru.
@@ -20,23 +31,137 @@ interface QueryOptions {
/**
* Kdyz je vyplnene, posle se POST s timhle telem.
*
* Je to tady kvuli davkovemu nacitani (`/widget-data`), ktere se ptá na
* Je to tady kvuli davkovemu nacitani (`/widget-data`), ktere se pta na
* seznam ID. GET s dvaceti ID v adrese by narazil na delku URL a druhy hook
* jen kvuli metode by znamenal dvakrat tentyz refetch a debounce.
*/
body?: unknown;
/** false = nedotazovat se vubec. Pro pripad, kdy jeste neni na co. */
enabled?: boolean;
/**
* Oprava dat na miste podle udalosti, bez dotazu na server.
*
* Vola se pro udalosti z `refetchOn`. Kdyz vrati novou hodnotu, pouzije se
* misto refetche; null znamena "nevim, nacti znovu". Ticket, ktery prisel
* i s celym zaznamem v payloadu, se tak jen vymeni v seznamu.
*/
patchOn?: (data: T, event: DashboardEvent) => T | null;
}
// ---------------------------------------------------------------- mezipamet
interface Entry {
data: unknown;
total: number | null;
}
/**
* Sdilena mezipamet dotazu, klic je firma + cesta + telo.
*
* Tentyz endpoint se na jedne strance pta vic hooku (napr. `/access` v ramu
* portalu i na strance pod nim). Bez mezipameti to bylo pet stejnych requestu
* pri kazdem otevreni. Ted se bezici dotaz sdili a hotova odpoved rozesle vsem,
* kdo na tentyz klic koukaji - takze se ani nemuzou rozejit.
*/
const cache = new Map<string, Entry>();
const inflight = new Map<string, Promise<Entry>>();
const watchers = new Map<string, Set<(entry: Entry) => void>>();
// Jina firma, jina data. Stare klice by jen zabiraly pamet.
onTenantChange(() => {
cache.clear();
});
function keyOf(tenantId: string | null, path: string, bodyKey: string): string {
return `${tenantId ?? ''}|${path}|${bodyKey}`;
}
function fetchEntry(key: string, path: string, bodyKey: string): Promise<Entry> {
const running = inflight.get(key);
if (running) return running;
const request =
bodyKey === ''
? apiFetchWithMeta<unknown>(path)
: apiFetchWithMeta<unknown>(path, { method: 'POST', body: JSON.parse(bodyKey) as unknown });
const promise = request
.then((result) => {
const entry: Entry = { data: result.data, total: result.total };
cache.set(key, entry);
for (const watcher of watchers.get(key) ?? []) watcher(entry);
return entry;
})
.finally(() => {
inflight.delete(key);
});
inflight.set(key, promise);
return promise;
}
function watch(key: string, watcher: (entry: Entry) => void): () => void {
const set = watchers.get(key) ?? new Set();
set.add(watcher);
watchers.set(key, set);
return () => {
set.delete(watcher);
if (set.size === 0) watchers.delete(key);
};
}
// ------------------------------------------------- obnova po udalosti streamu
const REFETCH_DELAY_MS = 150;
const pendingReloads = new Set<() => void>();
let reloadTimer: number | undefined;
/**
* Jeden casovac pro vsechny hooky na strance.
*
* Driv mel kazdy hook vlastni debounce, takze jedna udalost znamenala pet
* requestu v peti ruznych chvilich. Ted se vsechny obnovy za 150 ms sesbiraji
* a spusti naraz - a diky sdilene mezipameti jde stejny klic jen jednou.
*/
function scheduleReload(reload: () => void): void {
pendingReloads.add(reload);
if (reloadTimer !== undefined) return;
reloadTimer = window.setTimeout(() => {
reloadTimer = undefined;
const batch = [...pendingReloads];
pendingReloads.clear();
for (const fn of batch) fn();
}, REFETCH_DELAY_MS);
}
// --------------------------------------------------------------------- hook
interface HookState<T> {
data: T | null;
total: number | null;
loading: boolean;
refreshing: boolean;
error: string | null;
}
function initialState<T>(key: string | null): HookState<T> {
const cached = key ? cache.get(key) : undefined;
return {
data: (cached?.data as T | undefined) ?? null,
total: cached?.total ?? null,
loading: key !== null && cached === undefined,
refreshing: false,
error: null,
};
}
/**
* Minimalisticky data-fetching hook pro dashboard.
* Az bude dashboard vetsi, nahradit TanStack Query.
*
* Mezipamet, sdileni bezicich dotazu a spolecny debounce jsou schvalne
* jednoduche. Az bude dashboard vetsi, nahradit TanStack Query.
*/
export function useApiQuery<T>(path: string, options: QueryOptions = {}): QueryState<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
export function useApiQuery<T>(path: string, options: QueryOptions<T> = {}): QueryState<T> {
const [tick, setTick] = useState(0);
/*
* Firma je soucasti kazdeho dotazu (doplnuje ji `apiFetch`), takze jeji
@@ -44,70 +169,105 @@ export function useApiQuery<T>(path: string, options: QueryOptions = {}): QueryS
* cisla predchozi firmy, dokud by ji nekdo neobnovil.
*/
const [tenantId, setTenantId] = useState(getActiveTenant);
const reload = useCallback(() => setTick((value) => value + 1), []);
useEffect(() => onTenantChange(setTenantId), []);
const enabled = options.enabled !== false;
// Telo se do zavislosti effectu neda dat primo - nova reference pri kazdem
// vykresleni by znamenala dotaz v nekonecne smycce.
const bodyKey = options.body === undefined ? '' : JSON.stringify(options.body);
const key = enabled ? keyOf(tenantId, path, bodyKey) : null;
const [state, setState] = useState<HookState<T>>(() => initialState<T>(key));
const [stateKey, setStateKey] = useState(key);
// Jiny dotaz, jiny stav - hned pri vykresleni, ne az v effectu, aby stara
// data ani na okamzik nesvitila pod novym filtrem.
if (stateKey !== key) {
setStateKey(key);
setState(initialState<T>(key));
}
const reload = useCallback(() => setTick((value) => value + 1), []);
useEffect(() => {
if (!enabled) {
setLoading(false);
return;
}
if (!key) return;
let cancelled = false;
setLoading(true);
setError(null);
// Kdyz uz data jsou, jde jen o obnovu na pozadi.
setState((previous) => ({
...previous,
loading: previous.data === null,
refreshing: previous.data !== null,
error: null,
}));
const request =
bodyKey === ''
? apiFetch<T>(path)
: apiFetch<T>(path, { method: 'POST', body: JSON.parse(bodyKey) as unknown });
request
.then((result) => {
if (!cancelled) setData(result);
fetchEntry(key, path, bodyKey)
.then((entry) => {
if (cancelled) return;
setState({
data: entry.data as T,
total: entry.total,
loading: false,
refreshing: false,
error: null,
});
})
.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);
if (!cancelled) setError(message);
})
.finally(() => {
if (!cancelled) setLoading(false);
setState((previous) => ({ ...previous, loading: false, refreshing: false, error: message }));
});
return () => {
cancelled = true;
};
}, [path, tick, bodyKey, enabled, tenantId]);
}, [key, path, bodyKey, tick]);
// Kdyz tentyz dotaz dokonci jiny hook, prevezmeme jeho vysledek.
useEffect(() => {
if (!key) return;
return watch(key, (entry) => {
setState((previous) => ({
...previous,
data: entry.data as T,
total: entry.total,
loading: false,
error: null,
}));
});
}, [key]);
// Obnoveni dat pri prichozi udalosti ze streamu.
const { subscribe } = useEventStream();
const refetchKey = (options.refetchOn ?? []).join(',');
const debounce = useRef<number | undefined>(undefined);
const patchRef = useRef(options.patchOn);
patchRef.current = options.patchOn;
useEffect(() => {
if (refetchKey.length === 0) return;
const wanted = new Set(refetchKey.split(','));
const unsubscribe = subscribe((event) => {
return subscribe((event) => {
if (!wanted.has(event.type)) return;
// Kdyz prijde vic udalosti tesne po sobe, nacitame data jen jednou.
if (debounce.current) window.clearTimeout(debounce.current);
debounce.current = window.setTimeout(reload, 150);
// Nejdriv zkusit opravu na miste. Data se ctou z mezipameti, protoze
// stav hooku tady neni po ruce a mezipamet je s nim v souladu.
const patch = patchRef.current;
const cached = key ? cache.get(key) : undefined;
if (patch && cached && cached.data !== null) {
const next = patch(cached.data as T, event);
if (next !== null) {
const entry: Entry = { data: next, total: cached.total };
cache.set(key as string, entry);
for (const watcher of watchers.get(key as string) ?? []) watcher(entry);
return;
}
}
scheduleReload(reload);
});
}, [subscribe, refetchKey, reload, key]);
return () => {
unsubscribe();
if (debounce.current) window.clearTimeout(debounce.current);
};
}, [subscribe, refetchKey, reload]);
return { data, loading, error, reload };
return { ...state, reload };
}
+27
View File
@@ -0,0 +1,27 @@
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)';
+57
View File
@@ -0,0 +1,57 @@
import { useCallback, useRef, useState } from 'react';
import { ApiError } from '@/lib/api';
export interface SubmitState<Args extends unknown[]> {
/** Bezi odeslani. Tlacitka se podle toho vypinaji. */
busy: boolean;
error: string | null;
/** Dilci problemy ze serveru (`ApiError.issues`), kdyz je posila. */
issues: Array<{ field: string; message: string }>;
/** Spusti akci. Vraci true pri uspechu, aby slo po odeslani zavrit dialog. */
submit: (...args: Args) => Promise<boolean>;
/** Rucni nastaveni chyby, napr. z validace pred odeslanim. */
setError: (message: string | null) => void;
}
/**
* Odeslani formulare: busy, chyba, try/finally.
*
* Tahle dvanactka radku byla v kazdem formulari zvlast a v kazdem trochu
* jinak: nekde se chyba logovala, nekde ne, nekde zustalo `busy` zapnute po
* vyjimce. Jedno misto znamena jedno chovani.
*
* `action` se cte z refu, takze muze byt inline lambda - `submit` ma stalou
* identitu a da se predat do zavislosti effectu.
*/
export function useSubmit<Args extends unknown[]>(
action: (...args: Args) => Promise<void>,
fallback = 'Nepodařilo se uložit.',
): SubmitState<Args> {
const [busy, setBusy] = useState(false);
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 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);
}
}, []);
return { busy, error, issues, submit, setError };
}
+48
View File
@@ -0,0 +1,48 @@
import { useEffect } from 'react';
const DEFAULT_MESSAGE = 'Máte neuložené změny. Opravdu chcete odejít?';
/**
* Pojistka proti ztrate rozdelane prace.
*
* Builder drzel priznak `dirty`, ale nic ho nehlidalo: zavreni zalozky nebo
* klik na odkaz v menu zahodily pul hodiny skladani stromu bez jedine otazky.
*
* Dve veci:
* - `beforeunload` pro zavreni a obnoveni stranky (text si urcuje prohlizec),
* - odchyt kliknuti na odkaz uvnitr aplikace. `useBlocker` z routeru chce
* datovy router, ktery aplikace nepouziva, tak se chytaji odkazy primo.
* Tlacitko Zpet v prohlizeci to nezachyti, to je zname omezeni.
*/
export function useUnsavedChanges(dirty: boolean, message = DEFAULT_MESSAGE): void {
useEffect(() => {
if (!dirty) return;
const onBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault();
// Starsi prohlizece chteji returnValue, jinak dialog neukazou.
event.returnValue = '';
};
const onClick = (event: MouseEvent) => {
if (event.defaultPrevented || event.button !== 0) return;
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
const target = event.target instanceof Element ? event.target : null;
const anchor = target?.closest('a[href]');
if (!anchor || anchor.getAttribute('target') === '_blank') return;
const href = anchor.getAttribute('href') ?? '';
if (href.startsWith('#') || href.startsWith('mailto:') || href.startsWith('tel:')) return;
if (window.confirm(message)) return;
// Zastavit driv, nez se k tomu dostane router - ten by uz navigoval.
event.preventDefault();
event.stopPropagation();
};
window.addEventListener('beforeunload', onBeforeUnload);
document.addEventListener('click', onClick, true);
return () => {
window.removeEventListener('beforeunload', onBeforeUnload);
document.removeEventListener('click', onClick, true);
};
}, [dirty, message]);
}
+38 -68
View File
@@ -4,83 +4,55 @@ import { Card } from '@/components/ui/Card';
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';
const values = [
{
icon: Compass,
title: 'Nejdřív pochopit, potom kódovat',
text: 'Než něco postavíme, projdeme proces s lidmi, kteří ho denně dělají. Většina úspor je vidět už tam.',
},
{
icon: ShieldCheck,
title: 'Bez vendor lock-inu',
text: 'Stavíme na standardních technologiích. Kód i dokumentace jsou vaše, kdykoliv můžete odejít.',
},
{
icon: Radar,
title: 'Provoz bereme jako součást dodávky',
text: 'Monitoring, alerty a incident management nejsou příplatek. Bez nich automatizace dřív nebo později tiše umře.',
},
{
icon: HeartHandshake,
title: 'Mluvíme lidsky',
text: 'Žádné prezentace plné buzzwordů. Řekneme i to, co se automatizovat nevyplatí.',
},
const values: Array<{ icon: typeof Compass; title: MessageKey; text: MessageKey }> = [
{ icon: Compass, title: 'about.value1.title', text: 'about.value1.text' },
{ icon: ShieldCheck, title: 'about.value2.title', text: 'about.value2.text' },
{ icon: Radar, title: 'about.value3.title', text: 'about.value3.text' },
{ icon: HeartHandshake, title: 'about.value4.title', text: 'about.value4.text' },
];
const team = [
{ initials: 'JU', name: 'Jiří Uhlíř', role: 'zakladatel, architektura řešení' },
{ initials: 'MK', name: 'Martin Kříž', role: 'integrace a backend' },
{ initials: 'EN', name: 'Eva Nováková', role: 'voiceboti a konverzační design' },
{ initials: 'PS', name: 'Petr Souček', role: 'provoz, monitoring, podpora' },
/** Jmena se neprekladaji, role ano. */
const team: Array<{ initials: string; name: string; role: MessageKey }> = [
{ initials: 'JU', name: 'Jiří Uhlíř', role: 'about.team1.role' },
{ initials: 'MK', name: 'Martin Kříž', role: 'about.team2.role' },
{ initials: 'EN', name: 'Eva Nováková', role: 'about.team3.role' },
{ initials: 'PS', name: 'Petr Souček', role: 'about.team4.role' },
];
const milestones = [
{ year: '2018', text: 'Vznik firmy - první integrace e-shopů s účetnictvím.' },
{ year: '2020', text: 'První hlasová linka v produkci, tým se rozrůstá na čtyři lidi.' },
{ year: '2023', text: 'Spouštíme vlastní klientský portál s tickety a incidenty.' },
{ year: '2025', text: 'Provozujeme 120+ automatizací s dostupností 99,98 %.' },
const milestones: Array<{ year: string; text: MessageKey }> = [
{ year: '2018', text: 'about.milestone2018' },
{ year: '2020', text: 'about.milestone2020' },
{ year: '2023', text: 'about.milestone2023' },
{ year: '2025', text: 'about.milestone2025' },
];
export default function About() {
usePageMeta({
title: 'O nás',
description:
'Malý tým, který firmám staví automatizace, voiceboty a integrace - a pak je i provozuje.',
});
const t = useT();
usePageMeta({ title: t('about.title'), description: t('about.meta') });
return (
<>
<PageHeader
eyebrow="O nás"
eyebrow={t('about.eyebrow')}
title={
<>
Malý tým, <span className="text-gradient">který za svou práci ručí</span>
{t('about.titleFirst')} <span className="text-gradient">{t('about.titleAccent')}</span>
</>
}
subtitle={`${brand.name} funguje od roku ${brand.founded}. Nejsme softwarový dům na sto lidí - jsme parta, která staví věci, co musí fungovat v pondělí ráno.`}
subtitle={t('about.subtitle', { brand: brand.name, founded: brand.founded })}
/>
<Section>
<div className="grid gap-10 lg:grid-cols-[1.2fr_1fr]">
<div className="space-y-5 text-white/65">
<h2 className="text-2xl font-bold text-white">Jak jsme se k tomu dostali</h2>
<p className="leading-relaxed">
Začínali jsme tím, že jsme jedné velkoobchodní firmě přestali přepisovat objednávky
z e-mailu do skladu. Ukázalo se, že tenhle problém má skoro každý - jen mu každý říká
jinak. Od té doby jsme postavili automatizace pro firmy od pěti do pěti set lidí.
</p>
<p className="leading-relaxed">
Postupně se k automatizacím přidaly hlasové linky, protože telefon je pořád kanál,
kterým přichází nejvíc poptávek. A když jsme začali provozovat věci nonstop, přišly
nutně i dashboardy, tickety a incident management - abychom o problémech věděli
dřív než klient.
</p>
<p className="leading-relaxed">
Dnes je náš přístup jednoduchý: dodáme malý funkční celek, změříme, co ušetřil, a
teprve potom stavíme dál.
</p>
<h2 className="text-2xl font-bold text-white">{t('about.storyTitle')}</h2>
<p className="leading-relaxed">{t('about.story1')}</p>
<p className="leading-relaxed">{t('about.story2')}</p>
<p className="leading-relaxed">{t('about.story3')}</p>
</div>
<ol className="relative space-y-6 border-l border-ink-600/70 pl-6">
@@ -88,7 +60,7 @@ export default function About() {
<li key={milestone.year} className="relative">
<span className="absolute top-1.5 -left-[1.9rem] size-3 rounded-full border-2 border-ink-900 bg-brand-400" />
<p className="font-mono text-sm font-bold text-brand-300">{milestone.year}</p>
<p className="mt-1 text-sm leading-relaxed text-white/60">{milestone.text}</p>
<p className="mt-1 text-sm leading-relaxed text-white/60">{t(milestone.text)}</p>
</li>
))}
</ol>
@@ -97,9 +69,9 @@ export default function About() {
<Section className="bg-ink-950/40">
<SectionHeading
eyebrow="Jak pracujeme"
title="Čtyři věci, na kterých si trváme"
subtitle="Nejsou to hodnoty na zeď. Podle nich se rozhodujeme, jaké zakázky bereme."
eyebrow={t('about.valuesEyebrow')}
title={t('about.valuesTitle')}
subtitle={t('about.valuesSubtitle')}
/>
<div className="grid gap-5 sm:grid-cols-2">
{values.map((value) => {
@@ -109,8 +81,8 @@ export default function About() {
<span className="grid size-11 place-items-center rounded-xl bg-gradient-to-br from-brand-500/20 to-accent-500/20 text-brand-300">
<Icon className="size-5" />
</span>
<h3 className="mt-4 font-semibold text-white">{value.title}</h3>
<p className="mt-2 text-sm leading-relaxed text-white/60">{value.text}</p>
<h3 className="mt-4 font-semibold text-white">{t(value.title)}</h3>
<p className="mt-2 text-sm leading-relaxed text-white/60">{t(value.text)}</p>
</Card>
);
})}
@@ -119,9 +91,9 @@ export default function About() {
<Section>
<SectionHeading
eyebrow="Tým"
title="Lidé, se kterými budete mluvit"
subtitle="Žádná call centra ani account manažeři mezi vámi a tím, kdo řešení staví."
eyebrow={t('about.teamEyebrow')}
title={t('about.teamTitle')}
subtitle={t('about.teamSubtitle')}
/>
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
{team.map((member) => (
@@ -130,13 +102,11 @@ export default function About() {
{member.initials}
</span>
<h3 className="mt-4 font-semibold text-white">{member.name}</h3>
<p className="mt-1 text-sm text-white/50">{member.role}</p>
<p className="mt-1 text-sm text-white/50">{t(member.role)}</p>
</Card>
))}
</div>
<p className="mt-8 text-center text-xs text-white/30">
Složení týmu je v této verzi webu ukázkové (mockup).
</p>
<p className="mt-8 text-center text-xs text-white/30">{t('about.teamMockup')}</p>
</Section>
<CallToAction />
+82 -83
View File
@@ -4,31 +4,37 @@ import type { FormEvent, ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
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 { PageHeader } from '@/components/ui/PageHeader';
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';
const topics = [
{ value: 'automatizace', label: 'Automatizace procesů' },
{ value: 'voicebot', label: 'Voicebot / hlasová linka' },
{ value: 'integrace', label: 'Integrace systémů' },
{ value: 'dashboard', label: 'Dashboard a reporting' },
{ value: 'podpora', label: 'Podpora / incident' },
{ value: 'jine', label: 'Něco jiného' },
] as const;
/** Hodnota jde na server cesky, popisek se preklada. */
const topics: Array<{ value: string; label: MessageKey }> = [
{ value: 'automatizace', label: 'contact.topic.automation' },
{ value: 'voicebot', label: 'contact.topic.voicebot' },
{ value: 'integrace', label: 'contact.topic.integration' },
{ value: 'dashboard', label: 'contact.topic.dashboard' },
{ value: 'podpora', label: 'contact.topic.support' },
{ value: 'jine', label: 'contact.topic.other' },
];
type Status = { kind: 'idle' } | { kind: 'sending' } | { kind: 'ok'; message: string } | { kind: 'error'; message: string };
const inputClass =
'w-full rounded-xl border border-ink-600/70 bg-ink-850/70 px-4 py-3 text-sm text-white placeholder:text-white/30 transition-colors focus:border-brand-400/70 focus:outline-none';
type Status =
| { kind: 'idle' }
| { kind: 'sending' }
| { kind: 'ok'; message: string }
| { kind: 'error'; message: string };
export default function Contact() {
usePageMeta({
title: 'Kontakt',
description: 'Napište nám nebo zavolejte. Ozveme se do jednoho pracovního dne.',
});
const t = useT();
usePageMeta({ title: t('contact.title'), description: t('contact.meta') });
const [status, setStatus] = useState<Status>({ kind: 'idle' });
@@ -47,7 +53,7 @@ export default function Contact() {
setStatus({ kind: 'ok', message: response.message });
form.reset();
} catch (err) {
const message = err instanceof Error ? err.message : 'Zprávu se nepodařilo odeslat.';
const message = err instanceof Error ? err.message : t('contact.failed');
console.error('[contact] odeslani selhalo:', err);
setStatus({ kind: 'error', message });
}
@@ -56,67 +62,85 @@ export default function Contact() {
return (
<>
<PageHeader
eyebrow="Kontakt"
eyebrow={t('contact.eyebrow')}
title={
<>
Ozvěte se. <span className="text-gradient">Odpovídáme rychle.</span>
{t('contact.titleFirst')}{' '}
<span className="text-gradient">{t('contact.titleAccent')}</span>
</>
}
subtitle="Napište, co řešíte - i když ještě nevíte, jak by to mělo fungovat. Na první hovor stačí popis problému."
subtitle={t('contact.subtitle')}
/>
<Section>
<div className="grid gap-6 lg:grid-cols-[1.3fr_1fr]">
<Card className="p-6 sm:p-8">
<h2 className="text-xl font-bold text-white">Poptávka nebo dotaz</h2>
<p className="mt-1 text-sm text-white/50">Vyplnění zabere minutu.</p>
<h2 className="text-xl font-bold text-white">{t('contact.formTitle')}</h2>
<p className="mt-1 text-sm text-white/50">{t('contact.formLead')}</p>
<form className="mt-7 space-y-4" onSubmit={handleSubmit}>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Jméno a příjmení" htmlFor="name">
<input id="name" name="name" required minLength={2} className={inputClass} placeholder="Jan Novák" />
<Field label={t('contact.name')} htmlFor="name" size="md">
<Input
id="name"
name="name"
required
minLength={2}
size="md"
placeholder={t('contact.namePlaceholder')}
/>
</Field>
<Field label="E-mail" htmlFor="email">
<input id="email" name="email" type="email" required className={inputClass} placeholder="jan@firma.cz" />
<Field label={t('contact.email')} htmlFor="email" size="md">
<Input
id="email"
name="email"
type="email"
required
size="md"
placeholder={t('contact.emailPlaceholder')}
/>
</Field>
<Field label="Firma" htmlFor="company" optional>
<input id="company" name="company" className={inputClass} placeholder="Firma s.r.o." />
<Field label={t('contact.company')} htmlFor="company" size="md" optional>
<Input
id="company"
name="company"
size="md"
placeholder={t('contact.companyPlaceholder')}
/>
</Field>
<Field label="Telefon" htmlFor="phone" optional>
<input id="phone" name="phone" className={inputClass} placeholder="+420 ..." />
<Field label={t('contact.phone')} htmlFor="phone" size="md" optional>
<Input id="phone" name="phone" size="md" placeholder={t('contact.phonePlaceholder')} />
</Field>
</div>
<Field label="Téma" htmlFor="topic">
<select id="topic" name="topic" defaultValue="automatizace" className={inputClass}>
{topics.map((topic) => (
<option key={topic.value} value={topic.value} className="bg-ink-850">
{topic.label}
</option>
))}
</select>
<Field label={t('contact.topic')} htmlFor="topic" size="md">
<Select
id="topic"
name="topic"
defaultValue="automatizace"
size="md"
options={topics.map((topic) => ({ value: topic.value, label: t(topic.label) }))}
/>
</Field>
<Field label="Co řešíte" htmlFor="message">
<textarea
<Field label={t('contact.message')} htmlFor="message" size="md">
<Textarea
id="message"
name="message"
required
minLength={10}
rows={5}
className={`${inputClass} resize-y`}
placeholder="Např.: Objednávky z e-shopu přepisujeme ručně do skladu, denně asi 60 kusů..."
size="md"
placeholder={t('contact.messagePlaceholder')}
/>
</Field>
<div className="flex flex-wrap items-center gap-4 pt-2">
<Button type="submit" disabled={status.kind === 'sending'}>
<Send className="size-4" />
{status.kind === 'sending' ? 'Odesílám...' : 'Odeslat poptávku'}
{status.kind === 'sending' ? t('contact.submitting') : t('contact.submit')}
</Button>
<p className="text-xs text-white/35">
Odesláním souhlasíte se zpracováním údajů pro účely odpovědi.
</p>
<p className="text-xs text-white/35">{t('contact.consent')}</p>
</div>
{status.kind === 'ok' && (
@@ -136,23 +160,23 @@ export default function Contact() {
<div className="space-y-4">
<Card>
<h2 className="font-semibold text-white">Přímé kontakty</h2>
<h2 className="font-semibold text-white">{t('contact.directTitle')}</h2>
<ul className="mt-4 space-y-4 text-sm">
<ContactRow icon={<Mail className="size-4" />} label="E-mail">
<ContactRow icon={<Mail className="size-4" />} label={t('contact.email')}>
<a href={`mailto:${brand.email}`} className="text-white/70 hover:text-brand-300">
{brand.email}
</a>
</ContactRow>
<ContactRow icon={<Phone className="size-4" />} label="Telefon">
<ContactRow icon={<Phone className="size-4" />} label={t('contact.phone')}>
<a href={`tel:${brand.phoneHref}`} className="text-white/70 hover:text-brand-300">
{brand.phone}
</a>
</ContactRow>
<ContactRow icon={<Clock className="size-4" />} label="Dostupnost">
<ContactRow icon={<Clock className="size-4" />} label={t('contact.availability')}>
<span className="text-white/70">{brand.support.hours}</span>
<span className="mt-0.5 block text-xs text-white/40">{brand.support.sla}</span>
</ContactRow>
<ContactRow icon={<MapPin className="size-4" />} label="Adresa">
<ContactRow icon={<MapPin className="size-4" />} label={t('contact.address')}>
<span className="text-white/70">
{brand.address.street}
<br />
@@ -165,34 +189,31 @@ export default function Contact() {
</Card>
<Card>
<h2 className="font-semibold text-white">Fakturační údaje</h2>
<h2 className="font-semibold text-white">{t('contact.billingTitle')}</h2>
<dl className="mt-4 space-y-2 font-mono text-sm text-white/60">
<div className="flex justify-between gap-4">
<dt className="text-white/40">Firma</dt>
<dt className="text-white/40">{t('contact.billingCompany')}</dt>
<dd>{brand.legalName}</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-white/40">IČO</dt>
<dt className="text-white/40">{t('contact.billingIco')}</dt>
<dd>{brand.ico}</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-white/40">DIČ</dt>
<dt className="text-white/40">{t('contact.billingDic')}</dt>
<dd>{brand.dic}</dd>
</div>
</dl>
</Card>
<Card className="border-brand-400/25 bg-brand-500/6">
<h2 className="font-semibold text-white">Jste náš klient?</h2>
<p className="mt-2 text-sm leading-relaxed text-white/60">
Požadavky a incidenty zadávejte přímo v klientském portálu - mají tam SLA a vidíte
jejich stav.
</p>
<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>
<Link
to="/prihlaseni"
className="mt-4 inline-flex text-sm font-semibold text-brand-300 hover:text-brand-200"
>
Přejít do portálu
{t('contact.clientLink')}
</Link>
</Card>
</div>
@@ -202,28 +223,6 @@ export default function Contact() {
);
}
function Field({
label,
htmlFor,
optional = false,
children,
}: {
label: string;
htmlFor: string;
optional?: boolean;
children: ReactNode;
}) {
return (
<div>
<label htmlFor={htmlFor} className="mb-1.5 block text-sm font-medium text-white/70">
{label}
{optional && <span className="ml-1.5 text-xs text-white/30">nepovinné</span>}
</label>
{children}
</div>
);
}
function ContactRow({
icon,
label,
+3 -4
View File
@@ -5,14 +5,13 @@ import { Process } from '@/components/home/Process';
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';
export default function Home() {
const t = useT();
// Bez `title` slozi hook titulek ze znacky a claimu, tedy jedno misto pro obojí.
usePageMeta({
description:
'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.',
});
usePageMeta({ description: t('meta.home') });
return (
<>
+35 -44
View File
@@ -4,8 +4,11 @@ import { Link, useNavigate, useParams } from 'react-router-dom';
import { useAuth } from '@/auth/AuthContext';
import { Button } from '@/components/ui/Button';
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';
/**
* Prijeti pozvanky do firmy.
@@ -24,9 +27,6 @@ interface InviteInfo {
problem: string | null;
}
const inputClass =
'w-full rounded-xl border border-ink-600/70 bg-ink-850/70 px-3.5 py-2.5 text-sm text-white placeholder:text-white/30 focus:border-brand-400/70 focus:outline-none';
export default function Invite() {
const { code = '' } = useParams();
const navigate = useNavigate();
@@ -40,40 +40,36 @@ export default function Invite() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false);
useEffect(() => {
let cancelled = false;
apiFetch<InviteInfo>(`/api/invites/${code}`, { auth: false })
.then((data) => {
if (cancelled) return;
setInfo(data);
if (data.email) setEmail(data.email);
})
.catch((err: unknown) => {
if (cancelled) return;
setLoadError(err instanceof Error ? err.message : 'Pozvánku se nepodařilo načíst.');
});
return () => {
cancelled = true;
};
}, [code]);
async function accept() {
setBusy(true);
setError(null);
try {
await apiFetch(`/api/invites/${code}/accept`, {
method: 'POST',
auth: false,
body: { name, email, password },
});
setDone(true);
// Rovnou prihlasit: clovek uz heslo zadal, druhy formular by byl obtez.
await login(email, password);
navigate('/dashboard', { replace: true });
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Pozvánku se nepodařilo přijmout.');
} finally {
setBusy(false);
}
}
const { busy, error, submit: accept } = useSubmit(async () => {
await apiFetch(`/api/invites/${code}/accept`, {
method: 'POST',
auth: false,
body: { name, email, password },
});
setDone(true);
// Rovnou prihlasit: clovek uz heslo zadal, druhy formular by byl obtez.
await login(email, password);
navigate('/dashboard', { replace: true });
}, 'Pozvánku se nepodařilo přijmout.');
return (
<main className="flex min-h-screen items-center py-16">
@@ -116,44 +112,39 @@ export default function Invite() {
</p>
<div className="mt-6 space-y-4">
<label className="block">
<span className="mb-1 block text-sm text-white/70">Jméno a příjmení</span>
<input
<Field label="Jméno a příjmení">
<Input
value={name}
onChange={(event) => setName(event.target.value)}
autoComplete="name"
className={inputClass}
size="lg"
/>
</label>
</Field>
<label className="block">
<span className="mb-1 block text-sm text-white/70">E-mail</span>
<input
<Field label="E-mail">
<Input
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
/* Kdyz je pozvanka na konkretni adresu, nejde ji zmenit. */
readOnly={Boolean(info?.email)}
autoComplete="email"
className={inputClass}
size="lg"
/>
</label>
</Field>
<label className="block">
<span className="mb-1 block text-sm text-white/70">
{info?.knownUser ? 'Vaše heslo' : 'Nové heslo'}
</span>
<input
<Field
label={info?.knownUser ? 'Vaše heslo' : 'Nové heslo'}
hint={info?.knownUser ? undefined : 'Aspoň 8 znaků.'}
>
<Input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete={info?.knownUser ? 'current-password' : 'new-password'}
className={inputClass}
size="lg"
/>
{!info?.knownUser && (
<span className="mt-1 block text-xs text-white/35">Aspoň 8 znaků.</span>
)}
</label>
</Field>
{error && (
<p className="flex items-start gap-2 text-sm text-danger-400">
+43 -38
View File
@@ -1,11 +1,14 @@
import { AlertCircle, ArrowLeft, LockKeyhole, ShieldCheck } from 'lucide-react';
import { AlertCircle, ArrowLeft, Clock, LockKeyhole, ShieldCheck } from 'lucide-react';
import { useState } from 'react';
import type { FormEvent } from 'react';
import { Link, Navigate, useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '@/auth/AuthContext';
import { Logo } from '@/components/layout/Logo';
import { Button } from '@/components/ui/Button';
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';
/**
@@ -17,23 +20,22 @@ import { usePageMeta } from '@/lib/usePageMeta';
interface DemoAccount {
email: string;
password: string;
note: string;
note: MessageKey;
}
const demoAccounts: DemoAccount[] = [
{ email: 'admin@automia.cz', password: 'demo1234', note: 'správce platformy, vidí všechny firmy' },
{ email: 'karel.vomacka@automia.cz', password: 'demo1234', note: 'dvě firmy, jiná role v každé' },
{ email: 'martin.kriz@automia.cz', password: 'demo1234', note: 'řešitel jedné firmy' },
{ email: 'admin@automia.cz', password: 'demo1234', note: 'login.demo.admin' },
{ email: 'karel.vomacka@automia.cz', password: 'demo1234', note: 'login.demo.multi' },
{ email: 'martin.kriz@automia.cz', password: 'demo1234', note: 'login.demo.agent' },
];
const inputClass =
'w-full rounded-xl border border-ink-600/70 bg-ink-850/70 px-4 py-3 text-sm text-white placeholder:text-white/30 transition-colors focus:border-brand-400/70 focus:outline-none';
/** Prihlaseni do klientskeho portalu. Demo ucty jsou vypsane pod formularem. */
export default function Login() {
const t = useT();
usePageMeta({
title: 'Přihlášení',
description: `Přihlášení do klientského portálu ${brand.name}.`,
title: t('login.title'),
description: t('login.meta', { brand: brand.name }),
});
const { user, loading, login } = useAuth();
@@ -57,7 +59,10 @@ export default function Login() {
const [submitting, setSubmitting] = useState(false);
// Kam po prihlaseni - bud puvodni cil z RequireAuth, nebo dashboard.
const redirectTo = (location.state as { from?: string } | null)?.from ?? '/dashboard';
const state = location.state as { from?: string; reason?: string } | null;
const redirectTo = state?.from ?? '/dashboard';
// RequireAuth posila `reason`, kdyz clovek neprisel poprve, ale vyprselo mu prihlaseni.
const expired = state?.reason === 'expired';
if (!loading && user) return <Navigate to={redirectTo} replace />;
@@ -73,7 +78,7 @@ export default function Login() {
await login(email, password);
navigate(redirectTo, { replace: true });
} catch (err) {
const message = err instanceof Error ? err.message : 'Přihlášení se nepodařilo.';
const message = err instanceof Error ? err.message : t('login.failed');
console.warn('[login] neuspesne prihlaseni:', err);
setError(message);
} finally {
@@ -95,51 +100,53 @@ export default function Login() {
className="mb-8 inline-flex items-center gap-2 text-sm text-white/45 transition-colors hover:text-white"
>
<ArrowLeft className="size-4" />
Zpět na web
{t('login.back')}
</Link>
<div className="glass rounded-2xl p-7 shadow-2xl shadow-brand-500/10 sm:p-9">
<Logo />
<h1 className="mt-7 text-2xl font-bold text-white">Klientský portál</h1>
<p className="mt-2 text-sm text-white/55">
Přihlaste se a uvidíte stav automatizací, ticketů i incidentů.
</p>
<h1 className="mt-7 text-2xl font-bold text-white">{t('login.heading')}</h1>
<p className="mt-2 text-sm text-white/55">{t('login.lead')}</p>
{expired && (
<p className="mt-5 flex items-center gap-2 rounded-xl border border-warn-400/30 bg-warn-500/10 px-4 py-3 text-sm text-warn-400">
<Clock className="size-4 shrink-0" />
{t('login.expired')}
</p>
)}
<form className="mt-7 space-y-4" onSubmit={handleSubmit}>
<div>
<label htmlFor="email" className="mb-1.5 block text-sm font-medium text-white/70">
E-mail
</label>
<input
<Field label={t('login.email')} htmlFor="email" size="md">
<Input
id="email"
name="email"
type="email"
required
autoComplete="email"
autoFocus
size="md"
// Predvyplneno kvuli testovani prototypu. Pred ostrym pouzitim odebrat.
defaultValue={demoAccounts[0].email}
className={inputClass}
placeholder="vas@email.cz"
placeholder={t('login.emailPlaceholder')}
/>
</div>
</Field>
<div>
<div className="mb-1.5 flex items-baseline justify-between">
<label htmlFor="password" className="block text-sm font-medium text-white/70">
Heslo
{t('login.password')}
</label>
<span className="text-xs text-white/30">Zapomenuté heslo? Napište nám.</span>
<span className="text-xs text-white/30">{t('login.forgot')}</span>
</div>
<input
<Input
id="password"
name="password"
type="password"
required
autoComplete="current-password"
size="md"
defaultValue={demoAccounts[0].password}
className={inputClass}
placeholder="--------"
/>
</div>
@@ -153,18 +160,16 @@ export default function Login() {
<Button type="submit" className="w-full" disabled={submitting}>
<LockKeyhole className="size-4" />
{submitting ? 'Přihlašuji...' : 'Přihlásit se'}
{submitting ? t('login.submitting') : t('login.submit')}
</Button>
</form>
<div className="mt-7 rounded-xl border border-ink-600/60 bg-ink-850/60 p-4">
<p className="flex items-center gap-2 text-xs font-semibold text-brand-300">
<ShieldCheck className="size-3.5" />
Demo přístupy (prototyp)
</p>
<p className="mt-1 text-xs text-white/35">
Formulář je předvyplněný správcem platformy. Kliknutím se přepne účet.
{t('login.demoTitle')}
</p>
<p className="mt-1 text-xs text-white/35">{t('login.demoText')}</p>
<ul className="mt-2.5 space-y-1">
{demoAccounts.map((account) => (
<li key={account.email}>
@@ -174,7 +179,7 @@ export default function Login() {
className="w-full rounded-lg px-2 py-1 text-left font-mono text-xs text-white/50 transition-colors hover:bg-white/5 hover:text-white"
>
{account.email}
<span className="ml-2 font-sans text-white/30">{account.note}</span>
<span className="ml-2 font-sans text-white/30">{t(account.note)}</span>
</button>
</li>
))}
@@ -183,11 +188,11 @@ export default function Login() {
</div>
<p className="mt-6 text-center text-xs text-white/35">
Nemáte účet?{' '}
{t('login.noAccount')}{' '}
<Link to="/kontakt" className="font-medium text-brand-300 hover:text-brand-200">
Ozvěte se nám
{t('login.contactUs')}
</Link>{' '}
- portál zřizujeme klientům {brand.name}.
{t('login.noAccountTail', { brand: brand.name })}
</p>
</div>
</div>
+7 -7
View File
@@ -1,21 +1,21 @@
import { ButtonLink } from '@/components/ui/Button';
import { Container } from '@/components/ui/Container';
import { useT } from '@/i18n';
import { usePageMeta } from '@/lib/usePageMeta';
export default function NotFound() {
usePageMeta({ title: 'Stránka nenalezena' });
const t = useT();
usePageMeta({ title: t('notFound.title') });
return (
<Container className="flex min-h-[60vh] flex-col items-center justify-center py-20 text-center">
<p className="font-mono text-6xl font-extrabold text-brand-400/30">404</p>
<h1 className="mt-4 text-2xl font-bold text-white sm:text-3xl">Tuhle stránku nemáme</h1>
<p className="mt-3 max-w-md text-white/55">
Odkaz je nefunkční nebo se stránka přesunula. Zkuste to z rozcestí níž.
</p>
<h1 className="mt-4 text-2xl font-bold text-white sm:text-3xl">{t('notFound.heading')}</h1>
<p className="mt-3 max-w-md text-white/55">{t('notFound.text')}</p>
<div className="mt-8 flex flex-wrap justify-center gap-3">
<ButtonLink to="/">Zpět na homepage</ButtonLink>
<ButtonLink to="/">{t('notFound.home')}</ButtonLink>
<ButtonLink to="/kontakt" variant="secondary">
Napsat nám
{t('notFound.contact')}
</ButtonLink>
</div>
</Container>
+14 -14
View File
@@ -3,26 +3,26 @@ import { CallToAction } from '@/components/home/CallToAction';
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 { cn } from '@/lib/cn';
export default function Services() {
usePageMeta({
title: 'Služby',
description:
'Automatizace procesů, voiceboti, integrace systémů, dashboardy, tickety a incident management.',
});
const t = useT();
usePageMeta({ title: t('services.title'), description: t('services.meta') });
return (
<>
<PageHeader
eyebrow="Služby"
eyebrow={t('services.eyebrow')}
title={
<>
Co pro vás <span className="text-gradient">umíme postavit</span>
{t('services.titleFirst')}{' '}
<span className="text-gradient">{t('services.titleAccent')}</span>
</>
}
subtitle="Nabídku skládáme podle toho, co firmě reálně chybí. Nejčastěji ale řešíme těchto šest oblastí."
subtitle={t('services.subtitle')}
/>
<Section className="py-16! sm:py-20!">
@@ -49,18 +49,18 @@ export default function Services() {
<Icon className="size-5" />
</span>
<div>
<h2 className="text-xl font-bold text-white">{product.name}</h2>
<p className="text-sm text-brand-300/80">{product.tagline}</p>
<h2 className="text-xl font-bold text-white">{t(product.name)}</h2>
<p className="text-sm text-brand-300/80">{t(product.tagline)}</p>
</div>
</div>
<p className="mt-5 leading-relaxed text-white/65">{product.description}</p>
<p className="mt-5 leading-relaxed text-white/65">{t(product.description)}</p>
<ul className="mt-5 grid gap-2 sm:grid-cols-2">
{product.features.map((feature) => (
<li key={feature} className="flex items-start gap-2 text-sm text-white/55">
<Check className="mt-0.5 size-4 shrink-0 text-ok-400" />
{feature}
{t(feature)}
</li>
))}
</ul>
@@ -68,9 +68,9 @@ export default function Services() {
<div className="rounded-2xl border border-ink-600/60 bg-ink-850/60 p-6 text-center">
<p className="text-4xl font-extrabold text-white">{product.metric.value}</p>
<p className="mt-2 text-sm text-white/45">{product.metric.label}</p>
<p className="mt-2 text-sm text-white/45">{t(product.metric.label)}</p>
<p className="mt-5 border-t border-ink-600/60 pt-4 font-mono text-xs text-white/30">
ukázková metrika
{t('services.sampleMetric')}
</p>
</div>
</div>
+101 -64
View File
@@ -7,6 +7,7 @@ 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 { useCollection } from '@/lib/collections';
import {
countSteps,
createActionStep,
@@ -20,14 +21,12 @@ import {
updateStepInputs,
type FlowPath,
} from '@/lib/flow';
import { plural } from '@/lib/format';
import { useApiQuery } from '@/lib/useApiQuery';
import { usePageMeta } from '@/lib/usePageMeta';
import type {
AutomationFlow,
FlowStep,
ServiceCatalog,
TriggerField,
} from '@/types/dashboard';
import { useSubmit } from '@/lib/useSubmit';
import { useUnsavedChanges } from '@/lib/useUnsavedChanges';
import type { AutomationFlow, FlowStep, TriggerField } from '@/types/dashboard';
/**
* Editor tela akce.
@@ -37,7 +36,7 @@ import type {
* totez by znamenal dve mista, ktera se casem rozejdou.
*
* Misto karty spoustece je karta "spousti clovek" a parametry, na ktere jde
* v krocích odkazovat, jsou udaje ticketu. Ty pocita server podle typu, na
* v krocich odkazovat, jsou udaje ticketu. Ty pocita server podle typu, na
* ktery je akce navazana.
*/
@@ -59,21 +58,23 @@ interface PickerTarget {
index: number;
}
const noop = () => undefined;
export default function ActionDetail() {
const { id = '' } = useParams();
const action = useApiQuery<ActionRecord>(`/api/dashboard/settings/actions/${id}`);
const scope = useApiQuery<ScopeResponse>(`/api/dashboard/settings/actions/${id}/scope`);
const catalog = useApiQuery<ServiceCatalog>('/api/dashboard/services');
// Katalog neblokuje stranku: bez nej se kroky ukazou jako nezname, ale strom jde ulozit.
const catalog = useCollection('services');
const [steps, setSteps] = useState<FlowStep[]>([]);
const [picker, setPicker] = useState<PickerTarget | null>(null);
const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [savedAt, setSavedAt] = useState<string | null>(null);
usePageMeta({ title: `${action.data?.label ?? 'Akce'}`, area: 'portal' });
useUnsavedChanges(dirty);
useEffect(() => {
if (!action.data) return;
@@ -83,7 +84,7 @@ export default function ActionDetail() {
setDirty(false);
}, [action.data]);
const connectors = catalog.data?.items ?? [];
const connectors = catalog.items;
const stepCount = useMemo(() => countSteps(steps), [steps]);
/** Parametry, na ktere jde v krocich odkazovat: udaje ticketu plus dialog. */
@@ -95,29 +96,56 @@ export default function ActionDetail() {
/** FlowCanvas pracuje s celym tokem. Akce ma jen kroky, spoustec je clovek. */
const flow: AutomationFlow = useMemo(() => ({ trigger: null, steps }), [steps]);
const change = useCallback((next: FlowStep[]) => {
setSteps(next);
const start = useMemo(
() =>
action.data
? {
label: 'Spouští člověk tlačítkem na ticketu',
hint: startHint(action.data),
fields: startFields,
}
: null,
[action.data, startFields],
);
/** Zmena podle predchoziho stavu: callbacky maji stalou identitu. */
const change = useCallback((update: (previous: FlowStep[]) => FlowStep[]) => {
setSteps(update);
setDirty(true);
setSavedAt(null);
}, []);
async function save() {
setSaving(true);
setSaveError(null);
try {
await apiFetch(`/api/dashboard/settings/actions/${id}`, {
method: 'PATCH',
body: { body: { kind: 'tree', steps } },
});
setDirty(false);
setSavedAt(new Date().toISOString());
action.reload();
} catch (err: unknown) {
setSaveError(err instanceof Error ? err.message : 'Uložení se nepodařilo.');
} finally {
setSaving(false);
}
}
const onRemoveStep = useCallback(
(stepId: string) => change((previous) => removeStep(previous, stepId)),
[change],
);
const onUpdateCondition = useCallback<
(stepId: string, patch: Parameters<typeof updateCondition>[2]) => void
>((stepId, patch) => change((previous) => updateCondition(previous, stepId, patch)), [change]);
const onUpdateInputs = useCallback(
(stepId: string, inputs: Record<string, string>) =>
change((previous) => updateStepInputs(previous, stepId, inputs)),
[change],
);
const onUpdateForeach = useCallback(
(stepId: string, path: string) => change((previous) => updateForeachPath(previous, stepId, path)),
[change],
);
const onMoveStep = useCallback(
(stepId: string, offset: number) => change((previous) => moveStep(previous, stepId, offset)),
[change],
);
const onAddStep = useCallback((path: FlowPath, index: number) => setPicker({ path, index }), []);
const saving = useSubmit(async () => {
await apiFetch(`/api/dashboard/settings/actions/${id}`, {
method: 'PATCH',
body: { body: { kind: 'tree', steps } },
});
setDirty(false);
setSavedAt(new Date().toISOString());
action.reload();
}, 'Uložení se nepodařilo.');
return (
<div className="space-y-6">
@@ -130,14 +158,12 @@ export default function ActionDetail() {
</Link>
<DataState
loading={action.loading || catalog.loading}
error={action.error ?? catalog.error}
onRetry={() => {
action.reload();
catalog.reload();
}}
loading={action.loading}
refreshing={action.refreshing}
error={action.error}
onRetry={action.reload}
>
{action.data && (
{action.data && start && (
<div className="space-y-6">
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
@@ -145,7 +171,7 @@ export default function ActionDetail() {
<div className="mt-2 flex flex-wrap items-center gap-2">
<span className="font-mono text-xs text-white/35">{action.data.id}</span>
<span className="text-xs text-white/40">
{stepCount} {stepCount === 1 ? 'krok' : stepCount >= 2 && stepCount <= 4 ? 'kroky' : 'kroků'}
{stepCount} {plural(stepCount, ['krok', 'kroky', 'kroků'])}
</span>
{dirty && <Badge tone="warn">Neuložené změny</Badge>}
{!dirty && savedAt && (
@@ -157,16 +183,26 @@ export default function ActionDetail() {
</div>
</div>
<Button size="sm" onClick={() => void save()} disabled={saving || !dirty}>
<Button size="sm" onClick={() => void saving.submit()} disabled={saving.busy || !dirty}>
<Save className="size-4" />
{saving ? 'Ukládám...' : 'Uložit strom'}
{saving.busy ? 'Ukládám...' : 'Uložit strom'}
</Button>
</header>
{saveError && (
{saving.error && (
<p className="flex items-center gap-2 rounded-xl border border-danger-400/30 bg-danger-500/10 px-4 py-3 text-sm text-danger-400">
<AlertCircle className="size-4 shrink-0" />
{saveError}
{saving.error}
</p>
)}
{/* Vedlejsi data nebrani praci, jen se rekne, co chybi. */}
{(catalog.error || scope.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" />
{catalog.error
? `Katalog služeb se nepodařilo načíst (${catalog.error}).`
: `Údaje ticketu se nepodařilo načíst (${scope.error}).`}
</p>
)}
@@ -184,24 +220,16 @@ export default function ActionDetail() {
connectors={connectors}
webhookBaseUrl=""
regenerating={false}
start={{
label: 'Spouští člověk tlačítkem na ticketu',
hint: startHint(action.data),
fields: startFields,
}}
onPickTrigger={() => undefined}
onChangeFields={() => undefined}
onRegenerateToken={() => undefined}
onAddStep={(path, index) => setPicker({ path, index })}
onRemoveStep={(stepId) => change(removeStep(steps, stepId))}
onUpdateCondition={(stepId, patch) =>
change(updateCondition(steps, stepId, patch))
}
onUpdateInputs={(stepId, inputs) => change(updateStepInputs(steps, stepId, inputs))}
onUpdateForeach={(stepId, path) =>
change(updateForeachPath(steps, stepId, path))
}
onMoveStep={(stepId, offset) => change(moveStep(steps, stepId, offset))}
start={start}
onPickTrigger={noop}
onChangeFields={noop}
onRegenerateToken={noop}
onAddStep={onAddStep}
onRemoveStep={onRemoveStep}
onUpdateCondition={onUpdateCondition}
onUpdateInputs={onUpdateInputs}
onUpdateForeach={onUpdateForeach}
onMoveStep={onMoveStep}
/>
</section>
@@ -239,22 +267,31 @@ export default function ActionDetail() {
open={picker !== null}
mode="action"
connectors={connectors}
categories={catalog.data?.categories ?? []}
categories={catalog.extra?.categories ?? []}
onClose={() => setPicker(null)}
onPickOperation={(serviceId, operationId) => {
if (!picker) return;
change(insertStep(steps, picker.path, picker.index, createActionStep(serviceId, operationId)));
const target = picker;
change((previous) =>
insertStep(previous, target.path, target.index, createActionStep(serviceId, operationId)),
);
setPicker(null);
}}
onPickCondition={() => {
const first = startFields[0];
if (!picker || !first) return;
change(insertStep(steps, picker.path, picker.index, createConditionStep(first)));
const target = picker;
change((previous) =>
insertStep(previous, target.path, target.index, createConditionStep(first)),
);
setPicker(null);
}}
onPickForeach={() => {
if (!picker) return;
change(insertStep(steps, picker.path, picker.index, createForeachStep()));
const target = picker;
change((previous) =>
insertStep(previous, target.path, target.index, createForeachStep()),
);
setPicker(null);
}}
canAddCondition={startFields.length > 0}
+14 -14
View File
@@ -1,10 +1,11 @@
import { GitBranch, Info } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { EntityAdmin, parseJsonField, type AdminField } from '@/components/dashboard/EntityAdmin';
import { TenantScripts } from '@/components/dashboard/TenantScripts';
import { Badge } from '@/components/ui/Badge';
import { apiFetch } from '@/lib/api';
import { useCollection } from '@/lib/collections';
import { plural } from '@/lib/format';
import { usePageMeta } from '@/lib/usePageMeta';
/**
@@ -37,22 +38,16 @@ interface ActionRecord {
export default function Actions() {
usePageMeta({ title: 'Akce na ticketu', area: 'portal' });
const [types, setTypes] = useState<Array<{ id: string; name: string }>>([]);
// Typy ticketu z ulozist ciselniku. Chyba nacteni se ukaze pod nadpisem.
const ticketTypes = useCollection('ticketTypes');
const types = ticketTypes.items;
/*
* Skripty jsou tu vedle akci zamerne. Obojí je **definice toho, co aplikace
* ve firme umi**, obojí spravuje tentyz clovek pod pravem `action.manage`
* Skripty jsou tu vedle akci zamerne. Oboji je **definice toho, co aplikace
* ve firme umi**, oboji spravuje tentyz clovek pod pravem `action.manage`
* a skript je casto to, co akce nebo automatizace uvnitr pouziva.
*/
const [tab, setTab] = useState<'akce' | 'skripty'>('akce');
useEffect(() => {
apiFetch<{ items: Array<{ id: string; name: string }> }>(
'/api/dashboard/settings/ticket-types',
)
.then((data) => setTypes(data.items))
.catch(() => setTypes([]));
}, []);
const fields: Array<AdminField<ActionRecord>> = [
{ name: 'label', label: 'Nápis na tlačítku', kind: 'text', required: true },
{ name: 'icon', label: 'Ikona', kind: 'text', initial: 'Play', hint: 'Například Play, Send, FileText.' },
@@ -138,6 +133,11 @@ 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>
@@ -248,7 +248,7 @@ function BodySummary({ body }: { body: { kind: string; steps?: unknown[] } }) {
<span className="text-warn-400">prázdný strom</span>
) : (
<span className="text-white/60">
strom, {count} {count === 1 ? 'krok' : count <= 4 ? 'kroky' : 'kroků'}
strom, {count} {plural(count, ['krok', 'kroky', 'kroků'])}
</span>
);
}
+210 -156
View File
@@ -18,6 +18,7 @@ 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,
createActionStep,
@@ -31,13 +32,14 @@ import {
updateStepInputs,
type FlowPath,
} from '@/lib/flow';
import { formatDateTime } from '@/lib/format';
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,
ServiceCatalog,
TriggerField,
WebhookCall,
} from '@/types/dashboard';
@@ -54,21 +56,25 @@ export default function AutomationDetail() {
const navigate = useNavigate();
const automation = useApiQuery<Detail>(`/api/dashboard/automations/${id}`);
const catalog = useApiQuery<ServiceCatalog>('/api/dashboard/services');
/*
* Katalog sluzeb z ulozist ciselniku. **Neblokuje** otevreni stranky: bez
* nej se kroky ukazou jako neznama akce a vyber noveho kroku je prazdny,
* ale strom jde precist a ulozit.
*/
const catalog = useCollection('services');
const [name, setName] = useState('');
const [flow, setFlow] = useState<AutomationFlow>({ trigger: null, steps: [] });
const [enabled, setEnabled] = useState(false);
const [picker, setPicker] = useState<PickerTarget | null>(null);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [savedAt, setSavedAt] = useState<string | null>(null);
const [dirty, setDirty] = useState(false);
const [regenerating, setRegenerating] = useState(false);
/** Co podle serveru chybi k zapnuti. Prepocitava ho server pri kazdem ulozeni. */
const [issues, setIssues] = useState<string[]>([]);
usePageMeta({ title: `${name || 'Automatizace'}`, area: 'portal' });
// Rozdelany strom se nesmi ztratit zavrenim zalozky ani klikem v menu.
useUnsavedChanges(dirty);
// Prevzeti dat ze serveru do editovatelneho stavu.
useEffect(() => {
@@ -80,34 +86,66 @@ export default function AutomationDetail() {
setDirty(false);
}, [automation.data]);
const connectors = catalog.data?.items ?? [];
const categories = catalog.data?.categories ?? [];
const connectors = catalog.items;
const categories = catalog.extra?.categories ?? [];
const webhookBaseUrl = catalog.extra?.webhookBaseUrl ?? '';
const stepCount = useMemo(() => countSteps(flow.steps), [flow.steps]);
/** Kazda zmena stromu jde pres tohle, aby se drzel priznak "neulozeno". */
const changeFlow = useCallback((next: AutomationFlow) => {
setFlow(next);
/**
* Kazda zmena stromu jde pres tohle, aby se drzel priznak "neulozeno".
*
* Bere funkci nad predchozim stavem, ne hotovy strom: callbacky do platna
* pak nezavisi na `flow`, maji stalou identitu a karty kroku se nemusi
* prekreslovat jen proto, ze se zmenil jiny krok.
*/
const changeFlow = useCallback((update: (previous: AutomationFlow) => AutomationFlow) => {
setFlow(update);
setDirty(true);
setSavedAt(null);
}, []);
/** Parametry, ktere ma mit spoustec po jeho zmene. */
function fieldsForTrigger(serviceId: string, operationId: string): TriggerField[] {
const provided = findTrigger(serviceId, operationId)?.providedFields;
// Sluzba si data urcuje sama. Server je pri ulozeni stejne dosadi z katalogu,
// tady je nastavime hned, aby slo rovnou stavet podminky.
if (provided) return provided;
const previous = flow.trigger
? findTrigger(flow.trigger.serviceId, flow.trigger.operationId)
: undefined;
// Prechod z katalogoveho spoustece na vlastni - cizi parametry by tu neplatily.
if (previous?.providedFields) return [];
// Jinak drzime uz nadeklarovane parametry, aby se nezahodila prace.
return flow.trigger?.fields ?? [];
}
const onRemoveStep = useCallback(
(stepId: string) =>
changeFlow((previous) => ({ ...previous, steps: removeStep(previous.steps, stepId) })),
[changeFlow],
);
const onUpdateCondition = useCallback<
(stepId: string, patch: Parameters<typeof updateCondition>[2]) => void
>(
(stepId, patch) =>
changeFlow((previous) => ({
...previous,
steps: updateCondition(previous.steps, stepId, patch),
})),
[changeFlow],
);
const onUpdateInputs = useCallback(
(stepId: string, inputs: Record<string, string>) =>
changeFlow((previous) => ({
...previous,
steps: updateStepInputs(previous.steps, stepId, inputs),
})),
[changeFlow],
);
const onUpdateForeach = useCallback(
(stepId: string, path: string) =>
changeFlow((previous) => ({
...previous,
steps: updateForeachPath(previous.steps, stepId, path),
})),
[changeFlow],
);
const onMoveStep = useCallback(
(stepId: string, offset: number) =>
changeFlow((previous) => ({ ...previous, steps: moveStep(previous.steps, stepId, offset) })),
[changeFlow],
);
const onAddStep = useCallback(
(path: FlowPath, index: number) => setPicker({ mode: 'action', path, index }),
[],
);
const onPickTrigger = useCallback(() => setPicker({ mode: 'trigger', path: [], index: 0 }), []);
function findTrigger(serviceId: string, operationId: string) {
return connectors
@@ -115,6 +153,27 @@ export default function AutomationDetail() {
?.triggers.find((operation) => operation.id === operationId);
}
/** Parametry, ktere ma mit spoustec po jeho zmene. */
function fieldsForTrigger(
previous: AutomationFlow,
serviceId: string,
operationId: string,
): TriggerField[] {
const provided = findTrigger(serviceId, operationId)?.providedFields;
// Sluzba si data urcuje sama. Server je pri ulozeni stejne dosadi z katalogu,
// tady je nastavime hned, aby slo rovnou stavet podminky.
if (provided) return provided;
const before = previous.trigger
? findTrigger(previous.trigger.serviceId, previous.trigger.operationId)
: undefined;
// Prechod z katalogoveho spoustece na vlastni - cizi parametry by tu neplatily.
if (before?.providedFields) return [];
// Jinak drzime uz nadeklarovane parametry, aby se nezahodila prace.
return previous.trigger?.fields ?? [];
}
function handlePick(serviceId: string, operationId: string) {
if (!picker) {
console.warn('[builder] vyber potvrzen bez otevreneho cile');
@@ -122,25 +181,26 @@ export default function AutomationDetail() {
}
if (picker.mode === 'trigger') {
changeFlow({
...flow,
changeFlow((previous) => ({
...previous,
trigger: {
serviceId,
operationId,
fields: fieldsForTrigger(serviceId, operationId),
webhookToken: flow.trigger?.webhookToken,
fields: fieldsForTrigger(previous, serviceId, operationId),
webhookToken: previous.trigger?.webhookToken,
},
});
}));
} else {
changeFlow({
...flow,
const target = picker;
changeFlow((previous) => ({
...previous,
steps: insertStep(
flow.steps,
picker.path,
picker.index,
previous.steps,
target.path,
target.index,
createActionStep(serviceId, operationId),
),
});
}));
}
setPicker(null);
}
@@ -157,10 +217,16 @@ export default function AutomationDetail() {
return;
}
changeFlow({
...flow,
steps: insertStep(flow.steps, picker.path, picker.index, createConditionStep(firstField)),
});
const target = picker;
changeFlow((previous) => ({
...previous,
steps: insertStep(
previous.steps,
target.path,
target.index,
createConditionStep(firstField),
),
}));
setPicker(null);
}
@@ -170,10 +236,11 @@ export default function AutomationDetail() {
console.warn('[builder] smycku lze vlozit jen jako krok');
return;
}
changeFlow({
...flow,
steps: insertStep(flow.steps, picker.path, picker.index, createForeachStep()),
});
const target = picker;
changeFlow((previous) => ({
...previous,
steps: insertStep(previous.steps, target.path, target.index, createForeachStep()),
}));
setPicker(null);
}
@@ -181,93 +248,83 @@ export default function AutomationDetail() {
* Ukazka tela od odesilatele. Model se z ni odvodi na serveru, takze se
* po ulozeni nacte automatizace znovu a cesty jsou hned k dispozici.
*/
function handleChangeSample(sample: unknown) {
if (!flow.trigger) {
console.warn('[builder] ukazka tela bez spoustece');
return;
}
changeFlow({ ...flow, trigger: { ...flow.trigger, sample } });
}
const handleChangeSample = useCallback(
(sample: unknown) =>
changeFlow((previous) => {
if (!previous.trigger) {
console.warn('[builder] ukazka tela bez spoustece');
return previous;
}
return { ...previous, trigger: { ...previous.trigger, sample } };
}),
[changeFlow],
);
function handleChangeFields(fields: TriggerField[]) {
if (!flow.trigger) {
console.warn('[builder] zmena parametru bez spoustece');
return;
}
changeFlow({ ...flow, trigger: { ...flow.trigger, fields } });
}
const handleChangeFields = useCallback(
(fields: TriggerField[]) =>
changeFlow((previous) => {
if (!previous.trigger) {
console.warn('[builder] zmena parametru bez spoustece');
return previous;
}
return { ...previous, trigger: { ...previous.trigger, fields } };
}),
[changeFlow],
);
async function handleRegenerateToken() {
setRegenerating(true);
setSaveError(null);
try {
// Token mení server; napřed uložíme rozdělanou práci, ať se nepřepíše.
if (dirty) {
await apiFetch<Detail>(`/api/dashboard/automations/${id}`, {
method: 'PUT',
body: { name, enabled, flow },
});
}
const updated = await apiFetch<Detail>(
`/api/dashboard/automations/${id}/webhook/regenerate`,
{ method: 'POST' },
);
setFlow(updated.flow);
setIssues(updated.issues);
setDirty(false);
setSavedAt(updated.updatedAt);
} catch (err) {
const message = err instanceof Error ? err.message : 'Novou adresu se nepodařilo vytvořit.';
console.error('[builder] regenerace tokenu selhala:', err);
setSaveError(message);
} finally {
setRegenerating(false);
}
}
async function save(nextEnabled = enabled) {
setSaving(true);
setSaveError(null);
try {
const updated = await apiFetch<Detail>(`/api/dashboard/automations/${id}`, {
const regeneration = useSubmit(async () => {
// Token meni server; napred ulozime rozdelanou praci, at se neprepise.
if (dirty) {
await apiFetch<Detail>(`/api/dashboard/automations/${id}`, {
method: 'PUT',
body: { name, enabled: nextEnabled, flow },
body: { name, enabled, flow },
});
setEnabled(updated.enabled);
setFlow(updated.flow);
setIssues(updated.issues);
setDirty(false);
setSavedAt(updated.updatedAt);
// Server muze zapnuti odmitnout - rekneme presne proc, ne obecnou hlasku.
if (nextEnabled && !updated.enabled) {
setSaveError(
updated.issues.length > 0
? `Automatizaci nelze zapnout: ${updated.issues.join(' ')}`
: 'Automatizaci se nepodařilo zapnout.',
);
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Uložení se nepodařilo.';
console.error('[builder] ulozeni selhalo:', err);
setSaveError(message);
} finally {
setSaving(false);
}
}
const updated = await apiFetch<Detail>(
`/api/dashboard/automations/${id}/webhook/regenerate`,
{ method: 'POST' },
);
setFlow(updated.flow);
setIssues(updated.issues);
setDirty(false);
setSavedAt(updated.updatedAt);
}, 'Novou adresu se nepodařilo vytvořit.');
async function handleDelete() {
const saving = useSubmit(async (nextEnabled: boolean) => {
const updated = await apiFetch<Detail>(`/api/dashboard/automations/${id}`, {
method: 'PUT',
body: { name, enabled: nextEnabled, flow },
});
setEnabled(updated.enabled);
setFlow(updated.flow);
setIssues(updated.issues);
setDirty(false);
setSavedAt(updated.updatedAt);
// Server muze zapnuti odmitnout - rekneme presne proc, ne obecnou hlasku.
if (nextEnabled && !updated.enabled) {
throw new Error(
updated.issues.length > 0
? `Automatizaci nelze zapnout: ${updated.issues.join(' ')}`
: 'Automatizaci se nepodařilo zapnout.',
);
}
}, 'Uložení se nepodařilo.');
const deletion = useSubmit(async () => {
await apiFetch<void>(`/api/dashboard/automations/${id}`, { method: 'DELETE' });
setDirty(false);
navigate('/dashboard/automatizace', { replace: true });
}, 'Smazání se nepodařilo.');
function handleDelete() {
if (!window.confirm(`Smazat automatizaci "${name}"? Tuto akci nelze vzít zpět.`)) return;
try {
await apiFetch<void>(`/api/dashboard/automations/${id}`, { method: 'DELETE' });
navigate('/dashboard/automatizace', { replace: true });
} catch (err) {
const message = err instanceof Error ? err.message : 'Smazání se nepodařilo.';
console.error('[builder] smazani selhalo:', err);
setSaveError(message);
}
void deletion.submit();
}
const busy = saving.busy || regeneration.busy;
const saveError = saving.error ?? regeneration.error ?? deletion.error;
return (
<div className="space-y-6">
<Link
@@ -279,12 +336,10 @@ export default function AutomationDetail() {
</Link>
<DataState
loading={automation.loading || catalog.loading}
error={automation.error ?? catalog.error}
onRetry={() => {
automation.reload();
catalog.reload();
}}
loading={automation.loading}
refreshing={automation.refreshing}
error={automation.error}
onRetry={automation.reload}
>
<div className="space-y-6">
<header className="flex flex-wrap items-start justify-between gap-4">
@@ -314,7 +369,7 @@ export default function AutomationDetail() {
<Badge tone="warn">Koncept - chybí spouštěč</Badge>
)}
<span className="text-xs text-white/40">
{stepCount} {stepCount === 1 ? 'krok' : stepCount >= 2 && stepCount <= 4 ? 'kroky' : 'kroků'}
{stepCount} {plural(stepCount, ['krok', 'kroky', 'kroků'])}
</span>
{dirty && <Badge tone="warn">Neuložené změny</Badge>}
{!dirty && savedAt && (
@@ -330,20 +385,20 @@ export default function AutomationDetail() {
<Button
variant="secondary"
size="sm"
onClick={() => void save(!enabled)}
disabled={saving || !flow.trigger}
onClick={() => void saving.submit(!enabled)}
disabled={busy || !flow.trigger}
title={!flow.trigger ? 'Nejdřív vyberte spouštěč' : undefined}
>
{enabled ? <Pause className="size-4" /> : <Play className="size-4" />}
{enabled ? 'Pozastavit' : 'Zapnout'}
</Button>
<Button size="sm" onClick={() => void save()} disabled={saving || !dirty}>
<Button size="sm" onClick={() => void saving.submit(enabled)} disabled={busy || !dirty}>
<Save className="size-4" />
{saving ? 'Ukládám...' : 'Uložit'}
{saving.busy ? 'Ukládám...' : 'Uložit'}
</Button>
<button
type="button"
onClick={() => void handleDelete()}
onClick={handleDelete}
title="Smazat automatizaci"
className="grid size-9 place-items-center rounded-lg text-white/35 transition-colors hover:bg-danger-500/12 hover:text-danger-400"
>
@@ -360,6 +415,15 @@ export default function AutomationDetail() {
</p>
)}
{/* Katalog nedorazil: strom jde cist, ale kroky se nedaji poznat. */}
{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.
</p>
)}
{/* Rozdelana prace se ulozi vzdy, jen se nesmi pustit do provozu. */}
{issues.length > 0 && (
<div className="rounded-xl border border-warn-400/30 bg-warn-500/8 px-4 py-3">
@@ -389,29 +453,19 @@ export default function AutomationDetail() {
<FlowCanvas
flow={flow}
connectors={connectors}
webhookBaseUrl={catalog.data?.webhookBaseUrl ?? ''}
regenerating={regenerating}
webhookBaseUrl={webhookBaseUrl}
regenerating={regeneration.busy}
model={automation.data?.model ?? []}
onPickTrigger={() => setPicker({ mode: 'trigger', path: [], index: 0 })}
onPickTrigger={onPickTrigger}
onChangeFields={handleChangeFields}
onChangeSample={handleChangeSample}
onRegenerateToken={() => void handleRegenerateToken()}
onAddStep={(path, index) => setPicker({ mode: 'action', path, index })}
onRemoveStep={(stepId) =>
changeFlow({ ...flow, steps: removeStep(flow.steps, stepId) })
}
onUpdateCondition={(stepId, patch) =>
changeFlow({ ...flow, steps: updateCondition(flow.steps, stepId, patch) })
}
onUpdateInputs={(stepId, inputs) =>
changeFlow({ ...flow, steps: updateStepInputs(flow.steps, stepId, inputs) })
}
onUpdateForeach={(stepId, path) =>
changeFlow({ ...flow, steps: updateForeachPath(flow.steps, stepId, path) })
}
onMoveStep={(stepId, offset) =>
changeFlow({ ...flow, steps: moveStep(flow.steps, stepId, offset) })
}
onRegenerateToken={() => void regeneration.submit()}
onAddStep={onAddStep}
onRemoveStep={onRemoveStep}
onUpdateCondition={onUpdateCondition}
onUpdateInputs={onUpdateInputs}
onUpdateForeach={onUpdateForeach}
onMoveStep={onMoveStep}
/>
</section>
+30 -35
View File
@@ -14,9 +14,12 @@ import { Link, useNavigate } from 'react-router-dom';
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 { Modal } from '@/components/ui/Modal';
import { apiFetch } from '@/lib/api';
import { formatDuration, formatNumber, formatPercent, formatRelative } from '@/lib/format';
import { useSubmit } from '@/lib/useSubmit';
import { formatDuration, formatNumber, formatPercent, formatRelative, plural } from '@/lib/format';
import { useApiQuery } from '@/lib/useApiQuery';
import { usePageMeta } from '@/lib/usePageMeta';
import type { Automation, AutomationDetail, ListResponse } from '@/types/dashboard';
@@ -30,7 +33,7 @@ const kindMeta: Record<Automation['kind'], { label: string; icon: LucideIcon }>
export default function Automations() {
usePageMeta({ title: 'Automatizace', area: 'portal' });
const { data, loading, error, reload } = useApiQuery<ListResponse<Automation>>(
const { data, loading, refreshing, error, reload } = useApiQuery<ListResponse<Automation>>(
'/api/dashboard/automations',
{
refetchOn: [
@@ -69,6 +72,7 @@ export default function Automations() {
<DataState
loading={loading}
refreshing={refreshing}
error={error}
empty={data?.items.length === 0}
onRetry={reload}
@@ -108,7 +112,7 @@ function AutomationCard({ automation }: { automation: Automation }) {
<h2 className="truncate font-semibold text-white">{automation.name}</h2>
<p className="mt-0.5 font-mono text-xs text-white/35">
{automation.id} · {meta.label} · {automation.stepCount}{' '}
{automation.stepCount === 1 ? 'krok' : 'kroků'}
{plural(automation.stepCount, ['krok', 'kroky', 'kroků'])}
</p>
</div>
</div>
@@ -171,30 +175,22 @@ function CreateAutomationModal({
}) {
const navigate = useNavigate();
const [name, setName] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
const { busy: submitting, error, submit } = useSubmit(async () => {
const created = await apiFetch<AutomationDetail>('/api/dashboard/automations', {
method: 'POST',
body: { name },
});
onCreated();
setName('');
onClose();
// Rovnou do builderu - uzivatel chce skladat strom, ne cist seznam.
navigate(`/dashboard/automatizace/${created.id}`);
}, 'Automatizaci nešlo vytvořit.');
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitting(true);
setError(null);
try {
const created = await apiFetch<AutomationDetail>('/api/dashboard/automations', {
method: 'POST',
body: { name },
});
onCreated();
setName('');
onClose();
// Rovnou do builderu - uzivatel chce skladat strom, ne cist seznam.
navigate(`/dashboard/automatizace/${created.id}`);
} catch (err) {
const message = err instanceof Error ? err.message : 'Automatizaci nešlo vytvořit.';
console.error('[automations] vytvoreni selhalo:', err);
setError(message);
} finally {
setSubmitting(false);
}
void submit();
}
return (
@@ -206,23 +202,22 @@ function CreateAutomationModal({
className="max-w-lg"
>
<form onSubmit={handleSubmit} className="space-y-4 p-5">
<div>
<label htmlFor="automation-name" className="mb-1.5 block text-sm font-medium text-white/70">
Název
</label>
<input
<Field
label="Název"
htmlFor="automation-name"
size="md"
hint="Doporučení: napište, co automatizace dělá, ne kterou technologii používá."
>
<Input
id="automation-name"
value={name}
onChange={(event) => setName(event.target.value)}
required
minLength={3}
size="md"
placeholder="Např.: Poptávka z webu na CRM na e-mail"
className="w-full rounded-xl border border-ink-600/70 bg-ink-850/70 px-4 py-3 text-sm text-white placeholder:text-white/30 focus:border-brand-400/70 focus:outline-none"
/>
<p className="mt-2 text-xs text-white/35">
Doporučení: napište, co automatizace dělá, ne kterou technologii používá.
</p>
</div>
</Field>
{error && (
<p className="flex items-center gap-2 rounded-xl border border-danger-400/30 bg-danger-500/10 px-4 py-3 text-sm text-danger-400">
File diff suppressed because it is too large Load Diff
+160 -179
View File
@@ -1,16 +1,21 @@
import { MessageSquarePlus, Plus, Send } from 'lucide-react';
import { Plus, Send } from 'lucide-react';
import { useEffect, useState } from 'react';
import { DataState } from '@/components/dashboard/DataState';
import { TicketStatusBadge } from '@/components/dashboard/StatusBadge';
import { TicketBody, bodyPreview } from '@/components/dashboard/TicketBody';
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 { cn } from '@/lib/cn';
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 type { Ticket, TicketDetail, TicketPriority } from '@/types/dashboard';
/**
@@ -31,16 +36,6 @@ interface HelpdeskResponse {
canCreate: boolean;
}
const priorities: Array<{ value: TicketPriority; label: string }> = [
{ value: 'low', label: 'Nízká' },
{ value: 'normal', label: 'Normální' },
{ value: 'high', label: 'Vysoká' },
{ value: 'critical', label: 'Kritická' },
];
const inputClass =
'w-full rounded-lg border border-ink-600/70 bg-ink-900/70 px-3 py-2 text-sm text-white placeholder:text-white/25 focus:border-brand-400/70 focus:outline-none';
export default function Helpdesk() {
usePageMeta({ title: 'Helpdesk', area: 'portal' });
@@ -74,8 +69,8 @@ export default function Helpdesk() {
</header>
{/*
Bez dodavatele nemá požadavek kam odejít. Říká se to rovnou a s tím,
co s tím - prázdný seznam by vypadal jako by se nic nestalo.
Bez dodavatele nema pozadavek kam odejit. Rika se to rovnou a s tim,
co s tim - prazdny seznam by vypadal jako by se nic nestalo.
*/}
{data && !provider && (
<div className="rounded-card border border-warn-400/40 bg-warn-500/8 p-4 text-sm text-warn-400">
@@ -84,44 +79,42 @@ export default function Helpdesk() {
</div>
)}
<DataState loading={helpdesk.loading} error={helpdesk.error} onRetry={helpdesk.reload}>
{(data?.items.length ?? 0) === 0 ? (
<div className="glass rounded-card p-10 text-center">
<MessageSquarePlus className="mx-auto size-8 text-white/25" />
<p className="mt-3 text-sm text-white/45">
Zatím jste neposlali žádný požadavek.
</p>
</div>
) : (
<ul className="space-y-3">
{(data?.items ?? []).map((ticket) => (
<li key={ticket.id}>
<button
type="button"
onClick={() => setOpenId(ticket.id)}
className="glass w-full rounded-card p-4 text-left transition-colors hover:border-brand-400/40 sm:p-5"
>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
<span className="font-mono text-xs text-white/40">{ticket.id}</span>
<TicketStatusBadge status={ticket.status} closed={ticket.closed} />
{ticket.priority !== 'normal' && (
<Badge tone={ticket.priority === 'low' ? 'neutral' : 'warn'}>
{priorities.find((item) => item.value === ticket.priority)?.label}
</Badge>
)}
<span className="ml-auto text-xs text-white/35">
{formatRelative(ticket.updatedAt)}
</span>
</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>
<DataState
loading={helpdesk.loading}
refreshing={helpdesk.refreshing}
error={helpdesk.error}
onRetry={helpdesk.reload}
empty={(data?.items.length ?? 0) === 0}
emptyLabel="Zatím jste neposlali žádný požadavek."
>
<ul className="space-y-3">
{(data?.items ?? []).map((ticket) => (
<li key={ticket.id}>
<button
type="button"
onClick={() => setOpenId(ticket.id)}
className="glass w-full rounded-card p-4 text-left transition-colors hover:border-brand-400/40 sm:p-5"
>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
<span className="font-mono text-xs text-white/40">{ticket.id}</span>
<TicketStatusBadge status={ticket.status} closed={ticket.closed} />
{ticket.priority !== 'normal' && (
<Badge tone={ticket.priority === 'low' ? 'neutral' : 'warn'}>
{priorityLabel(ticket.priority)}
</Badge>
)}
</button>
</li>
))}
</ul>
)}
<span className="ml-auto text-xs text-white/35">
{formatRelative(ticket.updatedAt)}
</span>
</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>
)}
</button>
</li>
))}
</ul>
</DataState>
<NewRequestDialog
@@ -150,33 +143,27 @@ function NewRequestDialog({
const [subject, setSubject] = useState('');
const [body, setBody] = useState('');
const [priority, setPriority] = useState<TicketPriority>('normal');
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submit(event: React.FormEvent) {
const { busy, error, submit, setError } = useSubmit(async () => {
await apiFetch('/api/dashboard/helpdesk', {
method: 'POST',
// Bez druheho JSON.stringify, ten uz dela apiFetch.
body: { subject: subject.trim(), body, priority },
});
}, 'Požadavek se nepodařilo poslat.');
async function handleSubmit(event: React.FormEvent) {
event.preventDefault();
if (subject.trim() === '') {
setError('Napište, čeho se požadavek týká.');
return;
}
setSaving(true);
setError(null);
try {
await apiFetch('/api/dashboard/helpdesk', {
method: 'POST',
// Bez druheho JSON.stringify, ten uz dela apiFetch.
body: { subject: subject.trim(), body, priority },
});
if (await submit()) {
setSubject('');
setBody('');
setPriority('normal');
onCreated();
onClose();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Požadavek se nepodařilo poslat.');
} finally {
setSaving(false);
}
}
@@ -192,43 +179,35 @@ function NewRequestDialog({
}
className="max-w-2xl"
>
<form onSubmit={submit} className="space-y-4 p-5">
<label className="block">
<span className="text-xs text-white/55">Čeho se to týká</span>
<input
<form onSubmit={handleSubmit} className="space-y-4 p-5">
<Field label="Čeho se to týká">
<Input
value={subject}
onChange={(event) => setSubject(event.target.value)}
placeholder="Krátce, jednou větou"
className={cn(inputClass, 'mt-1')}
tone="card"
autoFocus
/>
</label>
</Field>
<label className="block">
<span className="text-xs text-white/55">Popis</span>
<textarea
<Field label="Popis">
<Textarea
value={body}
onChange={(event) => setBody(event.target.value)}
rows={7}
placeholder="Co se stalo, co jste zkusili, kde se to projevuje."
className={cn(inputClass, 'mt-1 resize-y')}
tone="card"
/>
</label>
</Field>
<label className="block sm:w-56">
<span className="text-xs text-white/55">Naléhavost</span>
<select
<Field label="Naléhavost" className="sm:w-56">
<Select
value={priority}
onChange={(event) => setPriority(event.target.value as TicketPriority)}
className={cn(inputClass, 'mt-1')}
>
{priorities.map((item) => (
<option key={item.value} value={item.value} className="bg-ink-850">
{item.label}
</option>
))}
</select>
</label>
tone="card"
options={priorities}
/>
</Field>
{error && (
<p className="rounded-lg border border-danger-500/40 bg-danger-500/8 p-3 text-sm text-danger-400">
@@ -237,10 +216,10 @@ function NewRequestDialog({
)}
<div className="flex gap-3 border-t border-ink-600/50 pt-4">
<Button type="submit" size="sm" disabled={saving}>
{saving ? 'Odesílám...' : 'Odeslat požadavek'}
<Button type="submit" size="sm" disabled={busy}>
{busy ? 'Odesílám...' : 'Odeslat požadavek'}
</Button>
<Button type="button" size="sm" variant="secondary" onClick={onClose} disabled={saving}>
<Button type="button" size="sm" variant="secondary" onClick={onClose} disabled={busy}>
Zrušit
</Button>
</div>
@@ -265,43 +244,41 @@ function RequestDetail({
onChanged: () => void;
}) {
const [ticket, setTicket] = useState<TicketDetail | null>(null);
const [error, setError] = useState<string | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [text, setText] = useState('');
const [sending, setSending] = useState(false);
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);
setError(null);
setLoadError(null);
setText('');
apiFetch<TicketDetail>(`/api/dashboard/helpdesk/${id}`)
.then(setTicket)
.catch((err: unknown) =>
setError(err instanceof Error ? err.message : 'Požadavek se nepodařilo načíst.'),
);
.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]);
async function comment(event: React.FormEvent) {
event.preventDefault();
if (!id || text.trim() === '') return;
setSending(true);
setError(null);
try {
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);
onChanged();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Komentář se nepodařilo uložit.');
} finally {
setSending(false);
}
}
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);
onChanged();
}, 'Komentář se nepodařilo uložit.');
return (
<Modal
@@ -312,68 +289,72 @@ function RequestDetail({
className="max-w-3xl"
>
<div className="max-h-[70vh] space-y-4 overflow-y-auto p-5">
{error && <p className="text-sm text-danger-400">{error}</p>}
{!error && !ticket && <p className="text-sm text-white/40">Načítám...</p>}
{ticket && (
<>
<div className="flex flex-wrap items-center gap-2">
<TicketStatusBadge status={ticket.status} closed={ticket.closed} />
<span className="text-xs text-white/35">
naposledy {formatRelative(ticket.updatedAt)}
</span>
</div>
{ticket.body && (
<div className="rounded-card border border-ink-600/50 bg-ink-850/50 p-4">
<TicketBody body={ticket.body} />
<DataState loading={!ticket && !loadError} error={loadError}>
{ticket && (
<>
<div className="flex flex-wrap items-center gap-2">
<TicketStatusBadge status={ticket.status} closed={ticket.closed} />
<span className="text-xs text-white/35">
naposledy {formatRelative(ticket.updatedAt)}
</span>
</div>
)}
<div>
<h3 className="text-xs font-semibold tracking-wide text-white/45 uppercase">
Průběh
</h3>
{ticket.trace.length === 0 ? (
<p className="mt-2 text-sm text-white/40">Zatím se nic nestalo.</p>
) : (
<ul className="mt-2 space-y-2">
{ticket.trace.map((entry) => (
<li
key={entry.id}
className="rounded-lg border border-ink-600/50 p-3 text-sm text-white/70"
>
<div className="flex flex-wrap items-center gap-x-3 text-xs text-white/35">
<span>{formatDateTime(entry.at)}</span>
</div>
<p className="mt-1">{entry.label}</p>
{entry.response && (
<p className="mt-1 text-xs text-white/45">{entry.response}</p>
)}
</li>
))}
</ul>
{ticket.body && (
<div className="mt-4 rounded-card border border-ink-600/50 bg-ink-850/50 p-4">
<TicketBody body={ticket.body} />
</div>
)}
</div>
<form onSubmit={comment} className="border-t border-ink-600/50 pt-4">
<label className="block">
<span className="text-xs text-white/55">Doplnit k požadavku</span>
<textarea
value={text}
onChange={(event) => setText(event.target.value)}
rows={3}
placeholder="Co jste zapomněli napsat, nebo co se mezitím změnilo."
className={cn(inputClass, 'mt-1 resize-y')}
/>
</label>
<Button type="submit" size="sm" className="mt-3" disabled={sending || text.trim() === ''}>
<Send className="size-4" />
{sending ? 'Odesílám...' : 'Odeslat'}
</Button>
</form>
</>
)}
<div className="mt-4">
<h3 className="text-xs font-semibold tracking-wide text-white/45 uppercase">
Průběh
</h3>
{ticket.trace.length === 0 ? (
<p className="mt-2 text-sm text-white/40">Zatím se nic nestalo.</p>
) : (
<ul className="mt-2 space-y-2">
{ticket.trace.map((entry) => (
<li
key={entry.id}
className="rounded-lg border border-ink-600/50 p-3 text-sm text-white/70"
>
<div className="flex flex-wrap items-center gap-x-3 text-xs text-white/35">
<span>{formatDateTime(entry.at)}</span>
</div>
<p className="mt-1">{entry.label}</p>
{entry.response && (
<p className="mt-1 text-xs text-white/45">{entry.response}</p>
)}
</li>
))}
</ul>
)}
</div>
<form
onSubmit={(event) => {
event.preventDefault();
if (text.trim() !== '') void submit();
}}
className="mt-4 border-t border-ink-600/50 pt-4"
>
<Field label="Doplnit k požadavku" error={error}>
<Textarea
value={text}
onChange={(event) => setText(event.target.value)}
rows={3}
placeholder="Co jste zapomněli napsat, nebo co se mezitím změnilo."
tone="card"
/>
</Field>
<Button type="submit" size="sm" className="mt-3" disabled={sending || text.trim() === ''}>
<Send className="size-4" />
{sending ? 'Odesílám...' : 'Odeslat'}
</Button>
</form>
</>
)}
</DataState>
</div>
</Modal>
);
+25 -8
View File
@@ -15,11 +15,16 @@ import type { ReactNode } from 'react';
import { useAuth } from '@/auth/AuthContext';
import { DataState } from '@/components/dashboard/DataState';
import { CustomWidgetCard } from '@/components/dashboard/widgets/CustomWidget';
import { WidgetCard, type WidgetData } from '@/components/dashboard/widgets/WidgetCard';
import {
TICKET_WIDGET_ROWS,
WidgetCard,
type WidgetData,
} from '@/components/dashboard/widgets/WidgetCard';
import { WidgetPicker } from '@/components/dashboard/widgets/WidgetPicker';
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 type {
@@ -30,7 +35,7 @@ import type {
ListResponse,
TicketListResponse,
WidgetDataResponse,
WidgetDefinition,
CatalogWidget,
WidgetSize,
Workload,
} from '@/types/dashboard';
@@ -67,7 +72,7 @@ export default function Overview() {
*/
const layout = useApiQuery<LayoutResponse>('/api/dashboard/layout');
const catalog = useApiQuery<ListResponse<WidgetDefinition>>('/api/dashboard/widgets');
const catalog = useApiQuery<ListResponse<CatalogWidget>>('/api/dashboard/widgets');
// Data si nacita prehled, ne jednotlive widgety. Deset dlazdic nesmi
// znamenat deset stejnych dotazu.
@@ -86,9 +91,15 @@ export default function Overview() {
'webhook.received',
],
});
const tickets = useApiQuery<TicketListResponse>(`/api/dashboard/tickets${scopeQuery}`, {
refetchOn: ['ticket.created', 'ticket.updated', 'ticket.assigned', 'ticket.resolved'],
});
// Widget ukazuje pet radku, tak se pet radku take stahuje. Driv sel cely
// seznam firmy kvuli peti polozkam pri kazde udalosti.
const tickets = useApiQuery<TicketListResponse>(
`/api/dashboard/tickets${scopeQuery}&limit=${TICKET_WIDGET_ROWS}`,
{
refetchOn: ['ticket.created', 'ticket.updated', 'ticket.assigned', 'ticket.resolved'],
patchOn: patchTicketList,
},
);
const incidents = useApiQuery<ListResponse<Incident>>('/api/dashboard/incidents', {
refetchOn: ['incident.started', 'incident.updated', 'incident.resolved'],
});
@@ -219,7 +230,7 @@ export default function Overview() {
setDraft(draft.filter((item) => item.id !== id));
}
function add(widget: WidgetDefinition) {
function add(widget: CatalogWidget) {
setDraft([
...draft,
// Instance musi mit vlastni ID, aby sel tentyz widget pridat vickrat.
@@ -324,10 +335,15 @@ export default function Overview() {
variant="secondary"
size="sm"
onClick={() => {
// Vsechno, co je na strance videt - vcetne rozlozeni a dat
// vlastnich widgetu, ktere tlacitko driv vynechavalo.
layout.reload();
catalog.reload();
summary.reload();
tickets.reload();
incidents.reload();
workload.reload();
widgetData.reload();
}}
>
<RefreshCw className="size-4" />
@@ -351,6 +367,7 @@ export default function Overview() {
<DataState
loading={layout.loading || catalog.loading}
refreshing={layout.refreshing || catalog.refreshing}
error={layout.error ?? catalog.error}
onRetry={() => {
layout.reload();
@@ -453,7 +470,7 @@ function EditBar({
onResize,
onRemove,
}: {
definition: WidgetDefinition;
definition: CatalogWidget;
size: WidgetSize;
canMoveLeft: boolean;
canMoveRight: boolean;
+13 -18
View File
@@ -2,7 +2,7 @@ import { Mail, Users } from 'lucide-react';
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { DataState } from '@/components/dashboard/DataState';
import { EntityAdmin } from '@/components/dashboard/EntityAdmin';
import { EntityAdmin, type NamedRecord } from '@/components/dashboard/EntityAdmin';
import { GroupPanel } from '@/components/dashboard/GroupPanel';
import { RolesAdmin } from '@/components/dashboard/RolesAdmin';
import { TicketTypesAdmin } from '@/components/dashboard/TicketTypesAdmin';
@@ -10,15 +10,10 @@ import { InvitePanel } from '@/components/dashboard/InvitePanel';
import { ViewSwitch, type ListView } from '@/components/dashboard/ViewSwitch';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/cn';
import { useAccess, useCollection } from '@/lib/collections';
import { useApiQuery } from '@/lib/useApiQuery';
import { usePageMeta } from '@/lib/usePageMeta';
import type { Access, PeopleResponse, Workload } from '@/types/dashboard';
interface NamedRecord {
id: string;
name?: string;
[key: string]: unknown;
}
import type { Workload } from '@/types/dashboard';
/**
* Lide firmy: kdo resi tickety a jak na tom je.
@@ -31,16 +26,18 @@ export default function People() {
const [view, setView] = useState<ListView>('table');
const [tab, setTab] = useState('prehled');
const access = useApiQuery<Access>('/api/dashboard/access');
const people = useApiQuery<PeopleResponse>('/api/dashboard/people');
// Prava, lide i skupiny z ulozist ciselniku - stejna data jako zbytek portalu.
const access = useAccess();
const people = useCollection('people');
const groups = useCollection('groups');
const workload = useApiQuery<Workload>('/api/dashboard/tickets/workload', {
refetchOn: ['ticket.created', 'ticket.updated', 'ticket.assigned', 'ticket.resolved'],
});
const rows = workload.data?.rows ?? [];
const byPerson = new Map(rows.map((row) => [row.person.id, row]));
const items = people.data?.items ?? [];
const items = people.items;
const meId = people.extra?.meId ?? null;
const can = useMemo(() => {
const owned = new Set(access.data?.permissions ?? []);
@@ -104,9 +101,7 @@ export default function People() {
tone={workload.data.unassigned > 0 ? 'warn' : undefined}
/>
<Stat label="Řešitelů" value={items.length} />
{(people.data?.groups ?? []).length > 0 && (
<Stat label="Skupin" value={(people.data?.groups ?? []).length} />
)}
{groups.items.length > 0 && <Stat label="Skupin" value={groups.items.length} />}
</div>
)}
@@ -142,7 +137,7 @@ export default function People() {
>
{person.name}
{row?.overloaded && <Badge tone="warn">přetížený</Badge>}
{person.id === people.data?.meId && <Badge tone="brand">já</Badge>}
{person.id === meId && <Badge tone="brand">já</Badge>}
</Link>
</td>
<td className="px-4 py-2.5 text-white/55">{person.email}</td>
@@ -213,14 +208,14 @@ export default function People() {
)}
</DataState>
{(people.data?.groups ?? []).length > 0 && (
{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">
{(people.data?.groups ?? []).map((group) => (
{groups.items.map((group) => (
<Badge key={group.id} tone="neutral">
{group.name}
</Badge>
+26 -60
View File
@@ -2,10 +2,11 @@ import { ArrowLeft, Mail, Users } from 'lucide-react';
import { useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { DataState } from '@/components/dashboard/DataState';
import { TicketCard } from '@/components/dashboard/TicketCard';
import { TicketTable } from '@/components/dashboard/TicketTable';
import { ViewSwitch, type ListView } from '@/components/dashboard/ViewSwitch';
import { TicketStatusBadge } from '@/components/dashboard/StatusBadge';
import { Badge } from '@/components/ui/Badge';
import { Chip } from '@/components/ui/Chip';
import { cn } from '@/lib/cn';
import { formatDuration, formatNumber, formatRelative } from '@/lib/format';
import { useApiQuery } from '@/lib/useApiQuery';
@@ -15,7 +16,7 @@ import type { AgentStatsRow, Person, Ticket } from '@/types/dashboard';
/**
* Detail resitele: kdo to je, jak mu to jde a co ma u sebe.
*
* Cisla sedi s widgetem vykonu, protoze je pocita tatáz funkce na serveru.
* Cisla sedi s widgetem vykonu, protoze je pocita tataz funkce na serveru.
* Kdyby si je stranka pocitala sama, na dvou mistech by vyslo neco jineho.
*/
interface PersonDetailResponse {
@@ -49,7 +50,12 @@ export default function PersonDetail() {
Zpět na lidi
</Link>
<DataState loading={detail.loading} error={detail.error} onRetry={detail.reload}>
<DataState
loading={detail.loading}
refreshing={detail.refreshing}
error={detail.error}
onRetry={detail.reload}
>
{detail.data && (
<div className="space-y-6">
<header>
@@ -115,12 +121,12 @@ export default function PersonDetail() {
<section className="glass rounded-card p-4 sm:p-5">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
<TabChip active={tab === 'open'} onClick={() => setTab('open')}>
<Chip active={tab === 'open'} onClick={() => setTab('open')}>
Má u sebe ({detail.data.open.length})
</TabChip>
<TabChip active={tab === 'resolved'} onClick={() => setTab('resolved')}>
</Chip>
<Chip active={tab === 'resolved'} onClick={() => setTab('resolved')}>
Vyřešil ({detail.data.resolved.length})
</TabChip>
</Chip>
</div>
<ViewSwitch view={view} onChange={setView} />
</div>
@@ -147,34 +153,7 @@ export default function PersonDetail() {
);
}
/** Zalozka nad tabulkou. Maly prvek, patri k teto strance. */
function TabChip({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={cn(
'rounded-full border px-3 py-1.5 text-xs font-medium transition-colors',
active
? 'border-brand-400/50 bg-brand-500/15 text-brand-200'
: 'border-ink-600/70 text-white/50 hover:border-white/20 hover:text-white',
)}
>
{children}
</button>
);
}
/** Dlazdicovy pohled na tytéz tickety. */
/** Dlazdicovy pohled na tytez tickety. Karta je spolecna, tady je jen poznamka. */
function TicketCards({
tickets,
showAge,
@@ -195,32 +174,19 @@ function TicketCards({
return (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{tickets.map((ticket) => (
<Link
<TicketCard
key={ticket.id}
to={`/dashboard/tickety/${ticket.id}`}
className="group rounded-card border border-ink-600/50 bg-ink-900/40 p-4 transition-colors hover:border-brand-400/40"
>
<div className="flex items-start justify-between gap-2">
<span className="font-mono text-xs text-white/35">{ticket.id}</span>
<TicketStatusBadge status={ticket.status} closed={ticket.closed} />
</div>
<p className="mt-1.5 line-clamp-2 text-sm text-white/85 group-hover:text-brand-200">
{ticket.subject}
</p>
<p className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-white/40">
{ticket.externalId && <span className="font-mono">externí {ticket.externalId}</span>}
<span>
{showAge
? `leží ${formatRelative(ticket.createdAt)}`
: ticket.resolvedAt
? `vyřešeno ${formatRelative(ticket.resolvedAt)}`
: ''}
</span>
{ticket.reopenCount > 0 && (
<span className="text-warn-400">vráceno {ticket.reopenCount}x</span>
)}
</p>
</Link>
ticket={ticket}
// U jednoho cloveka je resitel i zakaznik jasny, karta ma rict, jak dlouho lezi.
hide={['assignee', 'customer', 'channel', 'tags']}
note={
showAge
? `leží ${formatRelative(ticket.createdAt)}`
: ticket.resolvedAt
? `vyřešeno ${formatRelative(ticket.resolvedAt)}`
: undefined
}
/>
))}
</div>
);
+39 -53
View File
@@ -7,13 +7,17 @@ import {
RefreshCw,
Save,
} from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import type { ReactNode } 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';
@@ -74,6 +78,7 @@ export default function Scripts() {
<DataState
loading={catalog.loading}
refreshing={catalog.refreshing}
error={catalog.error}
onRetry={catalog.reload}
empty={items.length === 0 && (catalog.data?.problems.length ?? 0) === 0}
@@ -210,25 +215,18 @@ function ScriptPanel({
canEdit: boolean;
onSaved: () => void;
}) {
const [detail, setDetail] = useState<ScriptDetail | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const load = useCallback(() => {
setLoading(true);
setError(null);
apiFetch<ScriptDetail>(`/api/dashboard/scripts/${scriptId}`)
.then(setDetail)
.catch((err: unknown) => {
setError(err instanceof Error ? err.message : 'Skript se nepodařilo načíst.');
})
.finally(() => setLoading(false));
}, [scriptId]);
useEffect(load, [load]);
// Pres useApiQuery misto rucniho nacitani: stejne chyby, stejna mezipamet.
const query = useApiQuery<ScriptDetail>(`/api/dashboard/scripts/${scriptId}`);
const detail = query.data;
const load = query.reload;
return (
<DataState loading={loading} error={error} onRetry={load}>
<DataState
loading={query.loading}
refreshing={query.refreshing}
error={query.error}
onRetry={load}
>
{detail && (
<div className="space-y-5">
<div className="glass rounded-card p-5">
@@ -355,48 +353,39 @@ function FieldInput({
value: string;
onChange: (value: string) => void;
}) {
const className =
'w-full rounded-lg border border-ink-600/70 bg-ink-850/70 px-3 py-2 text-sm text-white placeholder:text-white/25 focus:border-brand-400/70 focus:outline-none';
const control = (): ReactNode => {
if (field.options) {
return (
<select className={className} value={value} onChange={(e) => onChange(e.target.value)}>
<option value="">Nevyplněno</option>
{field.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<Select
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Nevyplněno"
options={field.options}
/>
);
}
if (field.type === 'boolean') {
return (
<select className={className} value={value} onChange={(e) => onChange(e.target.value)}>
<option value="">Nevyplněno</option>
<option value="true">Ano</option>
<option value="false">Ne</option>
</select>
);
}
if (field.multiline) {
return (
<textarea
rows={3}
className={className}
<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
<Input
type={field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : 'text'}
className={className}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
@@ -404,14 +393,9 @@ function FieldInput({
};
return (
<label className="block">
<span className="mb-1 block text-sm text-white/70">
{field.label}
{field.required && <span className="text-warn-400"> *</span>}
</span>
<Field label={field.label} required={field.required} hint={field.hint}>
{control()}
{field.hint && <span className="mt-1 block text-xs text-white/35">{field.hint}</span>}
</label>
</Field>
);
}
@@ -639,12 +623,14 @@ function CodeEditor({
)}
</div>
<textarea
<Textarea
spellCheck={false}
readOnly={!canEdit}
value={draft}
onChange={(event) => setDraft(event.target.value)}
className="h-96 w-full resize-y rounded-xl border border-ink-600/70 bg-ink-950/70 p-4 font-mono text-xs leading-relaxed text-white/85 focus:border-brand-400/70 focus:outline-none"
mono
tone="deep"
className="h-96"
/>
{error && (
+14 -30
View File
@@ -4,8 +4,11 @@ import type { ReactNode } from 'react';
import { Link } from 'react-router-dom';
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 { 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';
@@ -46,7 +49,7 @@ function viewOf(service: ServiceWithUsage): ServiceView {
export default function Services() {
usePageMeta({ title: 'Služby', area: 'portal' });
const { data, loading, error, reload } = useApiQuery<ServiceOverview>(
const { data, loading, refreshing, error, reload } = useApiQuery<ServiceOverview>(
'/api/dashboard/connectors/services',
);
@@ -98,7 +101,13 @@ export default function Services() {
</p>
</header>
<DataState loading={loading} error={error} onRetry={reload} empty={items.length === 0}>
<DataState
loading={loading}
refreshing={refreshing}
error={error}
onRetry={reload}
empty={items.length === 0}
>
<div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-3">
<SummaryTile
@@ -119,12 +128,12 @@ export default function Services() {
<div className="glass rounded-card p-4 sm:p-5">
<div className="relative">
<Search className="pointer-events-none absolute top-1/2 left-3.5 size-4 -translate-y-1/2 text-white/35" />
<input
<Input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Hledat službu, spouštěč nebo akci"
className="w-full rounded-xl border border-ink-600/70 bg-ink-850/70 py-2.5 pr-4 pl-10 text-sm text-white placeholder:text-white/30 focus:border-brand-400/70 focus:outline-none"
size="search"
/>
</div>
@@ -206,31 +215,6 @@ function SummaryTile({
);
}
function Chip({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'rounded-full border px-3 py-1.5 text-xs font-medium transition-colors',
active
? 'border-brand-400/50 bg-brand-500/15 text-brand-200'
: 'border-ink-600/70 text-white/50 hover:border-white/20 hover:text-white',
)}
>
{children}
</button>
);
}
function ServiceCard({ service }: { service: ServiceWithUsage }) {
const Icon = serviceIcon(service.icon);
const meta = viewMeta[viewOf(service)];
@@ -276,7 +260,7 @@ function ServiceCard({ service }: { service: ServiceWithUsage }) {
<span className="text-white/40">
{service.connectorCount === 0
? 'Bez konektoru službu ve stromu použít nelze.'
: `${service.connectorCount} ${service.connectorCount === 1 ? 'konektor' : 'konektory'}`}
: `${service.connectorCount} ${plural(service.connectorCount, ['konektor', 'konektory', 'konektorů'])}`}
</span>
<Link
to={`/dashboard/konektory?service=${service.id}`}
+152 -118
View File
@@ -1,13 +1,20 @@
import { useEffect, useMemo, useState } from 'react';
import { useAuth } from '@/auth/AuthContext';
import { EntityAdmin, parseJsonField } from '@/components/dashboard/EntityAdmin';
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';
import { Chip } from '@/components/ui/Chip';
import { Field } from '@/components/ui/form/Field';
import { Input } from '@/components/ui/form/Input';
import { Select } from '@/components/ui/form/Select';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/cn';
import { useAccess } from '@/lib/collections';
import { formatDateTime } from '@/lib/format';
import { useApiQuery } from '@/lib/useApiQuery';
import { usePageMeta } from '@/lib/usePageMeta';
import type { Access } from '@/types/dashboard';
import { useSubmit } from '@/lib/useSubmit';
/**
* Nastaveni portalu.
@@ -37,18 +44,10 @@ interface SettingsCatalog {
platformAdmin: boolean;
}
interface NamedRecord {
id: string;
name?: string;
label?: string;
[key: string]: unknown;
}
export default function Settings() {
usePageMeta({ title: 'Nastavení', area: 'portal' });
const { user } = useAuth();
const access = useApiQuery<Access>('/api/dashboard/access');
const catalog = useApiQuery<SettingsCatalog>('/api/dashboard/settings/catalog');
/*
* Seznam firem zvlast, i kdyz si ho `EntityAdmin` nacita sam. Potrebuje se
@@ -60,12 +59,13 @@ export default function Settings() {
'/api/dashboard/settings/tenants',
);
const [tab, setTab] = useState('ucet');
const [aresOpen, setAresOpen] = useState(false);
const can = useMemo(() => {
const owned = new Set(access.data?.permissions ?? []);
return (permission: string) => owned.has(permission);
}, [access.data]);
/*
* Vsechny zalozky krome uctu jsou sprava platformy, ne firmy. Priznak
* `platformAdmin` posila server u uzivatele, takze se tu nehleda v pravech -
* ta patri k firme a stranky firmy (Lide, Tickety) se ptaji na ne.
*/
const platformAdmin = user?.platformAdmin === true;
/**
@@ -81,7 +81,7 @@ export default function Settings() {
{ key: 'audit', label: 'Audit', show: platformAdmin },
];
return all.filter((item) => item.show);
}, [can, platformAdmin]);
}, [platformAdmin]);
return (
<div className="space-y-6">
@@ -95,19 +95,9 @@ export default function Settings() {
<div className="flex flex-wrap gap-2">
{tabs.map((item) => (
<button
key={item.key}
type="button"
onClick={() => setTab(item.key)}
className={cn(
'rounded-full border px-3 py-1.5 text-xs font-medium transition-colors',
tab === item.key
? 'border-brand-400/50 bg-brand-500/15 text-brand-200'
: 'border-ink-600/70 text-white/50 hover:border-white/20 hover:text-white',
)}
>
<Chip key={item.key} active={tab === item.key} onClick={() => setTab(item.key)}>
{item.label}
</button>
</Chip>
))}
</div>
@@ -120,8 +110,25 @@ export default function Settings() {
title="Firmy"
description="Firmy, které portál používají. Firma je hranice viditelnosti."
endpoint="/api/dashboard/settings/tenants"
refetchOn={['tenant.created', 'tenant.updated', 'tenant.deleted']}
// Prazdne IC neni "IC s nula znaky", ale zadne IC. Server ma na
// vyplnene osm cislic, tak se prazdne posila jako null.
toBody={(values) =>
Object.fromEntries(
Object.entries(values).map(([key, value]) => [
key,
['ico', 'dic', 'address', 'legalForm', 'helpdeskProviderId'].includes(key) && value === '' ? null : value,
]),
)
}
columns={[
{ label: 'Název', render: (tenant: NamedRecord) => String(tenant.name ?? '') },
{
label: 'IČ',
narrow: true,
render: (tenant: NamedRecord) =>
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 ?? '') },
{
@@ -142,6 +149,10 @@ export default function Settings() {
]}
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: '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: 'enabled', label: 'Aktivní', kind: 'checkbox', initial: true },
{
@@ -164,7 +175,22 @@ 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í.
</span>
<Button size="sm" variant="secondary" onClick={() => setAresOpen(true)}>
Založit přes ARES
</Button>
</div>
<AresTenantDialog
open={aresOpen}
onClose={() => setAresOpen(false)}
onCreated={() => tenants.reload()}
/>
</EntityAdmin>
)}
{tab === 'features' && <FeaturesAdmin catalog={catalog.data} />}
@@ -177,25 +203,19 @@ export default function Settings() {
function MyAccount() {
const { user } = useAuth();
const access = useApiQuery<Access>('/api/dashboard/access');
const access = useAccess();
const [password, setPassword] = useState('');
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
async function changePassword() {
setError(null);
const { busy, error, submit } = useSubmit(async () => {
setMessage(null);
try {
await apiFetch(`/api/dashboard/settings/users/${user!.id}/password`, {
method: 'PATCH',
body: { password },
});
setPassword('');
setMessage('Heslo změněno.');
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Heslo se nepodařilo změnit.');
}
}
await apiFetch(`/api/dashboard/settings/users/${user!.id}/password`, {
method: 'PATCH',
body: { password },
});
setPassword('');
setMessage('Heslo změněno.');
}, 'Heslo se nepodařilo změnit.');
return (
<div className="space-y-4">
@@ -238,17 +258,15 @@ function MyAccount() {
<div className="glass rounded-card p-5">
<h3 className="text-sm font-semibold text-white">Změna hesla</h3>
<div className="mt-3 flex flex-wrap items-end gap-3">
<label className="block flex-1">
<span className="mb-1 block text-sm text-white/70">Nové heslo</span>
<input
<Field label="Nové heslo" className="flex-1">
<Input
type="password"
autoComplete="new-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
className="w-full rounded-lg border border-ink-600/70 bg-ink-850/70 px-3 py-2 text-sm text-white focus:border-brand-400/70 focus:outline-none"
/>
</label>
<Button size="sm" onClick={changePassword} disabled={password.length < 8}>
</Field>
<Button size="sm" onClick={() => void submit()} disabled={busy || password.length < 8}>
Změnit
</Button>
</div>
@@ -271,21 +289,15 @@ function MyAccount() {
*/
function AccountSwitch() {
const { startImpersonation, impersonation } = useAuth();
const [candidates, setCandidates] = useState<Array<{ id: string; name: string; email: string }>>(
[],
);
// Pres useApiQuery: chyba nacteni se ukaze, misto aby seznam tise zustal prazdny.
const candidatesQuery = useApiQuery<{
items: Array<{ id: string; name: string; email: string }>;
}>('/api/admin/impersonate/candidates');
const candidates = candidatesQuery.data?.items ?? [];
const [userId, setUserId] = useState('');
const [allowWrites, setAllowWrites] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
apiFetch<{ items: Array<{ id: string; name: string; email: string }> }>(
'/api/admin/impersonate/candidates',
)
.then((data) => setCandidates(data.items))
.catch(() => setCandidates([]));
}, []);
if (impersonation) {
return (
<div className="glass rounded-card p-5">
@@ -306,23 +318,17 @@ function AccountSwitch() {
</p>
<div className="mt-3 flex flex-wrap items-end gap-3">
<label className="block flex-1">
<span className="mb-1 block text-sm text-white/70">Účet</span>
<select
<Field label="Účet" className="flex-1">
<Select
value={userId}
onChange={(event) => setUserId(event.target.value)}
className="w-full rounded-lg border border-ink-600/70 bg-ink-850/70 px-3 py-2 text-sm text-white focus:border-brand-400/70 focus:outline-none"
>
<option value="" className="bg-ink-850">
Vyberte
</option>
{candidates.map((candidate) => (
<option key={candidate.id} value={candidate.id} className="bg-ink-850">
{candidate.name} ({candidate.email})
</option>
))}
</select>
</label>
placeholder="Vyberte"
options={candidates.map((candidate) => ({
value: candidate.id,
label: `${candidate.name} (${candidate.email})`,
}))}
/>
</Field>
<label className="flex items-center gap-2 pb-2 text-sm text-white/70">
<input
@@ -349,7 +355,10 @@ function AccountSwitch() {
</div>
{error && <p className="mt-2 text-sm text-danger-400">{error}</p>}
{candidates.length === 0 && (
{candidatesQuery.error && (
<p className="mt-2 text-sm text-danger-400">{candidatesQuery.error}</p>
)}
{!candidatesQuery.loading && !candidatesQuery.error && candidates.length === 0 && (
<p className="mt-2 text-xs text-white/35">Žádný účet k přepnutí není.</p>
)}
</div>
@@ -359,13 +368,11 @@ function AccountSwitch() {
// ----------------------------------------------------------------- uzivatele
function UsersAdmin() {
const [tenants, setTenants] = useState<Array<{ id: string; name: string }>>([]);
useEffect(() => {
apiFetch<{ items: Array<{ id: string; name: string }> }>('/api/dashboard/settings/tenants')
.then((data) => setTenants(data.items))
.catch(() => setTenants([]));
}, []);
// Nazvy firem ke clenstvim. Sdileny dotaz, zalozka Firmy se pta na totez.
const tenantsQuery = useApiQuery<{ items: Array<{ id: string; name: string }> }>(
'/api/dashboard/settings/tenants',
);
const tenants = tenantsQuery.data?.items ?? [];
return (
<EntityAdmin
@@ -443,40 +450,69 @@ function UsersAdmin() {
// ------------------------------------------------------------ zalozky firmy
interface TenantFeatures {
modules: string[];
limits: Record<string, number>;
serviceIds: string[];
}
function FeaturesAdmin({ catalog }: { catalog: SettingsCatalog | null }) {
const [features, setFeatures] = useState<{
modules: string[];
limits: Record<string, number>;
serviceIds: string[];
} | null>(null);
const [saving, setSaving] = useState(false);
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);
const [error, setError] = useState<string | null>(null);
const load = () => {
apiFetch<{ features: typeof features }>('/api/dashboard/settings/features')
.then((data) => setFeatures(data.features))
.catch((err: unknown) => setError(err instanceof Error ? err.message : 'Nepodařilo se načíst.'));
};
useEffect(() => {
if (saved.data) setFeatures(saved.data.features);
}, [saved.data]);
useEffect(load, []);
async function save() {
const { busy: saving, error, submit: save } = useSubmit(async () => {
if (!features) return;
setSaving(true);
setError(null);
setMessage(null);
try {
await apiFetch('/api/dashboard/settings/features', { method: 'PUT', body: features });
setMessage('Uloženo. Záložky se projeví po obnovení stránky.');
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Uložení selhalo.');
} finally {
setSaving(false);
}
}
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.');
if (!features || !catalog) return <p className="text-sm text-white/45">Načítám...</p>;
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">
@@ -517,9 +553,8 @@ function FeaturesAdmin({ catalog }: { catalog: SettingsCatalog | null }) {
<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]) => (
<label key={key} className="block">
<span className="mb-1 block text-xs text-white/60">{key}</span>
<input
<Field key={key} label={<span className="text-xs text-white/60">{key}</span>}>
<Input
type="number"
value={value}
onChange={(event) =>
@@ -528,9 +563,8 @@ function FeaturesAdmin({ catalog }: { catalog: SettingsCatalog | null }) {
limits: { ...features.limits, [key]: Number(event.target.value) },
})
}
className="w-full rounded-lg border border-ink-600/70 bg-ink-850/70 px-3 py-1.5 text-sm text-white focus:border-brand-400/70 focus:outline-none"
/>
</label>
</Field>
))}
</div>
</div>
@@ -599,7 +633,7 @@ function AuditView() {
{(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">
{new Date(entry.createdAt).toLocaleString('cs')}
{formatDateTime(entry.createdAt)}
</td>
<td className="px-4 py-2 text-white/75">
{entry.userEmail}
+151 -159
View File
@@ -23,16 +23,17 @@ import { TicketEvents } from '@/components/dashboard/TicketEvents';
import { TicketTrace, TraceSummary } from '@/components/dashboard/TicketTrace';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Field } from '@/components/ui/form/Field';
import { Input } from '@/components/ui/form/Input';
import { Select } from '@/components/ui/form/Select';
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 type {
ServiceCatalog,
PeopleResponse,
TicketDetail as Detail,
} from '@/types/dashboard';
import { useSubmit } from '@/lib/useSubmit';
import type { TicketDetail as Detail } from '@/types/dashboard';
/**
* Doporucene stavy, kdyz si typ ticketu zadne nenadefinoval.
@@ -42,22 +43,45 @@ import type {
*/
const defaultStatuses = ['Nový', 'V řešení', 'Čeká na klienta', 'Vyřešeno'];
const selectClass =
'rounded-xl border border-ink-600/70 bg-ink-850/70 px-3.5 py-2.5 text-sm text-white focus:border-brand-400/70 focus:outline-none';
export default function TicketDetail() {
const { id = '' } = useParams();
const ticket = useApiQuery<Detail>(`/api/dashboard/tickets/${id}`, {
refetchOn: ['ticket.updated', 'ticket.assigned', 'ticket.resolved'],
});
const people = useApiQuery<PeopleResponse>('/api/dashboard/people');
const catalog = useApiQuery<ServiceCatalog>('/api/dashboard/services');
const [busy, setBusy] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
/*
* Ciselniky z ulozist, ne vlastni dotazy. A hlavne **nic z nich nebrani
* otevrit ticket**: kdo nema pravo na lidi nebo katalog, dostane detail
* bez roletky resitele, ne prazdnou stranku s chybou.
*/
const people = useCollection('people');
const groups = useCollection('groups');
const services = useCollection('services');
const types = useCollection('ticketTypes');
const [comment, setComment] = useState('');
/** Stavy, ktere firma uz nekde pouziva. Jen naseptavac, ne omezeni. */
const [usedStatuses, setUsedStatuses] = useState<string[]>([]);
const [statusesError, setStatusesError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
apiFetch<{ items: string[] }>('/api/dashboard/tickets/statuses')
.then((data) => {
if (!cancelled) setUsedStatuses(data.items);
})
.catch((err: unknown) => {
// Bez naseptavace se da zit, ale musi byt videt, ze chybi a proc.
console.warn('[ticket] stavy se nepodarilo nacist:', err);
if (!cancelled) setStatusesError('Nabídku stavů se nepodařilo načíst.');
});
return () => {
cancelled = true;
};
}, []);
/*
* Co nabidnout ve vyberu stavu.
*
@@ -66,28 +90,13 @@ export default function TicketDetail() {
* co ticket ma prave ted** - jinak by hodnota z cizi aplikace ze seznamu
* zmizela a prvni zmena stavu by ji nenavratne prepsala.
*/
const [types, setTypes] = useState<Array<{ id: string; statuses: string[] }>>([]);
/** Stavy, ktere firma uz nekde pouziva. Jen naseptavac, ne omezeni. */
const [usedStatuses, setUsedStatuses] = useState<string[]>([]);
useEffect(() => {
apiFetch<{ items: Array<{ id: string; statuses: string[] }> }>(
'/api/dashboard/settings/ticket-types',
)
.then((data) => setTypes(data.items))
.catch(() => setTypes([]));
apiFetch<{ items: string[] }>('/api/dashboard/tickets/statuses')
.then((data) => setUsedStatuses(data.items))
.catch(() => setUsedStatuses([]));
}, []);
const statusChoices = useMemo(() => {
const type = types.find((item) => item.id === ticket.data?.typeId);
const type = types.items.find((item) => item.id === ticket.data?.typeId);
const own = type?.statuses ?? [];
const base = own.length > 0 ? own : defaultStatuses;
const current = ticket.data?.status;
return [...new Set([...base, ...usedStatuses, ...(current ? [current] : [])])];
}, [types, usedStatuses, ticket.data?.typeId, ticket.data?.status]);
}, [types.items, usedStatuses, ticket.data?.typeId, ticket.data?.status]);
/*
* Rozepsany stav.
@@ -102,7 +111,7 @@ export default function TicketDetail() {
* Ktery list detailu je navrchu.
*
* Vychozi je vzdycky pozadavek: to je duvod, proc clovek ticket otevrel.
* Log a udalosti jsou hloubkova data, na ta se sahá, az kdyz neco nesedi.
* Log a udalosti jsou hloubkova data, na ta se saha, az kdyz neco nesedi.
*/
const [activeSheet, setSheet] = useState('pozadavek');
@@ -128,39 +137,28 @@ export default function TicketDetail() {
usePageMeta({ title: `${ticket.data ? ticket.data.id : 'Ticket'}`, area: 'portal' });
/** Kazda zmena jde pres server a nasledne se ticket nacte znovu. */
const action = useSubmit(async (path: string, body: unknown) => {
await apiFetch<unknown>(`/api/dashboard/tickets/${id}${path}`, { method: 'POST', body });
ticket.reload();
}, 'Změnu se nepodařilo uložit.');
const { busy, submit: mutate, setError: setActionError } = action;
// Po zmene ticketu zmizi stara chyba, at nevisi u uz opraveneho stavu.
useEffect(() => {
setActionError(null);
}, [ticket.data?.updatedAt]);
/** Kazda zmena jde pres server a nasledne se ticket nacte znovu. Vraci uspech. */
async function mutate(path: string, body: unknown, fallback: string): Promise<boolean> {
setBusy(true);
setActionError(null);
try {
await apiFetch<unknown>(`/api/dashboard/tickets/${id}${path}`, { method: 'POST', body });
ticket.reload();
return true;
} catch (err) {
const message = err instanceof Error ? err.message : fallback;
console.error(`[ticket] ${path} selhalo:`, err);
setActionError(message);
return false;
} finally {
setBusy(false);
}
}
}, [ticket.data?.updatedAt, setActionError]);
async function submitComment(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const text = comment.trim();
if (text.length < 2) return;
// Pri chybe text necháme v poli, aby ho uzivatel nemusel psat znovu.
if (await mutate('/comment', { text }, 'Komentář se nepodařilo uložit.')) {
setComment('');
}
// Pri chybe text nechame v poli, aby ho uzivatel nemusel psat znovu.
if (await mutate('/comment', { text })) setComment('');
}
const meId = people.extra?.meId ?? null;
return (
<div className="space-y-6">
<Link
@@ -172,13 +170,10 @@ export default function TicketDetail() {
</Link>
<DataState
loading={ticket.loading || catalog.loading || people.loading}
error={ticket.error ?? catalog.error ?? people.error}
onRetry={() => {
ticket.reload();
catalog.reload();
people.reload();
}}
loading={ticket.loading}
refreshing={ticket.refreshing}
error={ticket.error}
onRetry={ticket.reload}
>
{ticket.data && (
<div className="space-y-6">
@@ -208,10 +203,10 @@ export default function TicketDetail() {
<h1 className="mt-2 text-2xl font-bold text-white">{ticket.data.subject}</h1>
</header>
{actionError && (
{action.error && (
<p className="flex items-center gap-2 rounded-xl border border-danger-400/30 bg-danger-500/10 px-4 py-3 text-sm text-danger-400">
<AlertCircle className="size-4 shrink-0" />
{actionError}
{action.error}
</p>
)}
@@ -219,13 +214,13 @@ export default function TicketDetail() {
Akce prichazi ze serveru uz vyfiltrovane podle typu, tagu,
podminek a prav. Klient si nepocita, co ukazat.
*/}
<TicketActions ticket={ticket.data} onDone={ticket.reload} />
<TicketActions ticket={ticket.data} types={types.items} onDone={ticket.reload} />
<div className="grid gap-6 xl:grid-cols-[1fr_20rem]">
{/*
Rozradovace misto tri karet pod sebou. Log prubehu a prichozi
udalosti jsou hloubkova data: dulezita, ale ne pokazde. Kdyz
stály na strance vsechny naraz, byl detail tri obrazovky dlouhy
staly na strance vsechny naraz, byl detail tri obrazovky dlouhy
a to podstatne, tedy obsah pozadavku, se v tom ztracelo.
*/}
<div className="space-y-3">
@@ -274,12 +269,14 @@ export default function TicketDetail() {
Přidat komentář
</label>
<div className="mt-2 flex flex-wrap gap-2">
<input
<Input
id="ticket-comment"
value={comment}
onChange={(event) => setComment(event.target.value)}
placeholder="Co jste zjistili nebo udělali..."
className="min-w-0 flex-1 rounded-xl border border-ink-600/70 bg-ink-850/70 px-3.5 py-2.5 text-sm text-white placeholder:text-white/30 focus:border-brand-400/70 focus:outline-none"
size="lg"
fullWidth={false}
className="min-w-0 flex-1"
/>
<Button
type="submit"
@@ -306,10 +303,8 @@ export default function TicketDetail() {
</h2>
<TraceSummary entries={ticket.data.trace} />
</div>
<TicketTrace
entries={ticket.data.trace}
services={catalog.data?.items ?? []}
/>
{/* Bez katalogu se jmena sluzeb nahradi ID, log zustava citelny. */}
<TicketTrace entries={ticket.data.trace} services={services.items} />
</>
)}
@@ -366,123 +361,120 @@ export default function TicketDetail() {
clovek sam. Je to jina vec nez prehazovani prace shora, proto
tlacitko a ne polozka v selectu.
*/}
{people.data?.meId && ticket.data.assignee?.id !== people.data.meId && (
{meId && ticket.data.assignee?.id !== meId && (
<Button
size="sm"
disabled={busy}
onClick={() => void mutate('/claim', {}, 'Převzetí se nepodařilo.')}
onClick={() => void mutate('/claim', {})}
className="mt-3 w-full justify-center"
>
Převzít
</Button>
)}
<label htmlFor="ticket-assignee" className="sr-only">
Přiřadit řešiteli
</label>
<select
id="ticket-assignee"
value={ticket.data.assignee?.id ?? ''}
disabled={busy}
onChange={(event) =>
void mutate(
'/assign',
{ assigneeId: event.target.value === '' ? null : event.target.value },
'Přiřazení se nepodařilo.',
)
}
className={cn(selectClass, 'mt-3 w-full')}
>
<option value="" className="bg-ink-850">
Vrátit do fronty
</option>
{(people.data?.items ?? []).map((person) => (
<option key={person.id} value={person.id} className="bg-ink-850">
{person.name}, {person.role}
</option>
))}
</select>
{/*
Bez prava na lidi se roletka nenabidne. Ticket jde otevrit
a resit dal, jen ho nejde predat - a rekne se to.
*/}
{people.error ? (
<p className="mt-3 text-xs text-white/40">
Seznam řešitelů není k dispozici, ticket nejde předat.
</p>
) : (
<>
<label htmlFor="ticket-assignee" className="sr-only">
Přiřadit řešiteli
</label>
<Select
id="ticket-assignee"
value={ticket.data.assignee?.id ?? ''}
disabled={busy}
onChange={(event) =>
void mutate('/assign', {
assigneeId: event.target.value === '' ? null : event.target.value,
})
}
size="lg"
className="mt-3"
placeholder="Vrátit do fronty"
options={people.items.map((person) => ({
value: person.id,
label: `${person.name}, ${person.role}`,
}))}
/>
</>
)}
{/*
Skupina je vedle resitele, ne misto nej. Ticket muze byt
"u ucetnich" a zaroven u konkretniho cloveka z nich.
*/}
{(people.data?.groups ?? []).length > 0 && (
<>
<label
htmlFor="ticket-group"
className="mt-4 block text-xs font-semibold tracking-wide text-white/45 uppercase"
>
Skupina
</label>
<select
{groups.items.length > 0 && (
<Field
label={<span className="text-xs font-semibold tracking-wide uppercase">Skupina</span>}
htmlFor="ticket-group"
className="mt-4"
>
<Select
id="ticket-group"
value={ticket.data.assigneeGroupId ?? ''}
disabled={busy}
onChange={(event) =>
void mutate(
'/group',
{ groupId: event.target.value === '' ? null : event.target.value },
'Přiřazení skupině se nepodařilo.',
)
void mutate('/group', {
groupId: event.target.value === '' ? null : event.target.value,
})
}
className={cn(selectClass, 'mt-2 w-full')}
>
<option value="" className="bg-ink-850">
Bez skupiny
</option>
{(people.data?.groups ?? []).map((group) => (
<option key={group.id} value={group.id} className="bg-ink-850">
{group.name}
</option>
))}
</select>
</>
size="lg"
placeholder="Bez skupiny"
options={groups.items.map((group) => ({
value: group.id,
label: group.name,
}))}
/>
</Field>
)}
<label
htmlFor="ticket-status"
className="mt-4 block text-xs font-semibold tracking-wide text-white/45 uppercase"
>
Stav
</label>
{/*
Otevreny naseptavac, ne ciselnik. Stav je volny retezec:
ticket muze prijit z cizi aplikace s jejim vlastnim stavem
a firma si smi zavest vlastni, aniz by ho nekdo predem
zapisoval do nastaveni. Nabidka je pohodli, ne omezeni.
*/}
<input
id="ticket-status"
list="ticket-status-options"
value={statusDraft ?? ticket.data.status}
disabled={busy}
onChange={(event) => setStatusDraft(event.target.value)}
onBlur={(event) => {
const next = event.target.value.trim();
setStatusDraft(null);
if (next === '' || next === ticket.data?.status) return;
void mutate('/status', { status: next }, 'Změna stavu se nepodařila.');
}}
onKeyDown={(event) => {
if (event.key === 'Enter') event.currentTarget.blur();
// Escape zahodi rozepsane a vrati puvodni hodnotu.
if (event.key === 'Escape') {
<Field
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.'}
>
<Input
id="ticket-status"
list="ticket-status-options"
value={statusDraft ?? ticket.data.status}
disabled={busy}
onChange={(event) => setStatusDraft(event.target.value)}
onBlur={(event) => {
const next = event.target.value.trim();
setStatusDraft(null);
event.currentTarget.blur();
}
}}
placeholder="Napište stav, nebo vyberte z nabídky"
className={cn(selectClass, 'mt-2 w-full')}
/>
<datalist id="ticket-status-options">
{statusChoices.map((option) => (
<option key={option} value={option} />
))}
</datalist>
<p className="mt-1 text-xs text-white/35">
Stav je volný text. Nabídka je jen z toho, co už používáte.
</p>
if (next === '' || next === ticket.data?.status) return;
void mutate('/status', { status: next });
}}
onKeyDown={(event) => {
if (event.key === 'Enter') event.currentTarget.blur();
// Escape zahodi rozepsane a vrati puvodni hodnotu.
if (event.key === 'Escape') {
setStatusDraft(null);
event.currentTarget.blur();
}
}}
placeholder="Napište stav, nebo vyberte z nabídky"
size="lg"
/>
<datalist id="ticket-status-options">
{statusChoices.map((option) => (
<option key={option} value={option} />
))}
</datalist>
</Field>
</div>
<div className="glass rounded-card p-5">
+143 -226
View File
@@ -1,27 +1,25 @@
import { Plus, Search } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { DataState } from '@/components/dashboard/DataState';
import {
TicketChannelBadge,
TicketPriorityBadge,
TicketStatusBadge,
} from '@/components/dashboard/StatusBadge';
import { TicketPriorityBadge, TicketStatusBadge } from '@/components/dashboard/StatusBadge';
import { NewTicketDialog } from '@/components/dashboard/NewTicketDialog';
import { QuickAssign } from '@/components/dashboard/QuickAssign';
import { TicketCard } from '@/components/dashboard/TicketCard';
import { TicketWorkload } from '@/components/dashboard/TicketWorkload';
import { TicketTable } from '@/components/dashboard/TicketTable';
import { ViewSwitch } from '@/components/dashboard/ViewSwitch';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Chip } from '@/components/ui/Chip';
import { Input } from '@/components/ui/form/Input';
import { Select } from '@/components/ui/form/Select';
import { cn } from '@/lib/cn';
import { formatDateTime, formatRelative } from '@/lib/format';
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 type {
Access,
PeopleResponse,
TicketChannel,
TicketListResponse,
TicketScope,
@@ -42,105 +40,119 @@ const channelFilters: Array<{ value: TicketChannel; label: string }> = [
{ value: 'portal', label: 'Portál' },
];
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];
export default function Tickets() {
usePageMeta({ title: 'Tickety', area: 'portal' });
// Co uzivatel smi, rozhoduje server. Klient jen kresli, co prislo.
const access = useApiQuery<Access>('/api/dashboard/access');
const access = useAccess();
/*
* Pocatecni filtr z adresy. Diky tomu vede proklik z widgetu na uz
* vyfiltrovany seznam a odkaz jde poslat kolegovi.
*/
const [params] = useSearchParams();
const initial = useRef({
tenantId: params.get('tenantId'),
assignee: params.get('assignee'),
status: params.get('status') as TicketStatus | null,
channel: params.get('channel') as TicketChannel | null,
typeId: params.get('typeId'),
tag: params.get('tag'),
stage: params.get('stage'),
groupId: params.get('groupId'),
}).current;
/*
* Vychozi zalozka podle adresy.
* Filtry zijou v adrese, ne ve stavu stranky.
*
* Odkaz z widgetu nese `assignee`, takze `unassigned` znamena frontu
* a konkretni resitel pohled na vsechny. Bez toho by clovek prisel
* z dlazdice "fronta bez resitele" a koukal na svoje tickety.
* Driv se adresa precetla jen jednou pri namontovani. Kdo prisel z widgetu
* na `?assignee=unassigned` a pak kliknul na Tickety v menu, koukal dal na
* frontu, protoze stranka zustala namontovana a novou adresu uz necetla.
* Ted se kazdy filtr cte z adresy pri kazdem vykresleni a zapisuje se do ni
* zpatky, takze odkaz jde poslat kolegovi a tlacitko Zpet vraci filtr.
*/
const initialTab: 'moje' | 'fronta' | 'vsechny' =
initial.assignee === 'unassigned'
? 'fronta'
: initial.assignee !== null || initial.groupId !== null
? 'vsechny'
: 'moje';
const [params, setParams] = useSearchParams();
const get = (key: FilterKey) => params.get(key);
const [scope, setScope] = useState<TicketScope | null>(
initialTab === 'moje' ? 'mine' : initial.tenantId ? 'tenant' : null,
const tenantId = get('tenantId');
const assignee = get('assignee');
const status = get('status') as TicketStatus | null;
const channel = get('channel') as TicketChannel | null;
const typeId = get('typeId');
const tag = get('tag');
const stage = get('stage');
const groupId = get('groupId');
/*
* Zalozka podle adresy. Odkaz z widgetu nese `assignee`, takze `unassigned`
* znamena frontu a konkretni resitel pohled na vsechny.
*/
const tab: Tab =
(get('tab') as Tab | null) ??
(assignee === 'unassigned'
? 'fronta'
: assignee !== null || groupId !== null
? 'vsechny'
: 'moje');
// "Moje" resi server pohledem `mine`, ostatni zalozky pohledem firmy.
const scope: TicketScope = tab === 'moje' ? 'mine' : get('scope') === 'all' ? 'all' : 'tenant';
const setFilters = useCallback(
(patch: Partial<Record<FilterKey, string | null>>) => {
setParams(
(previous) => {
const next = new URLSearchParams(previous);
for (const [key, value] of Object.entries(patch)) {
if (value === null || value === undefined || value === '') next.delete(key);
else next.set(key, value);
}
return next;
},
{ replace: true },
);
},
[setParams],
);
const [tenantId, setTenantId] = useState<string | null>(initial.tenantId);
/** null = vsichni, 'unassigned' = fronta, jinak ID resitele. */
const [assignee, setAssignee] = useState<string | null>(initial.assignee);
const [status, setStatus] = useState<TicketStatus | null>(initial.status);
const [channel, setChannel] = useState<TicketChannel | null>(initial.channel);
/** Filtry, ktere se daji nastavit jen odkazem. V UI k nim tlacitko neni. */
const [typeId, setTypeId] = useState<string | null>(initial.typeId);
const [tag, setTag] = useState<string | null>(initial.tag);
const [stage, setStage] = useState<string | null>(initial.stage);
const [groupId, setGroupId] = useState<string | null>(initial.groupId);
const [query, setQuery] = useState('');
/** Tabulka na porovnavani, dlazdice na rychly prehled. */
const [view, setView] = useState<'table' | 'grid'>('table');
const [creating, setCreating] = useState(false);
/** Jen moje. Server to resi pohledem `mine`, klient jen prepina. */
const [mine, setMine] = useState(initialTab === 'moje');
/*
* Tri zalozky misto prepinacu.
*
* Puvodne to byly dva chipy vedle ostatnich filtru a splyvaly s nimi.
* Jenze "co mam u sebe", "co jeste nikdo nema" a "co je ve firme" nejsou
* filtry, jsou to tri ruzne prace - a kazda chce jinou tabulku.
*/
const [tab, setTab] = useState(initialTab);
// Filtrovani resi server, aby seznam a prehled nikdy neukazovaly jina cisla.
const path = useMemo(() => {
const params = new URLSearchParams();
if (scope) params.set('scope', scope);
if (tenantId) params.set('tenantId', tenantId);
if (assignee) params.set('assignee', assignee);
if (typeId) params.set('typeId', typeId);
if (tag) params.set('tag', tag);
if (stage) params.set('stage', stage);
if (groupId) params.set('groupId', groupId);
if (status) params.set('status', status);
if (channel) params.set('channel', channel);
const search = params.toString();
return `/api/dashboard/tickets${search ? `?${search}` : ''}`;
// Vsechno, co se do adresy dosazuje, musi byt i tady. Chybel `groupId`
// a spol., takze prepnuti sekce adresu neprepocitalo a seznam se nenacetl
// znovu - filtr vypadal, ze nefunguje.
const query = new URLSearchParams();
query.set('scope', scope);
if (tenantId) query.set('tenantId', tenantId);
if (assignee) query.set('assignee', assignee);
if (typeId) query.set('typeId', typeId);
if (tag) query.set('tag', tag);
if (stage) query.set('stage', stage);
if (groupId) query.set('groupId', groupId);
if (status) query.set('status', status);
if (channel) query.set('channel', channel);
return `/api/dashboard/tickets?${query.toString()}`;
}, [scope, tenantId, assignee, status, channel, typeId, tag, stage, groupId]);
const workloadPath = useMemo(() => {
const params = new URLSearchParams();
const query = new URLSearchParams();
// Prehled vytizeni je vzdy za firmu. U pohledu "vse" nema tym co znamenat.
if (scope === 'all') params.set('scope', 'all');
else if (tenantId) params.set('tenantId', tenantId);
const search = params.toString();
if (scope === 'all') query.set('scope', 'all');
else if (tenantId) query.set('tenantId', tenantId);
const search = query.toString();
return `/api/dashboard/tickets/workload${search ? `?${search}` : ''}`;
}, [scope, tenantId]);
const tickets = useApiQuery<TicketListResponse>(path, { refetchOn: [...ticketEvents] });
const workload = useApiQuery<Workload>(workloadPath, { refetchOn: [...ticketEvents] });
// Do fronty: komu se da ticket predat. Nacita se jen pro zalozku Nezarazene.
const people = useApiQuery<PeopleResponse>('/api/dashboard/people', {
enabled: tab === 'fronta',
const tickets = useApiQuery<TicketListResponse>(path, {
refetchOn: [...ticketEvents],
patchOn: patchTicketList,
});
const workload = useApiQuery<Workload>(workloadPath, { refetchOn: [...ticketEvents] });
// Komu se da ticket predat. Z ulozist ciselniku, nacte se jednou za firmu.
const people = useCollection('people');
const groups = useCollection('groups');
// Hledani je jen dohledani v uz nactenem seznamu, proto na klientovi.
const items = useMemo(() => {
@@ -157,19 +169,16 @@ export default function Tickets() {
}, [tickets.data, query]);
/** Prepnuti zalozky. Jedno misto, kde se meni pohled i filtr na resitele. */
function openTab(next: 'moje' | 'fronta' | 'vsechny') {
setTab(next);
setGroupId(null);
if (next === 'moje') {
setMine(true);
setScope('mine');
setAssignee(null);
} else {
setMine(false);
setScope(scope === 'all' ? 'all' : 'tenant');
setAssignee(next === 'fronta' ? 'unassigned' : null);
}
}
const openTab = useCallback(
(next: Tab) => {
setFilters({
tab: next,
groupId: null,
assignee: next === 'fronta' ? 'unassigned' : null,
});
},
[setFilters],
);
const data = tickets.data;
const meId = data?.meId ?? null;
@@ -182,8 +191,6 @@ export default function Tickets() {
const allowedScopes = access.data?.scopes ?? [];
const tenants = access.data?.tenants ?? [];
// Dokud server neodpovi, drzime se toho, co poslal u seznamu.
const activeScope = scope ?? data?.scope ?? null;
const ticketTabs = ([
{ key: 'moje', label: 'Moje tickety', show: allowedScopes.includes('mine') },
@@ -202,7 +209,7 @@ export default function Tickets() {
if (ticketTabs.some((item) => item.key === tab)) return;
openTab(ticketTabs[0]?.key ?? 'fronta');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [access.data]);
}, [access.data, tab]);
return (
<div className="space-y-6">
@@ -262,12 +269,12 @@ export default function Tickets() {
<div className="glass rounded-card p-4 sm:p-5">
<div className="relative">
<Search className="pointer-events-none absolute top-1/2 left-3.5 size-4 -translate-y-1/2 text-white/35" />
<input
<Input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Hledat podle čísla, předmětu nebo firmy..."
className="w-full rounded-xl border border-ink-600/70 bg-ink-850/70 py-2.5 pr-4 pl-10 text-sm text-white placeholder:text-white/30 focus:border-brand-400/70 focus:outline-none"
size="search"
/>
</div>
@@ -278,21 +285,17 @@ export default function Tickets() {
*/}
<div className="mt-4 flex flex-wrap items-center gap-2">
{tenants.length > 1 && (
<select
value={activeScope === 'all' ? '' : (tenantId ?? data?.tenantId ?? '')}
<Select
value={scope === 'all' ? '' : (tenantId ?? data?.tenantId ?? '')}
onChange={(event) => {
const value = event.target.value;
if (value === '') {
// Prazdna volba = napric firmami. Smi ji jen ten, kdo na ni ma pravo.
setScope('all');
setTenantId(null);
} else {
setScope(mine ? 'mine' : 'tenant');
setTenantId(value);
}
// Prazdna volba = napric firmami. Smi ji jen ten, kdo na ni ma pravo.
if (value === '') setFilters({ scope: 'all', tenantId: null });
else setFilters({ scope: null, tenantId: value });
}}
aria-label="Firma"
className="rounded-full border border-ink-600/70 bg-ink-850/70 px-3 py-1.5 text-xs text-white focus:border-brand-400/70 focus:outline-none"
size="pill"
fullWidth={false}
>
{allowedScopes.includes('all') && (
<option value="" className="bg-ink-850">
@@ -304,7 +307,7 @@ export default function Tickets() {
{tenant.name}
</option>
))}
</select>
</Select>
)}
{/*
@@ -313,21 +316,15 @@ export default function Tickets() {
ani skupinu, prave proto v te fronte lezi.
*/}
{tab === 'vsechny' && visibleGroups.length > 0 && (
<select
<Select
value={groupId ?? ''}
onChange={(event) => setGroupId(event.target.value || null)}
onChange={(event) => setFilters({ groupId: event.target.value || null })}
aria-label="Sekce"
className="rounded-full border border-ink-600/70 bg-ink-850/70 px-3 py-1.5 text-xs text-white focus:border-brand-400/70 focus:outline-none"
>
<option value="" className="bg-ink-850">
Všechny sekce
</option>
{visibleGroups.map((group) => (
<option key={group.id} value={group.id} className="bg-ink-850">
{group.name}
</option>
))}
</select>
size="pill"
fullWidth={false}
placeholder="Všechny sekce"
options={visibleGroups.map((group) => ({ value: group.id, label: group.name }))}
/>
)}
{/*
@@ -335,22 +332,26 @@ export default function Tickets() {
jinak clovek prijde z widgetu a uz se z uzkeho vyberu nedostane.
*/}
{typeId && (
<Chip active onClick={() => setTypeId(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>
)}
{tag && (
<Chip active onClick={() => setTag(null)} title="Zrušit filtr na tag">
<Chip active onClick={() => setFilters({ tag: null })} title="Zrušit filtr na tag">
tag: {tag === 'none' ? 'bez tagu' : tag} x
</Chip>
)}
{stage && (
<Chip active onClick={() => setStage(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>
)}
{groupId && (
<Chip active onClick={() => setGroupId(null)} title="Zrušit filtr na skupinu">
<Chip
active
onClick={() => setFilters({ groupId: null })}
title="Zrušit filtr na skupinu"
>
skupina: {groupId === 'none' ? 'bez skupiny' : groupId} x
</Chip>
)}
@@ -365,7 +366,7 @@ export default function Tickets() {
<Chip
key={value}
active={status === value}
onClick={() => setStatus(status === value ? null : value)}
onClick={() => setFilters({ status: status === value ? null : value })}
>
{value}
</Chip>
@@ -377,7 +378,9 @@ export default function Tickets() {
<Chip
key={item.value}
active={channel === item.value}
onClick={() => setChannel(channel === item.value ? null : item.value)}
onClick={() =>
setFilters({ channel: channel === item.value ? null : item.value })
}
>
{item.label}
</Chip>
@@ -388,6 +391,7 @@ export default function Tickets() {
<div className={cn(view === 'table' && 'glass overflow-hidden rounded-card')}>
<DataState
loading={tickets.loading}
refreshing={tickets.refreshing}
error={tickets.error}
empty={items.length === 0}
onRetry={tickets.reload}
@@ -400,62 +404,7 @@ export default function Tickets() {
{view === 'grid' ? (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{items.map((ticket) => (
<Link
key={ticket.id}
to={`/dashboard/tickety/${ticket.id}`}
className="glass group flex flex-col rounded-card p-4 transition-colors hover:border-brand-400/40"
>
<div className="flex items-start justify-between gap-2">
<span className="font-mono text-xs text-white/35">{ticket.id}</span>
<TicketStatusBadge status={ticket.status} closed={ticket.closed} />
</div>
<p className="mt-2 line-clamp-2 text-sm text-white/85 group-hover:text-brand-200">
{ticket.subject}
</p>
<p className="mt-1 truncate text-xs text-white/40">
{ticket.customer.id ? (
ticket.customer.company
) : (
<span className="text-warn-400">zákazník nedohledán</span>
)}
</p>
{ticket.externalId && (
<p className="mt-1 font-mono text-xs text-white/30">
externí {ticket.externalId}
</p>
)}
<div className="mt-3 flex flex-wrap items-center gap-1.5">
<TicketChannelBadge channel={ticket.channel} />
<TicketPriorityBadge priority={ticket.priority} />
{ticket.tags.slice(0, 2).map((tag) => (
<Badge key={tag} tone="neutral">
{tag}
</Badge>
))}
</div>
<div className="mt-3 flex items-center justify-between gap-2 border-t border-ink-600/50 pt-3 text-xs">
<span
className={cn(
'truncate',
ticket.assignee
? ticket.assignee.id === meId
? 'text-brand-200'
: 'text-white/55'
: 'text-white/30',
)}
>
{ticket.assignee?.name ?? 've frontě'}
</span>
<span className="shrink-0 text-white/35" title={formatDateTime(ticket.updatedAt)}>
{formatRelative(ticket.updatedAt)}
</span>
</div>
</Link>
<TicketCard key={ticket.id} ticket={ticket} meId={meId} />
))}
</div>
) : tab === 'fronta' ? (
@@ -484,8 +433,8 @@ export default function Tickets() {
<QuickAssign
ticketId={ticket.id}
people={people.data?.items ?? []}
groups={people.data?.groups ?? []}
people={people.items}
groups={groups.items}
canAssignOthers={canAssignOthers}
meId={meId}
onDone={() => {
@@ -517,6 +466,7 @@ export default function Tickets() {
<aside>
<DataState
loading={workload.loading}
refreshing={workload.refreshing}
error={workload.error}
onRetry={workload.reload}
>
@@ -524,7 +474,7 @@ export default function Tickets() {
<TicketWorkload
workload={workload.data}
active={assignee}
onSelect={setAssignee}
onSelect={(next) => setFilters({ assignee: next })}
/>
)}
</DataState>
@@ -533,36 +483,3 @@ export default function Tickets() {
</div>
);
}
function Chip({
active,
onClick,
disabled,
title,
children,
}: {
active: boolean;
onClick: () => void;
disabled?: boolean;
title?: string;
children: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={title}
aria-pressed={active}
className={cn(
'rounded-full border px-3 py-1.5 text-xs font-medium transition-colors',
active
? 'border-brand-400/50 bg-brand-500/15 text-brand-200'
: 'border-ink-600/70 text-white/50 hover:border-white/20 hover:text-white',
disabled && 'cursor-not-allowed opacity-40 hover:border-ink-600/70 hover:text-white/50',
)}
>
{children}
</button>
);
}
+3 -10
View File
@@ -1,10 +1,8 @@
import { Info } from 'lucide-react';
import { useEffect, useState } from 'react';
import { EntityAdmin, parseJsonField, type AdminField } from '@/components/dashboard/EntityAdmin';
import { Badge } from '@/components/ui/Badge';
import { apiFetch } from '@/lib/api';
import { useCollection } from '@/lib/collections';
import { usePageMeta } from '@/lib/usePageMeta';
import type { Service } from '@/types/dashboard';
/**
* Definice vlastnich widgetu.
@@ -41,13 +39,8 @@ const sourceHint = `Příklady:
export default function Widgets() {
usePageMeta({ title: 'Widgety', area: 'portal' });
const [services, setServices] = useState<Service[]>([]);
useEffect(() => {
apiFetch<{ items: Service[] }>('/api/dashboard/services')
.then((data) => setServices(data.items))
.catch(() => setServices([]));
}, []);
// Katalog sluzeb z ulozist. Jen napoveda v textu, chyba se tu nehlasi.
const services = useCollection('services').items;
const fields: Array<AdminField<WidgetRecord>> = [
{ name: 'name', label: 'Název', kind: 'text', required: true },
@@ -0,0 +1,226 @@
import { CheckCircle2, Pencil, ScrollText, Star, Trash2, Wifi, Wrench } from 'lucide-react';
import { useState } from 'react';
import { ErrorDetail } from '@/components/dashboard/ErrorDetail';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
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 type {
Connector,
ConnectorTestResult,
ServiceWithUsage,
ToolsLoadResult,
} from '@/types/dashboard';
/** Mala karta jednoho napojeni: stav, overeni, logy, nastroje. */
export function ConnectorCard({
connector,
service,
onEdit,
onChanged,
}: {
connector: Connector;
service: ServiceWithUsage | undefined;
onEdit: () => void;
onChanged: () => void;
}) {
/**
* Kam konektor opravdu miri. Vychozi adresa je z konfigurace serveru
* a konektor ji smi prepsat, takze bez ni je "vratilo 403" jen pulka
* informace: nerika, jestli se to vubec trefilo na spravny stroj.
*/
// 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);
/**
* MCP se neoveruje jako ostatni sluzby.
*
* Jinde je overeni cteci volani navic, tady je to ta hlavni vec, kterou
* konektor umi: teprve tim se zjisti, jake nastroje server nabizi, a
* teprve pak jde postavit krok. Proto to neni "Overit", ale "Nacist nastroje".
*/
const isMcp = service?.transport === 'mcp';
const loadTools = useSubmit(async () => {
const result = await apiFetch<ToolsLoadResult>(
`/api/dashboard/connectors/${connector.id}/mcp/tools`,
{ method: 'POST' },
);
// Stejny tvar jako vysledek overeni, takze se ukaze stejnou cestou.
setTest({
ok: result.ok,
checked: result.checked,
message: result.message,
status: result.status,
detail: result.detail,
});
if (result.ok) setToolsOpen(true);
onChanged();
}, 'Načtení nástrojů se nepodařilo odeslat.');
const testing = useSubmit(async () => {
setTest(
await apiFetch<ConnectorTestResult>(`/api/dashboard/connectors/${connector.id}/test`, {
method: 'POST',
}),
);
onChanged();
}, 'Ověření se nepodařilo odeslat.');
const deletion = useSubmit(async () => {
await apiFetch(`/api/dashboard/connectors/${connector.id}`, { method: 'DELETE' });
onChanged();
}, 'Smazání selhalo.');
const busy = loadTools.busy || testing.busy || deletion.busy;
const error = loadTools.error ?? testing.error ?? deletion.error;
return (
<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" />
</span>
<div className="min-w-0 flex-1">
<h2 className="flex flex-wrap items-center gap-1.5 truncate font-semibold text-white">
{connector.name}
{connector.isDefault && (
<Star className="size-3 shrink-0 text-brand-300" aria-label="Výchozí konektor" />
)}
</h2>
<p className="truncate text-xs text-white/40">{service?.name ?? connector.serviceId}</p>
{targetUrl && (
<p className="truncate font-mono text-xs text-white/30" title={targetUrl}>
{targetUrl}
</p>
)}
</div>
</div>
<div className="mt-3 flex flex-wrap gap-1.5">
<ConnectorStatusBadge connector={connector} />
{!connector.enabled && <Badge tone="warn">vypnutý</Badge>}
{connector.mcp && (
<Badge tone={connector.mcp.tools.length > 0 ? 'ok' : 'warn'}>
nástrojů: {connector.mcp.tools.length}
</Badge>
)}
</div>
{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.
</p>
)}
{connector.missing.length > 0 && service && (
<p className="mt-2 text-xs text-warn-400">
Chybí:{' '}
{connector.missing
.map((id) => service.credentials.find((field) => field.id === id)?.label ?? id)
.join(', ')}
</p>
)}
{connector.lastError && !test && (
<p className="mt-2 text-xs text-danger-400">
{connector.lastError}
<span className="mt-0.5 block text-white/35">
Celá odpověď služby je pod tlačítkem Logy.
</span>
</p>
)}
{test?.ok && (
<p className="mt-2 flex items-start gap-1.5 text-xs text-ok-400">
<CheckCircle2 className="mt-0.5 size-3 shrink-0" />
<span>
{test.message}
<span className="block text-white/35">Ověřeno: {test.checked}</span>
</span>
</p>
)}
{test && !test.ok && (
<ErrorDetail
className="mt-2"
message={test.message}
detail={test.detail}
status={test.status}
request={test.request}
responseHeaders={test.responseHeaders}
defaultOpen
/>
)}
{error && <p className="mt-2 text-xs text-danger-400">{error}</p>}
<div className="mt-auto flex flex-wrap items-center gap-1.5 pt-4">
<Button size="sm" variant="secondary" onClick={onEdit} disabled={busy}>
<Pencil className="size-3.5" />
Upravit
</Button>
{isMcp ? (
<>
<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>
{connector.mcp && connector.mcp.tools.length > 0 && (
<Button size="sm" variant="ghost" onClick={() => setToolsOpen(true)}>
<Wrench className="size-3.5" />
Nástroje
<span className="text-white/40">{connector.mcp.tools.length}</span>
</Button>
)}
</>
) : (
<Button size="sm" variant="ghost" onClick={() => void testing.submit()} disabled={busy}>
<Wifi className="size-3.5" />
{testing.busy ? 'Ověřuji...' : 'Ověřit'}
</Button>
)}
<Button size="sm" variant="ghost" onClick={() => setLogsOpen(true)}>
<ScrollText className="size-3.5" />
Logy
{connector.checkCount > 0 && (
<span className="text-white/40">{connector.checkCount}</span>
)}
</Button>
<Button size="sm" variant="ghost" onClick={() => void deletion.submit()} disabled={busy}>
<Trash2 className="size-3.5" />
Smazat
</Button>
</div>
<ConnectorLogs
connector={connector}
targetUrl={targetUrl}
open={logsOpen}
onClose={() => setLogsOpen(false)}
/>
<ConnectorTools
connector={connector}
open={toolsOpen}
onClose={() => setToolsOpen(false)}
/>
</article>
);
}
function ConnectorStatusBadge({ connector }: { connector: Connector }) {
if (!connector.ready) return <Badge tone="warn">Chybí údaje</Badge>;
if (connector.status === 'ok') return <Badge tone="ok">Ověřeno</Badge>;
if (connector.status === 'error') return <Badge tone="danger">Ověření selhalo</Badge>;
return <Badge tone="neutral">Neověřeno</Badge>;
}
@@ -0,0 +1,332 @@
import { Eye, EyeOff, Star } from 'lucide-react';
import { useEffect, 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 { Modal } from '@/components/ui/Modal';
import { apiFetch } from '@/lib/api';
import { useSubmit } from '@/lib/useSubmit';
import type { Connector, Service, ServiceCredentialField } from '@/types/dashboard';
export type EditorState =
| { mode: 'create'; serviceId: string }
| { mode: 'edit'; connector: Connector }
| null;
/** Dialog na zalozeni a upravu konektoru. Pristupove udaje se odsud jen posilaji. */
export function ConnectorEditor({
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;
const [serviceId, setServiceId] = useState('');
const [name, setName] = useState('');
const [baseUrl, setBaseUrl] = useState('');
const [values, setValues] = useState<Record<string, string>>({});
const service = servicesById.get(serviceId) ?? services.find((item) => item.id === serviceId);
// Spravovana pole vyplnuje portal, ne clovek. Do formulare nepatri.
const visibleCredentials = (service?.credentials ?? []).filter((field) => !field.managed);
const saving = useSubmit(async () => {
// Posilaji se jen vyplnena pole. Prazdne u tajneho pole znamena
// "nech jak je", ne "smaz" - proto se prazdne klice zahazuji.
const changed = Object.fromEntries(
Object.entries(values).filter(([, value]) => value.trim() !== ''),
);
if (creating) {
await apiFetch('/api/dashboard/connectors', {
method: 'POST',
body: {
serviceId,
name: name.trim() || (service?.name ?? serviceId),
baseUrl: baseUrl.trim() === '' ? null : baseUrl.trim(),
values: changed,
},
});
} else if (existing) {
await apiFetch(`/api/dashboard/connectors/${existing.id}`, {
method: 'PATCH',
body: {
name: name.trim(),
baseUrl: baseUrl.trim() === '' ? null : baseUrl.trim(),
values: changed,
},
});
}
onSaved();
}, 'Uložení selhalo.');
const patching = useSubmit(async (body: { enabled?: boolean; isDefault?: boolean }) => {
if (!existing) return;
await apiFetch(`/api/dashboard/connectors/${existing.id}`, { method: 'PATCH', body });
onSaved();
}, 'Uložení selhalo.');
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
a React drzi jinou - a ulozi se to, co drzi React, tedy nic.
*/}
<form
autoComplete="off"
onSubmit={(event) => {
event.preventDefault();
void saving.submit();
}}
className="space-y-4 p-5"
>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Služba" as="div">
{creating ? (
<Select
value={serviceId}
onChange={(event) => {
setServiceId(event.target.value);
setName(servicesById.get(event.target.value)?.name ?? '');
setValues({});
}}
options={services.map((item) => ({ value: item.id, label: item.name }))}
/>
) : (
<p className="rounded-lg border border-ink-600/50 bg-ink-950/40 px-3 py-2 text-sm text-white/60">
{service?.name ?? serviceId}
</p>
)}
</Field>
<Field label="Název konektoru" hint="Firma může mít dva účty téže služby.">
<Input
name="connector-label"
autoComplete="off"
value={name}
onChange={(event) => setName(event.target.value)}
/>
</Field>
</div>
{/*
Pole, ktera spravuje portal (klic zarizeni u MCP EasyWebu), se
nezobrazuji. Uzivatel do nich nema co psat a prepsat by je mohl.
*/}
{service && visibleCredentials.length > 0 ? (
<div className="grid gap-4 border-t border-ink-600/50 pt-4 sm:grid-cols-2">
{visibleCredentials.map((field) => (
<CredentialInput
key={field.id}
field={field}
value={values[field.id] ?? ''}
filled={existing?.filled.includes(field.id) ?? false}
existing={existing?.config[field.id]}
onChange={(value) => setValues((prev) => ({ ...prev, [field.id]: value }))}
/>
))}
</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.
</p>
)}
{/*
U SMTP i u MCP nese adresu konektor mezi udaji, ne tohle pole.
Prazdne pole navic by svadelo psat adresu podruhe.
*/}
{(service?.transport ?? 'http') === 'http' && (
<Field
label="Vlastní adresa služby"
hint="Nechte prázdné, pokud nemáte vlastní instanci."
>
<Input
name="connector-endpoint"
autoComplete="off"
value={baseUrl}
onChange={(event) => setBaseUrl(event.target.value)}
placeholder="Výchozí podle služby"
/>
</Field>
)}
{error && (
<div className="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-1.5 ml-4 list-disc text-xs text-white/60">
{issues.map((issue) => (
<li key={`${issue.field}-${issue.message}`}>{issue.message}</li>
))}
</ul>
)}
</div>
)}
<div className="flex flex-wrap items-center gap-3 border-t border-ink-600/50 pt-4">
<Button type="submit" size="sm" disabled={busy || !service}>
{saving.busy ? 'Ukládám...' : creating ? 'Založit' : 'Uložit'}
</Button>
<Button type="button" size="sm" variant="secondary" onClick={onClose} disabled={busy}>
Zrušit
</Button>
{existing && (
<>
<span className="flex-1" />
{!existing.isDefault && (
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => void patching.submit({ isDefault: true })}
disabled={busy}
>
<Star className="size-3.5" />
Nastavit jako výchozí
</Button>
)}
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => void patching.submit({ enabled: !existing.enabled })}
disabled={busy}
>
{existing.enabled ? 'Vypnout' : 'Zapnout'}
</Button>
</>
)}
</div>
</form>
</Modal>
);
}
/**
* Pole pristupoveho udaje.
*
* Tri veci, ktere tady nejsou kosmetika:
*
* 1. `autoComplete="new-password"` u tajneho pole. Prohlizec `off` u hesel
* ignoruje a dosazuje ulozene prihlaseni. Vznikne tim stav, kdy uzivatel
* vidi jednu hodnotu a React drzi jinou, a ulozi se ta z Reactu, tedy nic.
* 2. `name`, ktere nepripomina heslo ani uzivatele. Podle nej se prohlizec
* rozhoduje, co doplnit.
* 3. Prepinac zobrazeni. Uzivatel musi mit jak overit, ze zapsal to, co chtel -
* bez toho se spatne zadany klic pozna az z neuspesneho overeni.
*/
function CredentialInput({
field,
value,
filled,
existing,
onChange,
}: {
field: ServiceCredentialField;
value: string;
filled: boolean;
existing?: string;
onChange: (value: string) => void;
}) {
const [visible, setVisible] = useState(false);
const placeholder = field.secret
? filled
? 'Vyplněno, prázdné pole hodnotu nezmění'
: 'Nevyplněno'
: (existing ?? '');
return (
<Field
label={
<>
{field.label}
{field.secret && <Badge tone="neutral">tajné</Badge>}
{filled && <Badge tone="ok">vyplněno</Badge>}
</>
}
required={field.required}
hint={
field.hint ??
`Posílá se jako ${field.target === 'header' ? 'hlavička' : 'nastavení'} ${field.name}.`
}
>
<div className="relative">
<Input
// Jmeno zamerne nepripomina heslo, jinak do nej prohlizec doplnuje.
name={`svc-${field.id}`}
type={field.secret && !visible ? 'password' : 'text'}
autoComplete={field.secret ? 'new-password' : 'off'}
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
className={field.secret ? 'pr-10' : undefined}
/>
{field.secret && (
<button
type="button"
onClick={() => setVisible((previous) => !previous)}
className="absolute top-1/2 right-2 grid size-7 -translate-y-1/2 place-items-center rounded text-white/40 transition-colors hover:text-white"
aria-label={visible ? 'Skrýt hodnotu' : 'Zobrazit hodnotu'}
tabIndex={-1}
>
{visible ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
</button>
)}
</div>
</Field>
);
}
@@ -0,0 +1,109 @@
import { CheckCircle2 } from 'lucide-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 { Modal } from '@/components/ui/Modal';
import { formatDateTime } from '@/lib/format';
import { useApiQuery } from '@/lib/useApiQuery';
import type { Connector, ConnectorCheck } from '@/types/dashboard';
/**
* Poslednich pet overeni konektoru.
*
* Duvod, proc to je vlastni dialog a ne radek na karte: u chyby je podstatne
* cele telo odpovedi sluzby, a to je nekolik radku. Do karty se to neveslo,
* takze to dosud nebylo nikde - jen v odpovedi na test, ktera zmizela
* s prekreslenim stranky.
*
* Nacita se az pri otevreni. V seznamu konektoru by to byla zatez navic
* za neco, co vetsinu casu nikdo necte.
*/
export function ConnectorLogs({
connector,
targetUrl,
open,
onClose,
}: {
connector: Connector;
/** Kam konektor miri. V hlavicce dialogu, at je to videt bez rozklikavani. */
targetUrl: string | null;
open: boolean;
onClose: () => void;
}) {
// Odchozi IP adresa je nase provozni vec, ne zakaznikova. V logu zustava,
// ale vidi ji jen spravce platformy - stejne jako hlasky nad seznamem.
const { user } = useAuth();
// `enabled: open` = dotaz odejde az po otevreni. Klic drzi i pocet overeni,
// takze po dalsim stisku Overit se log nacte znovu.
const checks = useApiQuery<{ items: ConnectorCheck[] }>(
`/api/dashboard/connectors/${connector.id}/checks?count=${connector.checkCount}`,
{ enabled: open },
);
const items = checks.data?.items ?? [];
return (
<Modal
open={open}
onClose={onClose}
title={`Logy: ${connector.name}`}
description="Posledních pět ověření. U neúspěchu je vidět celá odpověď služby."
className="max-w-4xl"
>
{targetUrl && (
<p className="border-b border-ink-600/60 px-5 py-3 font-mono text-xs break-all text-white/40">
{targetUrl}
</p>
)}
<div className="max-h-[70vh] space-y-3 overflow-y-auto p-5">
<DataState
loading={checks.loading}
error={checks.error}
onRetry={checks.reload}
empty={checks.data !== null && items.length === 0}
emptyLabel="Zatím tu nic není. Záznam vznikne při prvním stisku Ověřit."
>
{items.map((check, index) => (
<div key={`${check.at}-${index}`} className="rounded-lg border border-ink-600/60 p-3">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-white/40">
<span className="text-white/70">{formatDateTime(check.at)}</span>
{check.ok ? <Badge tone="ok">prošlo</Badge> : <Badge tone="danger">selhalo</Badge>}
<span>Ověřeno: {check.checked}</span>
{user?.platformAdmin && check.egressIp && (
<span>
voláno z IP <span className="font-mono text-white/60">{check.egressIp}</span>
</span>
)}
</div>
{check.ok ? (
<p className="mt-2 flex items-start gap-1.5 text-sm text-ok-400">
<CheckCircle2 className="mt-0.5 size-3.5 shrink-0" />
<span>
{check.message}
{check.request && (
<span className="mt-0.5 block font-mono text-xs break-all text-white/40">
{check.request.method} {check.request.url}
{check.status !== null && ` -> HTTP ${check.status}`}
</span>
)}
</span>
</p>
) : (
<ErrorDetail
className="mt-2"
message={check.message}
detail={check.detail ?? undefined}
status={check.status ?? undefined}
request={check.request ?? undefined}
responseHeaders={check.responseHeaders}
defaultOpen
/>
)}
</div>
))}
</DataState>
</div>
</Modal>
);
}
@@ -0,0 +1,86 @@
import { Modal } from '@/components/ui/Modal';
import { formatDateTime } from '@/lib/format';
import type { Connector } from '@/types/dashboard';
/**
* Nastroje, ktere MCP server nabizi.
*
* Ukazuje se tu **to same**, z ceho se stavi kroky v automatizaci - jmena poli
* i vystupu jsou prevedena tou samou funkci na serveru. Kdyby to byly dva
* prevody, portal by u konektoru ukazoval jina pole, nez jakymi se nastroj
* opravdu vola.
*
* Nenacita se: seznam uz je soucasti konektoru, protoze se uklada pri stisku
* Nacist nastroje. Tim je videt i to, jak stary ten seznam je.
*/
export function ConnectorTools({
connector,
open,
onClose,
}: {
connector: Connector;
open: boolean;
onClose: () => void;
}) {
const toolset = connector.mcp;
return (
<Modal
open={open}
onClose={onClose}
title={`Nástroje: ${connector.name}`}
description="Co server nabízí. Každý nástroj je v automatizaci samostatný krok."
className="max-w-4xl"
>
{toolset && (
<p className="border-b border-ink-600/60 px-5 py-3 text-xs text-white/40">
{toolset.server}
<span className="text-white/25"> / protokol {toolset.protocolVersion}</span>
<span className="block">Načteno {formatDateTime(toolset.at)}</span>
</p>
)}
<div className="max-h-[70vh] space-y-3 overflow-y-auto p-5">
{!toolset && (
<p className="text-sm text-white/40">
Nástroje ještě nejsou načtené. Stiskněte Načíst nástroje.
</p>
)}
{toolset?.tools.length === 0 && (
<p className="text-sm text-white/40">Server odpověděl, ale žádný nástroj nenabízí.</p>
)}
{toolset?.tools.map((tool) => (
<div key={tool.name} className="rounded-lg border border-ink-600/60 p-3">
<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>
)}
</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>
<dd className="text-white/70">
{tool.inputs.length > 0 ? tool.inputs.join(', ') : 'nic'}
</dd>
</div>
<div>
<dt className="text-white/35">Vrací</dt>
<dd className="text-white/70">{tool.outputs.join(', ')}</dd>
</div>
</dl>
</div>
))}
</div>
{toolset && toolset.tools.length > 0 && (
<p className="border-t border-ink-600/60 px-5 py-3 text-xs text-white/35">
Hvězdička u parametru znamená povinný. Nepovinný parametr, který necháte prázdný, se
serveru vůbec nepošle.
</p>
)}
</Modal>
);
}
+136 -887
View File
File diff suppressed because it is too large Load Diff
+7 -25
View File
@@ -1,28 +1,10 @@
/**
* Typy udalosti dashboardu.
* Musi zustat v souladu s apps/api/src/events/bus.ts.
* Typy udalosti dashboardu. Tvar urcuje server, viz src/shared/events.ts.
*/
export type DashboardEventType =
| 'ticket.created'
| 'ticket.updated'
| 'ticket.assigned'
| 'ticket.resolved'
| 'incident.started'
| 'incident.updated'
| 'incident.resolved'
| 'automation.created'
| 'automation.updated'
| 'automation.deleted'
| 'automation.run'
| 'webhook.received'
/** Nekomu prislo upozorneni. Portal podle toho prekresli cislo u zalozky. */
| 'notification.created';
export interface DashboardEvent {
id: string;
type: DashboardEventType;
at: string;
message: string;
payload?: Record<string, unknown>;
}
export type {
DashboardEvent,
DashboardEventType,
EntityEventKind,
EntityEventType,
} from '@shared/events';