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:
co-authored by
Claude Fable 5.1
parent
0c405ea55a
commit
104ae36783
+10
-1
@@ -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'));
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 />;
|
||||
|
||||
@@ -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 -
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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' },
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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)';
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+7
-25
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user