Nahrazeni sablony kompletnim webem a klientskym portalem
Web a portal Automia v jednom containeru. Express obsluhuje API i zbuildovanou React aplikaci z dist/public. Obsah: - verejny web: homepage, sluzby, o nas, kontakt, 404 - prihlaseni pres JWT, demo ucty - portal: prehled s grafem, tickety, incidenty, automatizace, konektory - builder automatizaci: strom akci, vetveni podminkou - katalog 25 konektoru v 8 kategoriich - webhook s registrovanou adresou, token generuje server - zivy dashboard pres SSE vcetne simulace provozu - Swagger UI na /docs a OpenAPI na /openapi.json Soulad s AGENTS.md: - ROOT_PATH z prostredi, prefix proxy nikde nehardcodovan - mount na koren i na prefix, funguje s handle_path i bez nej - base tag a window.__BASE_PATH__ vkladane do index.html za behu - OpenAPI servers obsahuje prefix, Try it out vola spravnou adresu - povinne /health a /docs, port 3000, naslouchani na 0.0.0.0 - secrets jen z environment variables, nikdy v logu Dokumentace ve slozce documentation/.
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
<!doctype html>
|
||||
<html lang="cs" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Automia — automatizace, voiceboti a integrace na míru</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Automatizujeme procesy, stavíme voiceboty a propojujeme systémy. K tomu dashboardy, tickety a incident management pod jednou střechou."
|
||||
/>
|
||||
<meta name="theme-color" content="#070b16" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#22d3ee" />
|
||||
<stop offset="1" stop-color="#8b5cf6" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="16" fill="#070b16" />
|
||||
<path
|
||||
d="M20 44 32 18l12 26"
|
||||
fill="none"
|
||||
stroke="url(#g)"
|
||||
stroke-width="5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path d="M25 36h14" fill="none" stroke="url(#g)" stroke-width="5" stroke-linecap="round" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 596 B |
@@ -0,0 +1,71 @@
|
||||
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';
|
||||
|
||||
// Verejny web se nacita hned, dashboard az po prihlaseni (mensi initial bundle).
|
||||
import Home from '@/pages/Home';
|
||||
import About from '@/pages/About';
|
||||
import Contact from '@/pages/Contact';
|
||||
import Login from '@/pages/Login';
|
||||
import NotFound from '@/pages/NotFound';
|
||||
import Services from '@/pages/Services';
|
||||
|
||||
const Overview = lazy(() => import('@/pages/dashboard/Overview'));
|
||||
const Automations = lazy(() => import('@/pages/dashboard/Automations'));
|
||||
const AutomationDetail = lazy(() => import('@/pages/dashboard/AutomationDetail'));
|
||||
const Connectors = lazy(() => import('@/pages/dashboard/Connectors'));
|
||||
const Tickets = lazy(() => import('@/pages/dashboard/Tickets'));
|
||||
const Incidents = lazy(() => import('@/pages/dashboard/Incidents'));
|
||||
const Settings = lazy(() => import('@/pages/dashboard/Settings'));
|
||||
|
||||
/**
|
||||
* Routovani. Cesty jsou zamerne cesky (SEO + citelnost URL),
|
||||
* mapa vsech cest je v docs/03-frontend.md.
|
||||
*/
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
{/* Verejny web */}
|
||||
<Route element={<PublicLayout />}>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/sluzby" element={<Services />} />
|
||||
<Route path="/o-nas" element={<About />} />
|
||||
<Route path="/kontakt" element={<Contact />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Route>
|
||||
|
||||
{/* Prihlaseni - vlastni layout bez hlavicky a paticky */}
|
||||
<Route path="/prihlaseni" element={<Login />} />
|
||||
|
||||
{/* Klientsky portal */}
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="grid min-h-screen place-items-center bg-ink-950">
|
||||
<Spinner label="Načítám portál…" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DashboardLayout />
|
||||
</Suspense>
|
||||
}
|
||||
>
|
||||
<Route index element={<Overview />} />
|
||||
<Route path="automatizace" element={<Automations />} />
|
||||
<Route path="automatizace/:id" element={<AutomationDetail />} />
|
||||
<Route path="konektory" element={<Connectors />} />
|
||||
<Route path="tickety" element={<Tickets />} />
|
||||
<Route path="incidenty" element={<Incidents />} />
|
||||
<Route path="nastaveni" element={<Settings />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { apiFetch, getToken, setToken } from '@/lib/api';
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: 'admin' | 'client';
|
||||
company: string;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
user: AuthUser | null;
|
||||
/** true dokud probiha prvotni overeni ulozeneho tokenu */
|
||||
loading: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthState | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Pri startu zkusime obnovit session z ulozeneho tokenu.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function restore() {
|
||||
if (!getToken()) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await apiFetch<{ user: AuthUser }>('/api/auth/me');
|
||||
if (!cancelled) setUser(data.user);
|
||||
} catch (err) {
|
||||
// Expirovany/neplatny token - zahodime ho a pokracujeme jako neprihlaseny.
|
||||
console.warn('[auth] obnoveni session selhalo, mazu token:', err);
|
||||
setToken(null);
|
||||
if (!cancelled) setUser(null);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
void restore();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (email: string, password: string) => {
|
||||
const data = await apiFetch<{ token: string; user: AuthUser }>('/api/auth/login', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
body: { email, password },
|
||||
});
|
||||
setToken(data.token);
|
||||
setUser(data.user);
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await apiFetch<void>('/api/auth/logout', { method: 'POST' });
|
||||
} catch (err) {
|
||||
// Odhlaseni na klientovi musi projit i kdyz server nedostupny.
|
||||
console.warn('[auth] logout na serveru selhal, odhlasuji lokalne:', err);
|
||||
} finally {
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const value = useMemo<AuthState>(
|
||||
() => ({ user, loading, login, logout }),
|
||||
[user, loading, login, logout],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthState {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth musí být použit uvnitř <AuthProvider>.');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Navigate, Outlet, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
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 location = useLocation();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-ink-900">
|
||||
<Spinner label="Načítám session…" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/prihlaseni" state={{ from: location.pathname }} replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import {
|
||||
AlarmClock,
|
||||
ExternalLink,
|
||||
FlaskConical,
|
||||
LayoutDashboard,
|
||||
LifeBuoy,
|
||||
LogOut,
|
||||
Menu,
|
||||
Plug,
|
||||
Settings,
|
||||
Workflow,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
import { EventStreamProvider } from '@/components/dashboard/EventStreamProvider';
|
||||
import { EventToasts } from '@/components/dashboard/EventToasts';
|
||||
import { LiveIndicator } from '@/components/dashboard/LiveIndicator';
|
||||
import { SimulationModal } from '@/components/dashboard/SimulationModal';
|
||||
import { Logo } from '@/components/layout/Logo';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
const nav = [
|
||||
{ to: '/dashboard', label: 'Přehled', icon: LayoutDashboard, end: true },
|
||||
{ to: '/dashboard/automatizace', label: 'Automatizace', icon: Workflow, end: false },
|
||||
{ to: '/dashboard/konektory', label: 'Konektory', icon: Plug, end: false },
|
||||
{ to: '/dashboard/tickety', label: 'Tickety', icon: LifeBuoy, end: false },
|
||||
{ to: '/dashboard/incidenty', label: 'Incidenty', icon: AlarmClock, end: false },
|
||||
{ to: '/dashboard/nastaveni', label: 'Nastavení', icon: Settings, end: false },
|
||||
];
|
||||
|
||||
/**
|
||||
* Shell klientskeho portalu: fixni sidebar (desktop) / vysouvaci (mobil) + obsah.
|
||||
* Sem se budou pripojovat dalsi moduly dashboardu - viz docs/06-dashboard.md.
|
||||
*/
|
||||
export function DashboardLayout() {
|
||||
return (
|
||||
// Jedno spojeni na stream pro cely portal, proto az tady nahore.
|
||||
<EventStreamProvider>
|
||||
<DashboardShell />
|
||||
</EventStreamProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardShell() {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [simulationOpen, setSimulationOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setSidebarOpen(false);
|
||||
}, [location.pathname]);
|
||||
|
||||
async function handleLogout() {
|
||||
await logout();
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-ink-950">
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={cn(
|
||||
'fixed inset-y-0 left-0 z-50 flex w-64 flex-col border-r border-ink-600/60 bg-ink-900 transition-transform duration-300 lg:translate-x-0',
|
||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full',
|
||||
)}
|
||||
>
|
||||
<div className="flex h-18 items-center justify-between border-b border-ink-600/60 px-5">
|
||||
<Logo to="/dashboard" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className="grid size-9 place-items-center rounded-lg text-white/50 hover:bg-white/5 hover:text-white lg:hidden"
|
||||
aria-label="Zavřít menu"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-1 overflow-y-auto p-3">
|
||||
{nav.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-3 rounded-xl px-3.5 py-2.5 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-brand-500/12 text-brand-200'
|
||||
: 'text-white/55 hover:bg-white/5 hover:text-white',
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Nastroj pro nahled zivého dashboardu, proto az za beznou navigaci. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSimulationOpen(true)}
|
||||
className="mt-2 flex w-full items-center gap-3 rounded-xl border border-dashed border-ink-600/70 px-3.5 py-2.5 text-sm font-medium text-white/55 transition-colors hover:border-accent-400/60 hover:text-white"
|
||||
>
|
||||
<FlaskConical className="size-4 shrink-0" />
|
||||
Simulace
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-ink-600/60 p-3">
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center gap-3 rounded-xl px-3.5 py-2.5 text-sm text-white/45 transition-colors hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
<ExternalLink className="size-4 shrink-0" />
|
||||
Veřejný web
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center gap-3 rounded-xl px-3.5 py-2.5 text-sm text-white/45 transition-colors hover:bg-danger-500/10 hover:text-danger-400"
|
||||
>
|
||||
<LogOut className="size-4 shrink-0" />
|
||||
Odhlásit se
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Prekryv pri otevrenem mobilnim sidebaru */}
|
||||
{sidebarOpen && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Zavřít menu"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className="fixed inset-0 z-40 bg-ink-950/70 backdrop-blur-sm lg:hidden"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="lg:pl-64">
|
||||
{/* Topbar */}
|
||||
<header className="sticky top-0 z-30 flex h-18 items-center gap-4 border-b border-ink-600/60 bg-ink-900/85 px-5 backdrop-blur-xl sm:px-7">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="grid size-10 place-items-center rounded-lg text-white/60 hover:bg-white/5 hover:text-white lg:hidden"
|
||||
aria-label="Otevřít menu"
|
||||
>
|
||||
<Menu className="size-5" />
|
||||
</button>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-white">
|
||||
{user?.company ?? 'Klientský portál'}
|
||||
</p>
|
||||
<p className="truncate text-xs text-white/40">
|
||||
{user?.role === 'admin' ? 'Interní přístup' : 'Klientský přístup'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<LiveIndicator className="hidden sm:inline-flex" />
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="hidden text-right sm:block">
|
||||
<p className="text-sm font-medium text-white">{user?.name}</p>
|
||||
<p className="text-xs text-white/40">{user?.email}</p>
|
||||
</div>
|
||||
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-gradient-to-br from-brand-400/25 to-accent-500/25 font-mono text-sm font-bold text-brand-200">
|
||||
{initials(user?.name)}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="px-5 py-7 sm:px-7">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<EventToasts />
|
||||
<SimulationModal open={simulationOpen} onClose={() => setSimulationOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function initials(name: string | undefined): string {
|
||||
if (!name) return '?';
|
||||
return name
|
||||
.split(' ')
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase() ?? '')
|
||||
.join('');
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { AlertCircle, Inbox, RefreshCw } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Spinner } from '@/components/ui/Spinner';
|
||||
|
||||
/**
|
||||
* Jednotne stavy pro data v dashboardu: nacitani / chyba / prazdno.
|
||||
* Chyba se vzdy zobrazi uzivateli, nikdy se nespolkne.
|
||||
*/
|
||||
export function DataState({
|
||||
loading,
|
||||
error,
|
||||
empty,
|
||||
onRetry,
|
||||
children,
|
||||
emptyLabel = 'Žádná data k zobrazení.',
|
||||
}: {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
empty?: boolean;
|
||||
onRetry?: () => void;
|
||||
children: ReactNode;
|
||||
emptyLabel?: string;
|
||||
}) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="grid min-h-40 place-items-center">
|
||||
<Spinner label="Načítám data…" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex min-h-40 flex-col items-center justify-center gap-4 text-center">
|
||||
<p className="flex items-center gap-2 text-sm text-danger-400">
|
||||
<AlertCircle className="size-4 shrink-0" />
|
||||
{error}
|
||||
</p>
|
||||
{onRetry && (
|
||||
<Button variant="secondary" size="sm" onClick={onRetry}>
|
||||
<RefreshCw className="size-4" />
|
||||
Zkusit znovu
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (empty) {
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { connectEventStream, type StreamStatus } from '@/lib/eventStream';
|
||||
import type { DashboardEvent, DashboardEventType } from '@/types/events';
|
||||
|
||||
interface EventStreamState {
|
||||
status: StreamStatus;
|
||||
/** Poslednich par udalosti, nejnovejsi prvni. */
|
||||
events: DashboardEvent[];
|
||||
/** Prihlaseni k odberu. Vraci funkci pro odhlaseni. */
|
||||
subscribe: (listener: (event: DashboardEvent) => void) => () => void;
|
||||
}
|
||||
|
||||
const noop = () => () => {};
|
||||
|
||||
const EventStreamContext = createContext<EventStreamState>({
|
||||
status: 'closed',
|
||||
events: [],
|
||||
subscribe: () => {
|
||||
console.warn('[stream] subscribe mimo EventStreamProvider, udalosti nedorazi');
|
||||
return noop();
|
||||
},
|
||||
});
|
||||
|
||||
const MAX_KEPT = 30;
|
||||
|
||||
/**
|
||||
* Drzi jedno spojeni na SSE stream pro cely dashboard a rozesila udalosti
|
||||
* vsem, kdo o ne stoji. Jedno spojeni na aplikaci, ne jedno na komponentu.
|
||||
*/
|
||||
export function EventStreamProvider({ children }: { children: ReactNode }) {
|
||||
const [status, setStatus] = useState<StreamStatus>('connecting');
|
||||
const [events, setEvents] = useState<DashboardEvent[]>([]);
|
||||
const listeners = useRef(new Set<(event: DashboardEvent) => void>());
|
||||
|
||||
useEffect(() => {
|
||||
const close = connectEventStream('/api/dashboard/stream', {
|
||||
onStatus: setStatus,
|
||||
onEvent: (event) => {
|
||||
setEvents((previous) => [event, ...previous].slice(0, MAX_KEPT));
|
||||
for (const listener of listeners.current) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch (err) {
|
||||
// Chyba jednoho odberatele nesmi shodit rozesilani ostatnim.
|
||||
console.error('[stream] chyba v posluchaci udalosti:', err);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return close;
|
||||
}, []);
|
||||
|
||||
const subscribe = useCallback((listener: (event: DashboardEvent) => void) => {
|
||||
listeners.current.add(listener);
|
||||
return () => {
|
||||
listeners.current.delete(listener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const value = useMemo<EventStreamState>(
|
||||
() => ({ status, events, subscribe }),
|
||||
[status, events, subscribe],
|
||||
);
|
||||
|
||||
return <EventStreamContext.Provider value={value}>{children}</EventStreamContext.Provider>;
|
||||
}
|
||||
|
||||
export function useEventStream(): EventStreamState {
|
||||
return useContext(EventStreamContext);
|
||||
}
|
||||
|
||||
/** Zavola callback jen pri udalostech uvedenych typu. */
|
||||
export function useEventListener(
|
||||
types: DashboardEventType[],
|
||||
handler: (event: DashboardEvent) => void,
|
||||
) {
|
||||
const { subscribe } = useEventStream();
|
||||
const handlerRef = useRef(handler);
|
||||
handlerRef.current = handler;
|
||||
|
||||
// Klic z pole, aby se odber neobnovoval pri kazdem prekresleni.
|
||||
const key = types.join(',');
|
||||
|
||||
useEffect(() => {
|
||||
if (types.length === 0) return;
|
||||
const wanted = new Set(key.split(','));
|
||||
return subscribe((event) => {
|
||||
if (wanted.has(event.type)) handlerRef.current(event);
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [subscribe, key]);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { AlarmClock, CheckCircle2, LifeBuoy, Webhook, Workflow, X } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { useEventStream } from '@/components/dashboard/EventStreamProvider';
|
||||
import { cn } from '@/lib/cn';
|
||||
import type { DashboardEvent, DashboardEventType } from '@/types/events';
|
||||
|
||||
/** Jak dlouho bublina zustane, nez sama zmizi. */
|
||||
const LIFETIME_MS = 6_000;
|
||||
const MAX_VISIBLE = 4;
|
||||
|
||||
const style: 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.resolved': { icon: CheckCircle2, tone: 'text-ok-400 bg-ok-500/12' },
|
||||
'incident.started': { icon: AlarmClock, tone: 'text-danger-400 bg-danger-500/12' },
|
||||
'incident.updated': { icon: AlarmClock, tone: 'text-warn-400 bg-warn-500/12' },
|
||||
'incident.resolved': { icon: CheckCircle2, tone: 'text-ok-400 bg-ok-500/12' },
|
||||
'automation.created': { icon: Workflow, tone: 'text-brand-300 bg-brand-500/12' },
|
||||
'automation.updated': { icon: Workflow, tone: 'text-brand-300 bg-brand-500/12' },
|
||||
'automation.deleted': { icon: Workflow, tone: 'text-white/50 bg-white/5' },
|
||||
'automation.run': { icon: Workflow, tone: 'text-ok-400 bg-ok-500/12' },
|
||||
'webhook.received': { icon: Webhook, tone: 'text-accent-300 bg-accent-500/12' },
|
||||
};
|
||||
|
||||
/** Bubliny o tom, co se prave stalo. Bez nich by zive zmeny nebyly poznat. */
|
||||
export function EventToasts() {
|
||||
const { events } = useEventStream();
|
||||
const [visible, setVisible] = useState<DashboardEvent[]>([]);
|
||||
const [dismissed, setDismissed] = useState<Set<string>>(new Set());
|
||||
|
||||
// Nove udalosti pridame mezi viditelne a po chvili je odebereme.
|
||||
useEffect(() => {
|
||||
const newest = events[0];
|
||||
if (!newest || dismissed.has(newest.id)) return;
|
||||
|
||||
setVisible((previous) => {
|
||||
if (previous.some((e) => e.id === newest.id)) return previous;
|
||||
return [newest, ...previous].slice(0, MAX_VISIBLE);
|
||||
});
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
setVisible((previous) => previous.filter((e) => e.id !== newest.id));
|
||||
}, LIFETIME_MS);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [events, dismissed]);
|
||||
|
||||
function dismiss(id: string) {
|
||||
setDismissed((previous) => new Set(previous).add(id));
|
||||
setVisible((previous) => previous.filter((e) => e.id !== id));
|
||||
}
|
||||
|
||||
if (visible.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none fixed right-4 bottom-4 z-50 flex w-full max-w-sm flex-col gap-2"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{visible.map((event) => {
|
||||
const look = style[event.type] ?? { icon: Workflow, tone: 'text-white/60 bg-white/5' };
|
||||
const Icon = look.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={event.id}
|
||||
className="animate-rise glass pointer-events-auto flex items-start gap-3 rounded-xl p-3.5 shadow-xl"
|
||||
>
|
||||
<span className={cn('grid size-8 shrink-0 place-items-center rounded-lg', look.tone)}>
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
<p className="min-w-0 flex-1 text-sm text-white/80">{event.message}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dismiss(event.id)}
|
||||
className="grid size-6 shrink-0 place-items-center rounded text-white/35 transition-colors hover:bg-white/5 hover:text-white"
|
||||
aria-label="Zavřít oznámení"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useEventStream } from '@/components/dashboard/EventStreamProvider';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
const meta = {
|
||||
connecting: { label: 'Připojuji', dot: 'bg-warn-400', text: 'text-warn-400', pulse: true },
|
||||
open: { label: 'Živě', dot: 'bg-ok-400', text: 'text-ok-400', pulse: true },
|
||||
reconnecting: { label: 'Obnovuji spojení', dot: 'bg-warn-400', text: 'text-warn-400', pulse: true },
|
||||
closed: { label: 'Odpojeno', dot: 'bg-white/40', text: 'text-white/45', pulse: false },
|
||||
} as const;
|
||||
|
||||
/** Stav spojeni se streamem. Uzivatel musi poznat, ze data nejsou zive. */
|
||||
export function LiveIndicator({ className }: { className?: string }) {
|
||||
const { status } = useEventStream();
|
||||
const current = meta[status];
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full border border-ink-600/70 px-2.5 py-1 text-xs font-medium',
|
||||
current.text,
|
||||
className,
|
||||
)}
|
||||
title={
|
||||
status === 'open'
|
||||
? 'Dashboard dostává změny okamžitě, bez obnovování stránky.'
|
||||
: 'Změny se teď nemusí projevovat okamžitě.'
|
||||
}
|
||||
>
|
||||
<span className={cn('size-1.5 rounded-full', current.dot, current.pulse && 'animate-pulse')} />
|
||||
{current.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { formatDay, formatNumber } from '@/lib/format';
|
||||
import type { SeriesPoint } from '@/types/dashboard';
|
||||
|
||||
/**
|
||||
* Sloupcovy graf spusteni automatizaci za poslednich 14 dni.
|
||||
*
|
||||
* Zamerne JEDNA serie (pocet spusteni) a jedna osa - chybovost je radove
|
||||
* mensi cislo a druha osa by graf jen zkreslila. Chyby jsou v tooltipu
|
||||
* a jako zvyrazneni sloupce, kde jich bylo nejvic.
|
||||
* Pod grafem je tabulka pro cteni bez barev (screen readery, tisk).
|
||||
*/
|
||||
export function RunsChart({ series }: { series: SeriesPoint[] }) {
|
||||
if (series.length === 0) {
|
||||
console.warn('[chart] RunsChart dostal prazdnou serii');
|
||||
return <p className="text-sm text-white/45">Zatím nemáme dost dat pro graf.</p>;
|
||||
}
|
||||
|
||||
const max = Math.max(...series.map((point) => point.runs));
|
||||
const peak = series.reduce((best, point) => (point.runs > best.runs ? point : best), series[0]);
|
||||
const total = series.reduce((sum, point) => sum + point.runs, 0);
|
||||
const failures = series.reduce((sum, point) => sum + point.failures, 0);
|
||||
|
||||
return (
|
||||
<figure className="m-0">
|
||||
<figcaption className="mb-5 flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">Spuštění automatizací</h3>
|
||||
<p className="mt-0.5 text-xs text-white/45">
|
||||
posledních {series.length} dní · celkem {formatNumber(total)} spuštění,{' '}
|
||||
{formatNumber(failures)} chyb
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-right text-xs text-white/35">
|
||||
maximum {formatNumber(peak.runs)} · {formatDay(peak.date)}
|
||||
</p>
|
||||
</figcaption>
|
||||
|
||||
<div
|
||||
className="relative h-44"
|
||||
role="img"
|
||||
aria-label={`Sloupcový graf spuštění automatizací za posledních ${series.length} dní. Celkem ${formatNumber(total)} spuštění, maximum ${formatNumber(peak.runs)} dne ${formatDay(peak.date)}.`}
|
||||
>
|
||||
{/* Recesivni mrizka - jen tri linky, aby nepretahovaly pozornost */}
|
||||
<div aria-hidden className="absolute inset-0 flex flex-col justify-between">
|
||||
{[0, 1, 2].map((line) => (
|
||||
<span key={line} className="h-px w-full bg-ink-600/40" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="relative flex h-full items-end gap-[2px]">
|
||||
{series.map((point) => {
|
||||
const height = Math.max(4, Math.round((point.runs / max) * 100));
|
||||
const isPeak = point.date === peak.date;
|
||||
|
||||
return (
|
||||
<div key={point.date} className="group relative flex h-full flex-1 items-end">
|
||||
<div
|
||||
className={
|
||||
isPeak
|
||||
? 'w-full rounded-t bg-brand-400 transition-opacity group-hover:opacity-100'
|
||||
: 'w-full rounded-t bg-brand-500/55 transition-colors group-hover:bg-brand-400'
|
||||
}
|
||||
style={{ height: `${height}%` }}
|
||||
/>
|
||||
|
||||
{/* Tooltip - vetsi hit area diky rodici pres celou vysku */}
|
||||
<div className="pointer-events-none absolute bottom-full left-1/2 z-10 mb-2 hidden -translate-x-1/2 group-hover:block">
|
||||
<div className="rounded-lg border border-ink-600 bg-ink-950/95 px-3 py-2 whitespace-nowrap shadow-xl">
|
||||
<p className="text-xs font-semibold text-white">{formatDay(point.date)}</p>
|
||||
<p className="mt-0.5 text-xs text-white/60">
|
||||
{formatNumber(point.runs)} spuštění
|
||||
</p>
|
||||
<p className="text-xs text-white/60">{formatNumber(point.failures)} chyb</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex justify-between text-xs text-white/35">
|
||||
<span>{formatDay(series[0].date)}</span>
|
||||
<span>{formatDay(series[series.length - 1].date)}</span>
|
||||
</div>
|
||||
|
||||
{/* Alternativa k barevnemu cteni grafu */}
|
||||
<details className="mt-4 text-xs text-white/40">
|
||||
<summary className="cursor-pointer transition-colors hover:text-white/70">
|
||||
Zobrazit data v tabulce
|
||||
</summary>
|
||||
<table className="mt-3 w-full text-left">
|
||||
<thead className="text-white/35">
|
||||
<tr>
|
||||
<th className="py-1 font-medium">Den</th>
|
||||
<th className="py-1 text-right font-medium">Spuštění</th>
|
||||
<th className="py-1 text-right font-medium">Chyby</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="font-mono text-white/55">
|
||||
{series.map((point) => (
|
||||
<tr key={point.date} className="border-t border-ink-600/40">
|
||||
<td className="py-1">{formatDay(point.date)}</td>
|
||||
<td className="py-1 text-right tabular-nums">{formatNumber(point.runs)}</td>
|
||||
<td className="py-1 text-right tabular-nums">{formatNumber(point.failures)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import { AlarmClock, AlertCircle, CheckCircle2, LifeBuoy, Play, Workflow } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import type { FormEvent, ReactNode } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
type Action =
|
||||
| 'ticket.created'
|
||||
| 'ticket.resolved'
|
||||
| 'incident.started'
|
||||
| 'incident.resolved'
|
||||
| 'automation.run';
|
||||
|
||||
interface Result {
|
||||
ok: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const priorities = [
|
||||
{ value: 'low', label: 'Nízká' },
|
||||
{ value: 'normal', label: 'Běžná' },
|
||||
{ value: 'high', label: 'Vysoká' },
|
||||
{ value: 'critical', label: 'Kritická' },
|
||||
] as const;
|
||||
|
||||
const severities = [
|
||||
{ value: 'sev3', label: 'SEV3 - menší' },
|
||||
{ value: 'sev2', label: 'SEV2 - vážný' },
|
||||
{ value: 'sev1', label: 'SEV1 - kritický' },
|
||||
] as const;
|
||||
|
||||
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';
|
||||
|
||||
/**
|
||||
* Simulace provoznich udalosti. Slouzi k nahledu zivého dashboardu bez toho,
|
||||
* aby se muselo cekat na skutecny provoz.
|
||||
*
|
||||
* Nejde o falesne notifikace - kazda akce opravdu zmeni data na serveru,
|
||||
* takze se projevi i v seznamech a v souhrnu.
|
||||
*/
|
||||
export function SimulationModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const [running, setRunning] = useState<Action | null>(null);
|
||||
const [result, setResult] = useState<Result | null>(null);
|
||||
|
||||
const [subject, setSubject] = useState('');
|
||||
const [requester, setRequester] = useState('');
|
||||
const [priority, setPriority] = useState<(typeof priorities)[number]['value']>('normal');
|
||||
|
||||
const [title, setTitle] = useState('');
|
||||
const [service, setService] = useState('');
|
||||
const [severity, setSeverity] = useState<(typeof severities)[number]['value']>('sev2');
|
||||
|
||||
async function run(action: Action, body: Record<string, unknown> = {}) {
|
||||
setRunning(action);
|
||||
setResult(null);
|
||||
try {
|
||||
await apiFetch<unknown>('/api/simulate', {
|
||||
method: 'POST',
|
||||
body: { action, ...body },
|
||||
});
|
||||
setResult({ ok: true, message: 'Hotovo. Změna se objevila v dashboardu.' });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Simulaci se nepodařilo spustit.';
|
||||
console.error('[simulace] selhalo:', err);
|
||||
setResult({ ok: false, message });
|
||||
} finally {
|
||||
setRunning(null);
|
||||
}
|
||||
}
|
||||
|
||||
function submitTicket(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
void run('ticket.created', {
|
||||
// Prazdna pole neposilame, server pak doplni ukazkovou hodnotu.
|
||||
...(subject.trim() ? { subject: subject.trim() } : {}),
|
||||
...(requester.trim() ? { requester: requester.trim() } : {}),
|
||||
priority,
|
||||
});
|
||||
setSubject('');
|
||||
}
|
||||
|
||||
function submitIncident(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
void run('incident.started', {
|
||||
...(title.trim() ? { title: title.trim() } : {}),
|
||||
...(service.trim() ? { service: service.trim() } : {}),
|
||||
severity,
|
||||
});
|
||||
setTitle('');
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Simulace provozu"
|
||||
description="Vyvolá skutečnou událost, aby bylo vidět, jak dashboard reaguje živě."
|
||||
>
|
||||
<div className="max-h-[65vh] space-y-5 overflow-y-auto p-5">
|
||||
<Panel icon={LifeBuoy} title="Nový ticket">
|
||||
<form onSubmit={submitTicket} className="space-y-3">
|
||||
<input
|
||||
value={subject}
|
||||
onChange={(event) => setSubject(event.target.value)}
|
||||
placeholder="Předmět (nepovinné, jinak se doplní ukázkový)"
|
||||
className={inputClass}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<input
|
||||
value={requester}
|
||||
onChange={(event) => setRequester(event.target.value)}
|
||||
placeholder="Zadavatel"
|
||||
className={cn(inputClass, 'min-w-0 flex-1')}
|
||||
/>
|
||||
<select
|
||||
value={priority}
|
||||
onChange={(event) =>
|
||||
setPriority(event.target.value as (typeof priorities)[number]['value'])
|
||||
}
|
||||
aria-label="Priorita"
|
||||
className={cn(inputClass, 'w-40')}
|
||||
>
|
||||
{priorities.map((item) => (
|
||||
<option key={item.value} value={item.value} className="bg-ink-850">
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<Button type="submit" size="sm" disabled={running !== null}>
|
||||
<Play className="size-4" />
|
||||
{running === 'ticket.created' ? 'Zakládám...' : 'Založit ticket'}
|
||||
</Button>
|
||||
</form>
|
||||
</Panel>
|
||||
|
||||
<Panel icon={AlarmClock} title="Nový incident">
|
||||
<form onSubmit={submitIncident} className="space-y-3">
|
||||
<input
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
placeholder="Popis (nepovinné, jinak se doplní ukázkový)"
|
||||
className={inputClass}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<input
|
||||
value={service}
|
||||
onChange={(event) => setService(event.target.value)}
|
||||
placeholder="Služba"
|
||||
className={cn(inputClass, 'min-w-0 flex-1')}
|
||||
/>
|
||||
<select
|
||||
value={severity}
|
||||
onChange={(event) =>
|
||||
setSeverity(event.target.value as (typeof severities)[number]['value'])
|
||||
}
|
||||
aria-label="Závažnost"
|
||||
className={cn(inputClass, 'w-44')}
|
||||
>
|
||||
{severities.map((item) => (
|
||||
<option key={item.value} value={item.value} className="bg-ink-850">
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<Button type="submit" size="sm" disabled={running !== null}>
|
||||
<Play className="size-4" />
|
||||
{running === 'incident.started' ? 'Zakládám...' : 'Vyvolat incident'}
|
||||
</Button>
|
||||
</form>
|
||||
</Panel>
|
||||
|
||||
<Panel icon={Workflow} title="Rychlé akce">
|
||||
<p className="mb-3 text-sm text-white/50">
|
||||
Působí na první vhodný záznam, nemusíte nic vybírat.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<QuickButton
|
||||
onClick={() => void run('ticket.resolved')}
|
||||
disabled={running !== null}
|
||||
busy={running === 'ticket.resolved'}
|
||||
>
|
||||
Vyřešit ticket
|
||||
</QuickButton>
|
||||
<QuickButton
|
||||
onClick={() => void run('incident.resolved')}
|
||||
disabled={running !== null}
|
||||
busy={running === 'incident.resolved'}
|
||||
>
|
||||
Vyřešit incident
|
||||
</QuickButton>
|
||||
<QuickButton
|
||||
onClick={() => void run('automation.run', { ok: true })}
|
||||
disabled={running !== null}
|
||||
busy={running === 'automation.run'}
|
||||
>
|
||||
Spustit automatizaci
|
||||
</QuickButton>
|
||||
<QuickButton
|
||||
onClick={() => void run('automation.run', { ok: false })}
|
||||
disabled={running !== null}
|
||||
busy={running === 'automation.run'}
|
||||
>
|
||||
Neúspěšný běh
|
||||
</QuickButton>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{result && (
|
||||
<p
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-xl border px-4 py-3 text-sm',
|
||||
result.ok
|
||||
? 'border-ok-400/30 bg-ok-500/10 text-ok-400'
|
||||
: 'border-danger-400/30 bg-danger-500/10 text-danger-400',
|
||||
)}
|
||||
>
|
||||
{result.ok ? (
|
||||
<CheckCircle2 className="size-4 shrink-0" />
|
||||
) : (
|
||||
<AlertCircle className="size-4 shrink-0" />
|
||||
)}
|
||||
{result.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="rounded-xl border border-ink-600/60 px-4 py-3 text-xs leading-relaxed text-white/40">
|
||||
Simulace zapisuje do stejných dat jako běžný provoz. Data jsou v paměti API,
|
||||
takže se restartem serveru vrátí do výchozího stavu.
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function Panel({
|
||||
icon: Icon,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
icon: typeof LifeBuoy;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-xl border border-ink-600/60 bg-ink-850/40 p-4">
|
||||
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-white">
|
||||
<Icon className="size-4 text-brand-300" />
|
||||
{title}
|
||||
</h3>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickButton({
|
||||
onClick,
|
||||
disabled,
|
||||
busy,
|
||||
children,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
disabled: boolean;
|
||||
busy: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className="rounded-lg border border-ink-600/70 px-3.5 py-2 text-sm text-white/70 transition-colors hover:border-brand-400/60 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{busy ? 'Spouštím...' : children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
/**
|
||||
* Dlazdice s jednim cislem. Hodnota je hero prvek, popisek pod ni,
|
||||
* ikona jen jako orientacni znacka - text vzdy nese informaci.
|
||||
*/
|
||||
export function StatTile({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
tone = 'brand',
|
||||
className,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: string;
|
||||
hint?: string;
|
||||
tone?: 'brand' | 'ok' | 'warn' | 'danger';
|
||||
className?: string;
|
||||
}) {
|
||||
const tones = {
|
||||
brand: 'bg-brand-500/12 text-brand-300',
|
||||
ok: 'bg-ok-500/12 text-ok-400',
|
||||
warn: 'bg-warn-500/12 text-warn-400',
|
||||
danger: 'bg-danger-500/12 text-danger-400',
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<div className={cn('glass rounded-card p-5', className)}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className="text-sm text-white/50">{label}</p>
|
||||
<span className={cn('grid size-8 shrink-0 place-items-center rounded-lg', tones[tone])}>
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-3 text-3xl font-extrabold text-white tabular-nums">{value}</p>
|
||||
{hint && <p className="mt-1 text-xs text-white/40">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { AlertTriangle, CheckCircle2, CircleDot, Clock, Search, ShieldAlert } from 'lucide-react';
|
||||
import { Badge, type BadgeTone } from '@/components/ui/Badge';
|
||||
import type {
|
||||
IncidentSeverity,
|
||||
IncidentStatus,
|
||||
TicketPriority,
|
||||
TicketStatus,
|
||||
} from '@/types/dashboard';
|
||||
|
||||
/**
|
||||
* Stavove odznaky. Barva NIKDY nenese informaci sama - vzdy je s ni
|
||||
* text i ikona (pristupnost, viz docs/05-design-system.md).
|
||||
*/
|
||||
|
||||
const ticketStatusMap: Record<TicketStatus, { label: string; tone: BadgeTone }> = {
|
||||
new: { label: 'Nový', tone: 'brand' },
|
||||
open: { label: 'V řešení', tone: 'warn' },
|
||||
waiting: { label: 'Čeká na klienta', tone: 'neutral' },
|
||||
resolved: { label: 'Vyřešeno', tone: 'ok' },
|
||||
};
|
||||
|
||||
const ticketPriorityMap: Record<TicketPriority, { label: string; tone: BadgeTone }> = {
|
||||
low: { label: 'Nízká', tone: 'neutral' },
|
||||
normal: { label: 'Běžná', tone: 'neutral' },
|
||||
high: { label: 'Vysoká', tone: 'warn' },
|
||||
critical: { label: 'Kritická', tone: 'danger' },
|
||||
};
|
||||
|
||||
const incidentSeverityMap: Record<IncidentSeverity, { label: string; tone: BadgeTone }> = {
|
||||
sev1: { label: 'SEV1 — kritický', tone: 'danger' },
|
||||
sev2: { label: 'SEV2 — vážný', tone: 'warn' },
|
||||
sev3: { label: 'SEV3 — menší', tone: 'neutral' },
|
||||
};
|
||||
|
||||
const incidentStatusMap: Record<IncidentStatus, { label: string; tone: BadgeTone }> = {
|
||||
investigating: { label: 'Zkoumáme', tone: 'warn' },
|
||||
identified: { label: 'Příčina známa', tone: 'warn' },
|
||||
monitoring: { label: 'Sledujeme', tone: 'brand' },
|
||||
resolved: { label: 'Vyřešeno', tone: 'ok' },
|
||||
};
|
||||
|
||||
export function TicketStatusBadge({ status }: { status: TicketStatus }) {
|
||||
const { label, tone } = ticketStatusMap[status];
|
||||
const Icon = status === 'resolved' ? CheckCircle2 : status === 'waiting' ? Clock : CircleDot;
|
||||
return (
|
||||
<Badge tone={tone}>
|
||||
<Icon className="size-3.5" />
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function TicketPriorityBadge({ priority }: { priority: TicketPriority }) {
|
||||
const { label, tone } = ticketPriorityMap[priority];
|
||||
return (
|
||||
<Badge tone={tone}>
|
||||
{(priority === 'critical' || priority === 'high') && <AlertTriangle className="size-3.5" />}
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function IncidentSeverityBadge({ severity }: { severity: IncidentSeverity }) {
|
||||
const { label, tone } = incidentSeverityMap[severity];
|
||||
return (
|
||||
<Badge tone={tone}>
|
||||
<ShieldAlert className="size-3.5" />
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function IncidentStatusBadge({ status }: { status: IncidentStatus }) {
|
||||
const { label, tone } = incidentStatusMap[status];
|
||||
const Icon = status === 'resolved' ? CheckCircle2 : status === 'investigating' ? Search : CircleDot;
|
||||
return (
|
||||
<Badge tone={tone}>
|
||||
<Icon className="size-3.5" />
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
import { ChevronDown, ChevronUp, GitBranch, Plus, Trash2, Zap } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { TriggerConfig } from '@/components/dashboard/flow/TriggerConfig';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { connectorIcon } from '@/lib/connectorIcons';
|
||||
import {
|
||||
defaultOperatorFor,
|
||||
describeCondition,
|
||||
isUnaryOperator,
|
||||
operatorLabel,
|
||||
operatorsByType,
|
||||
resolveOperation,
|
||||
type FlowPath,
|
||||
} from '@/lib/flow';
|
||||
import type {
|
||||
AutomationFlow,
|
||||
ConditionOperator,
|
||||
Connector,
|
||||
FlowStep,
|
||||
TriggerField,
|
||||
} from '@/types/dashboard';
|
||||
|
||||
/**
|
||||
* Vizualizace stromu akci. Sama nic nemeni - vsechny zmeny hlasi nahoru
|
||||
* pres callbacky, stav drzi stranka (AutomationDetail).
|
||||
*
|
||||
* Struktura: karta spoustece -> sekvence kroku. Podminka rozdeluje beh
|
||||
* na vetev ANO a NE, kazda ma vlastni sekvenci a vlastni "+".
|
||||
*/
|
||||
interface CanvasCallbacks {
|
||||
onAddStep: (path: FlowPath, index: number) => void;
|
||||
onRemoveStep: (stepId: string) => void;
|
||||
onUpdateCondition: (
|
||||
stepId: string,
|
||||
patch: { fieldId?: string; operator?: ConditionOperator; value?: string },
|
||||
) => void;
|
||||
onMoveStep: (stepId: string, offset: number) => void;
|
||||
}
|
||||
|
||||
export function FlowCanvas({
|
||||
flow,
|
||||
connectors,
|
||||
webhookBaseUrl,
|
||||
regenerating,
|
||||
onPickTrigger,
|
||||
onChangeFields,
|
||||
onRegenerateToken,
|
||||
...callbacks
|
||||
}: CanvasCallbacks & {
|
||||
flow: AutomationFlow;
|
||||
connectors: Connector[];
|
||||
webhookBaseUrl: string;
|
||||
regenerating: boolean;
|
||||
onPickTrigger: () => void;
|
||||
onChangeFields: (fields: TriggerField[]) => void;
|
||||
onRegenerateToken: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-stretch">
|
||||
<TriggerCard
|
||||
flow={flow}
|
||||
connectors={connectors}
|
||||
webhookBaseUrl={webhookBaseUrl}
|
||||
regenerating={regenerating}
|
||||
onPick={onPickTrigger}
|
||||
onChangeFields={onChangeFields}
|
||||
onRegenerateToken={onRegenerateToken}
|
||||
/>
|
||||
|
||||
{flow.trigger && (
|
||||
<StepSequence
|
||||
steps={flow.steps}
|
||||
path={[]}
|
||||
connectors={connectors}
|
||||
fields={flow.trigger.fields}
|
||||
{...callbacks}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Prvni karta stromu - dokud neni vybrany spoustec, je to vyzva ke kliknuti. */
|
||||
function TriggerCard({
|
||||
flow,
|
||||
connectors,
|
||||
webhookBaseUrl,
|
||||
regenerating,
|
||||
onPick,
|
||||
onChangeFields,
|
||||
onRegenerateToken,
|
||||
}: {
|
||||
flow: AutomationFlow;
|
||||
connectors: Connector[];
|
||||
webhookBaseUrl: string;
|
||||
regenerating: boolean;
|
||||
onPick: () => void;
|
||||
onChangeFields: (fields: TriggerField[]) => void;
|
||||
onRegenerateToken: () => void;
|
||||
}) {
|
||||
if (!flow.trigger) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPick}
|
||||
className="group flex items-center gap-4 rounded-card border border-dashed border-brand-400/40 bg-brand-500/5 p-5 text-left transition-colors hover:border-brand-400/80 hover:bg-brand-500/10"
|
||||
>
|
||||
<span className="grid size-11 shrink-0 place-items-center rounded-xl bg-brand-500/15 text-brand-300 transition-transform group-hover:scale-105">
|
||||
<Zap className="size-5" />
|
||||
</span>
|
||||
<span>
|
||||
<span className="block font-semibold text-white">Vyberte spouštěč</span>
|
||||
<span className="mt-0.5 block text-sm text-white/55">
|
||||
Webhook, plánovač, formulář, nebo událost v napojené službě.
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const resolved = resolveOperation(
|
||||
connectors,
|
||||
flow.trigger.connectorId,
|
||||
flow.trigger.operationId,
|
||||
'trigger',
|
||||
);
|
||||
|
||||
if (!resolved) {
|
||||
return (
|
||||
<BrokenCard
|
||||
label="Spouštěč odkazuje na neznámý konektor"
|
||||
detail={`${flow.trigger.connectorId} / ${flow.trigger.operationId}`}
|
||||
onFix={onPick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = connectorIcon(resolved.connector.icon);
|
||||
|
||||
return (
|
||||
<div className="rounded-card border border-brand-400/35 bg-gradient-to-br from-brand-500/12 to-transparent p-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<span className="grid size-11 shrink-0 place-items-center rounded-xl bg-brand-500/20 text-brand-200">
|
||||
<Icon className="size-5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge tone="brand">
|
||||
<Zap className="size-3" />
|
||||
Spouštěč
|
||||
</Badge>
|
||||
<span className="text-xs text-white/40">{resolved.connector.name}</span>
|
||||
</div>
|
||||
<p className="mt-2 font-semibold text-white">{resolved.operation.name}</p>
|
||||
<p className="mt-0.5 text-sm text-white/55">{resolved.operation.description}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPick}
|
||||
className="shrink-0 rounded-lg px-3 py-1.5 text-xs font-medium text-white/50 transition-colors hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
Změnit
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<TriggerConfig
|
||||
trigger={flow.trigger}
|
||||
connector={resolved.connector}
|
||||
webhookBaseUrl={webhookBaseUrl}
|
||||
onChangeFields={onChangeFields}
|
||||
onRegenerateToken={onRegenerateToken}
|
||||
regenerating={regenerating}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Sekvence kroku s "+" na konci i mezi kroky. */
|
||||
function StepSequence({
|
||||
steps,
|
||||
path,
|
||||
connectors,
|
||||
fields,
|
||||
compact = false,
|
||||
...callbacks
|
||||
}: CanvasCallbacks & {
|
||||
steps: FlowStep[];
|
||||
path: FlowPath;
|
||||
connectors: Connector[];
|
||||
fields: TriggerField[];
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { onAddStep, onRemoveStep, onMoveStep } = callbacks;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-stretch">
|
||||
{steps.map((step, index) => (
|
||||
<div key={step.id} className="flex flex-col items-stretch">
|
||||
<AddStepButton onClick={() => onAddStep(path, index)} compact={compact} />
|
||||
|
||||
{step.kind === 'action' ? (
|
||||
<ActionCard
|
||||
step={step}
|
||||
connectors={connectors}
|
||||
canMoveUp={index > 0}
|
||||
canMoveDown={index < steps.length - 1}
|
||||
onRemove={() => onRemoveStep(step.id)}
|
||||
onMove={(offset) => onMoveStep(step.id, offset)}
|
||||
/>
|
||||
) : (
|
||||
<ConditionCard
|
||||
step={step}
|
||||
path={path}
|
||||
connectors={connectors}
|
||||
fields={fields}
|
||||
canMoveUp={index > 0}
|
||||
canMoveDown={index < steps.length - 1}
|
||||
{...callbacks}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<AddStepButton onClick={() => onAddStep(path, steps.length)} compact={compact} last />
|
||||
</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,
|
||||
canMoveUp,
|
||||
canMoveDown,
|
||||
onRemove,
|
||||
onMove,
|
||||
}: {
|
||||
step: Extract<FlowStep, { kind: 'action' }>;
|
||||
connectors: Connector[];
|
||||
canMoveUp: boolean;
|
||||
canMoveDown: boolean;
|
||||
onRemove: () => void;
|
||||
onMove: (offset: number) => void;
|
||||
}) {
|
||||
const resolved = resolveOperation(connectors, step.connectorId, step.operationId, 'action');
|
||||
|
||||
if (!resolved) {
|
||||
return (
|
||||
<BrokenCard
|
||||
label="Krok odkazuje na neznámou akci"
|
||||
detail={`${step.connectorId} / ${step.operationId}`}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = connectorIcon(resolved.connector.icon);
|
||||
|
||||
return (
|
||||
<StepShell>
|
||||
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-ink-700/70 text-brand-300">
|
||||
<Icon className="size-4.5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs text-white/40">{resolved.connector.name}</p>
|
||||
<p className="mt-0.5 font-semibold text-white">{resolved.operation.name}</p>
|
||||
<p className="mt-0.5 text-sm text-white/50">{resolved.operation.description}</p>
|
||||
</div>
|
||||
<StepControls
|
||||
canMoveUp={canMoveUp}
|
||||
canMoveDown={canMoveDown}
|
||||
onMove={onMove}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
</StepShell>
|
||||
);
|
||||
}
|
||||
|
||||
function ConditionCard({
|
||||
step,
|
||||
path,
|
||||
connectors,
|
||||
fields,
|
||||
canMoveUp,
|
||||
canMoveDown,
|
||||
...callbacks
|
||||
}: CanvasCallbacks & {
|
||||
step: Extract<FlowStep, { kind: 'condition' }>;
|
||||
path: FlowPath;
|
||||
connectors: Connector[];
|
||||
fields: TriggerField[];
|
||||
canMoveUp: boolean;
|
||||
canMoveDown: boolean;
|
||||
}) {
|
||||
const { onRemoveStep, onMoveStep, onUpdateCondition } = callbacks;
|
||||
|
||||
const field = fields.find((f) => f.id === step.fieldId);
|
||||
const allowedOperators = field ? operatorsByType[field.type] : [];
|
||||
const needsValue = !isUnaryOperator(step.operator);
|
||||
const valueMissing = needsValue && (step.value ?? '').trim().length === 0;
|
||||
|
||||
/** Zmena parametru muze zneplatnit operator - v tom pripade ho prepneme. */
|
||||
function changeField(fieldId: string) {
|
||||
const next = fields.find((f) => f.id === fieldId);
|
||||
if (!next) {
|
||||
console.warn(`[flow] podminka: neznamy parametr ${fieldId}`);
|
||||
return;
|
||||
}
|
||||
const operatorStillValid = operatorsByType[next.type].includes(step.operator);
|
||||
onUpdateCondition(step.id, {
|
||||
fieldId,
|
||||
operator: operatorStillValid ? step.operator : defaultOperatorFor(next.type),
|
||||
value: operatorStillValid ? step.value : '',
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-card border border-accent-400/30 bg-accent-500/6 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-accent-500/20 text-accent-300">
|
||||
<GitBranch className="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>
|
||||
{valueMissing && <Badge tone="warn">Doplňte hodnotu</Badge>}
|
||||
</div>
|
||||
|
||||
{!field ? (
|
||||
<p className="mt-1 text-sm text-danger-400">
|
||||
Parametr, na který se podmínka odkazovala, už neexistuje. Vyberte jiný, nebo
|
||||
podmínku odeberte.
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-0.5 font-semibold text-white">
|
||||
{describeCondition(field, step.operator, step.value)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Vlastni editor: parametr - operator - hodnota */}
|
||||
<div className="mt-2.5 flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
value={field ? step.fieldId : ''}
|
||||
onChange={(event) => changeField(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={step.operator}
|
||||
onChange={(event) =>
|
||||
onUpdateCondition(step.id, {
|
||||
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"
|
||||
>
|
||||
{allowedOperators.map((operator) => (
|
||||
<option key={operator} value={operator} className="bg-ink-850">
|
||||
{field ? operatorLabel(operator, field.type) : operator}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{needsValue && field && (
|
||||
<input
|
||||
value={step.value ?? ''}
|
||||
onChange={(event) => onUpdateCondition(step.id, { 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',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StepControls
|
||||
canMoveUp={canMoveUp}
|
||||
canMoveDown={canMoveDown}
|
||||
onMove={(offset) => onMoveStep(step.id, offset)}
|
||||
onRemove={() => onRemoveStep(step.id)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-2">
|
||||
{(['yes', 'no'] as const).map((branch) => (
|
||||
<div
|
||||
key={branch}
|
||||
className={cn(
|
||||
'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}
|
||||
fields={fields}
|
||||
compact
|
||||
{...callbacks}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-card border border-ink-600/60 bg-ink-800/60 p-4 transition-colors hover:border-ink-600">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepControls({
|
||||
canMoveUp,
|
||||
canMoveDown,
|
||||
onMove,
|
||||
onRemove,
|
||||
}: {
|
||||
canMoveUp: boolean;
|
||||
canMoveDown: boolean;
|
||||
onMove: (offset: number) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMove(-1)}
|
||||
disabled={!canMoveUp}
|
||||
title="Posunout výš"
|
||||
className="grid size-8 place-items-center rounded-lg text-white/35 transition-colors hover:bg-white/5 hover:text-white disabled:pointer-events-none disabled:opacity-25"
|
||||
>
|
||||
<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-8 place-items-center rounded-lg text-white/35 transition-colors hover:bg-white/5 hover:text-white disabled:pointer-events-none disabled:opacity-25"
|
||||
>
|
||||
<ChevronDown className="size-4" />
|
||||
<span className="sr-only">Posunout níž</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
title="Odebrat krok"
|
||||
className="grid size-8 place-items-center rounded-lg text-white/35 transition-colors hover:bg-danger-500/12 hover:text-danger-400"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
<span className="sr-only">Odebrat 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,370 @@
|
||||
import { ArrowLeft, ChevronRight, GitBranch, Search } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { connectorIcon } from '@/lib/connectorIcons';
|
||||
import type { Connector, ConnectorCategory, ConnectorOperation } from '@/types/dashboard';
|
||||
|
||||
/**
|
||||
* Vyber toho, co se ma pridat do stromu.
|
||||
*
|
||||
* mode = 'trigger' -> nabizi jen udalosti, kterymi muze automatizace zacit
|
||||
* mode = 'action' -> nabizi akce konektoru + moznost rozvetvit podminkou
|
||||
*
|
||||
* Prvni krok = vyber konektoru, druhy = vyber operace. Kdyz uzivatel zacne
|
||||
* hledat, preskoci se rovnou na plochy seznam operaci napric konektory.
|
||||
*/
|
||||
export function StepPicker({
|
||||
open,
|
||||
mode,
|
||||
connectors,
|
||||
categories,
|
||||
onClose,
|
||||
onPickOperation,
|
||||
onPickCondition,
|
||||
canAddCondition,
|
||||
}: {
|
||||
open: boolean;
|
||||
mode: 'trigger' | 'action';
|
||||
connectors: Connector[];
|
||||
categories: Array<{ id: ConnectorCategory; label: string }>;
|
||||
onClose: () => void;
|
||||
onPickOperation: (connectorId: string, operationId: string) => void;
|
||||
onPickCondition?: () => void;
|
||||
/** false = spoustec nema zadne parametry, nebylo by podle ceho se rozhodovat */
|
||||
canAddCondition?: boolean;
|
||||
}) {
|
||||
const [selected, setSelected] = useState<Connector | null>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [category, setCategory] = useState<ConnectorCategory | 'all'>('all');
|
||||
|
||||
const operationsOf = (connector: Connector) =>
|
||||
mode === 'trigger' ? connector.triggers : connector.actions;
|
||||
|
||||
// Konektory, ktere v tomhle rezimu vubec maji co nabidnout.
|
||||
const usable = useMemo(
|
||||
() => connectors.filter((connector) => operationsOf(connector).length > 0),
|
||||
[connectors, mode],
|
||||
);
|
||||
|
||||
const visibleCategories = useMemo(
|
||||
() => categories.filter((cat) => usable.some((connector) => connector.category === cat.id)),
|
||||
[categories, usable],
|
||||
);
|
||||
|
||||
const filtered = useMemo(
|
||||
() => (category === 'all' ? usable : usable.filter((c) => c.category === category)),
|
||||
[usable, category],
|
||||
);
|
||||
|
||||
const trimmedQuery = query.trim().toLowerCase();
|
||||
const searching = trimmedQuery.length >= 2;
|
||||
|
||||
// Ploche vysledky hledani - hleda v nazvu konektoru i v nazvech operaci.
|
||||
const searchResults = useMemo(() => {
|
||||
if (!searching) return [];
|
||||
const results: Array<{ connector: Connector; operation: ConnectorOperation }> = [];
|
||||
for (const connector of usable) {
|
||||
for (const operation of operationsOf(connector)) {
|
||||
const haystack = `${connector.name} ${operation.name} ${operation.description}`.toLowerCase();
|
||||
if (haystack.includes(trimmedQuery)) results.push({ connector, operation });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}, [usable, trimmedQuery, searching, mode]);
|
||||
|
||||
function close() {
|
||||
setSelected(null);
|
||||
setQuery('');
|
||||
setCategory('all');
|
||||
onClose();
|
||||
}
|
||||
|
||||
function pick(connector: Connector, operation: ConnectorOperation) {
|
||||
if (connector.status === 'planned') {
|
||||
console.warn(`[picker] konektor ${connector.id} je na roadmape, nelze pouzit`);
|
||||
return;
|
||||
}
|
||||
onPickOperation(connector.id, operation.id);
|
||||
setSelected(null);
|
||||
setQuery('');
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={close}
|
||||
title={mode === 'trigger' ? 'Čím má automatizace začít?' : 'Co se má stát?'}
|
||||
description={
|
||||
mode === 'trigger'
|
||||
? 'Vyberte spouštěč — webhook, čas, nebo událost ve službě, kterou máte napojenou.'
|
||||
: 'Vyberte službu nebo konektor a pak konkrétní akci.'
|
||||
}
|
||||
>
|
||||
<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
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!searching && !selected && visibleCategories.length > 1 && (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<CategoryChip active={category === 'all'} onClick={() => setCategory('all')}>
|
||||
Vše
|
||||
</CategoryChip>
|
||||
{visibleCategories.map((cat) => (
|
||||
<CategoryChip
|
||||
key={cat.id}
|
||||
active={category === cat.id}
|
||||
onClick={() => setCategory(cat.id)}
|
||||
>
|
||||
{cat.label}
|
||||
</CategoryChip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="max-h-[55vh] overflow-y-auto p-5">
|
||||
{/* --- vysledky hledani napric konektory --- */}
|
||||
{searching && (
|
||||
<>
|
||||
{searchResults.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-white/45">
|
||||
Nic jsme nenašli. Zkuste jiné slovo, nebo použijte konektor „HTTP požadavek".
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{searchResults.map(({ connector, operation }) => (
|
||||
<li key={`${connector.id}.${operation.id}`}>
|
||||
<OperationRow
|
||||
connector={connector}
|
||||
operation={operation}
|
||||
showConnectorName
|
||||
onClick={() => pick(connector, operation)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* --- druhy krok: operace vybraneho konektoru --- */}
|
||||
{!searching && selected && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelected(null)}
|
||||
className="mb-4 inline-flex items-center gap-2 text-sm text-white/50 transition-colors hover:text-white"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Zpět na výběr služby
|
||||
</button>
|
||||
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<ConnectorGlyph connector={selected} />
|
||||
<div>
|
||||
<p className="font-semibold text-white">{selected.name}</p>
|
||||
<p className="text-xs text-white/45">{selected.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2">
|
||||
{operationsOf(selected).map((operation) => (
|
||||
<li key={operation.id}>
|
||||
<OperationRow
|
||||
connector={selected}
|
||||
operation={operation}
|
||||
onClick={() => pick(selected, operation)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* --- prvni krok: vyber konektoru --- */}
|
||||
{!searching && !selected && (
|
||||
<>
|
||||
{mode === 'action' && onPickCondition && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onPickCondition();
|
||||
close();
|
||||
}}
|
||||
disabled={canAddCondition === false}
|
||||
className={cn(
|
||||
'mb-5 flex w-full items-start gap-3 rounded-xl border border-accent-400/30 bg-accent-500/8 p-4 text-left transition-colors',
|
||||
canAddCondition === false
|
||||
? 'cursor-not-allowed opacity-50'
|
||||
: 'hover:border-accent-400/60 hover:bg-accent-500/14',
|
||||
)}
|
||||
>
|
||||
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-accent-500/20 text-accent-300">
|
||||
<GitBranch className="size-5" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block font-semibold text-white">Rozvětvit podmínkou</span>
|
||||
<span className="mt-0.5 block text-sm text-white/55">
|
||||
{canAddCondition === false
|
||||
? 'Nejdřív u spouštěče nadeklarujte vstupní parametry — bez nich není podle čeho se rozhodovat.'
|
||||
: 'Porovná hodnotu vstupního parametru (např. score ≥ 15) a rozdělí běh na větev ANO a NE.'}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRight className="mt-2.5 size-4 shrink-0 text-white/30" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{filtered.map((connector) => (
|
||||
<ConnectorCard
|
||||
key={connector.id}
|
||||
connector={connector}
|
||||
operationCount={operationsOf(connector).length}
|
||||
onClick={() => setSelected(connector)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
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: Connector }) {
|
||||
const Icon = connectorIcon(connector.icon);
|
||||
return (
|
||||
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-gradient-to-br from-brand-500/20 to-accent-500/20 text-brand-300">
|
||||
<Icon className="size-5" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectorCard({
|
||||
connector,
|
||||
operationCount,
|
||||
onClick,
|
||||
}: {
|
||||
connector: Connector;
|
||||
operationCount: number;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const planned = connector.status === 'planned';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={planned}
|
||||
className={cn(
|
||||
'flex w-full items-start gap-3 rounded-xl border border-ink-600/60 bg-ink-850/50 p-4 text-left transition-colors',
|
||||
planned
|
||||
? 'cursor-not-allowed opacity-50'
|
||||
: 'hover:border-brand-400/50 hover:bg-ink-800/70',
|
||||
)}
|
||||
>
|
||||
<ConnectorGlyph connector={connector} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="truncate font-semibold text-white">{connector.name}</span>
|
||||
{connector.status === 'available' && <Badge tone="neutral">Umíme napojit</Badge>}
|
||||
{planned && <Badge tone="warn">Na roadmapě</Badge>}
|
||||
</span>
|
||||
<span className="mt-1 block line-clamp-2 text-xs text-white/50">
|
||||
{connector.description}
|
||||
</span>
|
||||
<span className="mt-1.5 block text-xs text-white/30">
|
||||
{operationCount} {operationCount === 1 ? 'možnost' : 'možnosti'}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRight className="mt-2.5 size-4 shrink-0 text-white/25" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function OperationRow({
|
||||
connector,
|
||||
operation,
|
||||
showConnectorName = false,
|
||||
onClick,
|
||||
}: {
|
||||
connector: Connector;
|
||||
operation: ConnectorOperation;
|
||||
showConnectorName?: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const planned = connector.status === 'planned';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={planned}
|
||||
className={cn(
|
||||
'flex w-full items-start gap-3 rounded-xl border border-ink-600/50 bg-ink-850/40 p-4 text-left transition-colors',
|
||||
planned ? 'cursor-not-allowed opacity-50' : 'hover:border-brand-400/50 hover:bg-ink-800/70',
|
||||
)}
|
||||
>
|
||||
{showConnectorName && <ConnectorGlyph connector={connector} />}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold text-white">{operation.name}</span>
|
||||
{showConnectorName && (
|
||||
<span className="text-xs text-white/40">· {connector.name}</span>
|
||||
)}
|
||||
{planned && <Badge tone="warn">Na roadmapě</Badge>}
|
||||
</span>
|
||||
<span className="mt-1 block text-sm text-white/55">{operation.description}</span>
|
||||
{operation.fields && operation.fields.length > 0 && (
|
||||
<span className="mt-2 flex flex-wrap gap-1.5">
|
||||
{operation.fields.map((field) => (
|
||||
<span
|
||||
key={field}
|
||||
className="rounded-md border border-ink-600/60 px-2 py-0.5 font-mono text-[0.7rem] text-white/40"
|
||||
>
|
||||
{field}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronRight className="mt-1 size-4 shrink-0 text-white/25" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import { AlertTriangle, Check, Copy, KeyRound, Plus, RefreshCw, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { fieldTypeLabels, newFieldId } from '@/lib/flow';
|
||||
import type { Connector, FieldType, FlowTrigger, TriggerField } from '@/types/dashboard';
|
||||
|
||||
const fieldTypes: FieldType[] = ['string', 'number', 'boolean', 'date'];
|
||||
|
||||
/**
|
||||
* Nastaveni spoustece: registrovana adresa webhooku a deklarace parametru,
|
||||
* ktere na ni budou chodit. Podminky ve strome se pak odkazuji prave na ne.
|
||||
*/
|
||||
export function TriggerConfig({
|
||||
trigger,
|
||||
connector,
|
||||
webhookBaseUrl,
|
||||
onChangeFields,
|
||||
onRegenerateToken,
|
||||
regenerating,
|
||||
}: {
|
||||
trigger: FlowTrigger;
|
||||
connector: Connector | undefined;
|
||||
webhookBaseUrl: string;
|
||||
onChangeFields: (fields: TriggerField[]) => void;
|
||||
onRegenerateToken: () => void;
|
||||
regenerating: boolean;
|
||||
}) {
|
||||
const isWebhook = trigger.connectorId === 'webhook';
|
||||
const operation = connector?.triggers.find((t) => t.id === trigger.operationId);
|
||||
const editable = operation?.customPayload === true;
|
||||
|
||||
function addField() {
|
||||
onChangeFields([
|
||||
...trigger.fields,
|
||||
{ id: newFieldId(), name: '', type: 'string', required: true },
|
||||
]);
|
||||
}
|
||||
|
||||
function updateField(id: string, patch: Partial<TriggerField>) {
|
||||
onChangeFields(trigger.fields.map((f) => (f.id === id ? { ...f, ...patch } : f)));
|
||||
}
|
||||
|
||||
function removeField(id: string) {
|
||||
onChangeFields(trigger.fields.filter((f) => f.id !== id));
|
||||
}
|
||||
|
||||
const duplicates = new Set(
|
||||
trigger.fields
|
||||
.map((f) => f.name.trim())
|
||||
.filter((name, index, all) => name.length > 0 && all.indexOf(name) !== index),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-4 border-t border-ink-600/50 pt-4">
|
||||
{isWebhook && (
|
||||
<WebhookAddress
|
||||
token={trigger.webhookToken}
|
||||
baseUrl={webhookBaseUrl}
|
||||
onRegenerate={onRegenerateToken}
|
||||
regenerating={regenerating}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white">Vstupní parametry</h3>
|
||||
<p className="mt-0.5 text-xs text-white/45">
|
||||
{editable
|
||||
? 'Co bude na spouštěč přicházet. Podle těchto hodnot pak stavíte podmínky.'
|
||||
: 'Tato služba předává vlastní data. Parametry pro podmínky si můžete doplnit ručně.'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addField}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-ink-600/70 px-3 py-1.5 text-xs font-medium text-white/70 transition-colors hover:border-brand-400/60 hover:text-white"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
Přidat parametr
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{trigger.fields.length === 0 ? (
|
||||
<p className="mt-3 rounded-xl border border-dashed border-ink-600/70 px-4 py-3 text-xs text-white/40">
|
||||
Zatím žádné parametry. Bez nich nelze přidat podmínku — nebylo by podle čeho
|
||||
se rozhodovat.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-3 space-y-2">
|
||||
{trigger.fields.map((field) => {
|
||||
const duplicate = field.name.trim().length > 0 && duplicates.has(field.name.trim());
|
||||
|
||||
return (
|
||||
<li
|
||||
key={field.id}
|
||||
className="flex flex-wrap items-center gap-2 rounded-xl border border-ink-600/50 bg-ink-850/50 p-2.5"
|
||||
>
|
||||
<input
|
||||
value={field.name}
|
||||
onChange={(event) => updateField(field.id, { name: event.target.value })}
|
||||
placeholder="nazev_parametru"
|
||||
aria-label="Název parametru"
|
||||
className={cn(
|
||||
'min-w-0 flex-1 rounded-lg border bg-ink-900/70 px-3 py-1.5 font-mono text-sm text-white placeholder:text-white/25 focus:outline-none',
|
||||
duplicate
|
||||
? 'border-danger-400/60'
|
||||
: 'border-ink-600/70 focus:border-brand-400/70',
|
||||
)}
|
||||
/>
|
||||
|
||||
<select
|
||||
value={field.type}
|
||||
onChange={(event) =>
|
||||
updateField(field.id, { type: event.target.value as FieldType })
|
||||
}
|
||||
aria-label="Typ parametru"
|
||||
className="rounded-lg border border-ink-600/70 bg-ink-900/70 px-3 py-1.5 text-sm text-white focus:border-brand-400/70 focus:outline-none"
|
||||
>
|
||||
{fieldTypes.map((type) => (
|
||||
<option key={type} value={type} className="bg-ink-850">
|
||||
{fieldTypeLabels[type]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-white/55">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.required}
|
||||
onChange={(event) =>
|
||||
updateField(field.id, { required: event.target.checked })
|
||||
}
|
||||
className="size-3.5 accent-cyan-400"
|
||||
/>
|
||||
povinný
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeField(field.id)}
|
||||
title="Odebrat parametr"
|
||||
className="grid size-8 place-items-center rounded-lg text-white/35 transition-colors hover:bg-danger-500/12 hover:text-danger-400"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
<span className="sr-only">Odebrat parametr</span>
|
||||
</button>
|
||||
|
||||
{duplicate && (
|
||||
<p className="w-full text-xs text-danger-400">
|
||||
Tento název už je použitý — parametry musí být unikátní.
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Registrovana adresa webhooku - token generuje server, tady se jen ukazuje. */
|
||||
function WebhookAddress({
|
||||
token,
|
||||
baseUrl,
|
||||
onRegenerate,
|
||||
regenerating,
|
||||
}: {
|
||||
token: string | undefined;
|
||||
baseUrl: string;
|
||||
onRegenerate: () => void;
|
||||
regenerating: boolean;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="rounded-xl border border-warn-400/35 bg-warn-500/8 p-4">
|
||||
<p className="flex items-center gap-2 text-sm text-warn-400">
|
||||
<AlertTriangle className="size-4 shrink-0" />
|
||||
Adresa se vygeneruje při prvním uložení.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const url = `${baseUrl}/${token}`;
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
// Clipboard API muze byt zakazane (http, oprávnění) - reklame to uzivateli.
|
||||
console.warn('[webhook] kopirovani do schranky selhalo:', err);
|
||||
window.prompt('Zkopírujte adresu ručně:', url);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-ink-600/60 bg-ink-850/60 p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-white">
|
||||
<KeyRound className="size-4 text-brand-300" />
|
||||
Adresa webhooku
|
||||
</h3>
|
||||
<Badge tone="ok">
|
||||
<Check className="size-3" />
|
||||
Zaregistrováno
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-stretch gap-2">
|
||||
<code className="min-w-0 flex-1 overflow-x-auto rounded-lg border border-ink-600/70 bg-ink-900 px-3 py-2.5 font-mono text-xs whitespace-nowrap text-brand-200">
|
||||
POST {url}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copy()}
|
||||
title="Kopírovat adresu"
|
||||
className="grid w-10 shrink-0 place-items-center rounded-lg border border-ink-600/70 text-white/55 transition-colors hover:border-brand-400/60 hover:text-white"
|
||||
>
|
||||
{copied ? <Check className="size-4 text-ok-400" /> : <Copy className="size-4" />}
|
||||
<span className="sr-only">Kopírovat adresu</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mt-2.5 text-xs leading-relaxed text-white/45">
|
||||
Token v adrese je jediná ochrana — kdo ji zná, může automatizaci spustit.
|
||||
Nesdílejte ji veřejně a neposílejte ji v odkazech.
|
||||
</p>
|
||||
|
||||
{confirming ? (
|
||||
<div className="mt-3 rounded-lg border border-danger-400/40 bg-danger-500/8 p-3">
|
||||
<p className="text-xs text-white/70">
|
||||
Vygenerovat novou adresu? <strong className="text-danger-400">Ta stará okamžitě
|
||||
přestane fungovat</strong> a musíte ji přenastavit všude, odkud se volá.
|
||||
</p>
|
||||
<div className="mt-2.5 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onRegenerate();
|
||||
setConfirming(false);
|
||||
}}
|
||||
disabled={regenerating}
|
||||
className="rounded-lg bg-danger-500/20 px-3 py-1.5 text-xs font-semibold text-danger-400 transition-colors hover:bg-danger-500/30 disabled:opacity-50"
|
||||
>
|
||||
{regenerating ? 'Generuji…' : 'Ano, vygenerovat novou'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirming(false)}
|
||||
className="rounded-lg px-3 py-1.5 text-xs text-white/55 hover:text-white"
|
||||
>
|
||||
Zrušit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirming(true)}
|
||||
className="mt-3 inline-flex items-center gap-1.5 text-xs font-medium text-white/45 transition-colors hover:text-white"
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
Vygenerovat novou adresu
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ArrowRight, Mail, Phone } from 'lucide-react';
|
||||
import { ButtonLink } from '@/components/ui/Button';
|
||||
import { Container } from '@/components/ui/Container';
|
||||
import { brand } from '@/config/brand';
|
||||
|
||||
/** Zaverecna vyzva k akci pred paticku. */
|
||||
export function CallToAction() {
|
||||
return (
|
||||
<section className="py-20 sm:py-24">
|
||||
<Container>
|
||||
<div className="relative overflow-hidden rounded-3xl border border-brand-400/25 bg-gradient-to-br from-brand-500/12 via-ink-800/60 to-accent-500/12 px-6 py-14 text-center sm:px-14">
|
||||
<div
|
||||
aria-hidden
|
||||
className="animate-pulse-slow pointer-events-none absolute -top-24 left-1/2 size-96 -translate-x-1/2 rounded-full bg-brand-500/20 blur-[100px]"
|
||||
/>
|
||||
<h2 className="relative text-3xl font-bold text-white sm:text-4xl">
|
||||
Máte proces, který vás žere? <span className="text-gradient">Pojďme se na něj podívat.</span>
|
||||
</h2>
|
||||
<p className="relative mx-auto mt-4 max-w-xl text-white/60">
|
||||
Půlhodinový hovor, ve kterém si řekneme, co se dá automatizovat hned, co později a co
|
||||
nemá smysl vůbec. Bez závazku a bez prezentací na 40 slidů.
|
||||
</p>
|
||||
|
||||
<div className="relative mt-9 flex flex-wrap justify-center gap-3">
|
||||
<ButtonLink to="/kontakt" size="lg">
|
||||
Napsat nám
|
||||
<ArrowRight className="size-4" />
|
||||
</ButtonLink>
|
||||
<ButtonLink to={`tel:${brand.phoneHref}`} variant="secondary" size="lg">
|
||||
<Phone className="size-4" />
|
||||
{brand.phone}
|
||||
</ButtonLink>
|
||||
</div>
|
||||
|
||||
<p className="relative mt-6 inline-flex items-center gap-2 text-sm text-white/45">
|
||||
<Mail className="size-4" />
|
||||
{brand.email}
|
||||
</p>
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { ArrowRight, CheckCircle2, PhoneCall, Sparkles } from 'lucide-react';
|
||||
import { ButtonLink } from '@/components/ui/Button';
|
||||
import { Container } from '@/components/ui/Container';
|
||||
|
||||
/** Krátké uvítání + hlavní CTA + mockup živého provozu. */
|
||||
export function Hero() {
|
||||
return (
|
||||
<section className="relative overflow-hidden pt-16 pb-20 sm:pt-24 sm:pb-28">
|
||||
{/* Dekorativni pozadi */}
|
||||
<div aria-hidden className="pointer-events-none absolute inset-0 -z-10">
|
||||
<div className="absolute inset-0 bg-grid mask-fade-b opacity-40" />
|
||||
<div className="animate-pulse-slow absolute -top-40 left-1/2 size-[38rem] -translate-x-1/2 rounded-full bg-brand-500/18 blur-[120px]" />
|
||||
<div className="animate-pulse-slow absolute top-40 -right-32 size-[30rem] rounded-full bg-accent-500/16 blur-[120px]" />
|
||||
</div>
|
||||
|
||||
<Container>
|
||||
<div className="grid items-center gap-14 lg:grid-cols-[1.05fr_0.95fr]">
|
||||
<div className="animate-rise">
|
||||
<span className="glass inline-flex items-center gap-2 rounded-full px-4 py-1.5 text-xs font-medium text-brand-300">
|
||||
<Sparkles className="size-3.5" />
|
||||
Automatizace · Voiceboti · Integrace
|
||||
</span>
|
||||
|
||||
<h1 className="mt-6 text-4xl leading-[1.08] font-extrabold text-white sm:text-5xl lg:text-6xl">
|
||||
Vítejte. Děláme z ruční práce{' '}
|
||||
<span className="text-gradient">procesy, které běží samy</span>.
|
||||
</h1>
|
||||
|
||||
<p className="mt-6 max-w-xl text-lg leading-relaxed text-white/65">
|
||||
Jsme malý tým, který firmám staví automatizace, hlasové asistenty a propojení systémů —
|
||||
a pak je i provozuje. Vy vidíte výsledky v dashboardu, my držíme provoz.
|
||||
</p>
|
||||
|
||||
<div className="mt-9 flex flex-wrap items-center gap-3">
|
||||
<ButtonLink to="/kontakt" size="lg">
|
||||
Nezávazná konzultace
|
||||
<ArrowRight className="size-4" />
|
||||
</ButtonLink>
|
||||
<ButtonLink to="/sluzby" variant="secondary" size="lg">
|
||||
Co umíme
|
||||
</ButtonLink>
|
||||
</div>
|
||||
|
||||
<ul className="mt-9 flex flex-wrap gap-x-6 gap-y-2.5 text-sm text-white/55">
|
||||
{['Nasazení v týdnech, ne kvartálech', 'Provoz a podpora v ceně', 'Bez vendor lock-inu'].map(
|
||||
(item) => (
|
||||
<li key={item} className="flex items-center gap-2">
|
||||
<CheckCircle2 className="size-4 shrink-0 text-ok-400" />
|
||||
{item}
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<HeroPanel />
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Mockup panelu "z provozu" - jen ilustrace, zadna realna data. */
|
||||
function HeroPanel() {
|
||||
const steps = [
|
||||
{ label: 'Příchozí hovor rozpoznán', time: '0,4 s', done: true },
|
||||
{ label: 'Poptávka založena v CRM', time: '1,1 s', done: true },
|
||||
{ label: 'Nabídka vygenerována', time: '2,8 s', done: true },
|
||||
{ label: 'Předáno obchodníkovi', time: 'právě teď', done: false },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="animate-rise relative [animation-delay:150ms]">
|
||||
<div className="animate-float glass rounded-2xl p-5 shadow-2xl shadow-brand-500/10">
|
||||
<div className="flex items-center justify-between border-b border-ink-600/60 pb-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="grid size-9 place-items-center rounded-xl bg-brand-500/15 text-brand-300">
|
||||
<PhoneCall className="size-4" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">Voicebot — příjem poptávek</p>
|
||||
<p className="font-mono text-xs text-white/40">AUT-02 · běží</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-ok-400/35 bg-ok-500/12 px-2.5 py-1 text-xs font-medium text-ok-400">
|
||||
<span className="size-1.5 animate-pulse rounded-full bg-ok-400" />
|
||||
live
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ol className="mt-4 space-y-3">
|
||||
{steps.map((step) => (
|
||||
<li key={step.label} className="flex items-center gap-3">
|
||||
<span
|
||||
className={
|
||||
step.done
|
||||
? 'grid size-5 shrink-0 place-items-center rounded-full bg-ok-500/20 text-ok-400'
|
||||
: 'grid size-5 shrink-0 place-items-center rounded-full bg-brand-500/20 text-brand-300'
|
||||
}
|
||||
>
|
||||
{step.done ? (
|
||||
<CheckCircle2 className="size-3.5" />
|
||||
) : (
|
||||
<span className="size-1.5 animate-pulse rounded-full bg-brand-300" />
|
||||
)}
|
||||
</span>
|
||||
<span className="flex-1 text-sm text-white/75">{step.label}</span>
|
||||
<span className="font-mono text-xs text-white/35">{step.time}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<div className="mt-5 grid grid-cols-3 gap-3 border-t border-ink-600/60 pt-4">
|
||||
{[
|
||||
{ value: '137', label: 'hovorů dnes' },
|
||||
{ value: '96,1 %', label: 'úspěšnost' },
|
||||
{ value: '1,2 s', label: 'odezva' },
|
||||
].map((stat) => (
|
||||
<div key={stat.label}>
|
||||
<p className="text-lg font-bold text-white">{stat.value}</p>
|
||||
<p className="text-xs text-white/45">{stat.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Male "odlepene" karticky pro hloubku */}
|
||||
<div className="glass animate-float absolute -bottom-6 -left-6 hidden rounded-xl px-4 py-3 shadow-xl [animation-delay:1.2s] sm:block">
|
||||
<p className="text-xs text-white/45">Ušetřeno tento měsíc</p>
|
||||
<p className="text-base font-bold text-white">312 hodin</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Container } from '@/components/ui/Container';
|
||||
import { clientLogos } from '@/data/references';
|
||||
|
||||
/** Nekonecny pas nazvu klientu (mockup misto realnych log). */
|
||||
export function LogoCloud() {
|
||||
// Seznam zdvojujeme, aby marquee navazovalo bez skoku (posun je -50 %).
|
||||
const items = [...clientLogos, ...clientLogos];
|
||||
|
||||
return (
|
||||
<section className="border-y border-ink-600/50 bg-ink-950/50 py-10">
|
||||
<Container>
|
||||
<p className="mb-6 text-center text-xs font-semibold tracking-[0.18em] text-white/35 uppercase">
|
||||
Pracujeme pro firmy, které nemají čas na ruční práci
|
||||
</p>
|
||||
</Container>
|
||||
<div className="mask-fade-x overflow-hidden">
|
||||
<div className="animate-marquee flex w-max items-center gap-14">
|
||||
{items.map((name, index) => (
|
||||
<span
|
||||
key={`${name}-${index}`}
|
||||
className="text-lg font-semibold whitespace-nowrap text-white/25 transition-colors hover:text-white/50"
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Section, SectionHeading } from '@/components/ui/Section';
|
||||
|
||||
const steps = [
|
||||
{
|
||||
number: '01',
|
||||
title: 'Konzultace a mapování',
|
||||
text: 'Projdeme s vámi proces tak, jak reálně běží. Najdeme místa, kde se ztrácí čas a data.',
|
||||
duration: '1–2 týdny',
|
||||
},
|
||||
{
|
||||
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',
|
||||
},
|
||||
{
|
||||
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',
|
||||
},
|
||||
{
|
||||
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',
|
||||
},
|
||||
];
|
||||
|
||||
/** Jak spoluprace probiha - casova osa ve 4 krocich. */
|
||||
export function Process() {
|
||||
return (
|
||||
<Section id="postup">
|
||||
<SectionHeading
|
||||
eyebrow="Jak to probíhá"
|
||||
title={
|
||||
<>
|
||||
Od prvního hovoru <span className="text-gradient">do provozu za pár týdnů</span>
|
||||
</>
|
||||
}
|
||||
subtitle="Žádné půlroční analýzy. Nejdřív malý funkční celek, který přinese úsporu, potom rozšiřování."
|
||||
/>
|
||||
|
||||
<ol className="grid gap-5 md:grid-cols-2 lg:grid-cols-4">
|
||||
{steps.map((step) => (
|
||||
<li
|
||||
key={step.number}
|
||||
className="group relative rounded-card border border-ink-600/60 bg-ink-800/40 p-6 transition-colors hover:border-brand-400/40"
|
||||
>
|
||||
<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>
|
||||
<p className="mt-4 text-xs font-medium tracking-wide text-brand-300/70 uppercase">
|
||||
{step.duration}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { ArrowRight, Check } from 'lucide-react';
|
||||
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 { cn } from '@/lib/cn';
|
||||
|
||||
/** Mockup produktu / sluzeb - grid karet. */
|
||||
export function Products() {
|
||||
return (
|
||||
<Section id="produkty">
|
||||
<SectionHeading
|
||||
eyebrow="Produkty"
|
||||
title={
|
||||
<>
|
||||
Šest věcí, které <span className="text-gradient">umíme dodat a udržet</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."
|
||||
/>
|
||||
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{products.map((product) => (
|
||||
<ProductCard key={product.slug} product={product} />
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProductCard({ product }: { product: Product }) {
|
||||
const Icon = product.icon;
|
||||
|
||||
return (
|
||||
<Card
|
||||
interactive
|
||||
className={cn(
|
||||
'flex flex-col',
|
||||
product.featured && 'border-brand-400/35 bg-gradient-to-b from-brand-500/8 to-transparent',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<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}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Link
|
||||
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
|
||||
<ArrowRight className="size-3.5" />
|
||||
</Link>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
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';
|
||||
|
||||
/** Mockup referenci - citaty klientu + merutelny vysledek. */
|
||||
export function References() {
|
||||
return (
|
||||
<Section id="reference" className="bg-ink-950/40">
|
||||
<SectionHeading
|
||||
eyebrow="Reference"
|
||||
title={
|
||||
<>
|
||||
Co říkají firmy, <span className="text-gradient">se kterými to běží</span>
|
||||
</>
|
||||
}
|
||||
subtitle="Ukázka spoluprací napříč obory. Čísla vždy měříme před nasazením a po něm — jinak by to byl jen marketing."
|
||||
/>
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
{references.map((reference) => (
|
||||
<ReferenceCard key={reference.id} reference={reference} />
|
||||
))}
|
||||
</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>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function ReferenceCard({ reference }: { reference: Reference }) {
|
||||
return (
|
||||
<Card interactive className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="grid size-11 shrink-0 place-items-center rounded-xl border border-ink-600/70 bg-ink-700/60 font-mono text-sm font-bold text-brand-300">
|
||||
{reference.initials}
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-semibold text-white">{reference.company}</p>
|
||||
<p className="text-xs text-white/40">{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}“
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Container } from '@/components/ui/Container';
|
||||
|
||||
const stats = [
|
||||
{ value: '120+', label: 'nasazených automatizací' },
|
||||
{ value: '99,98 %', label: 'dostupnost provozovaných služeb' },
|
||||
{ value: '11 min', label: 'medián první reakce podpory' },
|
||||
{ value: '7 let', label: 'na trhu' },
|
||||
];
|
||||
|
||||
/** Pas s cisly mezi sekcemi - drzi pozornost a rozdeluje obsah. */
|
||||
export function Stats() {
|
||||
return (
|
||||
<section className="relative overflow-hidden py-14">
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 -z-10 bg-gradient-to-r from-brand-500/8 via-transparent to-accent-500/8"
|
||||
/>
|
||||
<Container>
|
||||
<dl className="grid grid-cols-2 gap-8 lg:grid-cols-4">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.label} className="text-center">
|
||||
<dt className="sr-only">{stat.label}</dt>
|
||||
<dd>
|
||||
<p className="text-3xl font-extrabold text-white sm:text-4xl">{stat.value}</p>
|
||||
<p className="mt-2 text-sm text-white/50">{stat.label}</p>
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Mail, MapPin, Phone } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Logo } from '@/components/layout/Logo';
|
||||
import { Container } from '@/components/ui/Container';
|
||||
import { brand } from '@/config/brand';
|
||||
import { footerNav } from '@/data/navigation';
|
||||
|
||||
export function Footer() {
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<footer className="border-t border-ink-600/60 bg-ink-950/80">
|
||||
<Container className="py-14">
|
||||
<div className="grid gap-10 md:grid-cols-[1.4fr_1fr_1fr_1.2fr]">
|
||||
<div>
|
||||
<Logo />
|
||||
<p className="mt-4 max-w-xs text-sm leading-relaxed text-white/55">{brand.claim}</p>
|
||||
</div>
|
||||
|
||||
{footerNav.map((column) => (
|
||||
<div key={column.title}>
|
||||
<h3 className="mb-4 text-sm font-semibold text-white">{column.title}</h3>
|
||||
<ul className="space-y-2.5">
|
||||
{column.items.map((item) => (
|
||||
<li key={item.to + item.label}>
|
||||
<Link
|
||||
to={item.to}
|
||||
className="text-sm text-white/55 transition-colors hover:text-brand-300"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div>
|
||||
<h3 className="mb-4 text-sm font-semibold text-white">Kontakt</h3>
|
||||
<ul className="space-y-3 text-sm text-white/55">
|
||||
<li>
|
||||
<a
|
||||
href={`mailto:${brand.email}`}
|
||||
className="inline-flex items-center gap-2 transition-colors hover:text-brand-300"
|
||||
>
|
||||
<Mail className="size-4 shrink-0 text-brand-400" />
|
||||
{brand.email}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href={`tel:${brand.phoneHref}`}
|
||||
className="inline-flex items-center gap-2 transition-colors hover:text-brand-300"
|
||||
>
|
||||
<Phone className="size-4 shrink-0 text-brand-400" />
|
||||
{brand.phone}
|
||||
</a>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<MapPin className="mt-0.5 size-4 shrink-0 text-brand-400" />
|
||||
<span>
|
||||
{brand.address.street}
|
||||
<br />
|
||||
{brand.address.zip} {brand.address.city}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 flex flex-col gap-3 border-t border-ink-600/50 pt-6 text-xs text-white/40 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p>
|
||||
© {year} {brand.legalName} · IČO {brand.ico}
|
||||
</p>
|
||||
<p className="font-mono">
|
||||
Prototyp webu — obsah a reference jsou ukázkové (mockup).
|
||||
</p>
|
||||
</div>
|
||||
</Container>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { brand } from '@/config/brand';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
export function Logo({ className, to = '/' }: { className?: string; to?: string }) {
|
||||
return (
|
||||
<Link to={to} className={cn('group inline-flex items-center gap-2.5', className)}>
|
||||
<span className="relative grid size-9 place-items-center rounded-xl bg-gradient-to-br from-brand-400 to-accent-500 shadow-lg shadow-brand-500/25 transition-transform duration-300 group-hover:scale-105">
|
||||
<span className="font-mono text-sm font-bold text-ink-950">A</span>
|
||||
</span>
|
||||
<span className="text-lg font-bold tracking-tight text-white">{brand.name}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { LayoutDashboard, LogIn, Menu, X } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
import { Logo } from '@/components/layout/Logo';
|
||||
import { ButtonLink } from '@/components/ui/Button';
|
||||
import { Container } from '@/components/ui/Container';
|
||||
import { mainNav } from '@/data/navigation';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
export function Navbar() {
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
const { user } = useAuth();
|
||||
|
||||
// Sticky hlavicka ztmavne po odscrollovani.
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 12);
|
||||
onScroll();
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', onScroll);
|
||||
}, []);
|
||||
|
||||
// Zavreni mobilniho menu pri zmene stranky.
|
||||
useEffect(() => {
|
||||
setMobileOpen(false);
|
||||
}, [location.pathname]);
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
'fixed inset-x-0 top-0 z-50 transition-all duration-300',
|
||||
scrolled ? 'border-b border-ink-600/60 bg-ink-900/85 backdrop-blur-xl' : 'bg-transparent',
|
||||
)}
|
||||
>
|
||||
<Container>
|
||||
<div className="flex h-18 items-center justify-between gap-4">
|
||||
<Logo />
|
||||
|
||||
<nav className="hidden items-center gap-1 md:flex">
|
||||
{mainNav.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'rounded-full px-4 py-2 text-sm font-medium transition-colors',
|
||||
isActive ? 'bg-white/8 text-white' : 'text-white/60 hover:text-white',
|
||||
)
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="hidden items-center gap-3 md:flex">
|
||||
{user ? (
|
||||
<ButtonLink to="/dashboard" size="sm">
|
||||
<LayoutDashboard className="size-4" />
|
||||
Dashboard
|
||||
</ButtonLink>
|
||||
) : (
|
||||
<>
|
||||
<ButtonLink to="/prihlaseni" variant="ghost" size="sm">
|
||||
<LogIn className="size-4" />
|
||||
Přihlášení
|
||||
</ButtonLink>
|
||||
<ButtonLink to="/kontakt" size="sm">
|
||||
Nezávazná konzultace
|
||||
</ButtonLink>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="grid size-10 place-items-center rounded-lg text-white/70 hover:bg-white/5 hover:text-white md:hidden"
|
||||
onClick={() => setMobileOpen((open) => !open)}
|
||||
aria-label={mobileOpen ? 'Zavřít menu' : 'Otevřít menu'}
|
||||
aria-expanded={mobileOpen}
|
||||
>
|
||||
{mobileOpen ? <X className="size-5" /> : <Menu className="size-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
{mobileOpen && (
|
||||
<div className="border-t border-ink-600/60 bg-ink-900/98 backdrop-blur-xl md:hidden">
|
||||
<Container className="flex flex-col gap-1 py-4">
|
||||
{mainNav.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'rounded-lg px-4 py-3 text-base font-medium transition-colors',
|
||||
isActive ? 'bg-white/8 text-white' : 'text-white/70 hover:bg-white/5',
|
||||
)
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
{user ? (
|
||||
<ButtonLink to="/dashboard">
|
||||
<LayoutDashboard className="size-4" />
|
||||
Dashboard
|
||||
</ButtonLink>
|
||||
) : (
|
||||
<>
|
||||
<ButtonLink to="/prihlaseni" variant="secondary">
|
||||
<LogIn className="size-4" />
|
||||
Přihlášení
|
||||
</ButtonLink>
|
||||
<ButtonLink to="/kontakt">Nezávazná konzultace</ButtonLink>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
import { Footer } from '@/components/layout/Footer';
|
||||
import { Navbar } from '@/components/layout/Navbar';
|
||||
|
||||
/** Layout verejneho webu: sticky hlavicka + obsah + paticka. */
|
||||
export function PublicLayout() {
|
||||
const { pathname, hash } = useLocation();
|
||||
|
||||
// Pri prechodu na jinou stranku scrollujeme nahoru, pri #odkazu na cilovou sekci.
|
||||
useEffect(() => {
|
||||
if (hash) {
|
||||
const target = document.querySelector(hash);
|
||||
if (target) {
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
return;
|
||||
}
|
||||
console.warn(`[layout] cil hashe ${hash} na strance neexistuje`);
|
||||
}
|
||||
window.scrollTo({ top: 0, behavior: 'instant' as ScrollBehavior });
|
||||
}, [pathname, hash]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-ink-900">
|
||||
<Navbar />
|
||||
<main className="flex-1 pt-18">
|
||||
<Outlet />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
export type BadgeTone = 'neutral' | 'brand' | 'ok' | 'warn' | 'danger';
|
||||
|
||||
const tones: Record<BadgeTone, string> = {
|
||||
neutral: 'border-white/12 bg-white/5 text-white/70',
|
||||
brand: 'border-brand-400/35 bg-brand-500/12 text-brand-300',
|
||||
ok: 'border-ok-400/35 bg-ok-500/12 text-ok-400',
|
||||
warn: 'border-warn-400/35 bg-warn-500/12 text-warn-400',
|
||||
danger: 'border-danger-400/35 bg-danger-500/12 text-danger-400',
|
||||
};
|
||||
|
||||
export function Badge({
|
||||
tone = 'neutral',
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
tone?: BadgeTone;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium whitespace-nowrap',
|
||||
tones[tone],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
type Variant = 'primary' | 'secondary' | 'ghost';
|
||||
type Size = 'sm' | 'md' | 'lg';
|
||||
|
||||
const base =
|
||||
'inline-flex items-center justify-center gap-2 rounded-full font-semibold transition-all duration-200 disabled:cursor-not-allowed disabled:opacity-55';
|
||||
|
||||
const variants: Record<Variant, string> = {
|
||||
primary:
|
||||
'bg-gradient-to-r from-brand-400 to-accent-500 text-ink-950 shadow-lg shadow-brand-500/20 hover:shadow-xl hover:shadow-brand-500/30 hover:brightness-110',
|
||||
secondary: 'glass text-white hover:border-brand-400/60 hover:bg-ink-700/70',
|
||||
ghost: 'text-white/70 hover:bg-white/5 hover:text-white',
|
||||
};
|
||||
|
||||
const sizes: Record<Size, string> = {
|
||||
sm: 'h-9 px-4 text-sm',
|
||||
md: 'h-11 px-6 text-[0.95rem]',
|
||||
lg: 'h-13 px-8 text-base',
|
||||
};
|
||||
|
||||
interface CommonProps {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: CommonProps & ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
return (
|
||||
<button className={cn(base, variants[variant], sizes[size], className)} {...rest}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Stejny vzhled jako Button, ale routovaci odkaz. */
|
||||
export function ButtonLink({
|
||||
to,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
className,
|
||||
children,
|
||||
}: CommonProps & { to: string }) {
|
||||
const isExternal = /^(https?:|mailto:|tel:)/.test(to);
|
||||
const classes = cn(base, variants[variant], sizes[size], className);
|
||||
|
||||
if (isExternal) {
|
||||
return (
|
||||
<a
|
||||
href={to}
|
||||
className={classes}
|
||||
target={to.startsWith('http') ? '_blank' : undefined}
|
||||
rel={to.startsWith('http') ? 'noreferrer' : undefined}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link to={to} className={classes}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
interface CardProps {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
/** Zvyrazni kartu pri hoveru - pro klikatelne / produktove karty. */
|
||||
interactive?: boolean;
|
||||
}
|
||||
|
||||
export function Card({ className, children, interactive = false }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'glass rounded-card p-6 transition-all duration-300',
|
||||
interactive && 'hover:-translate-y-1 hover:border-brand-400/50 hover:shadow-2xl hover:shadow-brand-500/10',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
/** Jednotna sirka obsahu a horizontalni odsazeni pro cely web. */
|
||||
export function Container({ className, children }: { className?: string; children: ReactNode }) {
|
||||
return <div className={cn('mx-auto w-full max-w-6xl px-5 sm:px-8', className)}>{children}</div>;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { X } from 'lucide-react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
/**
|
||||
* Dialog nad obsahem. Zavira se Esc, klikem na pozadi i krizkem.
|
||||
* Pri otevreni zamkne scroll stranky a preda focus dovnitr.
|
||||
*/
|
||||
export function Modal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
|
||||
// Zamek scrollu, aby se pod dialogem nescrollovalo.
|
||||
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"])',
|
||||
);
|
||||
firstFocusable?.focus();
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
document.body.style.overflow = previousOverflow;
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-start justify-center overflow-y-auto p-4 sm:items-center sm:p-6">
|
||||
<div
|
||||
className="fixed inset-0 bg-ink-950/80 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
aria-hidden
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
className={cn(
|
||||
'relative my-auto w-full max-w-3xl rounded-2xl border border-ink-600/70 bg-ink-900 shadow-2xl',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 border-b border-ink-600/60 p-5">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-white">{title}</h2>
|
||||
{description && <p className="mt-1 text-sm text-white/50">{description}</p>}
|
||||
</div>
|
||||
<button
|
||||
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"
|
||||
aria-label="Zavřít"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Container } from '@/components/ui/Container';
|
||||
|
||||
/** Hlavicka podstranky - jednotny vzhled pro O nas, Sluzby, Kontakt. */
|
||||
export function PageHeader({
|
||||
eyebrow,
|
||||
title,
|
||||
subtitle,
|
||||
}: {
|
||||
eyebrow?: string;
|
||||
title: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="relative overflow-hidden border-b border-ink-600/50 py-16 sm:py-20">
|
||||
<div aria-hidden className="pointer-events-none absolute inset-0 -z-10">
|
||||
<div className="absolute inset-0 bg-grid mask-fade-b opacity-30" />
|
||||
<div className="absolute -top-32 left-1/3 size-96 rounded-full bg-brand-500/12 blur-[110px]" />
|
||||
</div>
|
||||
<Container>
|
||||
{eyebrow && (
|
||||
<p className="mb-3 text-xs font-semibold tracking-[0.18em] text-brand-300 uppercase">
|
||||
{eyebrow}
|
||||
</p>
|
||||
)}
|
||||
<h1 className="max-w-3xl text-3xl font-extrabold text-white sm:text-5xl">{title}</h1>
|
||||
{subtitle && (
|
||||
<p className="mt-5 max-w-2xl text-lg leading-relaxed text-white/60">{subtitle}</p>
|
||||
)}
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Container } from '@/components/ui/Container';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
/** Sekce s jednotnym vertikalnim rytmem. */
|
||||
export function Section({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
id?: string;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section id={id} className={cn('relative py-20 sm:py-28', className)}>
|
||||
<Container>{children}</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Nadpis sekce: mala popiska + titulek + podtitulek. */
|
||||
export function SectionHeading({
|
||||
eyebrow,
|
||||
title,
|
||||
subtitle,
|
||||
align = 'center',
|
||||
className,
|
||||
}: {
|
||||
eyebrow?: string;
|
||||
title: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
align?: 'center' | 'left';
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'mb-14 max-w-2xl',
|
||||
align === 'center' ? 'mx-auto text-center' : 'text-left',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{eyebrow && (
|
||||
<p className="mb-3 text-xs font-semibold tracking-[0.18em] text-brand-300 uppercase">
|
||||
{eyebrow}
|
||||
</p>
|
||||
)}
|
||||
<h2 className="text-3xl font-bold text-white sm:text-4xl">{title}</h2>
|
||||
{subtitle && <p className="mt-4 text-base leading-relaxed text-white/60">{subtitle}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
export function Spinner({ label, className }: { label?: string; className?: string }) {
|
||||
return (
|
||||
<div className={cn('flex items-center gap-3 text-sm text-white/60', className)} role="status">
|
||||
<span className="size-5 animate-spin rounded-full border-2 border-white/15 border-t-brand-400" />
|
||||
{label && <span>{label}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Jedine misto s firemnimi udaji a texty, ktere se opakuji na webu.
|
||||
* Prejmenovani firmy = zmena tady (viz docs/03-frontend.md).
|
||||
*/
|
||||
export const brand = {
|
||||
name: 'Automia',
|
||||
legalName: 'Automia s.r.o.',
|
||||
claim: 'Automatizace, voiceboti a integrace, které vydrží provoz.',
|
||||
description:
|
||||
'Navrhujeme a provozujeme automatizace firemních procesů, hlasové asistenty a propojení systémů. K tomu dodáváme dashboardy, tickety a incident management pod jednou střechou.',
|
||||
email: 'info@automia.cz',
|
||||
phone: '+420 777 123 456',
|
||||
phoneHref: '+420777123456',
|
||||
address: {
|
||||
street: 'Náměstí Míru 12',
|
||||
city: 'Brno',
|
||||
zip: '602 00',
|
||||
country: 'Česká republika',
|
||||
},
|
||||
ico: '12345678',
|
||||
dic: 'CZ12345678',
|
||||
founded: 2018,
|
||||
social: {
|
||||
linkedin: 'https://www.linkedin.com/',
|
||||
github: 'https://github.com/',
|
||||
},
|
||||
/** Otevírací doba podpory zobrazená na kontaktech. */
|
||||
support: {
|
||||
hours: 'Po–Pá 8:00–18:00',
|
||||
sla: 'SLA 24/7 pro klienty s tarifem Provoz',
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,33 @@
|
||||
export interface NavItem {
|
||||
label: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
/** Hlavni navigace verejneho webu. Poradi = poradi v hlavicce. */
|
||||
export const mainNav: NavItem[] = [
|
||||
{ label: 'Domů', to: '/' },
|
||||
{ label: 'Služby', to: '/sluzby' },
|
||||
{ label: 'O nás', to: '/o-nas' },
|
||||
{ label: 'Kontakt', to: '/kontakt' },
|
||||
];
|
||||
|
||||
/** Odkazy v paticce, rozdelene do sloupcu. */
|
||||
export const footerNav: Array<{ title: string; items: NavItem[] }> = [
|
||||
{
|
||||
title: 'Služby',
|
||||
items: [
|
||||
{ label: 'Automatizace procesů', to: '/sluzby#automatizace' },
|
||||
{ label: 'Voiceboti', to: '/sluzby#voiceboti' },
|
||||
{ label: 'Integrace systémů', to: '/sluzby#integrace' },
|
||||
{ label: 'Dashboardy', to: '/sluzby#dashboardy' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Firma',
|
||||
items: [
|
||||
{ label: 'O nás', to: '/o-nas' },
|
||||
{ label: 'Kontakt', to: '/kontakt' },
|
||||
{ label: 'Klientský portál', to: '/prihlaseni' },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
AlarmClock,
|
||||
BarChart3,
|
||||
LifeBuoy,
|
||||
Network,
|
||||
PhoneCall,
|
||||
Workflow,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
export interface Product {
|
||||
slug: string;
|
||||
name: string;
|
||||
tagline: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
features: string[];
|
||||
/** Ukazkova metrika na karte produktu. */
|
||||
metric: { value: string; label: string };
|
||||
/** Zvyrazneni jedne karty v gridu. */
|
||||
featured?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* MOCKUP produktu pro homepage a stranku Sluzby.
|
||||
* Realna nabidka se doplni pozdeji - viz docs/07-obsah-a-copy.md.
|
||||
*/
|
||||
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.',
|
||||
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ě' },
|
||||
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.',
|
||||
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' },
|
||||
},
|
||||
{
|
||||
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ů.',
|
||||
icon: Network,
|
||||
features: ['API i webhooky', 'Mapování a transformace dat', 'Odolnost proti výpadkům'],
|
||||
metric: { value: '40+', label: 'napojených služeb' },
|
||||
},
|
||||
{
|
||||
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.',
|
||||
icon: BarChart3,
|
||||
features: ['Metriky v reálném čase', 'Automatické reporty e-mailem', 'Přístupy podle rolí'],
|
||||
metric: { value: '5 s', label: 'aktualizace dat' },
|
||||
},
|
||||
{
|
||||
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.',
|
||||
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' },
|
||||
},
|
||||
{
|
||||
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.',
|
||||
icon: AlarmClock,
|
||||
features: ['Healthchecky a alerty', 'On-call rozpis', 'Post-mortem po každém incidentu'],
|
||||
metric: { value: '99,98 %', label: 'dostupnost 2025' },
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,75 @@
|
||||
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. */
|
||||
initials: string;
|
||||
quote: string;
|
||||
author: string;
|
||||
role: string;
|
||||
result: { value: string; label: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* MOCKUP referenci. Vsechny firmy, citaty i cisla jsou VYMYSLENE
|
||||
* a slouzi jen pro nahled layoutu - viz docs/07-obsah-a-copy.md.
|
||||
*/
|
||||
export const references: Reference[] = [
|
||||
{
|
||||
id: 'ref-nordis',
|
||||
company: 'Nordis a.s.',
|
||||
industry: 'Velkoobchod',
|
||||
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.',
|
||||
author: 'Tomáš Vrána',
|
||||
role: 'provozní ředitel',
|
||||
result: { value: '−78 %', label: 'ruční práce v administraci' },
|
||||
},
|
||||
{
|
||||
id: 'ref-logitrans',
|
||||
company: 'LogiTrans',
|
||||
industry: 'Doprava a logistika',
|
||||
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.',
|
||||
author: 'Klára Doubravová',
|
||||
role: 'vedoucí dispečinku',
|
||||
result: { value: '1 400', label: 'hovorů zpracovaných měsíčně' },
|
||||
},
|
||||
{
|
||||
id: 'ref-medipoint',
|
||||
company: 'MediPoint',
|
||||
industry: 'Zdravotnictví',
|
||||
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.',
|
||||
author: 'MUDr. Jan Sedlák',
|
||||
role: 'jednatel',
|
||||
result: { value: '6', label: 'provozoven v jednom přehledu' },
|
||||
},
|
||||
{
|
||||
id: 'ref-bistro',
|
||||
company: 'Bistro Kolektiv',
|
||||
industry: 'Gastro',
|
||||
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ů.',
|
||||
author: 'Eliška Marešová',
|
||||
role: 'majitelka',
|
||||
result: { value: '99,9 %', label: 'dostupnost systémů' },
|
||||
},
|
||||
];
|
||||
|
||||
/** Nazvy firem pro "logo cloud" pas nad referencemi (take mockup). */
|
||||
export const clientLogos: string[] = [
|
||||
'Nordis',
|
||||
'LogiTrans',
|
||||
'MediPoint',
|
||||
'Bistro Kolektiv',
|
||||
'Stavko Group',
|
||||
'Voltera',
|
||||
'Reticum',
|
||||
'Kovoplast',
|
||||
];
|
||||
@@ -0,0 +1,190 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Design tokeny. Zmena barevnosti celeho webu = zmena tady.
|
||||
Dokumentace: docs/05-design-system.md
|
||||
------------------------------------------------------------------- */
|
||||
@theme {
|
||||
/* Podklady - tmave modrociste odstiny */
|
||||
--color-ink-950: #05070f;
|
||||
--color-ink-900: #070b16;
|
||||
--color-ink-850: #0a0f1f;
|
||||
--color-ink-800: #0e1526;
|
||||
--color-ink-700: #16203a;
|
||||
--color-ink-600: #1f2c4d;
|
||||
|
||||
/* Primarni akcent - cyan */
|
||||
--color-brand-200: #a5f3fc;
|
||||
--color-brand-300: #67e8f9;
|
||||
--color-brand-400: #22d3ee;
|
||||
--color-brand-500: #06b6d4;
|
||||
--color-brand-600: #0891b2;
|
||||
|
||||
/* Sekundarni akcent - violet */
|
||||
--color-accent-300: #c4b5fd;
|
||||
--color-accent-400: #a78bfa;
|
||||
--color-accent-500: #8b5cf6;
|
||||
--color-accent-600: #7c3aed;
|
||||
|
||||
/* Stavove barvy (tickety, incidenty, healthchecky) */
|
||||
--color-ok-400: #34d399;
|
||||
--color-ok-500: #10b981;
|
||||
--color-warn-400: #fbbf24;
|
||||
--color-warn-500: #f59e0b;
|
||||
--color-danger-400: #fb7185;
|
||||
--color-danger-500: #f43f5e;
|
||||
|
||||
--font-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', monospace;
|
||||
|
||||
--radius-card: 1rem;
|
||||
|
||||
--animate-marquee: marquee 38s linear infinite;
|
||||
--animate-float: float 7s ease-in-out infinite;
|
||||
--animate-pulse-slow: pulse-slow 4s ease-in-out infinite;
|
||||
--animate-rise: rise 0.7s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
@keyframes marquee {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-12px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-slow {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(18px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
border-color: --alpha(var(--color-ink-600) / 60%);
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-ink-900);
|
||||
color: --alpha(#ffffff / 88%);
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
text-wrap: balance;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: --alpha(var(--color-brand-400) / 30%);
|
||||
}
|
||||
|
||||
/* Viditelny focus ring pro klavesovou navigaci */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-brand-400);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--color-ink-950);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-ink-700);
|
||||
border-radius: 999px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-ink-600);
|
||||
}
|
||||
|
||||
/* Respekt k uzivatelum, kteri nechteji animace */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Vlastni utility. Pouzivat jen pro veci, ktere se opakuji vsude.
|
||||
------------------------------------------------------------------- */
|
||||
|
||||
/* Sklenena karta - zaklad vetsiny panelu na webu i v dashboardu */
|
||||
@utility glass {
|
||||
background-color: --alpha(var(--color-ink-800) / 70%);
|
||||
border: 1px solid --alpha(var(--color-ink-600) / 70%);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
/* Gradientni text pro zvyrazneni slov v nadpisech */
|
||||
@utility text-gradient {
|
||||
background-image: linear-gradient(
|
||||
100deg,
|
||||
var(--color-brand-300),
|
||||
var(--color-brand-400) 40%,
|
||||
var(--color-accent-400)
|
||||
);
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
/* Jemna mrizka na pozadi sekci */
|
||||
@utility bg-grid {
|
||||
background-image:
|
||||
linear-gradient(to right, --alpha(var(--color-ink-600) / 45%) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, --alpha(var(--color-ink-600) / 45%) 1px, transparent 1px);
|
||||
background-size: 56px 56px;
|
||||
}
|
||||
|
||||
/* Maska pro postupne zmizeni obsahu na okrajích (marquee, mrizka) */
|
||||
@utility mask-fade-x {
|
||||
mask-image: linear-gradient(to right, transparent, black 12%, black 88%, transparent);
|
||||
}
|
||||
|
||||
@utility mask-fade-b {
|
||||
mask-image: linear-gradient(to bottom, black, transparent);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Tenka vrstva nad fetch. Vsechny volani API jdou pres ni,
|
||||
* aby se autorizace a chybove hlaseni resily na jednom miste.
|
||||
*/
|
||||
|
||||
const TOKEN_KEY = 'automia.token';
|
||||
|
||||
/**
|
||||
* Prefix reverse proxy, napr. "/apps/csbot-prototype".
|
||||
* Server ho vklada do index.html podle ROOT_PATH, viz src/index.ts.
|
||||
* Bez nej by requesty smerovaly na koren domeny, kam aplikace nepatri.
|
||||
*/
|
||||
export function basePath(): string {
|
||||
const value = window.__BASE_PATH__;
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
/** Slozi absolutni cestu vcetne prefixu proxy. */
|
||||
export function apiUrl(path: string): string {
|
||||
return `${basePath()}${path}`;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
readonly code?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
export function getToken(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
} catch (err) {
|
||||
// Privatni rezim / zakazane storage - nesmi to spadnout, ale chceme vedet.
|
||||
console.warn('[api] localStorage neni dostupne:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setToken(token: string | null) {
|
||||
try {
|
||||
if (token) localStorage.setItem(TOKEN_KEY, token);
|
||||
else localStorage.removeItem(TOKEN_KEY);
|
||||
} catch (err) {
|
||||
console.warn('[api] token nelze ulozit:', err);
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
const { body, auth = true, headers, ...rest } = options;
|
||||
const token = auth ? getToken() : null;
|
||||
|
||||
const response = await fetch(apiUrl(path), {
|
||||
...rest,
|
||||
headers: {
|
||||
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...headers,
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (response.status === 204) return undefined as T;
|
||||
|
||||
const isJson = response.headers.get('content-type')?.includes('application/json');
|
||||
const payload = isJson ? await response.json().catch(() => null) : await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
(isJson && payload && typeof payload === 'object' && 'message' in payload
|
||||
? String((payload as { message: unknown }).message)
|
||||
: null) ?? `Požadavek selhal (HTTP ${response.status}).`;
|
||||
const code =
|
||||
isJson && payload && typeof payload === 'object' && 'error' in payload
|
||||
? String((payload as { error: unknown }).error)
|
||||
: undefined;
|
||||
console.error(`[api] ${path} -> ${response.status} ${code ?? ''} ${message}`);
|
||||
throw new ApiError(message, response.status, code);
|
||||
}
|
||||
|
||||
return payload as T;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Minimalisticke spojovani class names. Zamerne bez zavislosti (clsx/twMerge) -
|
||||
* v prototypu si vystacime s filtrovanim falsy hodnot.
|
||||
*/
|
||||
export type ClassValue = string | number | false | null | undefined;
|
||||
|
||||
export function cn(...values: ClassValue[]): string {
|
||||
return values.filter(Boolean).join(' ');
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
BarChart3,
|
||||
Building2,
|
||||
Clock,
|
||||
FileAudio,
|
||||
FileInput,
|
||||
Globe,
|
||||
Hash,
|
||||
Landmark,
|
||||
LifeBuoy,
|
||||
Mail,
|
||||
Megaphone,
|
||||
MessageSquare,
|
||||
MousePointer,
|
||||
MousePointerClick,
|
||||
Plug,
|
||||
PhoneCall,
|
||||
Receipt,
|
||||
ScrollText,
|
||||
Search,
|
||||
ShoppingCart,
|
||||
Shuffle,
|
||||
Sparkles,
|
||||
Timer,
|
||||
Truck,
|
||||
Users,
|
||||
Webhook,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
/**
|
||||
* API posila u konektoru jen klic ikony (string) - React komponentu poslat nemuze.
|
||||
* Mapovani je tady. Neznamy klic se zaloguje a dostane obecnou ikonu,
|
||||
* aby chybejici zapis nerozbil celou stranku.
|
||||
*/
|
||||
const icons: Record<string, LucideIcon> = {
|
||||
BarChart3,
|
||||
Building2,
|
||||
Clock,
|
||||
FileAudio,
|
||||
FileInput,
|
||||
Globe,
|
||||
Hash,
|
||||
Landmark,
|
||||
LifeBuoy,
|
||||
Mail,
|
||||
Megaphone,
|
||||
MessageSquare,
|
||||
MousePointer,
|
||||
MousePointerClick,
|
||||
PhoneCall,
|
||||
Receipt,
|
||||
ScrollText,
|
||||
Search,
|
||||
ShoppingCart,
|
||||
Shuffle,
|
||||
Sparkles,
|
||||
Timer,
|
||||
Truck,
|
||||
Users,
|
||||
Webhook,
|
||||
};
|
||||
|
||||
export function connectorIcon(key: string): LucideIcon {
|
||||
const icon = icons[key];
|
||||
if (!icon) {
|
||||
console.warn(`[connectorIcons] neznamy klic ikony "${key}", pouzivam zastupnou`);
|
||||
return Plug;
|
||||
}
|
||||
return icon;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { apiUrl, getToken } from '@/lib/api';
|
||||
import type { DashboardEvent } from '@/types/events';
|
||||
|
||||
/**
|
||||
* Cteni SSE streamu pres fetch.
|
||||
*
|
||||
* Zamerne se nepouziva EventSource - ten neumi poslat Authorization hlavicku
|
||||
* a token by musel byt v adrese, odkud se dostane do access logu.
|
||||
* Cenou je rucni parsovani formatu a rucni znovupripojeni.
|
||||
*/
|
||||
|
||||
export type StreamStatus = 'connecting' | 'open' | 'reconnecting' | 'closed';
|
||||
|
||||
interface StreamHandlers {
|
||||
onEvent: (event: DashboardEvent) => void;
|
||||
onStatus: (status: StreamStatus) => void;
|
||||
}
|
||||
|
||||
/** Vraci funkci, ktera stream zavre. */
|
||||
export function connectEventStream(path: string, handlers: StreamHandlers): () => void {
|
||||
let closed = false;
|
||||
let controller: AbortController | null = null;
|
||||
let retryTimer: number | undefined;
|
||||
let attempt = 0;
|
||||
|
||||
async function run() {
|
||||
if (closed) return;
|
||||
|
||||
controller = new AbortController();
|
||||
handlers.onStatus(attempt === 0 ? 'connecting' : 'reconnecting');
|
||||
|
||||
try {
|
||||
const token = getToken();
|
||||
const response = await fetch(apiUrl(path), {
|
||||
headers: {
|
||||
Accept: 'text/event-stream',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`Stream se nepodařilo otevřít (HTTP ${response.status}).`);
|
||||
}
|
||||
|
||||
attempt = 0;
|
||||
handlers.onStatus('open');
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// Jednotlive zpravy oddeluje prazdny radek.
|
||||
let separator = buffer.indexOf('\n\n');
|
||||
while (separator !== -1) {
|
||||
const raw = buffer.slice(0, separator);
|
||||
buffer = buffer.slice(separator + 2);
|
||||
handleMessage(raw, handlers.onEvent);
|
||||
separator = buffer.indexOf('\n\n');
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Stream ukončen serverem.');
|
||||
} catch (err) {
|
||||
if (closed) return;
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return;
|
||||
|
||||
console.warn('[stream] spojeni preruseno, zkousim znovu:', err);
|
||||
handlers.onStatus('reconnecting');
|
||||
|
||||
// Exponencialni odstup se stropem, at neubijime server pri vypadku.
|
||||
attempt += 1;
|
||||
const delay = Math.min(1000 * 2 ** (attempt - 1), 15_000);
|
||||
retryTimer = window.setTimeout(run, delay);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMessage(raw: string, onEvent: (event: DashboardEvent) => void) {
|
||||
// Radky zacinajici dvojteckou jsou komentare (heartbeat).
|
||||
const lines = raw.split('\n').filter((line) => !line.startsWith(':'));
|
||||
const dataLines = lines
|
||||
.filter((line) => line.startsWith('data:'))
|
||||
.map((line) => line.slice(5).trim());
|
||||
|
||||
if (dataLines.length === 0) return;
|
||||
|
||||
const eventName = lines.find((line) => line.startsWith('event:'))?.slice(6).trim();
|
||||
if (eventName === 'connected') return;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(dataLines.join('\n')) as DashboardEvent;
|
||||
onEvent(parsed);
|
||||
} catch (err) {
|
||||
console.error('[stream] nelze precist udalost:', err, dataLines);
|
||||
}
|
||||
}
|
||||
|
||||
void run();
|
||||
|
||||
return () => {
|
||||
closed = true;
|
||||
if (retryTimer) window.clearTimeout(retryTimer);
|
||||
controller?.abort();
|
||||
handlers.onStatus('closed');
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import type {
|
||||
ConditionOperator,
|
||||
Connector,
|
||||
ConnectorOperation,
|
||||
FieldType,
|
||||
FlowStep,
|
||||
TriggerField,
|
||||
} from '@/types/dashboard';
|
||||
|
||||
/**
|
||||
* Ciste funkce pro praci se stromem akci. Zadny React, zadny stav -
|
||||
* vsechno vraci novy strom, aby se React prekreslil a slo to snadno testovat.
|
||||
*
|
||||
* Popis modelu: docs/08-automatizace-builder.md
|
||||
*/
|
||||
|
||||
/**
|
||||
* Cesta k jedne sekvenci kroku ve strome.
|
||||
* Prazdne pole = hlavni (korenova) sekvence.
|
||||
* `[{ stepId: 'st_3', branch: 'yes' }]` = vetev ANO podminky st_3.
|
||||
*/
|
||||
export type FlowPath = Array<{ stepId: string; branch: 'yes' | 'no' }>;
|
||||
|
||||
let idCounter = 0;
|
||||
|
||||
/** Docasne ID kroku na klientovi. Server si ho pri ulozeni prevezme. */
|
||||
export function newStepId(): string {
|
||||
idCounter += 1;
|
||||
return `st_${Date.now().toString(36)}_${idCounter}`;
|
||||
}
|
||||
|
||||
export function createActionStep(connectorId: string, operationId: string): FlowStep {
|
||||
return { id: newStepId(), kind: 'action', connectorId, operationId };
|
||||
}
|
||||
|
||||
export function createConditionStep(field: TriggerField): FlowStep {
|
||||
return {
|
||||
id: newStepId(),
|
||||
kind: 'condition',
|
||||
fieldId: field.id,
|
||||
operator: defaultOperatorFor(field.type),
|
||||
value: '',
|
||||
yes: [],
|
||||
no: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function newFieldId(): string {
|
||||
idCounter += 1;
|
||||
return `f_${Date.now().toString(36)}_${idCounter}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- podminky
|
||||
|
||||
/**
|
||||
* Ktere operatory maji smysl pro ktery typ parametru.
|
||||
* POZOR: stejna tabulka je na serveru v apps/api/src/data/conditions.ts.
|
||||
* Server je autorita - tady je jen proto, aby UI nenabidlo nesmysl.
|
||||
*/
|
||||
export const operatorsByType: Record<FieldType, ConditionOperator[]> = {
|
||||
string: ['eq', 'neq', 'contains', 'startsWith', 'isEmpty', 'isNotEmpty'],
|
||||
number: ['eq', 'neq', 'gt', 'gte', 'lt', 'lte'],
|
||||
boolean: ['isTrue', 'isFalse'],
|
||||
date: ['eq', 'gt', 'lt'],
|
||||
};
|
||||
|
||||
/** Operatory, ktere nepotrebuji hodnotu k porovnani. */
|
||||
const unaryOperators: ConditionOperator[] = ['isEmpty', 'isNotEmpty', 'isTrue', 'isFalse'];
|
||||
|
||||
export function isUnaryOperator(operator: ConditionOperator): boolean {
|
||||
return unaryOperators.includes(operator);
|
||||
}
|
||||
|
||||
export function defaultOperatorFor(type: FieldType): ConditionOperator {
|
||||
const first = operatorsByType[type][0];
|
||||
if (!first) {
|
||||
console.warn(`[flow] typ ${type} nema zadny operator, pouzivam "eq"`);
|
||||
return 'eq';
|
||||
}
|
||||
return first;
|
||||
}
|
||||
|
||||
const operatorLabels: Record<ConditionOperator, string> = {
|
||||
eq: 'je rovno',
|
||||
neq: 'není rovno',
|
||||
gt: 'je větší než',
|
||||
gte: 'je větší nebo rovno',
|
||||
lt: 'je menší než',
|
||||
lte: 'je menší nebo rovno',
|
||||
contains: 'obsahuje',
|
||||
startsWith: 'začíná na',
|
||||
isEmpty: 'je prázdné',
|
||||
isNotEmpty: 'není prázdné',
|
||||
isTrue: 'je splněno',
|
||||
isFalse: 'není splněno',
|
||||
};
|
||||
|
||||
/** U datumu zni porovnani prirozeneji jinak nez u cisel. */
|
||||
const dateOperatorLabels: Partial<Record<ConditionOperator, string>> = {
|
||||
gt: 'je po',
|
||||
lt: 'je před',
|
||||
eq: 'je přesně',
|
||||
};
|
||||
|
||||
export function operatorLabel(operator: ConditionOperator, type: FieldType): string {
|
||||
if (type === 'date' && dateOperatorLabels[operator]) return dateOperatorLabels[operator];
|
||||
return operatorLabels[operator];
|
||||
}
|
||||
|
||||
export const fieldTypeLabels: Record<FieldType, string> = {
|
||||
string: 'text',
|
||||
number: 'číslo',
|
||||
boolean: 'ano/ne',
|
||||
date: 'datum',
|
||||
};
|
||||
|
||||
/** Lidsky citelny zapis podminky, napr. „score je větší nebo rovno 15". */
|
||||
export function describeCondition(
|
||||
field: TriggerField | undefined,
|
||||
operator: ConditionOperator,
|
||||
value: string | undefined,
|
||||
): string {
|
||||
if (!field) return 'Neznámý parametr';
|
||||
const label = operatorLabel(operator, field.type);
|
||||
if (isUnaryOperator(operator)) return `${field.name} ${label}`;
|
||||
return `${field.name} ${label} ${value?.trim() || '…'}`;
|
||||
}
|
||||
|
||||
/** Zmena vlastnosti podminky kdekoliv ve strome. */
|
||||
export function updateCondition(
|
||||
steps: FlowStep[],
|
||||
stepId: string,
|
||||
patch: { fieldId?: string; operator?: ConditionOperator; value?: string },
|
||||
): FlowStep[] {
|
||||
return steps.map((step) => {
|
||||
if (step.kind !== 'condition') return step;
|
||||
if (step.id === stepId) return { ...step, ...patch };
|
||||
return {
|
||||
...step,
|
||||
yes: updateCondition(step.yes, stepId, patch),
|
||||
no: updateCondition(step.no, stepId, patch),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Vloz krok do sekvence na dane cesta+pozice. */
|
||||
export function insertStep(
|
||||
steps: FlowStep[],
|
||||
path: FlowPath,
|
||||
index: number,
|
||||
step: FlowStep,
|
||||
): FlowStep[] {
|
||||
if (path.length === 0) {
|
||||
const next = [...steps];
|
||||
next.splice(index, 0, step);
|
||||
return next;
|
||||
}
|
||||
|
||||
const [head, ...rest] = path;
|
||||
return steps.map((current) => {
|
||||
if (current.id !== head.stepId) return current;
|
||||
if (current.kind !== 'condition') {
|
||||
console.warn(`[flow] cesta vede pres krok ${current.id}, ktery neni podminka`);
|
||||
return current;
|
||||
}
|
||||
if (head.branch === 'yes') {
|
||||
return { ...current, yes: insertStep(current.yes, rest, index, step) };
|
||||
}
|
||||
return { ...current, no: insertStep(current.no, rest, index, step) };
|
||||
});
|
||||
}
|
||||
|
||||
/** Odstran krok podle ID kdekoliv ve strome (vcetne jeho vetvi). */
|
||||
export function removeStep(steps: FlowStep[], stepId: string): FlowStep[] {
|
||||
return steps
|
||||
.filter((step) => step.id !== stepId)
|
||||
.map((step) =>
|
||||
step.kind === 'condition'
|
||||
? { ...step, yes: removeStep(step.yes, stepId), no: removeStep(step.no, stepId) }
|
||||
: step,
|
||||
);
|
||||
}
|
||||
|
||||
/** Posun krok v ramci jeho sekvence o `offset` pozic. */
|
||||
export function moveStep(steps: FlowStep[], stepId: string, offset: number): FlowStep[] {
|
||||
const index = steps.findIndex((step) => step.id === stepId);
|
||||
|
||||
if (index !== -1) {
|
||||
const target = index + offset;
|
||||
if (target < 0 || target >= steps.length) return steps;
|
||||
const next = [...steps];
|
||||
const [moved] = next.splice(index, 1);
|
||||
next.splice(target, 0, moved);
|
||||
return next;
|
||||
}
|
||||
|
||||
return steps.map((step) =>
|
||||
step.kind === 'condition'
|
||||
? { ...step, yes: moveStep(step.yes, stepId, offset), no: moveStep(step.no, stepId, offset) }
|
||||
: step,
|
||||
);
|
||||
}
|
||||
|
||||
/** Pocet vsech kroku vcetne vnorenych vetvi. */
|
||||
export function countSteps(steps: FlowStep[]): number {
|
||||
return steps.reduce(
|
||||
(sum, step) =>
|
||||
step.kind === 'condition'
|
||||
? sum + 1 + countSteps(step.yes) + countSteps(step.no)
|
||||
: sum + 1,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
interface ResolvedOperation {
|
||||
connector: Connector;
|
||||
operation: ConnectorOperation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Najde konektor a operaci pro krok. Vraci null, pokud je odkaz rozbity
|
||||
* (napr. konektor byl z katalogu odebran) - volajici to musi zobrazit.
|
||||
*/
|
||||
export function resolveOperation(
|
||||
connectors: Connector[],
|
||||
connectorId: string,
|
||||
operationId: string,
|
||||
type: 'trigger' | 'action',
|
||||
): ResolvedOperation | null {
|
||||
const connector = connectors.find((c) => c.id === connectorId);
|
||||
if (!connector) {
|
||||
console.warn(`[flow] neznamy konektor ${connectorId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const pool = type === 'trigger' ? connector.triggers : connector.actions;
|
||||
const operation = pool.find((op) => op.id === operationId);
|
||||
if (!operation) {
|
||||
console.warn(`[flow] konektor ${connectorId} nema ${type} ${operationId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return { connector, operation };
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/** Formatovaci helpery - vsude cs-CZ, aby se cisla a datumy nemichaly. */
|
||||
|
||||
const numberFormat = new Intl.NumberFormat('cs-CZ');
|
||||
const dateTimeFormat = new Intl.DateTimeFormat('cs-CZ', {
|
||||
day: 'numeric',
|
||||
month: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
const dayFormat = new Intl.DateTimeFormat('cs-CZ', { day: 'numeric', month: 'numeric' });
|
||||
|
||||
export function formatNumber(value: number): string {
|
||||
return numberFormat.format(value);
|
||||
}
|
||||
|
||||
export function formatPercent(value: number, digits = 1): string {
|
||||
return `${value.toFixed(digits).replace('.', ',')} %`;
|
||||
}
|
||||
|
||||
export function formatDateTime(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
console.warn('[format] neplatne datum:', iso);
|
||||
return '—';
|
||||
}
|
||||
return dateTimeFormat.format(date);
|
||||
}
|
||||
|
||||
export function formatDay(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
console.warn('[format] neplatne datum:', iso);
|
||||
return '—';
|
||||
}
|
||||
return dayFormat.format(date);
|
||||
}
|
||||
|
||||
/** "před 12 min" / "před 3 h" / "před 2 dny" */
|
||||
export function formatRelative(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
console.warn('[format] neplatne datum:', iso);
|
||||
return '—';
|
||||
}
|
||||
const diffMin = Math.round((Date.now() - date.getTime()) / 60_000);
|
||||
if (diffMin < 1) return 'právě teď';
|
||||
if (diffMin < 60) return `před ${diffMin} min`;
|
||||
const diffHours = Math.round(diffMin / 60);
|
||||
if (diffHours < 24) return `před ${diffHours} h`;
|
||||
const diffDays = Math.round(diffHours / 24);
|
||||
return diffDays === 1 ? 'včera' : `před ${diffDays} dny`;
|
||||
}
|
||||
|
||||
/** Trvani v ms na citelny tvar (1,2 s / 74 s / 5 min). */
|
||||
export function formatDuration(ms: number): string {
|
||||
if (ms < 10_000) return `${(ms / 1000).toFixed(1).replace('.', ',')} s`;
|
||||
if (ms < 120_000) return `${Math.round(ms / 1000)} s`;
|
||||
return `${Math.round(ms / 60_000)} min`;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useEventStream } from '@/components/dashboard/EventStreamProvider';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import type { DashboardEventType } from '@/types/events';
|
||||
|
||||
interface QueryState<T> {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
reload: () => void;
|
||||
}
|
||||
|
||||
interface QueryOptions {
|
||||
/**
|
||||
* Typy udalosti, po kterych se maji data znovu nacist.
|
||||
* Diky tomu je dashboard zivy bez pravidelneho dotazovani serveru.
|
||||
*/
|
||||
refetchOn?: DashboardEventType[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimalisticky data-fetching hook pro dashboard.
|
||||
* 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);
|
||||
const [tick, setTick] = useState(0);
|
||||
|
||||
const reload = useCallback(() => setTick((value) => value + 1), []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
apiFetch<T>(path)
|
||||
.then((result) => {
|
||||
if (!cancelled) setData(result);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
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);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [path, tick]);
|
||||
|
||||
// Obnoveni dat pri prichozi udalosti ze streamu.
|
||||
const { subscribe } = useEventStream();
|
||||
const refetchKey = (options.refetchOn ?? []).join(',');
|
||||
const debounce = useRef<number | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (refetchKey.length === 0) return;
|
||||
const wanted = new Set(refetchKey.split(','));
|
||||
|
||||
const unsubscribe = 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);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
if (debounce.current) window.clearTimeout(debounce.current);
|
||||
};
|
||||
}, [subscribe, refetchKey, reload]);
|
||||
|
||||
return { data, loading, error, reload };
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Nastavi <title> a meta description pro danou stranku.
|
||||
* Zamerne bez knihovny (react-helmet) - pro prototyp staci.
|
||||
*/
|
||||
export function usePageMeta({ title, description }: { title: string; description?: string }) {
|
||||
useEffect(() => {
|
||||
document.title = title;
|
||||
|
||||
if (!description) return;
|
||||
const tag = document.querySelector('meta[name="description"]');
|
||||
if (!tag) {
|
||||
console.warn('[meta] tag <meta name="description"> v index.html chybi');
|
||||
return;
|
||||
}
|
||||
tag.setAttribute('content', description);
|
||||
}, [title, description]);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from '@/App';
|
||||
import { AuthProvider } from '@/auth/AuthContext';
|
||||
import { basePath } from '@/lib/api';
|
||||
import '@/index.css';
|
||||
|
||||
const container = document.getElementById('root');
|
||||
if (!container) {
|
||||
// Bez #root nema smysl pokracovat - chceme jasnou chybu, ne bilou stranku.
|
||||
throw new Error('Element #root nebyl v index.html nalezen.');
|
||||
}
|
||||
|
||||
createRoot(container).render(
|
||||
<StrictMode>
|
||||
{/* basename kvuli reverse proxy, aplikace nebezi v korenu domeny */}
|
||||
<BrowserRouter basename={basePath() || undefined}>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,145 @@
|
||||
import { Compass, HeartHandshake, Radar, ShieldCheck } from 'lucide-react';
|
||||
import { CallToAction } from '@/components/home/CallToAction';
|
||||
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 { 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 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' },
|
||||
];
|
||||
|
||||
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 %.' },
|
||||
];
|
||||
|
||||
export default function About() {
|
||||
usePageMeta({
|
||||
title: 'O nás — Automia',
|
||||
description:
|
||||
'Malý tým, který firmám staví automatizace, voiceboty a integrace — a pak je i provozuje.',
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow="O nás"
|
||||
title={
|
||||
<>
|
||||
Malý tým, <span className="text-gradient">který za svou práci ručí</span>
|
||||
</>
|
||||
}
|
||||
subtitle={`${brand.name} funguje od roku ${brand.founded}. Nejsme softwarový dům na sto lidí — jsme parta, která staví věci, co musí fungovat v pondělí ráno.`}
|
||||
/>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<ol className="relative space-y-6 border-l border-ink-600/70 pl-6">
|
||||
{milestones.map((milestone) => (
|
||||
<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>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<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."
|
||||
/>
|
||||
<div className="grid gap-5 sm:grid-cols-2">
|
||||
{values.map((value) => {
|
||||
const Icon = value.icon;
|
||||
return (
|
||||
<Card key={value.title} interactive>
|
||||
<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>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<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í."
|
||||
/>
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{team.map((member) => (
|
||||
<Card key={member.initials} interactive className="text-center">
|
||||
<span className="mx-auto grid size-16 place-items-center rounded-2xl bg-gradient-to-br from-brand-400/25 to-accent-500/25 font-mono text-lg font-bold text-brand-200">
|
||||
{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>
|
||||
</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>
|
||||
</Section>
|
||||
|
||||
<CallToAction />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { AlertCircle, CheckCircle2, Clock, Mail, MapPin, Phone, Send } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
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 { PageHeader } from '@/components/ui/PageHeader';
|
||||
import { Section } from '@/components/ui/Section';
|
||||
import { brand } from '@/config/brand';
|
||||
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;
|
||||
|
||||
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';
|
||||
|
||||
export default function Contact() {
|
||||
usePageMeta({
|
||||
title: 'Kontakt — Automia',
|
||||
description: 'Napište nám nebo zavolejte. Ozveme se do jednoho pracovního dne.',
|
||||
});
|
||||
|
||||
const [status, setStatus] = useState<Status>({ kind: 'idle' });
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const data = Object.fromEntries(new FormData(form).entries());
|
||||
|
||||
setStatus({ kind: 'sending' });
|
||||
try {
|
||||
const response = await apiFetch<{ message: string }>('/api/contact', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
body: data,
|
||||
});
|
||||
setStatus({ kind: 'ok', message: response.message });
|
||||
form.reset();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Zprávu se nepodařilo odeslat.';
|
||||
console.error('[contact] odeslani selhalo:', err);
|
||||
setStatus({ kind: 'error', message });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow="Kontakt"
|
||||
title={
|
||||
<>
|
||||
Ozvěte se. <span className="text-gradient">Odpovídáme rychle.</span>
|
||||
</>
|
||||
}
|
||||
subtitle="Napište, co řešíte — i když ještě nevíte, jak by to mělo fungovat. Na první hovor stačí popis problému."
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<Field label="E-mail" htmlFor="email">
|
||||
<input id="email" name="email" type="email" required className={inputClass} placeholder="jan@firma.cz" />
|
||||
</Field>
|
||||
<Field label="Firma" htmlFor="company" optional>
|
||||
<input id="company" name="company" className={inputClass} placeholder="Firma s.r.o." />
|
||||
</Field>
|
||||
<Field label="Telefon" htmlFor="phone" optional>
|
||||
<input id="phone" name="phone" className={inputClass} placeholder="+420 …" />
|
||||
</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>
|
||||
|
||||
<Field label="Co řešíte" htmlFor="message">
|
||||
<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ů…"
|
||||
/>
|
||||
</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'}
|
||||
</Button>
|
||||
<p className="text-xs text-white/35">
|
||||
Odesláním souhlasíte se zpracováním údajů pro účely odpovědi.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{status.kind === 'ok' && (
|
||||
<p className="flex items-center gap-2 rounded-xl border border-ok-400/30 bg-ok-500/10 px-4 py-3 text-sm text-ok-400">
|
||||
<CheckCircle2 className="size-4 shrink-0" />
|
||||
{status.message}
|
||||
</p>
|
||||
)}
|
||||
{status.kind === '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" />
|
||||
{status.message}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<h2 className="font-semibold text-white">Přímé kontakty</h2>
|
||||
<ul className="mt-4 space-y-4 text-sm">
|
||||
<ContactRow icon={<Mail className="size-4" />} label="E-mail">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<span className="text-white/70">
|
||||
{brand.address.street}
|
||||
<br />
|
||||
{brand.address.zip} {brand.address.city}
|
||||
<br />
|
||||
{brand.address.country}
|
||||
</span>
|
||||
</ContactRow>
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h2 className="font-semibold text-white">Fakturační údaje</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>
|
||||
<dd>{brand.legalName}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt className="text-white/40">IČO</dt>
|
||||
<dd>{brand.ico}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt className="text-white/40">DIČ</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>
|
||||
<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 →
|
||||
</Link>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
children,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<li className="flex gap-3">
|
||||
<span className="mt-0.5 grid size-8 shrink-0 place-items-center rounded-lg bg-brand-500/12 text-brand-300">
|
||||
{icon}
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-xs text-white/40">{label}</p>
|
||||
<div className="mt-0.5">{children}</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { CallToAction } from '@/components/home/CallToAction';
|
||||
import { Hero } from '@/components/home/Hero';
|
||||
import { LogoCloud } from '@/components/home/LogoCloud';
|
||||
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 { usePageMeta } from '@/lib/usePageMeta';
|
||||
|
||||
export default function Home() {
|
||||
usePageMeta({
|
||||
title: 'Automia — automatizace, voiceboti a integrace na míru',
|
||||
description:
|
||||
'Automatizujeme firemní procesy, stavíme voiceboty a propojujeme systémy. K tomu dashboardy, tickety a incident management.',
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Hero />
|
||||
<LogoCloud />
|
||||
<Products />
|
||||
<Stats />
|
||||
<References />
|
||||
<Process />
|
||||
<CallToAction />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { AlertCircle, ArrowLeft, 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 { brand } from '@/config/brand';
|
||||
import { usePageMeta } from '@/lib/usePageMeta';
|
||||
|
||||
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() {
|
||||
usePageMeta({
|
||||
title: 'Přihlášení — Automia',
|
||||
description: 'Přihlášení do klientského portálu Automia.',
|
||||
});
|
||||
|
||||
const { user, loading, login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
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';
|
||||
|
||||
if (!loading && user) return <Navigate to={redirectTo} replace />;
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const email = String(formData.get('email') ?? '');
|
||||
const password = String(formData.get('password') ?? '');
|
||||
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await login(email, password);
|
||||
navigate(redirectTo, { replace: true });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Přihlášení se nepodařilo.';
|
||||
console.warn('[login] neuspesne prihlaseni:', err);
|
||||
setError(message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-ink-900 px-5 py-14">
|
||||
<div aria-hidden className="pointer-events-none absolute inset-0 -z-10">
|
||||
<div className="absolute inset-0 bg-grid opacity-25" />
|
||||
<div className="animate-pulse-slow absolute -top-32 left-1/2 size-[34rem] -translate-x-1/2 rounded-full bg-brand-500/16 blur-[120px]" />
|
||||
<div className="animate-pulse-slow absolute -bottom-40 right-0 size-[26rem] rounded-full bg-accent-500/14 blur-[120px]" />
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-md">
|
||||
<Link
|
||||
to="/"
|
||||
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
|
||||
</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>
|
||||
|
||||
<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
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
className={inputClass}
|
||||
placeholder="vas@email.cz"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-1.5 flex items-baseline justify-between">
|
||||
<label htmlFor="password" className="block text-sm font-medium text-white/70">
|
||||
Heslo
|
||||
</label>
|
||||
<span className="text-xs text-white/30">Zapomenuté heslo? Napište nám.</span>
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className={inputClass}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{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" />
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full" disabled={submitting}>
|
||||
<LockKeyhole className="size-4" />
|
||||
{submitting ? 'Přihlašuji…' : 'Přihlásit se'}
|
||||
</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>
|
||||
<ul className="mt-2.5 space-y-1 font-mono text-xs text-white/50">
|
||||
<li>admin@automia.cz / demo1234</li>
|
||||
<li>klient@firma.cz / demo1234</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-center text-xs text-white/35">
|
||||
Nemáte účet?{' '}
|
||||
<Link to="/kontakt" className="font-medium text-brand-300 hover:text-brand-200">
|
||||
Ozvěte se nám
|
||||
</Link>{' '}
|
||||
— portál zřizujeme klientům {brand.name}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ButtonLink } from '@/components/ui/Button';
|
||||
import { Container } from '@/components/ui/Container';
|
||||
import { usePageMeta } from '@/lib/usePageMeta';
|
||||
|
||||
export default function NotFound() {
|
||||
usePageMeta({ title: 'Stránka nenalezena — Automia' });
|
||||
|
||||
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>
|
||||
<div className="mt-8 flex flex-wrap justify-center gap-3">
|
||||
<ButtonLink to="/">Zpět na homepage</ButtonLink>
|
||||
<ButtonLink to="/kontakt" variant="secondary">
|
||||
Napsat nám
|
||||
</ButtonLink>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Check } from 'lucide-react';
|
||||
import { CallToAction } from '@/components/home/CallToAction';
|
||||
import { PageHeader } from '@/components/ui/PageHeader';
|
||||
import { Section } from '@/components/ui/Section';
|
||||
import { products } from '@/data/products';
|
||||
import { usePageMeta } from '@/lib/usePageMeta';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
export default function Services() {
|
||||
usePageMeta({
|
||||
title: 'Služby — Automia',
|
||||
description:
|
||||
'Automatizace procesů, voiceboti, integrace systémů, dashboardy, tickety a incident management.',
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow="Služby"
|
||||
title={
|
||||
<>
|
||||
Co pro vás <span className="text-gradient">umíme postavit</span>
|
||||
</>
|
||||
}
|
||||
subtitle="Nabídku skládáme podle toho, co firmě reálně chybí. Nejčastěji ale řešíme těchto šest oblastí."
|
||||
/>
|
||||
|
||||
<Section className="py-16! sm:py-20!">
|
||||
<div className="space-y-6">
|
||||
{products.map((product, index) => {
|
||||
const Icon = product.icon;
|
||||
const reversed = index % 2 === 1;
|
||||
|
||||
return (
|
||||
<article
|
||||
key={product.slug}
|
||||
id={product.slug}
|
||||
className="glass scroll-mt-28 rounded-card p-6 sm:p-8"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-8 lg:grid-cols-[1.4fr_1fr] lg:items-center',
|
||||
reversed && 'lg:[&>*:first-child]:order-2',
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<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>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white">{product.name}</h2>
|
||||
<p className="text-sm text-brand-300/80">{product.tagline}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-5 leading-relaxed text-white/65">{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}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<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-5 border-t border-ink-600/60 pt-4 font-mono text-xs text-white/30">
|
||||
ukázková metrika
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<CallToAction />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
import { AlertCircle, ArrowLeft, Check, Info, Pause, Play, Save, Trash2 } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { DataState } from '@/components/dashboard/DataState';
|
||||
import { FlowCanvas } from '@/components/dashboard/flow/FlowCanvas';
|
||||
import { StepPicker } from '@/components/dashboard/flow/StepPicker';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import {
|
||||
countSteps,
|
||||
createActionStep,
|
||||
createConditionStep,
|
||||
insertStep,
|
||||
moveStep,
|
||||
removeStep,
|
||||
updateCondition,
|
||||
type FlowPath,
|
||||
} from '@/lib/flow';
|
||||
import { formatDateTime } from '@/lib/format';
|
||||
import { useApiQuery } from '@/lib/useApiQuery';
|
||||
import { usePageMeta } from '@/lib/usePageMeta';
|
||||
import type {
|
||||
AutomationDetail as Detail,
|
||||
AutomationFlow,
|
||||
ConnectorCatalog,
|
||||
TriggerField,
|
||||
} from '@/types/dashboard';
|
||||
|
||||
/** Kam se ma vlozit dalsi krok - null znamena, ze vyber neni otevreny. */
|
||||
interface PickerTarget {
|
||||
mode: 'trigger' | 'action';
|
||||
path: FlowPath;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export default function AutomationDetail() {
|
||||
const { id = '' } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const automation = useApiQuery<Detail>(`/api/dashboard/automations/${id}`);
|
||||
const catalog = useApiQuery<ConnectorCatalog>('/api/dashboard/connectors');
|
||||
|
||||
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'} — portál Automia` });
|
||||
|
||||
// Prevzeti dat ze serveru do editovatelneho stavu.
|
||||
useEffect(() => {
|
||||
if (!automation.data) return;
|
||||
setName(automation.data.name);
|
||||
setFlow(automation.data.flow);
|
||||
setEnabled(automation.data.enabled);
|
||||
setIssues(automation.data.issues);
|
||||
setDirty(false);
|
||||
}, [automation.data]);
|
||||
|
||||
const connectors = catalog.data?.items ?? [];
|
||||
const categories = catalog.data?.categories ?? [];
|
||||
|
||||
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);
|
||||
setDirty(true);
|
||||
setSavedAt(null);
|
||||
}, []);
|
||||
|
||||
function handlePick(connectorId: string, operationId: string) {
|
||||
if (!picker) {
|
||||
console.warn('[builder] vyber potvrzen bez otevreneho cile');
|
||||
return;
|
||||
}
|
||||
|
||||
if (picker.mode === 'trigger') {
|
||||
// Pri zmene spoustece drzime uz nadeklarovane parametry, aby se nezahodila prace.
|
||||
changeFlow({
|
||||
...flow,
|
||||
trigger: {
|
||||
connectorId,
|
||||
operationId,
|
||||
fields: flow.trigger?.fields ?? [],
|
||||
webhookToken: flow.trigger?.webhookToken,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
changeFlow({
|
||||
...flow,
|
||||
steps: insertStep(
|
||||
flow.steps,
|
||||
picker.path,
|
||||
picker.index,
|
||||
createActionStep(connectorId, operationId),
|
||||
),
|
||||
});
|
||||
}
|
||||
setPicker(null);
|
||||
}
|
||||
|
||||
function handlePickCondition() {
|
||||
if (!picker || picker.mode !== 'action') {
|
||||
console.warn('[builder] podminku lze vlozit jen jako krok');
|
||||
return;
|
||||
}
|
||||
|
||||
const firstField = flow.trigger?.fields[0];
|
||||
if (!firstField) {
|
||||
console.warn('[builder] podminku nelze pridat - spoustec nema zadne parametry');
|
||||
return;
|
||||
}
|
||||
|
||||
changeFlow({
|
||||
...flow,
|
||||
steps: insertStep(flow.steps, picker.path, picker.index, createConditionStep(firstField)),
|
||||
});
|
||||
setPicker(null);
|
||||
}
|
||||
|
||||
function handleChangeFields(fields: TriggerField[]) {
|
||||
if (!flow.trigger) {
|
||||
console.warn('[builder] zmena parametru bez spoustece');
|
||||
return;
|
||||
}
|
||||
changeFlow({ ...flow, trigger: { ...flow.trigger, fields } });
|
||||
}
|
||||
|
||||
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}`, {
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
async 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);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Link
|
||||
to="/dashboard/automatizace"
|
||||
className="inline-flex items-center gap-2 text-sm text-white/45 transition-colors hover:text-white"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Zpět na automatizace
|
||||
</Link>
|
||||
|
||||
<DataState
|
||||
loading={automation.loading || catalog.loading}
|
||||
error={automation.error ?? catalog.error}
|
||||
onRetry={() => {
|
||||
automation.reload();
|
||||
catalog.reload();
|
||||
}}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<input
|
||||
value={name}
|
||||
onChange={(event) => {
|
||||
setName(event.target.value);
|
||||
setDirty(true);
|
||||
setSavedAt(null);
|
||||
}}
|
||||
aria-label="Název automatizace"
|
||||
className="w-full rounded-lg border border-transparent bg-transparent px-2 py-1 -mx-2 text-2xl font-bold text-white transition-colors hover:border-ink-600/70 focus:border-brand-400/70 focus:bg-ink-850/60 focus:outline-none"
|
||||
/>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 px-0.5">
|
||||
<span className="font-mono text-xs text-white/35">{id}</span>
|
||||
{flow.trigger ? (
|
||||
<Badge tone={enabled ? 'ok' : 'neutral'}>
|
||||
<span
|
||||
className={
|
||||
enabled ? 'size-1.5 rounded-full bg-ok-400' : 'size-1.5 rounded-full bg-white/40'
|
||||
}
|
||||
/>
|
||||
{enabled ? 'Aktivní' : 'Pozastaveno'}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge tone="warn">Koncept — chybí spouštěč</Badge>
|
||||
)}
|
||||
<span className="text-xs text-white/40">
|
||||
{stepCount} {stepCount === 1 ? 'krok' : stepCount >= 2 && stepCount <= 4 ? 'kroky' : 'kroků'}
|
||||
</span>
|
||||
{dirty && <Badge tone="warn">Neuložené změny</Badge>}
|
||||
{!dirty && savedAt && (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-ok-400">
|
||||
<Check className="size-3.5" />
|
||||
Uloženo {formatDateTime(savedAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => void save(!enabled)}
|
||||
disabled={saving || !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}>
|
||||
<Save className="size-4" />
|
||||
{saving ? 'Ukládám…' : 'Uložit'}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void 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"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
<span className="sr-only">Smazat automatizaci</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{saveError && (
|
||||
<p className="flex items-center gap-2 rounded-xl border border-danger-400/30 bg-danger-500/10 px-4 py-3 text-sm text-danger-400">
|
||||
<AlertCircle className="size-4 shrink-0" />
|
||||
{saveError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
<p className="flex items-center gap-2 text-sm font-semibold text-warn-400">
|
||||
<AlertCircle className="size-4 shrink-0" />
|
||||
Než půjde zapnout, chybí doplnit
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1 pl-6 text-sm text-white/60">
|
||||
{issues.map((issue) => (
|
||||
<li key={issue} className="list-disc">
|
||||
{issue}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1fr_18rem]">
|
||||
<section className="glass rounded-card p-5 sm:p-6">
|
||||
<div className="mb-6 flex items-center justify-between gap-3">
|
||||
<h2 className="font-semibold text-white">Strom akcí</h2>
|
||||
<p className="text-xs text-white/35">
|
||||
Klikněte na <span className="font-mono">+</span> a vyberte, co se má stát
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FlowCanvas
|
||||
flow={flow}
|
||||
connectors={connectors}
|
||||
webhookBaseUrl={catalog.data?.webhookBaseUrl ?? ''}
|
||||
regenerating={regenerating}
|
||||
onPickTrigger={() => setPicker({ mode: 'trigger', path: [], index: 0 })}
|
||||
onChangeFields={handleChangeFields}
|
||||
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) })
|
||||
}
|
||||
onMoveStep={(stepId, offset) =>
|
||||
changeFlow({ ...flow, steps: moveStep(flow.steps, stepId, offset) })
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<aside className="space-y-4">
|
||||
<div className="glass rounded-card p-5">
|
||||
<h2 className="font-semibold text-white">Jak to funguje</h2>
|
||||
<ol className="mt-3 space-y-2.5 text-sm text-white/55">
|
||||
<li className="flex gap-2">
|
||||
<span className="font-mono text-brand-300">1.</span>
|
||||
Vyberte spouštěč — čím automatizace začne.
|
||||
</li>
|
||||
<li className="flex gap-2">
|
||||
<span className="font-mono text-brand-300">2.</span>
|
||||
Klikněte na <span className="font-mono">+</span> a přidejte akci nad
|
||||
napojenou službou.
|
||||
</li>
|
||||
<li className="flex gap-2">
|
||||
<span className="font-mono text-brand-300">3.</span>
|
||||
Potřebujete rozhodování? Přidejte podmínku — běh se rozdělí na větev
|
||||
ANO a NE.
|
||||
</li>
|
||||
<li className="flex gap-2">
|
||||
<span className="font-mono text-brand-300">4.</span>
|
||||
Uložte a zapněte.
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{automation.data && (
|
||||
<div className="glass rounded-card p-5">
|
||||
<h2 className="font-semibold text-white">Provoz</h2>
|
||||
<dl className="mt-3 space-y-2.5 text-sm">
|
||||
<Row label="Vytvořeno" value={formatDateTime(automation.data.createdAt)} />
|
||||
<Row label="Poslední úprava" value={formatDateTime(automation.data.updatedAt)} />
|
||||
<Row label="Spuštění dnes" value={String(automation.data.runsToday)} />
|
||||
<Row label="Úspěšnost" value={`${automation.data.successRate} %`} />
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="flex gap-2 rounded-xl border border-ink-600/60 px-4 py-3 text-xs leading-relaxed text-white/40">
|
||||
<Info className="mt-0.5 size-3.5 shrink-0" />
|
||||
Prototyp: nastavení jednotlivých polí kroku (mapování dat, filtry) zatím
|
||||
není součástí builderu. Uložený strom se nespouští.
|
||||
</p>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</DataState>
|
||||
|
||||
<StepPicker
|
||||
open={picker !== null}
|
||||
mode={picker?.mode ?? 'action'}
|
||||
connectors={connectors}
|
||||
categories={categories}
|
||||
onClose={() => setPicker(null)}
|
||||
onPickOperation={handlePick}
|
||||
onPickCondition={handlePickCondition}
|
||||
canAddCondition={(flow.trigger?.fields.length ?? 0) > 0}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex justify-between gap-3">
|
||||
<dt className="text-white/40">{label}</dt>
|
||||
<dd className="text-right text-white/70">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
BarChart3,
|
||||
ChevronRight,
|
||||
Network,
|
||||
PhoneCall,
|
||||
Plus,
|
||||
Workflow,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
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 { Modal } from '@/components/ui/Modal';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { formatDuration, formatNumber, formatPercent, formatRelative } from '@/lib/format';
|
||||
import { useApiQuery } from '@/lib/useApiQuery';
|
||||
import { usePageMeta } from '@/lib/usePageMeta';
|
||||
import type { Automation, AutomationDetail, ListResponse } from '@/types/dashboard';
|
||||
|
||||
const kindMeta: Record<Automation['kind'], { label: string; icon: LucideIcon }> = {
|
||||
workflow: { label: 'Workflow', icon: Workflow },
|
||||
voicebot: { label: 'Voicebot', icon: PhoneCall },
|
||||
integrace: { label: 'Integrace', icon: Network },
|
||||
report: { label: 'Report', icon: BarChart3 },
|
||||
};
|
||||
|
||||
export default function Automations() {
|
||||
usePageMeta({ title: 'Automatizace — portál Automia' });
|
||||
const { data, loading, error, reload } = useApiQuery<ListResponse<Automation>>(
|
||||
'/api/dashboard/automations',
|
||||
{
|
||||
refetchOn: [
|
||||
'automation.created',
|
||||
'automation.updated',
|
||||
'automation.deleted',
|
||||
'automation.run',
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Automatizace</h1>
|
||||
<p className="mt-1 text-sm text-white/50">
|
||||
Přehled běžících procesů. Klikněte na automatizaci pro úpravu stromu akcí.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={() => setCreating(true)}>
|
||||
<Plus className="size-4" />
|
||||
Nová automatizace
|
||||
</Button>
|
||||
<Link
|
||||
to="/dashboard/konektory"
|
||||
className="rounded-full px-4 py-2 text-sm font-medium text-white/55 transition-colors hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
Konektory →
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<DataState
|
||||
loading={loading}
|
||||
error={error}
|
||||
empty={data?.items.length === 0}
|
||||
onRetry={reload}
|
||||
emptyLabel="Zatím tu nic není. Vytvořte první automatizaci."
|
||||
>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{data?.items.map((automation) => (
|
||||
<AutomationCard key={automation.id} automation={automation} />
|
||||
))}
|
||||
</div>
|
||||
</DataState>
|
||||
|
||||
<CreateAutomationModal
|
||||
open={creating}
|
||||
onClose={() => setCreating(false)}
|
||||
onCreated={reload}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AutomationCard({ automation }: { automation: Automation }) {
|
||||
const meta = kindMeta[automation.kind];
|
||||
const Icon = meta.icon;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/dashboard/automatizace/${automation.id}`}
|
||||
className="glass group block rounded-card p-5 transition-all hover:-translate-y-0.5 hover:border-brand-400/50"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-brand-500/12 text-brand-300">
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<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ů'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{!automation.configured ? (
|
||||
<Badge tone="warn">Koncept</Badge>
|
||||
) : (
|
||||
<Badge tone={automation.enabled ? 'ok' : 'neutral'}>
|
||||
<span
|
||||
className={
|
||||
automation.enabled
|
||||
? 'size-1.5 rounded-full bg-ok-400'
|
||||
: 'size-1.5 rounded-full bg-white/40'
|
||||
}
|
||||
/>
|
||||
{automation.enabled ? 'Aktivní' : 'Pozastaveno'}
|
||||
</Badge>
|
||||
)}
|
||||
<ChevronRight className="size-4 text-white/20 transition-colors group-hover:text-brand-300" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl className="mt-5 grid grid-cols-2 gap-4 border-t border-ink-600/50 pt-4 sm:grid-cols-4">
|
||||
<Metric label="Dnes" value={formatNumber(automation.runsToday)} />
|
||||
<Metric label="Úspěšnost" value={formatPercent(automation.successRate)} />
|
||||
<Metric
|
||||
label="Prům. běh"
|
||||
value={automation.avgDurationMs === 0 ? '—' : formatDuration(automation.avgDurationMs)}
|
||||
/>
|
||||
<Metric label="Naposled" value={formatRelative(automation.lastRunAt)} />
|
||||
</dl>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="text-xs text-white/35">{label}</dt>
|
||||
<dd className="mt-0.5 text-sm font-semibold text-white tabular-nums">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Vytvoreni prazdne automatizace - strom se pak sklada na jejim detailu. */
|
||||
function CreateAutomationModal({
|
||||
open,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}) {
|
||||
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>) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Nová automatizace"
|
||||
description="Pojmenujte ji. Spouštěč a kroky vyberete hned na dalším kroku."
|
||||
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
|
||||
id="automation-name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
required
|
||||
minLength={3}
|
||||
placeholder="Např.: Poptávka z webu → CRM → 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>
|
||||
|
||||
{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" />
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
Zrušit
|
||||
</Button>
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting ? 'Vytvářím…' : 'Vytvořit a pokračovat'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { CheckCircle2, Clock, Plug, Search, Zap } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { DataState } from '@/components/dashboard/DataState';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { brand } from '@/config/brand';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { connectorIcon } from '@/lib/connectorIcons';
|
||||
import { useApiQuery } from '@/lib/useApiQuery';
|
||||
import { usePageMeta } from '@/lib/usePageMeta';
|
||||
import type { Connector, ConnectorCatalog, ConnectorCategory, ConnectorStatus } from '@/types/dashboard';
|
||||
|
||||
const statusMeta: Record<ConnectorStatus, { label: string; tone: 'ok' | 'neutral' | 'warn' }> = {
|
||||
connected: { label: 'Napojeno', tone: 'ok' },
|
||||
available: { label: 'Umíme napojit', tone: 'neutral' },
|
||||
planned: { label: 'Na roadmapě', tone: 'warn' },
|
||||
};
|
||||
|
||||
export default function Connectors() {
|
||||
usePageMeta({ title: 'Konektory — portál Automia' });
|
||||
|
||||
const { data, loading, error, reload } = useApiQuery<ConnectorCatalog>(
|
||||
'/api/dashboard/connectors',
|
||||
);
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
const [category, setCategory] = useState<ConnectorCategory | 'all'>('all');
|
||||
const [status, setStatus] = useState<ConnectorStatus | 'all'>('all');
|
||||
|
||||
const items = data?.items ?? [];
|
||||
const categories = data?.categories ?? [];
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
return items.filter((connector) => {
|
||||
if (category !== 'all' && connector.category !== category) return false;
|
||||
if (status !== 'all' && connector.status !== status) return false;
|
||||
if (needle.length === 0) return true;
|
||||
const haystack = [
|
||||
connector.name,
|
||||
connector.description,
|
||||
...connector.triggers.map((t) => t.name),
|
||||
...connector.actions.map((a) => a.name),
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
return haystack.includes(needle);
|
||||
});
|
||||
}, [items, query, category, status]);
|
||||
|
||||
const counts = useMemo(
|
||||
() => ({
|
||||
connected: items.filter((c) => c.status === 'connected').length,
|
||||
available: items.filter((c) => c.status === 'available').length,
|
||||
planned: items.filter((c) => c.status === 'planned').length,
|
||||
}),
|
||||
[items],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="text-2xl font-bold text-white">Konektory</h1>
|
||||
<p className="mt-1 text-sm text-white/50">
|
||||
Služby, které jdou použít v automatizacích. Každý konektor nabízí spouštěče
|
||||
(čím běh začne) a akce (co se má stát).
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<DataState loading={loading} error={error} onRetry={reload} empty={items.length === 0}>
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<SummaryTile
|
||||
icon={CheckCircle2}
|
||||
value={counts.connected}
|
||||
label="napojeno a připraveno k použití"
|
||||
tone="ok"
|
||||
/>
|
||||
<SummaryTile
|
||||
icon={Plug}
|
||||
value={counts.available}
|
||||
label="umíme napojit na požádání"
|
||||
tone="brand"
|
||||
/>
|
||||
<SummaryTile icon={Clock} value={counts.planned} label="na roadmapě" tone="warn" />
|
||||
</div>
|
||||
|
||||
<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
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Chip active={category === 'all'} onClick={() => setCategory('all')}>
|
||||
Všechny kategorie
|
||||
</Chip>
|
||||
{categories.map((cat) => (
|
||||
<Chip
|
||||
key={cat.id}
|
||||
active={category === cat.id}
|
||||
onClick={() => setCategory(cat.id)}
|
||||
>
|
||||
{cat.label}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<Chip active={status === 'all'} onClick={() => setStatus('all')}>
|
||||
Vše
|
||||
</Chip>
|
||||
{(['connected', 'available', 'planned'] as const).map((value) => (
|
||||
<Chip key={value} active={status === value} onClick={() => setStatus(value)}>
|
||||
{statusMeta[value].label}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-white/45">
|
||||
Nic neodpovídá filtru. Chybí vám konektor?{' '}
|
||||
<a href={`mailto:${brand.email}`} className="text-brand-300 hover:text-brand-200">
|
||||
Napište nám
|
||||
</a>
|
||||
, většinu služeb umíme napojit přes HTTP požadavek.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{filtered.map((connector) => (
|
||||
<ConnectorCard key={connector.id} connector={connector} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DataState>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryTile({
|
||||
icon: Icon,
|
||||
value,
|
||||
label,
|
||||
tone,
|
||||
}: {
|
||||
icon: typeof Plug;
|
||||
value: number;
|
||||
label: string;
|
||||
tone: 'ok' | 'brand' | 'warn';
|
||||
}) {
|
||||
const tones = {
|
||||
ok: 'bg-ok-500/12 text-ok-400',
|
||||
brand: 'bg-brand-500/12 text-brand-300',
|
||||
warn: 'bg-warn-500/12 text-warn-400',
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<div className="glass flex items-center gap-4 rounded-card p-5">
|
||||
<span className={cn('grid size-10 shrink-0 place-items-center rounded-xl', tones[tone])}>
|
||||
<Icon className="size-5" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-2xl font-extrabold text-white tabular-nums">{value}</p>
|
||||
<p className="text-xs text-white/45">{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 ConnectorCard({ connector }: { connector: Connector }) {
|
||||
const Icon = connectorIcon(connector.icon);
|
||||
const meta = statusMeta[connector.status];
|
||||
|
||||
return (
|
||||
<article className="glass flex h-full flex-col rounded-card p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<span className="grid size-11 shrink-0 place-items-center rounded-xl bg-gradient-to-br from-brand-500/20 to-accent-500/20 text-brand-300">
|
||||
<Icon className="size-5" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-semibold text-white">{connector.name}</h2>
|
||||
<p className="mt-0.5 text-sm text-white/55">{connector.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge tone={meta.tone}>{meta.label}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid flex-1 gap-4 border-t border-ink-600/50 pt-4 sm:grid-cols-2">
|
||||
<OperationList
|
||||
title="Spouštěče"
|
||||
icon={<Zap className="size-3 text-brand-300" />}
|
||||
names={connector.triggers.map((t) => t.name)}
|
||||
emptyLabel="Nelze použít jako spouštěč"
|
||||
/>
|
||||
<OperationList
|
||||
title="Akce"
|
||||
icon={<Plug className="size-3 text-accent-300" />}
|
||||
names={connector.actions.map((a) => a.name)}
|
||||
emptyLabel="Žádné akce"
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function OperationList({
|
||||
title,
|
||||
icon,
|
||||
names,
|
||||
emptyLabel,
|
||||
}: {
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
names: string[];
|
||||
emptyLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-2 flex items-center gap-1.5 text-xs font-semibold tracking-wide text-white/45 uppercase">
|
||||
{icon}
|
||||
{title}
|
||||
</p>
|
||||
{names.length === 0 ? (
|
||||
<p className="text-xs text-white/25">{emptyLabel}</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{names.map((name) => (
|
||||
<li key={name} className="text-sm text-white/60">
|
||||
{name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { DataState } from '@/components/dashboard/DataState';
|
||||
import { IncidentSeverityBadge, IncidentStatusBadge } from '@/components/dashboard/StatusBadge';
|
||||
import { formatDateTime, formatRelative } from '@/lib/format';
|
||||
import { useApiQuery } from '@/lib/useApiQuery';
|
||||
import { usePageMeta } from '@/lib/usePageMeta';
|
||||
import type { Incident, ListResponse } from '@/types/dashboard';
|
||||
|
||||
export default function Incidents() {
|
||||
usePageMeta({ title: 'Incidenty — portál Automia' });
|
||||
const { data, loading, error, reload } = useApiQuery<ListResponse<Incident>>(
|
||||
'/api/dashboard/incidents',
|
||||
{ refetchOn: ['incident.started', 'incident.updated', 'incident.resolved'] },
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="text-2xl font-bold text-white">Incidenty</h1>
|
||||
<p className="mt-1 text-sm text-white/50">
|
||||
Výpadky a degradace služeb. Timeline zásahů a post-mortem doplníme v další iteraci.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<DataState
|
||||
loading={loading}
|
||||
error={error}
|
||||
empty={data?.items.length === 0}
|
||||
onRetry={reload}
|
||||
emptyLabel="Žádné incidenty. Všechno běží."
|
||||
>
|
||||
<ul className="space-y-4">
|
||||
{data?.items.map((incident) => (
|
||||
<li key={incident.id} className="glass rounded-card p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="font-mono text-xs text-white/40">{incident.id}</span>
|
||||
<IncidentSeverityBadge severity={incident.severity} />
|
||||
</div>
|
||||
<h2 className="mt-2 font-semibold text-white">{incident.title}</h2>
|
||||
<p className="mt-1 text-sm text-white/50">Služba: {incident.service}</p>
|
||||
</div>
|
||||
<IncidentStatusBadge status={incident.status} />
|
||||
</div>
|
||||
|
||||
<dl className="mt-5 grid gap-4 border-t border-ink-600/50 pt-4 text-sm sm:grid-cols-3">
|
||||
<div>
|
||||
<dt className="text-xs text-white/35">Začátek</dt>
|
||||
<dd className="mt-0.5 text-white/70">{formatDateTime(incident.startedAt)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-white/35">Trvání</dt>
|
||||
<dd className="mt-0.5 text-white/70">
|
||||
{incident.resolvedAt
|
||||
? `vyřešeno ${formatRelative(incident.resolvedAt)}`
|
||||
: `běží ${formatRelative(incident.startedAt).replace('před ', '')}`}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-white/35">Post-mortem</dt>
|
||||
<dd className="mt-0.5 text-white/70">
|
||||
{incident.status === 'resolved' ? 'k dispozici' : 'po vyřešení'}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</DataState>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { Activity, AlarmClock, Clock, LifeBuoy, RefreshCw, Workflow } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
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 { Button } from '@/components/ui/Button';
|
||||
import { formatNumber, formatPercent, formatRelative } from '@/lib/format';
|
||||
import { useApiQuery } from '@/lib/useApiQuery';
|
||||
import { usePageMeta } from '@/lib/usePageMeta';
|
||||
import type { DashboardSummary, Incident, ListResponse, Ticket } from '@/types/dashboard';
|
||||
|
||||
export default function Overview() {
|
||||
usePageMeta({ title: 'Přehled — portál Automia' });
|
||||
|
||||
const { user } = useAuth();
|
||||
|
||||
// Prehled se prekresluje na kazdou zmenu, proto sleduje vsechny udalosti.
|
||||
const summary = useApiQuery<DashboardSummary>('/api/dashboard/summary', {
|
||||
refetchOn: [
|
||||
'ticket.created',
|
||||
'ticket.resolved',
|
||||
'incident.started',
|
||||
'incident.resolved',
|
||||
'automation.updated',
|
||||
'automation.created',
|
||||
'automation.deleted',
|
||||
'automation.run',
|
||||
'webhook.received',
|
||||
],
|
||||
});
|
||||
const tickets = useApiQuery<ListResponse<Ticket>>('/api/dashboard/tickets', {
|
||||
refetchOn: ['ticket.created', 'ticket.updated', 'ticket.resolved'],
|
||||
});
|
||||
const incidents = useApiQuery<ListResponse<Incident>>('/api/dashboard/incidents', {
|
||||
refetchOn: ['incident.started', 'incident.updated', 'incident.resolved'],
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-7">
|
||||
<header className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">
|
||||
Dobrý den, {user?.name?.split(' ')[0] ?? 'vítejte'}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-white/50">
|
||||
Tady je stav vašich automatizací a požadavků.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
summary.reload();
|
||||
tickets.reload();
|
||||
incidents.reload();
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
Obnovit
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<DataState loading={summary.loading} error={summary.error} onRetry={summary.reload}>
|
||||
{summary.data && (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<StatTile
|
||||
icon={Workflow}
|
||||
label="Aktivní automatizace"
|
||||
value={formatNumber(summary.data.activeAutomations)}
|
||||
hint={`${formatNumber(summary.data.runsToday)} spuštění dnes`}
|
||||
/>
|
||||
<StatTile
|
||||
icon={LifeBuoy}
|
||||
label="Otevřené tickety"
|
||||
value={formatNumber(summary.data.openTickets)}
|
||||
hint="včetně čekajících na vás"
|
||||
tone={summary.data.openTickets > 3 ? 'warn' : 'ok'}
|
||||
/>
|
||||
<StatTile
|
||||
icon={AlarmClock}
|
||||
label="Běžící incidenty"
|
||||
value={formatNumber(summary.data.activeIncidents)}
|
||||
hint={summary.data.activeIncidents === 0 ? 'vše v pořádku' : 'pracujeme na tom'}
|
||||
tone={summary.data.activeIncidents > 0 ? 'danger' : 'ok'}
|
||||
/>
|
||||
<StatTile
|
||||
icon={Clock}
|
||||
label="Ušetřeno tento měsíc"
|
||||
value={`${formatNumber(summary.data.savedHoursMonth)} h`}
|
||||
hint={`dostupnost ${formatPercent(summary.data.uptime, 2)}`}
|
||||
tone="ok"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="glass rounded-card p-5 sm:p-6">
|
||||
<RunsChart series={summary.data.series} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DataState>
|
||||
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
<section className="glass rounded-card p-5 sm:p-6">
|
||||
<div className="mb-5 flex items-center justify-between gap-3">
|
||||
<h2 className="font-semibold text-white">Poslední tickety</h2>
|
||||
<Link
|
||||
to="/dashboard/tickety"
|
||||
className="text-sm font-medium text-brand-300 hover:text-brand-200"
|
||||
>
|
||||
Všechny →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<DataState
|
||||
loading={tickets.loading}
|
||||
error={tickets.error}
|
||||
empty={tickets.data?.items.length === 0}
|
||||
onRetry={tickets.reload}
|
||||
emptyLabel="Žádné tickety — dobrá zpráva."
|
||||
>
|
||||
<ul className="divide-y divide-ink-600/50">
|
||||
{tickets.data?.items.slice(0, 4).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">
|
||||
<p className="truncate text-sm text-white/80">{ticket.subject}</p>
|
||||
<p className="mt-0.5 text-xs text-white/40">
|
||||
{ticket.requester} · {formatRelative(ticket.updatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<TicketStatusBadge status={ticket.status} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</DataState>
|
||||
</section>
|
||||
|
||||
<section className="glass rounded-card p-5 sm:p-6">
|
||||
<div className="mb-5 flex items-center justify-between gap-3">
|
||||
<h2 className="font-semibold text-white">Incidenty</h2>
|
||||
<Link
|
||||
to="/dashboard/incidenty"
|
||||
className="text-sm font-medium text-brand-300 hover:text-brand-200"
|
||||
>
|
||||
Všechny →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<DataState
|
||||
loading={incidents.loading}
|
||||
error={incidents.error}
|
||||
empty={incidents.data?.items.length === 0}
|
||||
onRetry={incidents.reload}
|
||||
emptyLabel="Žádné incidenty."
|
||||
>
|
||||
<ul className="divide-y divide-ink-600/50">
|
||||
{incidents.data?.items.slice(0, 4).map((incident) => (
|
||||
<li key={incident.id} className="flex items-start gap-3 py-3.5 first:pt-0 last:pb-0">
|
||||
<span className="mt-0.5 grid size-7 shrink-0 place-items-center rounded-lg bg-white/5 text-white/40">
|
||||
<Activity className="size-3.5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-white/80">{incident.title}</p>
|
||||
<p className="mt-0.5 text-xs text-white/40">
|
||||
{incident.service} · začátek {formatRelative(incident.startedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<IncidentStatusBadge status={incident.status} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</DataState>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Construction } from 'lucide-react';
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
import { usePageMeta } from '@/lib/usePageMeta';
|
||||
|
||||
/** Zamerne jen kostra - obsah nastaveni doresime pri stavbe dashboardu. */
|
||||
export default function Settings() {
|
||||
usePageMeta({ title: 'Nastavení — portál Automia' });
|
||||
const { user } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="text-2xl font-bold text-white">Nastavení</h1>
|
||||
<p className="mt-1 text-sm text-white/50">Údaje o účtu a organizaci.</p>
|
||||
</header>
|
||||
|
||||
<section className="glass rounded-card p-5 sm:p-6">
|
||||
<h2 className="font-semibold text-white">Váš účet</h2>
|
||||
<dl className="mt-4 grid gap-4 sm:grid-cols-2">
|
||||
<Row label="Jméno" value={user?.name ?? '—'} />
|
||||
<Row label="E-mail" value={user?.email ?? '—'} />
|
||||
<Row label="Organizace" value={user?.company ?? '—'} />
|
||||
<Row label="Role" value={user?.role === 'admin' ? 'Interní správce' : 'Klient'} />
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="rounded-card border border-dashed border-ink-600/70 p-8 text-center">
|
||||
<Construction className="mx-auto size-6 text-white/30" />
|
||||
<h2 className="mt-3 font-semibold text-white">Připravujeme</h2>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm leading-relaxed text-white/50">
|
||||
Změna hesla, dvoufaktorové ověření, správa uživatelů organizace, notifikace a API klíče.
|
||||
Rozsah domluvíme při stavbě dashboardu.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-ink-600/50 bg-ink-850/50 px-4 py-3">
|
||||
<dt className="text-xs text-white/35">{label}</dt>
|
||||
<dd className="mt-0.5 text-sm text-white/80">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { DataState } from '@/components/dashboard/DataState';
|
||||
import { TicketPriorityBadge, TicketStatusBadge } from '@/components/dashboard/StatusBadge';
|
||||
import { formatDateTime, formatRelative } from '@/lib/format';
|
||||
import { useApiQuery } from '@/lib/useApiQuery';
|
||||
import { usePageMeta } from '@/lib/usePageMeta';
|
||||
import type { ListResponse, Ticket } from '@/types/dashboard';
|
||||
|
||||
export default function Tickets() {
|
||||
usePageMeta({ title: 'Tickety — portál Automia' });
|
||||
const { data, loading, error, reload } = useApiQuery<ListResponse<Ticket>>(
|
||||
'/api/dashboard/tickets',
|
||||
{ refetchOn: ['ticket.created', 'ticket.updated', 'ticket.resolved'] },
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="text-2xl font-bold text-white">Tickety</h1>
|
||||
<p className="mt-1 text-sm text-white/50">
|
||||
Požadavky napříč kanály. Filtrování, komentáře a zakládání ticketů přidáme v další
|
||||
iteraci.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="glass overflow-hidden rounded-card">
|
||||
<DataState
|
||||
loading={loading}
|
||||
error={error}
|
||||
empty={data?.items.length === 0}
|
||||
onRetry={reload}
|
||||
emptyLabel="Žádné tickety."
|
||||
>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[52rem] text-left text-sm">
|
||||
<thead className="border-b border-ink-600/60 text-xs tracking-wide text-white/40 uppercase">
|
||||
<tr>
|
||||
<th className="px-5 py-3.5 font-medium">ID</th>
|
||||
<th className="px-5 py-3.5 font-medium">Předmět</th>
|
||||
<th className="px-5 py-3.5 font-medium">Zadavatel</th>
|
||||
<th className="px-5 py-3.5 font-medium">Stav</th>
|
||||
<th className="px-5 py-3.5 font-medium">Priorita</th>
|
||||
<th className="px-5 py-3.5 font-medium">Řeší</th>
|
||||
<th className="px-5 py-3.5 font-medium">Aktualizace</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-600/40">
|
||||
{data?.items.map((ticket) => (
|
||||
<tr key={ticket.id} className="transition-colors hover:bg-white/[0.03]">
|
||||
<td className="px-5 py-4 font-mono text-xs text-white/45">{ticket.id}</td>
|
||||
<td className="px-5 py-4 text-white/85">{ticket.subject}</td>
|
||||
<td className="px-5 py-4 text-white/55">{ticket.requester}</td>
|
||||
<td className="px-5 py-4">
|
||||
<TicketStatusBadge status={ticket.status} />
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<TicketPriorityBadge priority={ticket.priority} />
|
||||
</td>
|
||||
<td className="px-5 py-4 text-white/55">
|
||||
{ticket.assignee ?? <span className="text-white/30">nepřiřazeno</span>}
|
||||
</td>
|
||||
<td className="px-5 py-4 text-white/45">
|
||||
<span title={formatDateTime(ticket.updatedAt)}>
|
||||
{formatRelative(ticket.updatedAt)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</DataState>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Typy odpovedi dashboard API. Musi zustat v souladu s apps/api/src/data/mock.ts.
|
||||
* Az bude API stabilni, vygenerovat je sdilene (viz docs/06-dashboard.md).
|
||||
*/
|
||||
|
||||
export type TicketStatus = 'new' | 'open' | 'waiting' | 'resolved';
|
||||
export type TicketPriority = 'low' | 'normal' | 'high' | 'critical';
|
||||
export type IncidentSeverity = 'sev1' | 'sev2' | 'sev3';
|
||||
export type IncidentStatus = 'investigating' | 'identified' | 'monitoring' | 'resolved';
|
||||
|
||||
export interface Ticket {
|
||||
id: string;
|
||||
subject: string;
|
||||
requester: string;
|
||||
status: TicketStatus;
|
||||
priority: TicketPriority;
|
||||
assignee: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Incident {
|
||||
id: string;
|
||||
title: string;
|
||||
service: string;
|
||||
severity: IncidentSeverity;
|
||||
status: IncidentStatus;
|
||||
startedAt: string;
|
||||
resolvedAt: string | null;
|
||||
}
|
||||
|
||||
export type AutomationKind = 'workflow' | 'voicebot' | 'integrace' | 'report';
|
||||
|
||||
export interface Automation {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: AutomationKind;
|
||||
enabled: boolean;
|
||||
runsToday: number;
|
||||
successRate: number;
|
||||
avgDurationMs: number;
|
||||
lastRunAt: string;
|
||||
/** Pocet vsech kroku vcetne vnorenych vetvi. */
|
||||
stepCount: number;
|
||||
/** false = automatizace jeste nema spoustec (koncept). */
|
||||
configured: boolean;
|
||||
/** Co chybi k zapnuti. Prazdne = hotova. */
|
||||
issues: string[];
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- konektory
|
||||
|
||||
export type ConnectorCategory =
|
||||
| 'spoustece'
|
||||
| 'crm'
|
||||
| 'ekonomika'
|
||||
| 'logistika'
|
||||
| 'komunikace'
|
||||
| 'analytika'
|
||||
| 'ai'
|
||||
| 'nastroje';
|
||||
|
||||
export type ConnectorStatus = 'connected' | 'available' | 'planned';
|
||||
|
||||
export interface ConnectorOperation {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
fields?: string[];
|
||||
/**
|
||||
* Jen u triggeru: true = vstupni parametry si definuje uzivatel
|
||||
* (webhook, formular). false/chybi = data urcuje sluzba.
|
||||
*/
|
||||
customPayload?: boolean;
|
||||
}
|
||||
|
||||
export interface Connector {
|
||||
id: string;
|
||||
name: string;
|
||||
category: ConnectorCategory;
|
||||
description: string;
|
||||
/** Klic ikony - mapuje se v lib/connectorIcons.ts */
|
||||
icon: string;
|
||||
status: ConnectorStatus;
|
||||
triggers: ConnectorOperation[];
|
||||
actions: ConnectorOperation[];
|
||||
}
|
||||
|
||||
export interface ConnectorCatalog {
|
||||
categories: Array<{ id: ConnectorCategory; label: string }>;
|
||||
items: Connector[];
|
||||
/** Ktere operatory server povoli pro ktery typ parametru. */
|
||||
operatorsByType: Record<FieldType, ConditionOperator[]>;
|
||||
/** Zaklad adresy webhooku, napr. "https://api.automia.cz/webhook". */
|
||||
webhookBaseUrl: string;
|
||||
}
|
||||
|
||||
// ------------------------------------------------- parametry a podminky
|
||||
|
||||
export type FieldType = 'string' | 'number' | 'boolean' | 'date';
|
||||
|
||||
export type ConditionOperator =
|
||||
| 'eq'
|
||||
| 'neq'
|
||||
| 'gt'
|
||||
| 'gte'
|
||||
| 'lt'
|
||||
| 'lte'
|
||||
| 'contains'
|
||||
| 'startsWith'
|
||||
| 'isEmpty'
|
||||
| 'isNotEmpty'
|
||||
| 'isTrue'
|
||||
| 'isFalse';
|
||||
|
||||
/** Vstupni parametr, ktery spoustec preda do stromu. */
|
||||
export interface TriggerField {
|
||||
id: string;
|
||||
/** Klic v prichozich datech. Podminky se odkazuji na `id`, ne na nej. */
|
||||
name: string;
|
||||
type: FieldType;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- strom automatizace
|
||||
|
||||
export interface FlowTrigger {
|
||||
connectorId: string;
|
||||
operationId: string;
|
||||
fields: TriggerField[];
|
||||
/** Generuje vyhradne server, klient ho jen zobrazuje. */
|
||||
webhookToken?: string;
|
||||
}
|
||||
|
||||
/** Krok stromu: bud akce nad konektorem, nebo podminka se dvema vetvemi. */
|
||||
export type FlowStep =
|
||||
| {
|
||||
id: string;
|
||||
kind: 'action';
|
||||
connectorId: string;
|
||||
operationId: string;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
kind: 'condition';
|
||||
/** id parametru z trigger.fields */
|
||||
fieldId: string;
|
||||
operator: ConditionOperator;
|
||||
value?: string;
|
||||
yes: FlowStep[];
|
||||
no: FlowStep[];
|
||||
};
|
||||
|
||||
export interface AutomationFlow {
|
||||
trigger: FlowTrigger | null;
|
||||
steps: FlowStep[];
|
||||
}
|
||||
|
||||
export interface AutomationDetail extends Automation {
|
||||
flow: AutomationFlow;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SeriesPoint {
|
||||
date: string;
|
||||
runs: number;
|
||||
failures: number;
|
||||
}
|
||||
|
||||
export interface DashboardSummary {
|
||||
openTickets: number;
|
||||
activeIncidents: number;
|
||||
activeAutomations: number;
|
||||
runsToday: number;
|
||||
savedHoursMonth: number;
|
||||
uptime: number;
|
||||
series: SeriesPoint[];
|
||||
}
|
||||
|
||||
export interface ListResponse<T> {
|
||||
items: T[];
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Typy udalosti dashboardu.
|
||||
* Musi zustat v souladu s apps/api/src/events/bus.ts.
|
||||
*/
|
||||
|
||||
export type DashboardEventType =
|
||||
| 'ticket.created'
|
||||
| 'ticket.updated'
|
||||
| 'ticket.resolved'
|
||||
| 'incident.started'
|
||||
| 'incident.updated'
|
||||
| 'incident.resolved'
|
||||
| 'automation.created'
|
||||
| 'automation.updated'
|
||||
| 'automation.deleted'
|
||||
| 'automation.run'
|
||||
| 'webhook.received';
|
||||
|
||||
export interface DashboardEvent {
|
||||
id: string;
|
||||
type: DashboardEventType;
|
||||
at: string;
|
||||
message: string;
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
/**
|
||||
* Prefix reverse proxy vlozeny serverem do index.html podle ROOT_PATH.
|
||||
* Prazdny retezec znamena, ze aplikace bezi v korenu domeny.
|
||||
*/
|
||||
__BASE_PATH__?: string;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user