From 7b045a9f20ef68cd89e753e83d5ec18961cf2edd Mon Sep 17 00:00:00 2001 From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:00:37 +0200 Subject: [PATCH] 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/. --- .dockerignore | 4 + .gitignore | 16 + Dockerfile | 2 + README.md | 69 +- documentation/01-prehled-a-stav.md | 45 + documentation/02-appfactory-proxy.md | 103 + documentation/03-architektura-a-mapa-kodu.md | 85 + documentation/04-api.md | 124 + documentation/05-dashboard-a-builder.md | 119 + documentation/99-zmeny.md | 59 + package-lock.json | 4547 +++++++++++++++++ package.json | 43 +- src/config.ts | 58 + src/data/automationStore.ts | 582 +++ src/data/conditions.ts | 48 + src/data/connectors.ts | 699 +++ src/data/incidentStore.ts | 115 + src/data/mock.ts | 48 + src/data/ticketStore.ts | 148 + src/data/users.ts | 37 + src/events/bus.ts | 80 + src/index.ts | 196 +- src/middleware/auth.ts | 52 + src/openapi.ts | 600 +++ src/routes/auth.ts | 63 + src/routes/contact.ts | 38 + src/routes/dashboard.ts | 251 + src/routes/simulate.ts | 153 + src/routes/stream.ts | 62 + src/routes/webhook.ts | 107 + src/types.ts | 31 + tsconfig.json | 18 +- vite.config.ts | 38 + web/index.html | 24 + web/public/favicon.svg | 18 + web/src/App.tsx | 71 + web/src/auth/AuthContext.tsx | 89 + web/src/auth/RequireAuth.tsx | 23 + .../components/dashboard/DashboardLayout.tsx | 199 + web/src/components/dashboard/DataState.tsx | 60 + .../dashboard/EventStreamProvider.tsx | 94 + web/src/components/dashboard/EventToasts.tsx | 88 + .../components/dashboard/LiveIndicator.tsx | 33 + web/src/components/dashboard/RunsChart.tsx | 113 + .../components/dashboard/SimulationModal.tsx | 281 + web/src/components/dashboard/StatTile.tsx | 42 + web/src/components/dashboard/StatusBadge.tsx | 82 + .../components/dashboard/flow/FlowCanvas.tsx | 567 ++ .../components/dashboard/flow/StepPicker.tsx | 370 ++ .../dashboard/flow/TriggerConfig.tsx | 277 + web/src/components/home/CallToAction.tsx | 43 + web/src/components/home/Hero.tsx | 134 + web/src/components/home/LogoCloud.tsx | 30 + web/src/components/home/Process.tsx | 63 + web/src/components/home/Products.tsx | 74 + web/src/components/home/References.tsx | 66 + web/src/components/home/Stats.tsx | 33 + web/src/components/layout/Footer.tsx | 82 + web/src/components/layout/Logo.tsx | 14 + web/src/components/layout/Navbar.tsx | 129 + web/src/components/layout/PublicLayout.tsx | 32 + web/src/components/ui/Badge.tsx | 34 + web/src/components/ui/Button.tsx | 74 + web/src/components/ui/Card.tsx | 23 + web/src/components/ui/Container.tsx | 7 + web/src/components/ui/Modal.tsx | 90 + web/src/components/ui/PageHeader.tsx | 33 + web/src/components/ui/Section.tsx | 53 + web/src/components/ui/Spinner.tsx | 10 + web/src/config/brand.ts | 32 + web/src/data/navigation.ts | 33 + web/src/data/products.ts | 90 + web/src/data/references.ts | 75 + web/src/index.css | 190 + web/src/lib/api.ts | 92 + web/src/lib/cn.ts | 9 + web/src/lib/connectorIcons.ts | 71 + web/src/lib/eventStream.ts | 112 + web/src/lib/flow.ts | 244 + web/src/lib/format.ts | 59 + web/src/lib/useApiQuery.ts | 79 + web/src/lib/usePageMeta.ts | 19 + web/src/main.tsx | 24 + web/src/pages/About.tsx | 145 + web/src/pages/Contact.tsx | 247 + web/src/pages/Home.tsx | 28 + web/src/pages/Login.tsx | 147 + web/src/pages/NotFound.tsx | 23 + web/src/pages/Services.tsx | 86 + web/src/pages/dashboard/AutomationDetail.tsx | 416 ++ web/src/pages/dashboard/Automations.tsx | 239 + web/src/pages/dashboard/Connectors.tsx | 270 + web/src/pages/dashboard/Incidents.tsx | 72 + web/src/pages/dashboard/Overview.tsx | 180 + web/src/pages/dashboard/Settings.tsx | 46 + web/src/pages/dashboard/Tickets.tsx | 75 + web/src/types/dashboard.ts | 183 + web/src/types/events.ts | 25 + web/src/vite-env.d.ts | 13 + web/tsconfig.json | 25 + 100 files changed, 15409 insertions(+), 35 deletions(-) create mode 100644 .gitignore create mode 100644 documentation/01-prehled-a-stav.md create mode 100644 documentation/02-appfactory-proxy.md create mode 100644 documentation/03-architektura-a-mapa-kodu.md create mode 100644 documentation/04-api.md create mode 100644 documentation/05-dashboard-a-builder.md create mode 100644 documentation/99-zmeny.md create mode 100644 package-lock.json create mode 100644 src/config.ts create mode 100644 src/data/automationStore.ts create mode 100644 src/data/conditions.ts create mode 100644 src/data/connectors.ts create mode 100644 src/data/incidentStore.ts create mode 100644 src/data/mock.ts create mode 100644 src/data/ticketStore.ts create mode 100644 src/data/users.ts create mode 100644 src/events/bus.ts create mode 100644 src/middleware/auth.ts create mode 100644 src/openapi.ts create mode 100644 src/routes/auth.ts create mode 100644 src/routes/contact.ts create mode 100644 src/routes/dashboard.ts create mode 100644 src/routes/simulate.ts create mode 100644 src/routes/stream.ts create mode 100644 src/routes/webhook.ts create mode 100644 src/types.ts create mode 100644 vite.config.ts create mode 100644 web/index.html create mode 100644 web/public/favicon.svg create mode 100644 web/src/App.tsx create mode 100644 web/src/auth/AuthContext.tsx create mode 100644 web/src/auth/RequireAuth.tsx create mode 100644 web/src/components/dashboard/DashboardLayout.tsx create mode 100644 web/src/components/dashboard/DataState.tsx create mode 100644 web/src/components/dashboard/EventStreamProvider.tsx create mode 100644 web/src/components/dashboard/EventToasts.tsx create mode 100644 web/src/components/dashboard/LiveIndicator.tsx create mode 100644 web/src/components/dashboard/RunsChart.tsx create mode 100644 web/src/components/dashboard/SimulationModal.tsx create mode 100644 web/src/components/dashboard/StatTile.tsx create mode 100644 web/src/components/dashboard/StatusBadge.tsx create mode 100644 web/src/components/dashboard/flow/FlowCanvas.tsx create mode 100644 web/src/components/dashboard/flow/StepPicker.tsx create mode 100644 web/src/components/dashboard/flow/TriggerConfig.tsx create mode 100644 web/src/components/home/CallToAction.tsx create mode 100644 web/src/components/home/Hero.tsx create mode 100644 web/src/components/home/LogoCloud.tsx create mode 100644 web/src/components/home/Process.tsx create mode 100644 web/src/components/home/Products.tsx create mode 100644 web/src/components/home/References.tsx create mode 100644 web/src/components/home/Stats.tsx create mode 100644 web/src/components/layout/Footer.tsx create mode 100644 web/src/components/layout/Logo.tsx create mode 100644 web/src/components/layout/Navbar.tsx create mode 100644 web/src/components/layout/PublicLayout.tsx create mode 100644 web/src/components/ui/Badge.tsx create mode 100644 web/src/components/ui/Button.tsx create mode 100644 web/src/components/ui/Card.tsx create mode 100644 web/src/components/ui/Container.tsx create mode 100644 web/src/components/ui/Modal.tsx create mode 100644 web/src/components/ui/PageHeader.tsx create mode 100644 web/src/components/ui/Section.tsx create mode 100644 web/src/components/ui/Spinner.tsx create mode 100644 web/src/config/brand.ts create mode 100644 web/src/data/navigation.ts create mode 100644 web/src/data/products.ts create mode 100644 web/src/data/references.ts create mode 100644 web/src/index.css create mode 100644 web/src/lib/api.ts create mode 100644 web/src/lib/cn.ts create mode 100644 web/src/lib/connectorIcons.ts create mode 100644 web/src/lib/eventStream.ts create mode 100644 web/src/lib/flow.ts create mode 100644 web/src/lib/format.ts create mode 100644 web/src/lib/useApiQuery.ts create mode 100644 web/src/lib/usePageMeta.ts create mode 100644 web/src/main.tsx create mode 100644 web/src/pages/About.tsx create mode 100644 web/src/pages/Contact.tsx create mode 100644 web/src/pages/Home.tsx create mode 100644 web/src/pages/Login.tsx create mode 100644 web/src/pages/NotFound.tsx create mode 100644 web/src/pages/Services.tsx create mode 100644 web/src/pages/dashboard/AutomationDetail.tsx create mode 100644 web/src/pages/dashboard/Automations.tsx create mode 100644 web/src/pages/dashboard/Connectors.tsx create mode 100644 web/src/pages/dashboard/Incidents.tsx create mode 100644 web/src/pages/dashboard/Overview.tsx create mode 100644 web/src/pages/dashboard/Settings.tsx create mode 100644 web/src/pages/dashboard/Tickets.tsx create mode 100644 web/src/types/dashboard.ts create mode 100644 web/src/types/events.ts create mode 100644 web/src/vite-env.d.ts create mode 100644 web/tsconfig.json diff --git a/.dockerignore b/.dockerignore index ac33e3c..fbb94ad 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,7 @@ node_modules/ dist/ .git/ +documentation/ +*.log +.env +.env.* diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fea4962 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +node_modules/ +dist/ +build/ + +.env +.env.local +.env.*.local + +*.log +npm-debug.log* + +.vite/ +coverage/ +.DS_Store +Thumbs.db +.idea/ diff --git a/Dockerfile b/Dockerfile index 413527e..1927cb9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,10 +3,12 @@ WORKDIR /app COPY package*.json ./ RUN npm install COPY . . +# Zbuilduje server (tsc -> dist) i web (vite -> dist/public) RUN npm run build FROM node:20-slim WORKDIR /app +ENV NODE_ENV=production ENV PORT=3000 EXPOSE 3000 COPY package*.json ./ diff --git a/README.md b/README.md index a384fd7..9b89372 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,69 @@ # csbot-prototype -Node.js TypeScript služba vytvořená přes CSBot Services Portal. +Web a klientsky portal firmy zamerene na automatizace, voiceboty, integrace, +dashboardy, tickety a incident management. -## Endpointy +Jedna aplikace v jednom containeru: Express obsluhuje API i zbuildovanou +React aplikaci. Bezi v AppFactory za reverse proxy na `/apps/`. -- GET / -- GET /health +## Rychly start + +```bash +npm install +npm run build +npm start +``` + +Aplikace nasloucha na `0.0.0.0:3000`. + +Lokalni vyvoj s hot reloadem (API na 3000, web na 5173): + +```bash +npm run dev +``` + +## Povinne endpointy + +| Cesta | Ucel | +| --------------- | --------------------------------------- | +| `/health` | Health check pro AppFactory, vraci 200 | +| `/docs` | Swagger UI | +| `/openapi.json` | OpenAPI definice | + +Verejne pres proxy jako `/apps//health` a `/apps//docs`. + +## Demo prihlaseni + +| E-mail | Heslo | Role | +| ------------------ | ---------- | --------------- | +| `admin@automia.cz` | `demo1234` | interni spravce | +| `klient@firma.cz` | `demo1234` | klient | + +## Environment variables + +| Promenna | Povinna | Vychozi | Popis | +| ---------------- | -------------- | --------- | -------------------------------------------- | +| `PORT` | ne | 3000 | Port containeru, urcuje AppFactory sablona | +| `ROOT_PATH` | ne | prazdne | Prefix proxy, napr. `/apps/csbot-prototype` | +| `JWT_SECRET` | ano v produkci | - | Podpis tokenu, bez nej aplikace nenastartuje | +| `JWT_EXPIRES_IN` | ne | 8h | Platnost tokenu | +| `PUBLIC_ORIGIN` | ne | prazdne | Verejna domena pro absolutni adresy webhooku | +| `CORS_ORIGIN` | ne | localhost | Povolene originy, jen pro lokalni vyvoj | + +Secrets se nikdy nelogují ani neukladaji do kodu. + +## Skripty + +| Prikaz | Co dela | +| ------------------- | ------------------------------------ | +| `npm run build` | Zbuilduje server i web do `dist/` | +| `npm start` | Spusti zbuildovanou aplikaci | +| `npm run dev` | Vyvoj s hot reloadem | +| `npm run typecheck` | Kontrola typu bez generovani vystupu | + +## Dokumentace + +Podrobnosti jsou ve slozce [documentation/](documentation/). Pred upravou projektu +staci precist ji, neni nutne prochazet cely kod. + +Pravidla pro AI asistenty a nastroje jsou v [AGENTS.md](AGENTS.md). diff --git a/documentation/01-prehled-a-stav.md b/documentation/01-prehled-a-stav.md new file mode 100644 index 0000000..90b21e2 --- /dev/null +++ b/documentation/01-prehled-a-stav.md @@ -0,0 +1,45 @@ +# 01 - Prehled a stav + +## Co aplikace je + +Web a klientsky portal IT firmy. Verejna cast prodava sluzbu, cast za prihlasenim +ukazuje klientovi stav jeho automatizaci, ticketu a incidentu. + +Vse je jedna aplikace v jednom containeru. Express obsluhuje API i zbuildovanou +React aplikaci ze slozky `dist/public`. + +## Stav + +| Oblast | Stav | Poznamka | +| --------------------------------- | ------ | ----------------------------------------------------- | +| Verejny web | hotovo | homepage, sluzby, o nas, kontakt, 404 | +| Prihlaseni | hotovo | JWT, demo ucty | +| Dashboard | hotovo | prehled, tickety, incidenty, automatizace, nastaveni | +| Zivy dashboard pres SSE | hotovo | zmeny se projevi bez obnoveni stranky | +| Simulace provozu | hotovo | tlacitko v postrannim menu portalu | +| Katalog konektoru | hotovo | 25 sluzeb, 8 kategorii | +| Builder automatizaci | hotovo | strom akci, vetveni podminkou | +| Webhook s registrovanou adresou | hotovo | token generuje server, verejny endpoint validuje data | +| Nastaveni poli akci | chybi | akce zatim neumi cerpat z parametru spoustece | +| Beh automatizaci | chybi | ulozeny strom se nevykonava, neni runtime | +| Databaze | chybi | data jsou v pameti, restart je vrati na vychozi stav | +| Odesilani e-mailu z formulare | chybi | poptavka se zatim jen loguje | + +## Znama omezeni + +Data jsou v pameti procesu. Restart containeru vrati tickety, incidenty +i automatizace do vychoziho stavu. Nove vytvorene zaznamy se ztrati. + +Obsah verejneho webu je ukazkovy. Nazev firmy, reference, tym i cisla jsou +vymyslene a pred ostrym pouzitim se musi nahradit. Firemni udaje jsou na jednom +miste v `web/src/config/brand.ts`. + +Zivy stream drzi seznam posluchacu v pameti jedne instance. Pri vice instancich +by ho musel nahradit sdileny kanal, napriklad Redis pub/sub. + +## Dalsi krok + +Nejuzitecnejsi pristavek je nastaveni poli akci a s nim predavani dat mezi kroky, +aby slo rict "do e-mailu dej parametr customer ze spoustece". Je to zasah do +datoveho modelu, vyplati se navrhnout drive nez se builder rozsiri dal. +Podrobnosti v [05-dashboard-a-builder.md](05-dashboard-a-builder.md). diff --git a/documentation/02-appfactory-proxy.md b/documentation/02-appfactory-proxy.md new file mode 100644 index 0000000..f2e8a28 --- /dev/null +++ b/documentation/02-appfactory-proxy.md @@ -0,0 +1,103 @@ +# 02 - AppFactory a reverse proxy + +Pravidla jsou v [AGENTS.md](../AGENTS.md). Tenhle soubor popisuje, jak je +aplikace plni. + +## Kde aplikace bezi + +``` +prohlizec Caddy container +https://services.csbot.cz/apps//dashboard + handle_path odstrani prefix + GET /dashboard +``` + +Prohlizec tedy vidi prefix, aplikace uvnitr uz ne. Z toho plyne vsechno ostatni. + +## Prefix se nikdy nehardcoduje + +Prichazi z promenne `ROOT_PATH`. Zpracovava ho `src/config.ts`, ktery ho +normalizuje (doplni uvodni lomitko, odstrani koncove). + +Aplikace se mountuje na koren **i** na prefix: + +```ts +app.use(api); +if (config.rootPath) app.use(config.rootPath, api); +``` + +Diky tomu funguje at uz Caddy prefix odstrani, nebo ne, a taky lokalne bez proxy. + +## Jak se resi statika a routovani SPA + +Tohle je nejcastejsi misto, kde aplikace za proxy spadne. + +Vite build ma `base: './'`, tedy relativni odkazy na soubory. Server pri odeslani +`index.html` vklada do hlavicky: + +```html + + +``` + +- `` zajisti, ze se relativni odkazy na CSS a JS slozi spravne i na vnorene + ceste jako `/apps//dashboard/tickety`. +- `window.__BASE_PATH__` cte frontend. Pouziva ho `web/src/lib/api.ts` pro + skladani adres API a `web/src/main.tsx` jako `basename` pro React Router. + +Bez `` by prohlizec hledal soubory v `/apps//dashboard/assets/...` +a dostal by HTML aplikace misto skriptu. + +## Povinne endpointy + +| Verejna cesta | Vraci | +| ------------------------------ | -------------------------------------- | +| `/apps//health` | `{"status":"ok","uptimeSec":N}` | +| `/apps//docs` | presmeruje na `/docs/` | +| `/apps//docs/` | Swagger UI | +| `/apps//openapi.json` | OpenAPI definice | + +Presmerovani z `/docs` na `/docs/` je nutne. Bez koncoveho lomitka by se +relativni odkazy Swagger UI na CSS a JS skladaly o uroven vys a nenacetly by se. + +Router je proto vytvoreny s `strict: true`. Bez toho by se cesta `/docs` +shodovala i s `/docs/` a presmerovani by se zacyklilo. + +## Swagger Try it out + +`servers` v OpenAPI obsahuje prefix z `ROOT_PATH`: + +```json +{ "servers": [{ "url": "/apps/csbot-prototype" }] } +``` + +Diky tomu tlacitko Try it out vola endpointy pres prefix, ne na koreni domeny. +Definici sestavuje `src/openapi.ts`. + +Pozn.: `/api/dashboard/stream` je Server-Sent Events. Swagger UI streamovanou +odpoved rozumne nezobrazi, testuje se prohlizecem nebo curlem. + +## Overeni po zmene + +```bash +curl -i https://services.csbot.cz/apps//health +curl -i https://services.csbot.cz/apps//docs/ +curl -s https://services.csbot.cz/apps//openapi.json | head -40 +``` + +Ve Swagger UI zkontrolovat, ze Try it out vola adresu s `/apps/`. + +Lokalne se da proxy simulovat: + +```bash +ROOT_PATH=/apps/csbot-prototype npm start +curl -i http://localhost:3000/apps/csbot-prototype/health +curl -i http://localhost:3000/health +``` + +Obe varianty musi vratit 200. + +## Co se v tomhle repozitari nemeni + +Deploy mechanismus, konfigurace Caddy, Gitea webhooky, registry, secrets storage +ani AppFactory sluzby. Port 3000 se nemeni bez upravy metadat aplikace. diff --git a/documentation/03-architektura-a-mapa-kodu.md b/documentation/03-architektura-a-mapa-kodu.md new file mode 100644 index 0000000..e2a38f6 --- /dev/null +++ b/documentation/03-architektura-a-mapa-kodu.md @@ -0,0 +1,85 @@ +# 03 - Architektura a mapa kodu + +## Technologie + +| Vrstva | Technologie | +| ------- | ---------------------------------------------------- | +| Server | Node.js 20, Express 4, TypeScript, ESM | +| Web | React 18, Vite 6, TypeScript, Tailwind 4, React Router 6 | +| Auth | JWT (jsonwebtoken), hesla bcrypt | +| Validace| zod | +| Docs | swagger-ui-express nad rucne psanou OpenAPI definici | + +Jeden `package.json`. Runtime zavislosti jsou v `dependencies`, nastroje pro build +webu v `devDependencies` - runtime image je pak instaluje pres `--omit=dev`. + +## Build + +``` +tsc src/**.ts -> dist/*.js +vite web/ -> dist/public/ +``` + +Server obsluhuje `dist/public` jako statiku. Dockerfile kopiruje do vysledneho +image jen `dist`, takze staci jedna slozka. + +## Mapa kodu - server + +| Cesta | K cemu je | +| --------------------------- | -------------------------------------------------------- | +| `src/index.ts` | vstupni bod: middleware, mount routeru, statika, SPA, Swagger | +| `src/config.ts` | cteni environment variables, normalizace `ROOT_PATH` | +| `src/openapi.ts` | OpenAPI definice vcetne `servers` s prefixem proxy | +| `src/types.ts` | typy uzivatele a JWT payloadu | +| `src/middleware/auth.ts` | `requireAuth`, `requireRole` | +| `src/events/bus.ts` | sbernice udalosti, ze ktere cerpa SSE stream | +| `src/routes/auth.ts` | prihlaseni, odhlaseni, kdo jsem | +| `src/routes/dashboard.ts` | data portalu, katalog konektoru, CRUD automatizaci | +| `src/routes/stream.ts` | SSE stream zmen | +| `src/routes/simulate.ts` | vyvolani provoznich udalosti | +| `src/routes/webhook.ts` | verejny prijem dat do automatizace | +| `src/routes/contact.ts` | poptavkovy formular z webu | +| `src/data/ticketStore.ts` | tickety vcetne zmen a udalosti | +| `src/data/incidentStore.ts` | incidenty vcetne zmen a udalosti | +| `src/data/automationStore.ts` | automatizace, strom akci, tokeny webhooku | +| `src/data/connectors.ts` | katalog konektoru, jejich spousteču a akci | +| `src/data/conditions.ts` | typy parametru a operatory podminek | +| `src/data/users.ts` | demo uzivatele | +| `src/data/mock.ts` | souhrn pro prehled a casova rada grafu | + +## Mapa kodu - web + +| Cesta | K cemu je | +| ---------------------------------- | -------------------------------------------------- | +| `web/src/main.tsx` | vstupni bod, `basename` routeru podle prefixu proxy | +| `web/src/App.tsx` | routovani, portal se nacita lazy | +| `web/src/index.css` | design tokeny a vlastni utility Tailwindu | +| `web/src/config/brand.ts` | vsechny firemni udaje na jednom miste | +| `web/src/lib/api.ts` | fetch wrapper, sprava tokenu, skladani adres | +| `web/src/lib/eventStream.ts` | cteni SSE streamu pres fetch | +| `web/src/lib/useApiQuery.ts` | nacitani dat vcetne obnoveni pri udalosti | +| `web/src/lib/flow.ts` | ciste funkce nad stromem automatizace | +| `web/src/components/dashboard/` | shell portalu, dlazdice, graf, stream, simulace | +| `web/src/components/dashboard/flow/` | strom akci a vyber kroku | +| `web/src/components/home/` | sekce homepage | +| `web/src/pages/` | jedna stranka je jeden soubor | + +## Klicova rozhodnuti + +**Jeden container misto dvou.** AppFactory nasazuje jednu aplikaci, proto Express +obsluhuje i statiku. Odpada CORS i druha deploy jednotka. + +**SSE misto WebSocketu.** Tok dat je jednosmerny, server ke klientovi. Klient posila +zmeny beznym REST volanim. SSE prochazi reverse proxy bez zvlastni konfigurace. + +**Stream pres fetch, ne pres EventSource.** EventSource neumi poslat hlavicku +`Authorization` a token by musel byt v adrese, odkud se dostane do access logu. +Cenou je rucni parsovani a rucni znovupripojeni v `web/src/lib/eventStream.ts`. + +**Ceske cesty v URL.** `/sluzby`, `/o-nas`, `/prihlaseni`, `/dashboard/tickety`. +Kod zustava anglicky. + +**Data v pameti.** Vedome zjednoduseni prototypu. Uloziste jsou oddelena od rout, +takze napojeni na databazi znamena prepsat soubory v `src/data/`, ne endpointy. + +**Zadna ticha selhani.** Kazdy `catch` loguje a uzivatel se o chybe dozvi. diff --git a/documentation/04-api.md b/documentation/04-api.md new file mode 100644 index 0000000..7119a56 --- /dev/null +++ b/documentation/04-api.md @@ -0,0 +1,124 @@ +# 04 - API + +Interaktivni dokumentace je na `/apps//docs`. Tenhle soubor popisuje to, +co ze Swaggeru neni videt. + +## Endpointy + +Verejne: + +| Metoda | Cesta | Popis | +| ------ | ------------------- | --------------------------------------- | +| GET | `/health` | health check | +| GET | `/docs` | Swagger UI | +| GET | `/openapi.json` | OpenAPI definice | +| POST | `/api/auth/login` | prihlaseni, vraci JWT | +| POST | `/api/contact` | poptavka z webu | +| POST | `/webhook/:token` | prijem dat do automatizace | + +Vyzaduji `Authorization: Bearer `: + +| Metoda | Cesta | +| ------ | --------------------------------------------------- | +| GET | `/api/auth/me` | +| POST | `/api/auth/logout` | +| GET | `/api/dashboard/summary` | +| GET | `/api/dashboard/tickets` | +| GET | `/api/dashboard/incidents` | +| GET | `/api/dashboard/connectors` | +| GET | `/api/dashboard/stream` | +| GET | `/api/dashboard/automations` | +| POST | `/api/dashboard/automations` | +| GET | `/api/dashboard/automations/:id` | +| PUT | `/api/dashboard/automations/:id` | +| DELETE | `/api/dashboard/automations/:id` | +| POST | `/api/dashboard/automations/:id/webhook/regenerate` | +| POST | `/api/simulate` | + +## Format chyb + +Jednotny pro cele API: + +```json +{ "error": "validation_error", "message": "Zadejte platny e-mail." } +``` + +| HTTP | `error` | Kdy | +| ---- | --------------------- | --------------------------------------- | +| 400 | `validation_error` | vstup neprosel schematem | +| 401 | `unauthorized` | chybi nebo neplatny token | +| 401 | `invalid_credentials` | spatny e-mail nebo heslo | +| 403 | `forbidden` | nedostatecna role | +| 404 | `not_found` | zaznam nebo endpoint neexistuje | +| 409 | ruzne | operace nedava v danem stavu smysl | +| 500 | `internal_error` | neodchycena chyba, detail jen mimo produkci | + +`message` je vzdy cesky a je urcena k zobrazeni uzivateli. + +## Autentizace + +Hesla se hashuji bcryptem, plaintext se nikde neuklada. Login vraci JWT +podepsany `JWT_SECRET` s platnosti `JWT_EXPIRES_IN`. + +Spatne heslo i neexistujici e-mail vraci stejnou odpoved, aby se neprozradilo, +ktere ucty existuji. Pokus se loguje bez hesla. + +Token si drzi klient v `localStorage`. Pro produkci je cilovy stav `httpOnly` +cookie se `Secure` a `SameSite` plus CSRF token. + +## Zivy stream + +`GET /api/dashboard/stream` je Server-Sent Events. Po pripojeni posle potvrzeni +a poslednich par udalosti, pak uz jen nove. Kazdych 25 sekund jde komentarovy +radek, aby spojeni neuspalo proxy. + +Typy udalosti: `ticket.created`, `ticket.updated`, `ticket.resolved`, +`incident.started`, `incident.updated`, `incident.resolved`, +`automation.created`, `automation.updated`, `automation.deleted`, +`automation.run`, `webhook.received`. + +Klient se pripojuje pres fetch s hlavickou `Authorization`, ne pres EventSource. +Duvod je v [03-architektura-a-mapa-kodu.md](03-architektura-a-mapa-kodu.md). + +## Webhook + +Verejny endpoint bez prihlaseni. Autorizuje neuhodnutelny token v adrese, +32 znaku z `randomBytes(24)` v base64url. + +Token generuje **vyhradne server**. Hodnota `webhookToken` poslana klientem se +ignoruje, jinak by si sel nastavit predvidatelnou adresu. + +| Situace | Odpoved | +| ----------------------------------- | ------- | +| vse v poradku | 202 | +| neznamy token | 404 | +| automatizace je pozastavena | 409 | +| chybi povinny parametr, spatny typ | 400 | + +Parametry navic se neodmitaji, jen loguji. Odesilatele bezne posilaji i vlastni +data a odmitat je by rozbijelo integrace. + +```bash +curl -X POST https://services.csbot.cz/apps//webhook/ \ + -H "Content-Type: application/json" \ + -d '{"customer":"Nordis","score":18}' +``` + +Prototyp pozadavek prijme, zvaliduje a zapocita do metrik, ale strom akci +nevykona - runtime neexistuje. + +## Simulace + +`POST /api/simulate` vyvola provozni udalost pro nahled ziveho dashboardu. +Zamerne meni skutecna data, ne jen posila falesnou notifikaci. + +Akce: `ticket.created`, `ticket.resolved`, `incident.started`, +`incident.resolved`, `automation.run`. + +Nevyplnena pole server doplni ukazkovou hodnotou. U akci s "resolved" se bez +zadaneho id pouzije prvni nevyrizeny zaznam. + +## Pri pridani endpointu + +Soucasne aktualizovat `src/openapi.ts` a tenhle soubor. Swagger musi odpovidat +skutecnemu chovani aplikace, jinak je horsi nez zadny. diff --git a/documentation/05-dashboard-a-builder.md b/documentation/05-dashboard-a-builder.md new file mode 100644 index 0000000..88e83a8 --- /dev/null +++ b/documentation/05-dashboard-a-builder.md @@ -0,0 +1,119 @@ +# 05 - Dashboard, builder automatizaci a simulace + +## Stranky portalu + +``` +/dashboard prehled: dlazdice, graf za 14 dni, posledni tickety a incidenty +/dashboard/automatizace seznam a zalozeni nove +/dashboard/automatizace/:id builder: strom akci +/dashboard/konektory katalog sluzeb, jejich spousteču a akci +/dashboard/tickety tabulka ticketu +/dashboard/incidenty prehled incidentu +/dashboard/nastaveni udaje o uctu +``` + +V postrannim menu je pod Nastavenim tlacitko **Simulace**. + +## Zivy dashboard + +Portal drzi jedno SSE spojeni pro celou aplikaci. Zajistuje ho +`EventStreamProvider` v `web/src/components/dashboard/`. + +- Stav spojeni ukazuje `LiveIndicator` v horni liste. Uzivatel musi poznat, + ze data nejsou ziva. +- Prichozi udalosti ukazuje `EventToasts` jako bubliny vpravo dole. +- Data se obnovuji sama. `useApiQuery` ma volitelny `refetchOn` se seznamem typu + udalosti, po kterych se ma dotaz zopakovat. Vice udalosti tesne po sobe se + slouci do jednoho nacteni. + +Pri vypadku se stream znovu pripojuje s exponencialne rostoucim odstupem +az do 15 sekund, aby pri vypadku serveru neubijel provoz. + +## Simulace provozu + +Modalni okno se otevre tlacitkem Simulace. Umoznuje: + +- zalozit ticket s vlastnim predmetem, zadavatelem a prioritou, +- vyvolat incident s vlastnim popisem, sluzbou a zavaznosti, +- vyresit prvni nevyrizeny ticket nebo bezici incident, +- spustit automatizaci uspesne nebo s chybou. + +Kazda akce opravdu meni data na serveru, takze se projevi i v seznamech +a v souhrnu, ne jen v bublinach. + +## Strom akci + +Automatizace se sklada z **spoustece** a **kroku**. Krok je bud akce nad +konektorem, nebo podminka se dvema vetvemi - proto je to strom, ne seznam. + +```ts +interface AutomationFlow { + trigger: { + connectorId: string; + operationId: string; + fields: TriggerField[]; // vstupni parametry + webhookToken?: string; // generuje vyhradne server + } | null; + steps: FlowStep[]; +} + +type FlowStep = + | { id: string; kind: 'action'; connectorId: string; operationId: string } + | { id: string; kind: 'condition'; fieldId: string; operator: string; + value?: string; yes: FlowStep[]; no: FlowStep[] }; +``` + +Podminka odkazuje na `field.id`, ne na nazev. Prejmenovani parametru proto +existujici podminky nerozbije. + +Misto vlozeni urcuje `FlowPath` v `web/src/lib/flow.ts`: prazdne pole je hlavni +sekvence, `[{ stepId, branch }]` je vetev konkretni podminky. + +## Webhook a vstupni parametry + +U spoustece typu webhook vygeneruje server pri ulozeni adresu +`POST /webhook/`. U spoustece se deklaruji vstupni +parametry: nazev, typ (text, cislo, ano-ne, datum) a povinnost. + +Podminky pak porovnavaji hodnotu parametru, napriklad `score >= 15`. Nabidka +operatoru se ridi typem, na cislo nejde pustit "obsahuje". Tabulka operatoru je +na obou stranach - `src/data/conditions.ts` a `web/src/lib/flow.ts`. Server je +autorita, kopie na klientovi existuje jen proto, aby UI nenabidlo nesmysl. +**Pri zmene upravit obe.** + +Dokud spoustec nema zadny parametr, nejde pridat podminka - nebylo by podle ceho +se rozhodovat. Dialog to vysvetli. + +## Validace + +Rozlisuji se dve veci: + +**Chyby** vraci 400 a neulozi se: neexistujici konektor nebo operace, operace +spatneho druhu, podminka na neexistujici parametr, operator nesedici na typ, +duplicitni nebo nevalidni nazev parametru. + +**Nedodelky** se ulozi, jen brani zapnuti: chybi spoustec, zadny krok, webhook +bez adresy, podminka bez hodnoty. Vraci se v poli `issues` a builder je vypise. +Rozdelana prace se nikdy nezahazuje. + +## Pridani konektoru + +1. Pridat zaznam do `connectors` v `src/data/connectors.ts` vcetne `triggers` + a `actions`. +2. Pokud pouziva novou ikonu, doplnit klic do `web/src/lib/connectorIcons.ts`. + Musi existovat v `lucide-react`. +3. Pokud patri do nove kategorie, doplnit ji do `connectorCategories` a do typu + `ConnectorCategory` na obou stranach. + +Builder i katalog ji vezmou automaticky. + +## Co chybi + +| Chybi | Poznamka | +| --------------------------- | -------------------------------------------------------- | +| Nastaveni poli akci | `fields` u akci se zobrazuji jen jako napoveda | +| Predavani dat do akci | chybi syntaxe odkazu, navrh je `{{trigger.customer}}` | +| Kombinovane podminky | jedna podminka je jedno porovnani, AND a OR jen vnorenim | +| Beh automatizaci | ulozeny strom se nevykonava | +| Historie behu a logy | prazdne, chybi runtime | +| Drag and drop | presouvani je zatim tlacitky nahoru a dolu | diff --git a/documentation/99-zmeny.md b/documentation/99-zmeny.md new file mode 100644 index 0000000..bf6bb04 --- /dev/null +++ b/documentation/99-zmeny.md @@ -0,0 +1,59 @@ +# 99 - Zaznam zmen + +Nejnovejsi nahore. + +## 2026-07-31 + +Prvni nasazeni aplikace do repozitare csbot-prototype. + +Puvodni sablona byla holy Express s endpointy `/` a `/health`. Nahradil ji +kompletni web a klientsky portal. + +### Pridano + +- Verejny web: homepage se sekcemi, sluzby, o nas, kontakt s formularem, 404. +- Prihlaseni pres JWT s demo ucty. +- Klientsky portal: prehled s grafem, tickety, incidenty, automatizace, + konektory, nastaveni. +- Builder automatizaci: strom akci, spoustec, vetveni podminkou. +- Katalog 25 konektoru v 8 kategoriich. +- Webhook s registrovanou adresou, token generuje server. +- Zivy dashboard pres SSE, vcetne indikatoru spojeni a bublin s udalostmi. +- Simulace provozu pod tlacitkem v postrannim menu portalu. +- Swagger UI na `/docs` a OpenAPI definice na `/openapi.json`. +- Dokumentace ve slozce `documentation/`. +- `.gitignore`, ktery drzi `node_modules` a `dist` mimo repozitar. + +### Zmeneno oproti sablone + +- Aplikace prepnuta na ESM (`"type": "module"`) a `module: NodeNext`. +- Jeden container obsluhuje API i zbuildovanou React aplikaci z `dist/public`. +- Dockerfile buildu je server i web, vysledny image dostava jen `dist`. +- Port zustava 3000, naslouchani na `0.0.0.0` beze zmeny. + +### Reseni reverse proxy + +- `ROOT_PATH` se cte z prostredi, nikde neni hardcoded. +- Aplikace se mountuje na koren i na prefix, funguje tedy at Caddy prefix + odstrani nebo ne. +- Server vklada do `index.html` znacku `` a `window.__BASE_PATH__`, + aby SPA nasla soubory i na vnorenych cestach. +- OpenAPI `servers` obsahuje prefix, takze Swagger Try it out vola spravnou adresu. +- Router ma `strict: true`, jinak by se presmerovani `/docs` na `/docs/` zacyklilo. + +### Overeno lokalne + +S `ROOT_PATH=/apps/csbot-prototype`: + +- `/apps/csbot-prototype/health` i `/health` vraci 200, +- `/apps/csbot-prototype/docs` presmeruje na `/docs/`, ta vraci Swagger UI, +- `swagger-ui.css` se nacte pres prefix, +- OpenAPI `servers` obsahuje `/apps/csbot-prototype`, +- `index.html` na vnorene ceste obsahuje spravny ``, +- prihlaseni pres prefix vraci token, +- neexistujici cesta pod `/api` vraci JSON, ne HTML aplikace. + +### Znama omezeni + +Data jsou v pameti, restart je vrati do vychoziho stavu. Obsah webu je ukazkovy. +Ulozeny strom automatizace se nevykonava. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..a5951b8 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,4547 @@ +{ + "name": "csbot-prototype", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "csbot-prototype", + "version": "1.0.0", + "dependencies": { + "bcryptjs": "^2.4.3", + "cors": "^2.8.5", + "express": "^4.21.2", + "jsonwebtoken": "^9.0.2", + "swagger-ui-express": "^5.0.1", + "zod": "^3.24.1" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "@types/bcryptjs": "^2.4.6", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.7", + "@types/node": "^22.10.5", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@types/swagger-ui-express": "^4.1.7", + "@vitejs/plugin-react": "^4.3.4", + "concurrently": "^9.1.2", + "lucide-react": "^0.469.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.1", + "tailwindcss": "^4.0.0", + "tsx": "^4.19.2", + "typescript": "^5.7.3", + "vite": "^6.0.7" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.3.tgz", + "integrity": "sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.3.tgz", + "integrity": "sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.3.tgz", + "integrity": "sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.3.tgz", + "integrity": "sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.3.tgz", + "integrity": "sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.3.tgz", + "integrity": "sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.3.tgz", + "integrity": "sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.3.tgz", + "integrity": "sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.3.tgz", + "integrity": "sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.3.tgz", + "integrity": "sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.3.tgz", + "integrity": "sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.3.tgz", + "integrity": "sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.3.tgz", + "integrity": "sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.3.tgz", + "integrity": "sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.3.tgz", + "integrity": "sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.3.tgz", + "integrity": "sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.3.tgz", + "integrity": "sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.3.tgz", + "integrity": "sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.3.tgz", + "integrity": "sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.3.tgz", + "integrity": "sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.3.tgz", + "integrity": "sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.3.tgz", + "integrity": "sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.3.tgz", + "integrity": "sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.3.tgz", + "integrity": "sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.3.tgz", + "integrity": "sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.9", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.9.tgz", + "integrity": "sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/swagger-ui-express": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.8.tgz", + "integrity": "sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.8.tgz", + "integrity": "sha512-zAgkquC2WYF0PIc6XbNYkA2uuxxFavzgmX61R+dHDUa558V8Ejf8ozTZFR6QzM24RWu4kBcRkhJ5kpz77j9fnQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concurrently": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.4.tgz", + "integrity": "sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.9.0", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.399", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.399.tgz", + "integrity": "sha512-lEcqhErbHjXRvd41rnWLpzbyU/IXfIYo7QwaFWmxGeLiLyY2TBCdHnWY88vB+p3ubnihRypDm66panXl7TylLA==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.469.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.469.0.tgz", + "integrity": "sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.3.tgz", + "integrity": "sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.3", + "@rollup/rollup-android-arm64": "4.62.3", + "@rollup/rollup-darwin-arm64": "4.62.3", + "@rollup/rollup-darwin-x64": "4.62.3", + "@rollup/rollup-freebsd-arm64": "4.62.3", + "@rollup/rollup-freebsd-x64": "4.62.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.3", + "@rollup/rollup-linux-arm-musleabihf": "4.62.3", + "@rollup/rollup-linux-arm64-gnu": "4.62.3", + "@rollup/rollup-linux-arm64-musl": "4.62.3", + "@rollup/rollup-linux-loong64-gnu": "4.62.3", + "@rollup/rollup-linux-loong64-musl": "4.62.3", + "@rollup/rollup-linux-ppc64-gnu": "4.62.3", + "@rollup/rollup-linux-ppc64-musl": "4.62.3", + "@rollup/rollup-linux-riscv64-gnu": "4.62.3", + "@rollup/rollup-linux-riscv64-musl": "4.62.3", + "@rollup/rollup-linux-s390x-gnu": "4.62.3", + "@rollup/rollup-linux-x64-gnu": "4.62.3", + "@rollup/rollup-linux-x64-musl": "4.62.3", + "@rollup/rollup-openbsd-x64": "4.62.3", + "@rollup/rollup-openharmony-arm64": "4.62.3", + "@rollup/rollup-win32-arm64-msvc": "4.62.3", + "@rollup/rollup-win32-ia32-msvc": "4.62.3", + "@rollup/rollup-win32-x64-gnu": "4.62.3", + "@rollup/rollup-win32-x64-msvc": "4.62.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shell-quote": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", + "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/swagger-ui-dist": { + "version": "5.32.11", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.11.tgz", + "integrity": "sha512-NEZzRuxHHQkbG3GCjNbzz+XRDoM7AztnXyzc2VCW5RXUvZBDW7bb3W29/SPfvav3yOzqnDTOLP2Xzbjxo0bldQ==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json index 4815544..cab0b7d 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,49 @@ { "name": "csbot-prototype", "version": "1.0.0", + "private": true, + "type": "module", + "description": "Automia - web a klientsky portal. Automatizace, voiceboti, integrace, tickety a incidenty.", "scripts": { - "build": "tsc", - "start": "node dist/index.js" + "build": "npm run build:server && npm run build:web", + "build:server": "tsc -p tsconfig.json", + "build:web": "vite build", + "start": "node dist/index.js", + "dev": "concurrently -n api,web -c magenta,cyan \"npm:dev:server\" \"npm:dev:web\"", + "dev:server": "tsx watch src/index.ts", + "dev:web": "vite", + "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p web/tsconfig.json --noEmit" }, "dependencies": { - "express": "^4.18.3" + "bcryptjs": "^2.4.3", + "cors": "^2.8.5", + "express": "^4.21.2", + "jsonwebtoken": "^9.0.2", + "swagger-ui-express": "^5.0.1", + "zod": "^3.24.1" }, "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "@types/bcryptjs": "^2.4.6", + "@types/cors": "^2.8.17", "@types/express": "^4.17.21", - "@types/node": "^20.11.30", - "typescript": "^5.4.0" + "@types/jsonwebtoken": "^9.0.7", + "@types/node": "^22.10.5", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@types/swagger-ui-express": "^4.1.7", + "@vitejs/plugin-react": "^4.3.4", + "concurrently": "^9.1.2", + "lucide-react": "^0.469.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.1", + "tailwindcss": "^4.0.0", + "tsx": "^4.19.2", + "typescript": "^5.7.3", + "vite": "^6.0.7" + }, + "engines": { + "node": ">=20" } } diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..6e597ab --- /dev/null +++ b/src/config.ts @@ -0,0 +1,58 @@ +/** + * Konfigurace z environment variables. + * AppFactory je predava containeru, viz AGENTS.md, sekce Variables a secrets. + * Zadna hodnota se nehardcoduje a zadny secret se neloguje. + */ + +const isProduction = process.env.NODE_ENV === 'production'; + +function requiredInProduction(name: string, fallback: string): string { + const value = process.env[name]; + if (value && value.trim().length > 0) return value; + if (isProduction) { + // Zamerne padame pri startu - tichy fallback na dev secret by byl bezpecnostni dira. + throw new Error(`Chybi povinna promenna prostredi ${name} (NODE_ENV=production).`); + } + console.warn(`[config] ${name} neni nastavena, pouzivam DEV fallback. Nepouzivat v produkci.`); + return fallback; +} + +/** + * Prefix verejne adresy, napr. "/apps/csbot-prototype". + * Caddy ho pred predanim do containeru odstranuje (handle_path), ale prohlizec + * ho vidi - proto se z nej sklada base pro SPA, odkazy, Swagger i webhooky. + */ +function normalizeRootPath(value: string | undefined): string { + const trimmed = (value ?? '').trim(); + if (trimmed.length === 0 || trimmed === '/') return ''; + const withSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`; + return withSlash.replace(/\/+$/, ''); +} + +export const config = { + isProduction, + /** Port urcuje AppFactory sablona, vychozi 3000. Nemenit bez upravy metadat. */ + port: Number(process.env.PORT ?? 3000), + rootPath: normalizeRootPath(process.env.ROOT_PATH), + jwtSecret: requiredInProduction('JWT_SECRET', 'dev-only-secret-nepouzivat-v-produkci'), + jwtExpiresIn: process.env.JWT_EXPIRES_IN ?? '8h', + /** + * Povolene originy pro CORS. V nasazeni bezi web i API na stejne domene, + * takze se CORS neuplatni. Je tu kvuli lokalnimu vyvoji s Vite dev serverem. + */ + corsOrigins: (process.env.CORS_ORIGIN ?? 'http://localhost:5173,http://localhost:4173') + .split(',') + .map((o) => o.trim()) + .filter(Boolean), + /** + * Verejna adresa bez prefixu, napr. "https://services.csbot.cz". + * Sklada se z ni absolutni URL webhooku. Kdyz neni vyplnena, pouzije se + * relativni tvar - nikdy se nehardcoduje produkcni domena. + */ + publicOrigin: (process.env.PUBLIC_ORIGIN ?? '').trim().replace(/\/+$/, ''), +}; + +/** Zaklad verejne adresy aplikace vcetne prefixu proxy. */ +export function publicBaseUrl(): string { + return `${config.publicOrigin}${config.rootPath}`; +} diff --git a/src/data/automationStore.ts b/src/data/automationStore.ts new file mode 100644 index 0000000..6a98115 --- /dev/null +++ b/src/data/automationStore.ts @@ -0,0 +1,582 @@ +/** + * Uloziste automatizaci vcetne jejich stromu akci (flow). + * + * POZOR: data jsou v pameti procesu - restart API je vrati na vychozi sadu. + * To je vedome zjednoduseni prototypu, nahrada za databazi je popsana + * v docs/04-backend-api.md, sekce "Kam dal". + */ + +import { randomBytes } from 'node:crypto'; +import { publish } from '../events/bus.js'; +import { isUnary, type ConditionOperator, type FieldType } from './conditions.js'; +import { findConnector } from './connectors.js'; + +export type AutomationKind = 'workflow' | 'voicebot' | 'integrace' | 'report'; + +/** Jeden vstupni parametr, ktery spoustec preda dal do stromu. */ +export interface TriggerField { + id: string; + /** Klic v prichozich datech - napr. "orderTotal". Musi byt unikatni. */ + name: string; + type: FieldType; + required: boolean; +} + +export interface FlowTrigger { + connectorId: string; + operationId: string; + /** Deklarovane vstupni parametry. Podminky se odkazuji na jejich `id`. */ + fields: TriggerField[]; + /** + * Neodhadnutelny token v adrese webhooku. Generuje VZDY server, + * klient ho nesmi urcovat ani menit. + */ + webhookToken?: string; +} + +/** + * Krok stromu. `action` je jeden ukon nad konektorem, `condition` rozdeluje + * beh na dve vetve podle hodnoty vstupniho parametru - proto je to strom. + */ +export type FlowStep = + | { + id: string; + kind: 'action'; + connectorId: string; + operationId: string; + } + | { + id: string; + kind: 'condition'; + /** id parametru z trigger.fields */ + fieldId: string; + operator: ConditionOperator; + /** Chybi u operatoru, ktere hodnotu nepotrebuji (isEmpty, isTrue…). */ + value?: string; + yes: FlowStep[]; + no: FlowStep[]; + }; + +export interface AutomationFlow { + trigger: FlowTrigger | null; + steps: FlowStep[]; +} + +/** Neodhadnutelny token do adresy webhooku (32 znaku, base64url). */ +export function generateWebhookToken(): string { + return randomBytes(24).toString('base64url'); +} + +/** Polozka v seznamu automatizaci - bez celeho stromu. */ +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, je to koncept. */ + configured: boolean; + /** + * Co chybi k tomu, aby se dala zapnout. Prazdne = je hotova. + * Zobrazuje se uzivateli, nesmi zmizet tise. + */ + issues: string[]; +} + +export interface AutomationDetail extends Automation { + flow: AutomationFlow; + createdAt: string; + updatedAt: string; +} + +interface StoredAutomation { + id: string; + name: string; + kind: AutomationKind; + enabled: boolean; + runsToday: number; + successRate: number; + avgDurationMs: number; + lastRunAt: string; + flow: AutomationFlow; + createdAt: string; + updatedAt: string; +} + +function minutesAgo(minutes: number): string { + return new Date(Date.now() - minutes * 60_000).toISOString(); +} + +/** Rekurzivne secte kroky vcetne obou vetvi podminek. */ +export function countSteps(steps: FlowStep[]): number { + return steps.reduce((sum, step) => { + if (step.kind === 'condition') { + return sum + 1 + countSteps(step.yes) + countSteps(step.no); + } + return sum + 1; + }, 0); +} + +/** + * Co brani zapnuti automatizace. Zamerne to NENI chyba pri ukladani - + * rozdelanou praci chceme ulozit, jen ji nesmime pustit do provozu. + */ +export function collectFlowIssues(flow: AutomationFlow): string[] { + const issues: string[] = []; + + if (!flow.trigger) { + issues.push('Chybí spouštěč.'); + return issues; + } + + if (flow.steps.length === 0) { + issues.push('Automatizace nemá žádný krok.'); + } + + // Webhook bez registrovaneho tokenu nelze zavolat. + const isWebhook = flow.trigger.connectorId === 'webhook'; + if (isWebhook && !flow.trigger.webhookToken) { + issues.push('Webhook nemá vygenerovanou adresu.'); + } + + const fieldById = new Map(flow.trigger.fields.map((field) => [field.id, field])); + + const walk = (steps: FlowStep[]) => { + for (const step of steps) { + if (step.kind !== 'condition') continue; + + const field = fieldById.get(step.fieldId); + if (!field) { + issues.push('Podmínka se odkazuje na parametr, který už neexistuje.'); + } else if (!isUnary(step.operator) && (step.value ?? '').trim().length === 0) { + issues.push(`Podmínka nad parametrem „${field.name}" nemá vyplněnou hodnotu.`); + } else if (field.type === 'number' && !isUnary(step.operator)) { + if (Number.isNaN(Number(step.value))) { + issues.push(`Podmínka nad parametrem „${field.name}" má nečíselnou hodnotu.`); + } + } + + walk(step.yes); + walk(step.no); + } + }; + walk(flow.steps); + + return issues; +} + +/** Pouziva strom nekde konektor z dane kategorie? */ +function flowUsesCategory(steps: FlowStep[], category: string): boolean { + return steps.some((step) => { + if (step.kind === 'condition') { + return flowUsesCategory(step.yes, category) || flowUsesCategory(step.no, category); + } + return findConnector(step.connectorId)?.category === category; + }); +} + +/** + * Druh automatizace se dopocitava ze stromu - klient ho nezadava. + * Je to jen stitek v seznamu, proto zamerne jednoducha heuristika: + * rozhoduje spoustec, u planovace jeste to, zda se ve krocich pracuje s analytikou. + */ +function deriveKind(flow: AutomationFlow): AutomationKind { + if (!flow.trigger) return 'workflow'; + + const connector = findConnector(flow.trigger.connectorId); + if (!connector) { + console.warn(`[automations] spoustec odkazuje na neznamy konektor: ${flow.trigger.connectorId}`); + return 'workflow'; + } + + if (connector.id === 'voicebot') return 'voicebot'; + if (connector.category === 'analytika') return 'report'; + + // Planovac + prace s analytikou = pravidelny report, ne obecne workflow. + if (connector.id === 'scheduler' && flowUsesCategory(flow.steps, 'analytika')) return 'report'; + + if ( + connector.category === 'crm' || + connector.category === 'ekonomika' || + connector.category === 'logistika' + ) { + return 'integrace'; + } + + return 'workflow'; +} + +const store = new Map(); +let idCounter = 0; + +function nextId(): string { + idCounter += 1; + return `AUT-${String(idCounter).padStart(2, '0')}`; +} + +function seed(automation: Omit) { + const id = nextId(); + store.set(id, { + ...automation, + id, + kind: deriveKind(automation.flow), + createdAt: minutesAgo(60 * 24 * 90), + updatedAt: minutesAgo(60 * 12), + }); +} + +seed({ + name: 'Objednávka → sklad → fakturace', + enabled: true, + runsToday: 428, + successRate: 99.3, + avgDurationMs: 1_240, + lastRunAt: minutesAgo(3), + flow: { + trigger: { + connectorId: 'eshop', + operationId: 'order-created', + fields: [ + { id: 'f_1', name: 'orderId', type: 'string', required: true }, + { id: 'f_2', name: 'total', type: 'number', required: true }, + { id: 'f_3', name: 'customerEmail', type: 'string', required: true }, + ], + }, + steps: [ + { id: 'st_1', kind: 'action', connectorId: 'transform', operationId: 'map-fields' }, + { id: 'st_2', kind: 'action', connectorId: 'eshop', operationId: 'update-stock' }, + { + id: 'st_3', + kind: 'condition', + fieldId: 'f_2', + operator: 'gte', + value: '5000', + yes: [ + { id: 'st_4', kind: 'action', connectorId: 'idoklad', operationId: 'create-proforma' }, + { id: 'st_5', kind: 'action', connectorId: 'email', operationId: 'send' }, + ], + no: [{ id: 'st_6', kind: 'action', connectorId: 'idoklad', operationId: 'create-invoice' }], + }, + { id: 'st_7', kind: 'action', connectorId: 'ppl', operationId: 'create-shipment' }, + ], + }, +}); + +seed({ + name: 'Voicebot: příjem poptávek 24/7', + enabled: true, + runsToday: 137, + successRate: 96.1, + avgDurationMs: 74_000, + lastRunAt: minutesAgo(11), + flow: { + trigger: { + connectorId: 'voicebot', + operationId: 'call-received', + fields: [ + { id: 'f_11', name: 'callerNumber', type: 'string', required: true }, + { id: 'f_12', name: 'wantsOperator', type: 'boolean', required: false }, + ], + }, + steps: [ + { id: 'st_11', kind: 'action', connectorId: 'voicebot', operationId: 'play-scenario' }, + { id: 'st_12', kind: 'action', connectorId: 'transcription', operationId: 'transcribe' }, + { id: 'st_13', kind: 'action', connectorId: 'ai-text', operationId: 'classify' }, + { + id: 'st_14', + kind: 'condition', + fieldId: 'f_12', + operator: 'isTrue', + yes: [{ id: 'st_15', kind: 'action', connectorId: 'voicebot', operationId: 'transfer' }], + no: [ + { id: 'st_16', kind: 'action', connectorId: 'raynet', operationId: 'create-lead' }, + { id: 'st_17', kind: 'action', connectorId: 'email', operationId: 'send' }, + ], + }, + ], + }, +}); + +seed({ + name: 'Synchronizace CRM ↔ účetnictví', + enabled: true, + runsToday: 96, + successRate: 98.9, + avgDurationMs: 2_050, + lastRunAt: minutesAgo(26), + flow: { + trigger: { + connectorId: 'raynet', + operationId: 'company-changed', + fields: [{ id: 'f_21', name: 'companyId', type: 'string', required: true }], + }, + steps: [ + { id: 'st_21', kind: 'action', connectorId: 'transform', operationId: 'deduplicate' }, + { id: 'st_22', kind: 'action', connectorId: 'idoklad', operationId: 'create-invoice' }, + { id: 'st_23', kind: 'action', connectorId: 'log', operationId: 'write' }, + ], + }, +}); + +seed({ + name: 'Noční report pro management', + enabled: false, + runsToday: 0, + successRate: 100, + avgDurationMs: 18_400, + lastRunAt: minutesAgo(1_020), + flow: { + trigger: { connectorId: 'scheduler', operationId: 'interval', fields: [] }, + steps: [ + { id: 'st_31', kind: 'action', connectorId: 'ga4', operationId: 'run-report' }, + { id: 'st_32', kind: 'action', connectorId: 'google-ads', operationId: 'campaign-report' }, + { id: 'st_33', kind: 'action', connectorId: 'sklik', operationId: 'campaign-report' }, + { id: 'st_34', kind: 'action', connectorId: 'ai-text', operationId: 'generate' }, + { id: 'st_35', kind: 'action', connectorId: 'email', operationId: 'send' }, + ], + }, +}); + +// Ukazka webhooku s deklarovanymi parametry a podminkou nad cislem. +seed({ + name: 'Webhook: hodnocení z dotazníku', + enabled: true, + runsToday: 61, + successRate: 100, + avgDurationMs: 640, + lastRunAt: minutesAgo(18), + flow: { + trigger: { + connectorId: 'webhook', + operationId: 'received', + webhookToken: generateWebhookToken(), + fields: [ + { id: 'f_41', name: 'customer', type: 'string', required: true }, + { id: 'f_42', name: 'score', type: 'number', required: true }, + { id: 'f_43', name: 'comment', type: 'string', required: false }, + ], + }, + steps: [ + { + id: 'st_41', + kind: 'condition', + fieldId: 'f_42', + operator: 'gte', + value: '15', + yes: [{ id: 'st_42', kind: 'action', connectorId: 'raynet', operationId: 'add-activity' }], + no: [ + { id: 'st_43', kind: 'action', connectorId: 'ticket', operationId: 'create' }, + { id: 'st_44', kind: 'action', connectorId: 'microsoft365', operationId: 'post-teams' }, + ], + }, + ], + }, +}); + +function toSummary(stored: StoredAutomation): Automation { + const { flow: _flow, createdAt: _createdAt, updatedAt: _updatedAt, ...rest } = stored; + return { + ...rest, + stepCount: countSteps(stored.flow.steps), + configured: stored.flow.trigger !== null, + issues: collectFlowIssues(stored.flow), + }; +} + +function toDetail(stored: StoredAutomation): AutomationDetail { + return { + ...toSummary(stored), + flow: stored.flow, + createdAt: stored.createdAt, + updatedAt: stored.updatedAt, + }; +} + +export function listAutomations(): Automation[] { + // Nejnovejsi nahoru, aby prave vytvorena automatizace byla hned videt. + return [...store.values()] + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) + .map(toSummary); +} + +export function getAutomation(id: string): AutomationDetail | undefined { + const stored = store.get(id); + return stored ? toDetail(stored) : undefined; +} + +export function createAutomation(name: string): AutomationDetail { + const id = nextId(); + const now = new Date().toISOString(); + const stored: StoredAutomation = { + id, + name, + kind: 'workflow', + enabled: false, + runsToday: 0, + successRate: 100, + avgDurationMs: 0, + lastRunAt: now, + flow: { trigger: null, steps: [] }, + createdAt: now, + updatedAt: now, + }; + store.set(id, stored); + console.info(`[automations] vytvorena automatizace ${id} "${name}"`); + publish('automation.created', `Vytvořena automatizace ${id}: ${name}`, { automationId: id }); + return toDetail(stored); +} + +export function updateAutomation( + id: string, + patch: { name?: string; enabled?: boolean; flow?: AutomationFlow }, +): AutomationDetail | undefined { + const stored = store.get(id); + if (!stored) { + console.warn(`[automations] pokus o upravu neexistujici automatizace: ${id}`); + return undefined; + } + + const flow = withWebhookToken(patch.flow ?? stored.flow, stored.flow, id); + const updated: StoredAutomation = { + ...stored, + name: patch.name ?? stored.name, + enabled: patch.enabled ?? stored.enabled, + flow, + kind: deriveKind(flow), + updatedAt: new Date().toISOString(), + }; + + // Nedokoncenou automatizaci nepustime do provozu - "aktivni" by nic nedelala + // nebo by delala neco jineho, nez uzivatel ceka. + const issues = collectFlowIssues(updated.flow); + if (updated.enabled && issues.length > 0) { + console.warn(`[automations] ${id}: zapnuti odmitnuto - ${issues.join(' ')}`); + updated.enabled = false; + } + + store.set(id, updated); + console.info( + `[automations] ulozena automatizace ${id} (kroku: ${countSteps(updated.flow.steps)}, aktivni: ${updated.enabled}, nedodelku: ${issues.length})`, + ); + publish('automation.updated', `Automatizace ${id} uložena: ${updated.name}`, { + automationId: id, + enabled: updated.enabled, + }); + return toDetail(updated); +} + +/** + * Token webhooku spravuje VYHRADNE server: + * - webhook spoustec bez tokenu ho dostane vygenerovany, + * - existujici token se prevezme z ulozene verze (klient ho nemuze zmenit), + * - pri zmene spoustece na neco jineho se token zahodi. + */ +function withWebhookToken( + next: AutomationFlow, + previous: AutomationFlow, + id: string, +): AutomationFlow { + if (!next.trigger) return next; + + if (next.trigger.connectorId !== 'webhook') { + if (next.trigger.webhookToken) { + console.info(`[automations] ${id}: spoustec neni webhook, zahazuji token`); + } + return { ...next, trigger: { ...next.trigger, webhookToken: undefined } }; + } + + // Existujici token drzime, aby se uz zaregistrovana adresa nezmenila pod rukama. + const keptToken = + previous.trigger?.connectorId === 'webhook' ? previous.trigger.webhookToken : undefined; + + if (keptToken) { + return { ...next, trigger: { ...next.trigger, webhookToken: keptToken } }; + } + + const token = generateWebhookToken(); + console.info(`[automations] ${id}: vygenerovana adresa webhooku`); + return { ...next, trigger: { ...next.trigger, webhookToken: token } }; +} + +/** Vygeneruje novy token - stara adresa okamzite prestane fungovat. */ +export function regenerateWebhookToken(id: string): AutomationDetail | undefined { + const stored = store.get(id); + if (!stored) { + console.warn(`[automations] regenerace tokenu pro neexistujici automatizaci: ${id}`); + return undefined; + } + if (stored.flow.trigger?.connectorId !== 'webhook') { + console.warn(`[automations] ${id}: regenerace tokenu, ale spoustec neni webhook`); + return undefined; + } + + const updated: StoredAutomation = { + ...stored, + flow: { + ...stored.flow, + trigger: { ...stored.flow.trigger, webhookToken: generateWebhookToken() }, + }, + updatedAt: new Date().toISOString(), + }; + store.set(id, updated); + console.info(`[automations] ${id}: token webhooku pregenerovan, stara adresa neplati`); + return toDetail(updated); +} + +/** Najde automatizaci podle tokenu v adrese webhooku. */ +export function findByWebhookToken(token: string): AutomationDetail | undefined { + for (const stored of store.values()) { + if (stored.flow.trigger?.webhookToken === token) return toDetail(stored); + } + return undefined; +} + +/** Zapise beh automatizace - drzi metriky i graf zive. */ +export function recordRun(id: string, ok = true): AutomationDetail | undefined { + const stored = store.get(id); + if (!stored) { + console.warn(`[automations] recordRun pro neexistujici automatizaci: ${id}`); + return undefined; + } + + const runs = stored.runsToday + 1; + // Klouzavy prumer uspesnosti pres dnesni behy, ne prepisovani na 0 nebo 100. + const previousOk = (stored.successRate / 100) * stored.runsToday; + const successRate = runs === 0 ? 100 : ((previousOk + (ok ? 1 : 0)) / runs) * 100; + + const updated: StoredAutomation = { + ...stored, + runsToday: runs, + successRate: Math.round(successRate * 10) / 10, + lastRunAt: new Date().toISOString(), + }; + store.set(id, updated); + + publish( + 'automation.run', + ok + ? `Automatizace ${id} proběhla: ${updated.name}` + : `Automatizace ${id} skončila chybou: ${updated.name}`, + { automationId: id, ok }, + ); + return toDetail(updated); +} + +export function deleteAutomation(id: string): boolean { + const name = store.get(id)?.name; + const existed = store.delete(id); + if (!existed) { + console.warn(`[automations] pokus o smazani neexistujici automatizace: ${id}`); + } else { + console.info(`[automations] smazana automatizace ${id}`); + publish('automation.deleted', `Automatizace ${id} smazána: ${name ?? ''}`, { + automationId: id, + }); + } + return existed; +} diff --git a/src/data/conditions.ts b/src/data/conditions.ts new file mode 100644 index 0000000..8b2eb71 --- /dev/null +++ b/src/data/conditions.ts @@ -0,0 +1,48 @@ +/** + * Typy vstupnich parametru a operatory podminek. + * + * POZOR: stejne tabulky ma i frontend v apps/web/src/lib/flow.ts. + * Pri zmene je nutne upravit obe strany (viz docs/08-automatizace-builder.md). + */ + +export type FieldType = 'string' | 'number' | 'boolean' | 'date'; + +export type ConditionOperator = + | 'eq' + | 'neq' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'contains' + | 'startsWith' + | 'isEmpty' + | 'isNotEmpty' + | 'isTrue' + | 'isFalse'; + +export const fieldTypes: FieldType[] = ['string', 'number', 'boolean', 'date']; + +/** Ktere operatory maji smysl pro ktery typ. */ +export const operatorsByType: Record = { + 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. */ +export const unaryOperators: ConditionOperator[] = [ + 'isEmpty', + 'isNotEmpty', + 'isTrue', + 'isFalse', +]; + +export function isUnary(operator: ConditionOperator): boolean { + return unaryOperators.includes(operator); +} + +export function operatorAllowedForType(operator: ConditionOperator, type: FieldType): boolean { + return operatorsByType[type].includes(operator); +} diff --git a/src/data/connectors.ts b/src/data/connectors.ts new file mode 100644 index 0000000..6752d7f --- /dev/null +++ b/src/data/connectors.ts @@ -0,0 +1,699 @@ +/** + * Katalog konektoru = zdroj pravdy o tom, co lze v automatizaci pouzit. + * + * Kazdy konektor ma: + * - triggers: udalosti, kterymi muze automatizace ZACIT (spoustec) + * - actions: co se s nim da UDELAT uprostred behu + * + * Konektor muze mit jen triggery (webhook), jen akce (odeslani e-mailu), nebo obojí. + * Jak pridat novy konektor: docs/08-automatizace-builder.md + */ + +export type ConnectorCategory = + | 'spoustece' + | 'crm' + | 'ekonomika' + | 'logistika' + | 'komunikace' + | 'analytika' + | 'ai' + | 'nastroje'; + +/** connected = klient ho ma napojeny, available = umime napojit, planned = na roadmape */ +export type ConnectorStatus = 'connected' | 'available' | 'planned'; + +export interface ConnectorOperation { + id: string; + name: string; + description: string; + /** Popis toho, co bude potreba nastavit. Zatim jen informativni. */ + 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 - frontend si ho mapuje na komponentu (lib/connectorIcons.ts). */ + icon: string; + status: ConnectorStatus; + triggers: ConnectorOperation[]; + actions: ConnectorOperation[]; +} + +export const connectorCategories: Array<{ id: ConnectorCategory; label: string }> = [ + { id: 'spoustece', label: 'Spouštěče' }, + { id: 'crm', label: 'CRM' }, + { id: 'ekonomika', label: 'Ekonomika a banky' }, + { id: 'logistika', label: 'Logistika' }, + { id: 'komunikace', label: 'Komunikace' }, + { id: 'analytika', label: 'Analytika' }, + { id: 'ai', label: 'AI a hlas' }, + { id: 'nastroje', label: 'Nástroje' }, +]; + +export const connectors: Connector[] = [ + // ---------------------------------------------------------------- spoustece + { + id: 'webhook', + name: 'Webhook', + category: 'spoustece', + description: 'Spustí automatizaci příchozím HTTP požadavkem z libovolného systému.', + icon: 'Webhook', + status: 'connected', + triggers: [ + { + id: 'received', + name: 'Přijat požadavek', + description: + 'Portál vygeneruje neodhadnutelnou adresu. Vy určíte, jaké parametry na ni budou přicházet.', + customPayload: true, + }, + ], + actions: [], + }, + { + id: 'scheduler', + name: 'Plánovač', + category: 'spoustece', + description: 'Spouštění podle času — každou hodinu, denně, nebo podle cron výrazu.', + icon: 'Clock', + status: 'connected', + triggers: [ + { + id: 'interval', + name: 'V pravidelném intervalu', + description: 'Například každých 15 minut nebo každý den v 6:00.', + fields: ['Interval / cron výraz', 'Časová zóna'], + }, + ], + actions: [], + }, + { + id: 'manual', + name: 'Ruční spuštění', + category: 'spoustece', + description: 'Automatizaci spustí člověk tlačítkem v portálu. Vhodné pro testování.', + icon: 'MousePointerClick', + status: 'connected', + triggers: [ + { + id: 'button', + name: 'Spuštěno z portálu', + description: 'Spustí se stiskem tlačítka na detailu automatizace.', + }, + ], + actions: [], + }, + { + id: 'form', + name: 'Webový formulář', + category: 'spoustece', + description: 'Odeslání formuláře z webu — poptávka, registrace, reklamace.', + icon: 'FileInput', + status: 'connected', + triggers: [ + { + id: 'submitted', + name: 'Formulář odeslán', + description: 'Spustí se po odeslání formuláře. Pole formuláře si definujete sami.', + customPayload: true, + }, + ], + actions: [], + }, + + // --------------------------------------------------------------------- crm + { + id: 'raynet', + name: 'RAYNET CRM', + category: 'crm', + description: 'Firmy, kontakty, obchodní případy a aktivity v RAYNET CRM.', + icon: 'Users', + status: 'connected', + triggers: [ + { + id: 'lead-created', + name: 'Nový obchodní případ', + description: 'Spustí se při založení nového obchodního případu.', + }, + { + id: 'company-changed', + name: 'Změna firmy', + description: 'Spustí se při úpravě údajů firmy.', + }, + ], + actions: [ + { + id: 'create-lead', + name: 'Založit obchodní případ', + description: 'Vytvoří nový obchodní případ včetně napojení na firmu.', + fields: ['Název', 'Firma', 'Vlastník', 'Fáze'], + }, + { + id: 'upsert-contact', + name: 'Založit nebo aktualizovat kontakt', + description: 'Podle e-mailu kontakt najde a doplní, jinak vytvoří nový.', + fields: ['E-mail', 'Jméno', 'Telefon', 'Firma'], + }, + { + id: 'add-activity', + name: 'Přidat aktivitu', + description: 'Zapíše hovor, e-mail nebo poznámku k záznamu.', + fields: ['Typ aktivity', 'Text', 'Vazba na záznam'], + }, + ], + }, + + // -------------------------------------------------------------- ekonomika + { + id: 'idoklad', + name: 'iDoklad', + category: 'ekonomika', + description: 'Fakturace — vydané i přijaté doklady, kontakty, úhrady.', + icon: 'Receipt', + status: 'connected', + triggers: [ + { + id: 'invoice-paid', + name: 'Faktura uhrazena', + description: 'Spustí se, jakmile je vydaná faktura označená jako zaplacená.', + }, + { + id: 'invoice-overdue', + name: 'Faktura po splatnosti', + description: 'Spustí se v den, kdy faktura překročí splatnost.', + }, + ], + actions: [ + { + id: 'create-invoice', + name: 'Vystavit fakturu', + description: 'Vytvoří vydanou fakturu včetně položek a odešle ji odběrateli.', + fields: ['Odběratel', 'Položky', 'Splatnost', 'Odeslat e-mailem'], + }, + { + id: 'create-proforma', + name: 'Vystavit proforma fakturu', + description: 'Vytvoří zálohovou fakturu.', + fields: ['Odběratel', 'Položky'], + }, + { + id: 'mark-paid', + name: 'Označit jako uhrazenou', + description: 'Zapíše úhradu k existující faktuře.', + fields: ['Číslo faktury', 'Datum úhrady'], + }, + ], + }, + { + id: 'csob', + name: 'ČSOB (PSD2)', + category: 'ekonomika', + description: 'Bankovní pohyby a zůstatky přes PSD2 rozhraní ČSOB.', + icon: 'Landmark', + status: 'connected', + triggers: [ + { + id: 'payment-received', + name: 'Přijatá platba', + description: 'Spustí se při nové příchozí platbě na účtu.', + }, + ], + actions: [ + { + id: 'list-transactions', + name: 'Načíst pohyby', + description: 'Stáhne transakce za zvolené období pro další zpracování.', + fields: ['Účet', 'Období'], + }, + { + id: 'match-payment', + name: 'Spárovat platbu s fakturou', + description: 'Podle variabilního symbolu a částky najde odpovídající fakturu.', + fields: ['Tolerance částky'], + }, + ], + }, + + // -------------------------------------------------------------- logistika + { + id: 'ppl', + name: 'PPL CPL', + category: 'logistika', + description: 'Zásilky, štítky a svozy v systému PPL.', + icon: 'Truck', + status: 'connected', + triggers: [ + { + id: 'shipment-delivered', + name: 'Zásilka doručena', + description: 'Spustí se při změně stavu zásilky na doručeno.', + }, + ], + actions: [ + { + id: 'create-shipment', + name: 'Vytvořit zásilku', + description: 'Založí zásilku a vrátí číslo balíku i štítek k tisku.', + fields: ['Příjemce', 'Adresa', 'Hmotnost', 'Služba'], + }, + { + id: 'order-pickup', + name: 'Objednat svoz', + description: 'Objedná svoz na zvolený den a adresu.', + fields: ['Datum svozu', 'Adresa', 'Počet zásilek'], + }, + { + id: 'track', + name: 'Zjistit stav zásilky', + description: 'Vrátí aktuální stav a historii zásilky.', + fields: ['Číslo zásilky'], + }, + ], + }, + { + id: 'eshop', + name: 'E-shop', + category: 'logistika', + description: 'Objednávky, sklad a zákazníci z e-shopu (Shoptet, WooCommerce, vlastní).', + icon: 'ShoppingCart', + status: 'available', + triggers: [ + { + id: 'order-created', + name: 'Nová objednávka', + description: 'Spustí se při vytvoření objednávky v e-shopu.', + }, + { + id: 'order-status-changed', + name: 'Změna stavu objednávky', + description: 'Spustí se při přechodu objednávky do jiného stavu.', + }, + ], + actions: [ + { + id: 'update-order', + name: 'Změnit stav objednávky', + description: 'Nastaví objednávce nový stav a volitelně informuje zákazníka.', + fields: ['Číslo objednávky', 'Nový stav'], + }, + { + id: 'update-stock', + name: 'Upravit stav skladu', + description: 'Naskladní nebo odepíše položky.', + fields: ['SKU', 'Množství'], + }, + ], + }, + + // ------------------------------------------------------------- komunikace + { + id: 'email', + name: 'E-mail', + category: 'komunikace', + description: 'Příjem i odesílání e-mailů včetně příloh.', + icon: 'Mail', + status: 'connected', + triggers: [ + { + id: 'received', + name: 'Přijat e-mail', + description: 'Spustí se při doručení e-mailu do sledované schránky.', + fields: ['Schránka', 'Filtr odesílatele nebo předmětu'], + }, + ], + actions: [ + { + id: 'send', + name: 'Odeslat e-mail', + description: 'Odešle zprávu podle šablony s daty z předchozích kroků.', + fields: ['Příjemce', 'Předmět', 'Šablona', 'Přílohy'], + }, + ], + }, + { + id: 'microsoft365', + name: 'Microsoft 365', + category: 'komunikace', + description: 'Outlook, kalendář, Teams, SharePoint a OneDrive.', + icon: 'Building2', + status: 'connected', + triggers: [ + { + id: 'calendar-event', + name: 'Nová schůzka v kalendáři', + description: 'Spustí se při založení schůzky ve sledovaném kalendáři.', + }, + ], + actions: [ + { + id: 'create-event', + name: 'Vytvořit schůzku', + description: 'Založí schůzku a pozve účastníky.', + fields: ['Kalendář', 'Termín', 'Účastníci'], + }, + { + id: 'upload-file', + name: 'Uložit soubor', + description: 'Nahraje dokument do SharePointu nebo OneDrive.', + fields: ['Knihovna', 'Cesta', 'Soubor'], + }, + { + id: 'post-teams', + name: 'Poslat zprávu do Teams', + description: 'Odešle zprávu do kanálu nebo konkrétnímu člověku.', + fields: ['Kanál', 'Text zprávy'], + }, + ], + }, + { + id: 'sms', + name: 'SMS', + category: 'komunikace', + description: 'Odesílání SMS zpráv zákazníkům nebo obsluze.', + icon: 'MessageSquare', + status: 'available', + triggers: [], + actions: [ + { + id: 'send', + name: 'Odeslat SMS', + description: 'Odešle krátkou zprávu na telefonní číslo.', + fields: ['Telefon', 'Text'], + }, + ], + }, + { + id: 'slack', + name: 'Slack', + category: 'komunikace', + description: 'Notifikace a interní komunikace v Slacku.', + icon: 'Hash', + status: 'planned', + triggers: [], + actions: [ + { + id: 'post-message', + name: 'Poslat zprávu do kanálu', + description: 'Odešle zprávu do zvoleného kanálu.', + fields: ['Kanál', 'Text'], + }, + ], + }, + + // --------------------------------------------------------------- analytika + { + id: 'ga4', + name: 'Google Analytics 4', + category: 'analytika', + description: 'Návštěvnost, konverze a chování uživatelů.', + icon: 'BarChart3', + status: 'connected', + triggers: [], + actions: [ + { + id: 'run-report', + name: 'Načíst report', + description: 'Stáhne metriky za období pro další zpracování nebo report.', + fields: ['Property', 'Metriky', 'Dimenze', 'Období'], + }, + ], + }, + { + id: 'search-console', + name: 'Search Console', + category: 'analytika', + description: 'Pozice ve vyhledávání, dotazy a prokliky.', + icon: 'Search', + status: 'connected', + triggers: [], + actions: [ + { + id: 'run-report', + name: 'Načíst výkon ve vyhledávání', + description: 'Vrátí dotazy, prokliky, zobrazení a průměrnou pozici.', + fields: ['Web', 'Období'], + }, + ], + }, + { + id: 'google-ads', + name: 'Google Ads', + category: 'analytika', + description: 'Výkon kampaní a náklady na reklamu.', + icon: 'Megaphone', + status: 'connected', + triggers: [], + actions: [ + { + id: 'campaign-report', + name: 'Načíst výkon kampaní', + description: 'Stáhne náklady, konverze a ROAS podle kampaní.', + fields: ['Účet', 'Období'], + }, + ], + }, + { + id: 'sklik', + name: 'Sklik', + category: 'analytika', + description: 'Kampaně a náklady v Skliku.', + icon: 'MousePointer', + status: 'connected', + triggers: [], + actions: [ + { + id: 'campaign-report', + name: 'Načíst výkon kampaní', + description: 'Stáhne statistiky kampaní za období.', + fields: ['Účet', 'Období'], + }, + ], + }, + + // ---------------------------------------------------------------------- ai + { + id: 'voicebot', + name: 'Voicebot', + category: 'ai', + description: 'Hlasová linka — příjem hovorů, rozpoznání záměru, předání operátorovi.', + icon: 'PhoneCall', + status: 'connected', + triggers: [ + { + id: 'call-received', + name: 'Příchozí hovor', + description: 'Spustí se při přijetí hovoru na hlasovou linku.', + fields: ['Linka', 'Jazyk'], + }, + { + id: 'call-ended', + name: 'Hovor ukončen', + description: 'Spustí se po skončení hovoru, k dispozici je přepis i záměr.', + }, + ], + actions: [ + { + id: 'play-scenario', + name: 'Přehrát scénář', + description: 'Provede volajícího hlasovým scénářem a vrátí odpovědi.', + fields: ['Scénář', 'Jazyk'], + }, + { + id: 'transfer', + name: 'Předat operátorovi', + description: 'Přepojí hovor na člověka a předá mu souhrn.', + fields: ['Skupina', 'Souhrn'], + }, + { + id: 'outbound-call', + name: 'Zavolat zákazníkovi', + description: 'Zahájí odchozí hovor podle scénáře.', + fields: ['Telefon', 'Scénář'], + }, + ], + }, + { + id: 'transcription', + name: 'Přepis hovoru', + category: 'ai', + description: 'Přepis zvuku na text (Deepgram + Whisper) se sloučením výsledků.', + icon: 'FileAudio', + status: 'connected', + triggers: [], + actions: [ + { + id: 'transcribe', + name: 'Přepsat nahrávku', + description: 'Vrátí přepis, jazyk a časové značky.', + fields: ['Zdroj nahrávky', 'Jazyk'], + }, + { + id: 'summarize', + name: 'Vytvořit souhrn', + description: 'Z přepisu udělá krátký souhrn a seznam dalších kroků.', + fields: ['Délka souhrnu'], + }, + ], + }, + { + id: 'ai-text', + name: 'AI zpracování textu', + category: 'ai', + description: 'Klasifikace, extrakce dat a generování textu jazykovým modelem.', + icon: 'Sparkles', + status: 'connected', + triggers: [], + actions: [ + { + id: 'classify', + name: 'Zařadit do kategorie', + description: 'Rozhodne, do které kategorie text patří (např. téma ticketu).', + fields: ['Seznam kategorií', 'Text'], + }, + { + id: 'extract', + name: 'Vytáhnout údaje', + description: 'Z volného textu získá strukturovaná data podle schématu.', + fields: ['Schéma polí', 'Text'], + }, + { + id: 'generate', + name: 'Vygenerovat text', + description: 'Napíše odpověď nebo shrnutí podle instrukce.', + fields: ['Instrukce', 'Vstupní data'], + }, + ], + }, + + // ---------------------------------------------------------------- nastroje + { + id: 'http', + name: 'HTTP požadavek', + category: 'nastroje', + description: 'Zavolá libovolné API, které nemá vlastní konektor.', + icon: 'Globe', + status: 'connected', + triggers: [], + actions: [ + { + id: 'request', + name: 'Zavolat API', + description: 'Odešle HTTP požadavek a vrátí odpověď dalším krokům.', + fields: ['Metoda', 'URL', 'Hlavičky', 'Tělo'], + }, + ], + }, + { + id: 'transform', + name: 'Transformace dat', + category: 'nastroje', + description: 'Přemapování polí, formátování a čištění dat mezi kroky.', + icon: 'Shuffle', + status: 'connected', + triggers: [], + actions: [ + { + id: 'map-fields', + name: 'Přemapovat pole', + description: 'Přeloží data z jednoho tvaru do druhého.', + fields: ['Mapování polí'], + }, + { + id: 'deduplicate', + name: 'Odstranit duplicity', + description: 'Vyřadí záznamy, které už systémem prošly.', + fields: ['Klíč pro srovnání'], + }, + ], + }, + { + id: 'delay', + name: 'Pauza', + category: 'nastroje', + description: 'Pozdrží běh o daný čas nebo do konkrétního okamžiku.', + icon: 'Timer', + status: 'connected', + triggers: [], + actions: [ + { + id: 'wait', + name: 'Počkat', + description: 'Pozastaví běh na zadanou dobu.', + fields: ['Doba čekání'], + }, + ], + }, + { + id: 'ticket', + name: 'Tickety', + category: 'nastroje', + description: 'Servicedesk — zakládání a aktualizace požadavků.', + icon: 'LifeBuoy', + status: 'connected', + triggers: [ + { + id: 'created', + name: 'Nový ticket', + description: 'Spustí se při založení ticketu.', + }, + ], + actions: [ + { + id: 'create', + name: 'Založit ticket', + description: 'Vytvoří požadavek včetně priority a přiřazení.', + fields: ['Předmět', 'Popis', 'Priorita', 'Řešitel'], + }, + { + id: 'comment', + name: 'Přidat komentář', + description: 'Zapíše komentář k existujícímu ticketu.', + fields: ['ID ticketu', 'Text'], + }, + ], + }, + { + id: 'log', + name: 'Zápis do logu', + category: 'nastroje', + description: 'Uloží zprávu do provozního logu automatizace.', + icon: 'ScrollText', + status: 'connected', + triggers: [], + actions: [ + { + id: 'write', + name: 'Zapsat zprávu', + description: 'Přidá záznam do historie běhu — užitečné při ladění.', + fields: ['Úroveň', 'Zpráva'], + }, + ], + }, +]; + +export function findConnector(connectorId: string): Connector | undefined { + return connectors.find((c) => c.id === connectorId); +} + +/** + * Overi, ze konektor existuje a ma danou operaci pozadovaneho druhu. + * Pouziva se pri ukladani stromu, aby se do nej nedostaly neexistujici kroky. + */ +export function findOperation( + connectorId: string, + operationId: string, + type: 'trigger' | 'action', +): ConnectorOperation | undefined { + const connector = findConnector(connectorId); + if (!connector) return undefined; + const pool = type === 'trigger' ? connector.triggers : connector.actions; + return pool.find((op) => op.id === operationId); +} diff --git a/src/data/incidentStore.ts b/src/data/incidentStore.ts new file mode 100644 index 0000000..bcb52e7 --- /dev/null +++ b/src/data/incidentStore.ts @@ -0,0 +1,115 @@ +/** + * Uloziste incidentu. Stejny princip jako ticketStore - kazda zmena + * posle udalost na sbernici a projevi se v dashboardu okamzite. + */ + +import { publish } from '../events/bus.js'; + +export type IncidentSeverity = 'sev1' | 'sev2' | 'sev3'; +export type IncidentStatus = 'investigating' | 'identified' | 'monitoring' | 'resolved'; + +export interface Incident { + id: string; + title: string; + service: string; + severity: IncidentSeverity; + status: IncidentStatus; + startedAt: string; + resolvedAt: string | null; +} + +function minutesAgo(minutes: number): string { + return new Date(Date.now() - minutes * 60_000).toISOString(); +} + +const incidents: Incident[] = [ + { + id: 'INC-231', + title: 'Zvýšená latence hlasové brány (region EU-West)', + service: 'Voicebot Gateway', + severity: 'sev2', + status: 'monitoring', + startedAt: minutesAgo(88), + resolvedAt: null, + }, + { + id: 'INC-230', + title: 'Timeouty při zápisu do fakturačního API', + service: 'Integrace / iDoklad', + severity: 'sev3', + status: 'identified', + startedAt: minutesAgo(240), + resolvedAt: null, + }, + { + id: 'INC-228', + title: 'Neúspěšné doručení webhooků z e-shopu', + service: 'Webhook Router', + severity: 'sev3', + status: 'resolved', + startedAt: minutesAgo(2_900), + resolvedAt: minutesAgo(2_700), + }, +]; + +let counter = 231; + +export function listIncidents(): Incident[] { + return [...incidents].sort((a, b) => { + if (a.status === 'resolved' && b.status !== 'resolved') return 1; + if (b.status === 'resolved' && a.status !== 'resolved') return -1; + return b.startedAt.localeCompare(a.startedAt); + }); +} + +export function createIncident(input: { + title: string; + service: string; + severity: IncidentSeverity; +}): Incident { + counter += 1; + const incident: Incident = { + id: `INC-${counter}`, + title: input.title, + service: input.service, + severity: input.severity, + status: 'investigating', + startedAt: new Date().toISOString(), + resolvedAt: null, + }; + incidents.unshift(incident); + + publish('incident.started', `Nový incident ${incident.id}: ${incident.title}`, { + incidentId: incident.id, + severity: incident.severity, + }); + return incident; +} + +export function updateIncidentStatus(id: string, status: IncidentStatus): Incident | undefined { + const incident = incidents.find((i) => i.id === id); + if (!incident) { + console.warn(`[incidents] zmena stavu neexistujiciho incidentu: ${id}`); + return undefined; + } + + incident.status = status; + incident.resolvedAt = status === 'resolved' ? new Date().toISOString() : null; + + if (status === 'resolved') { + publish('incident.resolved', `Incident ${incident.id} vyřešen: ${incident.title}`, { + incidentId: incident.id, + }); + } else { + publish('incident.updated', `Incident ${incident.id}: ${status}`, { + incidentId: incident.id, + status, + }); + } + return incident; +} + +/** Prvni bezici incident - pouziva simulace, kdyz uzivatel neurci ktery. */ +export function firstActiveIncident(): Incident | undefined { + return incidents.find((i) => i.status !== 'resolved'); +} diff --git a/src/data/mock.ts b/src/data/mock.ts new file mode 100644 index 0000000..8dcf923 --- /dev/null +++ b/src/data/mock.ts @@ -0,0 +1,48 @@ +/** + * Souhrn pro dashboard a casova rada do grafu. + * + * Tickety, incidenty ani automatizace tu uz nejsou - maji vlastni uloziste + * (ticketStore, incidentStore, automationStore), protoze se daji menit + * a kazda zmena posila udalost na sbernici. + */ + +import { listAutomations } from './automationStore.js'; +import { listIncidents } from './incidentStore.js'; +import { listTickets } from './ticketStore.js'; + +/** Casova rada pro graf na dashboardu - poslednich 14 dni. */ +export function getRunsSeries(days = 14) { + const series: Array<{ date: string; runs: number; failures: number }> = []; + for (let i = days - 1; i >= 0; i -= 1) { + const date = new Date(Date.now() - i * 86_400_000); + // Deterministicky "sum" podle dne, aby graf nepreskakoval pri kazdem refreshi. + const seed = date.getUTCDate(); + const runs = 380 + ((seed * 37) % 260); + const failures = 2 + ((seed * 7) % 11); + series.push({ date: date.toISOString().slice(0, 10), runs, failures }); + } + return series; +} + +export function getSummary() { + const tickets = listTickets(); + const incidents = listIncidents(); + const automations = listAutomations(); + const series = getRunsSeries(); + + // Dnesni sloupec grafu doplnujeme o skutecne behy, aby se simulace projevila. + const runsToday = automations.reduce((sum, a) => sum + a.runsToday, 0); + if (series.length > 0) { + series[series.length - 1] = { ...series[series.length - 1], runs: runsToday }; + } + + return { + openTickets: tickets.filter((t) => t.status !== 'resolved').length, + activeIncidents: incidents.filter((i) => i.status !== 'resolved').length, + activeAutomations: automations.filter((a) => a.enabled).length, + runsToday, + savedHoursMonth: 312, + uptime: 99.98, + series, + }; +} diff --git a/src/data/ticketStore.ts b/src/data/ticketStore.ts new file mode 100644 index 0000000..abedbec --- /dev/null +++ b/src/data/ticketStore.ts @@ -0,0 +1,148 @@ +/** + * Uloziste ticketu. Zmeny posilaji udalost na sbernici, takze se projevi + * v dashboardu okamzite bez obnoveni stranky. + * + * POZOR: data jsou v pameti procesu, restart API je vrati na vychozi sadu. + */ + +import { publish } from '../events/bus.js'; + +export type TicketStatus = 'new' | 'open' | 'waiting' | 'resolved'; +export type TicketPriority = 'low' | 'normal' | 'high' | 'critical'; + +export interface Ticket { + id: string; + subject: string; + requester: string; + status: TicketStatus; + priority: TicketPriority; + assignee: string | null; + createdAt: string; + updatedAt: string; +} + +function minutesAgo(minutes: number): string { + return new Date(Date.now() - minutes * 60_000).toISOString(); +} + +const tickets: Ticket[] = [ + { + id: 'TK-4821', + subject: 'Voicebot neodpovídá na volání po 18:00', + requester: 'Firma s.r.o.', + status: 'open', + priority: 'high', + assignee: 'Jiří U.', + createdAt: minutesAgo(310), + updatedAt: minutesAgo(42), + }, + { + id: 'TK-4820', + subject: 'Přidat pole IČO do synchronizace CRM a fakturace', + requester: 'Nordis a.s.', + status: 'waiting', + priority: 'normal', + assignee: 'Martin K.', + createdAt: minutesAgo(1_180), + updatedAt: minutesAgo(190), + }, + { + id: 'TK-4819', + subject: 'Chybí denní report objednávek v e-mailu', + requester: 'Bistro Kolektiv', + status: 'new', + priority: 'normal', + assignee: null, + createdAt: minutesAgo(95), + updatedAt: minutesAgo(95), + }, + { + id: 'TK-4817', + subject: 'Rozšíření hlasového scénáře o objednávku svozu', + requester: 'LogiTrans', + status: 'open', + priority: 'low', + assignee: 'Eva N.', + createdAt: minutesAgo(2_600), + updatedAt: minutesAgo(420), + }, + { + id: 'TK-4812', + subject: 'Duplicitní zápis kontaktů z webového formuláře', + requester: 'Firma s.r.o.', + status: 'resolved', + priority: 'critical', + assignee: 'Jiří U.', + createdAt: minutesAgo(5_100), + updatedAt: minutesAgo(1_500), + }, +]; + +let counter = 4_821; + +export function listTickets(): Ticket[] { + // Nejdriv nevyrizene, uvnitr od nejnovejsi upravy. + return [...tickets].sort((a, b) => { + if (a.status === 'resolved' && b.status !== 'resolved') return 1; + if (b.status === 'resolved' && a.status !== 'resolved') return -1; + return b.updatedAt.localeCompare(a.updatedAt); + }); +} + +export function getTicket(id: string): Ticket | undefined { + return tickets.find((t) => t.id === id); +} + +export function createTicket(input: { + subject: string; + requester: string; + priority: TicketPriority; +}): Ticket { + counter += 1; + const now = new Date().toISOString(); + const ticket: Ticket = { + id: `TK-${counter}`, + subject: input.subject, + requester: input.requester, + status: 'new', + priority: input.priority, + assignee: null, + createdAt: now, + updatedAt: now, + }; + tickets.unshift(ticket); + + publish('ticket.created', `Nový ticket ${ticket.id}: ${ticket.subject}`, { + ticketId: ticket.id, + priority: ticket.priority, + }); + return ticket; +} + +export function updateTicketStatus(id: string, status: TicketStatus): Ticket | undefined { + const ticket = tickets.find((t) => t.id === id); + if (!ticket) { + console.warn(`[tickets] zmena stavu neexistujiciho ticketu: ${id}`); + return undefined; + } + + ticket.status = status; + ticket.updatedAt = new Date().toISOString(); + + if (status === 'resolved') { + publish('ticket.resolved', `Ticket ${ticket.id} vyřešen: ${ticket.subject}`, { + ticketId: ticket.id, + }); + } else { + publish('ticket.updated', `Ticket ${ticket.id} má nový stav`, { + ticketId: ticket.id, + status, + }); + } + return ticket; +} + +/** Prvni nevyrizeny ticket - pouziva simulace, kdyz uzivatel neurci ktery. */ +export function firstOpenTicket(): Ticket | undefined { + return tickets.find((t) => t.status !== 'resolved'); +} diff --git a/src/data/users.ts b/src/data/users.ts new file mode 100644 index 0000000..c15ed99 --- /dev/null +++ b/src/data/users.ts @@ -0,0 +1,37 @@ +import bcrypt from 'bcryptjs'; +import type { User } from '../types.js'; + +/** + * PROTOTYP: uzivatele jsou v pameti. Az prijde realny dashboard, tohle nahradi + * databaze (viz docs/04-backend-api.md, sekce "Kam dal"). + * Hesla jsou hashovana pri startu, aby v kodu nebyl plaintext v uloziste-podobne strukture. + */ +const DEMO_PASSWORD = 'demo1234'; + +export const users: User[] = [ + { + id: 'usr_1', + email: 'admin@automia.cz', + passwordHash: bcrypt.hashSync(DEMO_PASSWORD, 10), + name: 'Jiří Uhlíř', + role: 'admin', + company: 'Automia', + }, + { + id: 'usr_2', + email: 'klient@firma.cz', + passwordHash: bcrypt.hashSync(DEMO_PASSWORD, 10), + name: 'Petra Klientová', + role: 'client', + company: 'Firma s.r.o.', + }, +]; + +export function findUserByEmail(email: string): User | undefined { + const normalized = email.trim().toLowerCase(); + return users.find((u) => u.email.toLowerCase() === normalized); +} + +export function findUserById(id: string): User | undefined { + return users.find((u) => u.id === id); +} diff --git a/src/events/bus.ts b/src/events/bus.ts new file mode 100644 index 0000000..c4332bc --- /dev/null +++ b/src/events/bus.ts @@ -0,0 +1,80 @@ +import { EventEmitter } from 'node:events'; +import { randomUUID } from 'node:crypto'; + +/** + * Sbernice udalosti dashboardu. Vse, co zmeni data, sem posle udalost + * a pripojeni klienti ji dostanou pres SSE stream okamzite. + * + * Zamerne v pameti procesu - pri vice instancich API by tohle musel + * nahradit Redis pub/sub nebo jina sdilena fronta (viz dokumentace). + */ + +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; + /** Kratka veta pro uzivatele, zobrazuje se v notifikaci. */ + message: string; + /** Doplnkova data, napr. id ticketu. */ + payload?: Record; +} + +const CHANNEL = 'dashboard'; +const MAX_RECENT = 50; + +const emitter = new EventEmitter(); +// Kazdy pripojeny klient je jeden listener, vychozich 10 je malo. +emitter.setMaxListeners(200); + +const recent: DashboardEvent[] = []; + +export function publish( + type: DashboardEventType, + message: string, + payload?: Record, +): DashboardEvent { + const event: DashboardEvent = { + id: randomUUID(), + type, + at: new Date().toISOString(), + message, + payload, + }; + + recent.unshift(event); + if (recent.length > MAX_RECENT) recent.length = MAX_RECENT; + + emitter.emit(CHANNEL, event); + console.info(`[event] ${type}: ${message} (posluchacu: ${emitter.listenerCount(CHANNEL)})`); + return event; +} + +/** Vraci funkci pro odhlaseni. Volajici ji MUSI zavolat pri odpojeni. */ +export function subscribe(listener: (event: DashboardEvent) => void): () => void { + emitter.on(CHANNEL, listener); + return () => { + emitter.off(CHANNEL, listener); + }; +} + +/** Poslednich par udalosti - klient je dostane hned po pripojeni. */ +export function recentEvents(limit = 10): DashboardEvent[] { + return recent.slice(0, limit); +} + +export function listenerCount(): number { + return emitter.listenerCount(CHANNEL); +} diff --git a/src/index.ts b/src/index.ts index ddd2e40..fccd2c7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,35 +1,185 @@ -import express from "express"; +import cors from 'cors'; +import express, { + Router, + type NextFunction, + type Request, + type Response, +} from 'express'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import swaggerUi from 'swagger-ui-express'; +import { config } from './config.js'; +import { buildOpenApiDocument } from './openapi.js'; +import { authRouter } from './routes/auth.js'; +import { contactRouter } from './routes/contact.js'; +import { dashboardRouter } from './routes/dashboard.js'; +import { simulateRouter } from './routes/simulate.js'; +import { webhookRouter } from './routes/webhook.js'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +/** Zbuildovana SPA. Vite ji zapisuje do dist/public, viz vite.config.ts. */ +const webRoot = path.join(here, 'public'); const app = express(); -const port = Number(process.env.PORT || 3000); -const rootPath = process.env.ROOT_PATH || ""; +// Aplikace bezi za reverse proxy, jinak by req.ip a protokol byly containeru. +app.set('trust proxy', true); -app.get("/", (_req, res) => { - res.json({ - name: "csbot-prototype", - service: "csbot-prototype", - status: "ok" +app.use( + cors({ + origin(origin, callback) { + // Bez Origin (curl, server-to-server) i stejna domena projdou vzdy. + if (!origin || isOriginAllowed(origin)) return callback(null, true); + console.warn(`[cors] zablokovan origin: ${origin}`); + return callback(null, false); + }, + credentials: true, + }), +); +app.use(express.json({ limit: '256kb' })); + +app.use((req, _res, next) => { + // Loguje se jen metoda a cesta, nikdy hlavicky ani telo - obsahuji secrets. + console.info(`[req] ${req.method} ${req.originalUrl}`); + next(); +}); + +function isOriginAllowed(origin: string): boolean { + if (config.corsOrigins.includes(origin)) return true; + // V dev rezimu si Vite pri obsazenem portu vezme jiny, proto cely localhost. + if (!config.isProduction && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) { + return true; + } + return false; +} + +// ---------------------------------------------------------------- API router + +/** + * strict: true je nutne. Bez nej by se cesta /docs shodovala i s /docs/ + * a presmerovani nize by se zacyklilo. + */ +const api = Router({ strict: true }); + +api.get('/health', (_req, res) => { + res.json({ status: 'ok', uptimeSec: Math.round(process.uptime()) }); +}); + +/** + * Swagger UI. Cesta bez lomitka presmerujeme na variantu s lomitkem, + * jinak by se relativni odkazy na CSS a JS skladaly o uroven vys + * a za reverse proxy by se nenacetly. + */ +const openApiDocument = buildOpenApiDocument(); +const swaggerOptions: swaggerUi.SwaggerUiOptions = { + customSiteTitle: 'Automia API', + swaggerOptions: { persistAuthorization: true }, +}; + +api.get('/docs', (_req, res) => res.redirect(`${config.rootPath}/docs/`)); +api.get('/openapi.json', (_req, res) => res.json(openApiDocument)); +api.use( + '/docs', + swaggerUi.serveFiles(openApiDocument, swaggerOptions), + swaggerUi.setup(openApiDocument, swaggerOptions), +); + +api.use('/api/auth', authRouter); +api.use('/api/dashboard', dashboardRouter); +api.use('/api/simulate', simulateRouter); +api.use('/api/contact', contactRouter); +api.use('/webhook', webhookRouter); + +// Mount na koren i na prefix proxy. Caddy prefix pres handle_path odstranuje, +// ale takhle aplikace funguje i kdyby ho nechal - a lokalne bez proxy taky. +app.use(api); +if (config.rootPath) app.use(config.rootPath, api); + +// Neexistujici API cesta musi vratit JSON, ne HTML aplikace. +app.use((req, res, next) => { + if (/^(\/apps\/[^/]+)?\/(api|webhook)\//.test(req.path)) { + console.warn(`[404] ${req.method} ${req.originalUrl}`); + return res.status(404).json({ error: 'not_found', message: 'Endpoint neexistuje.' }); + } + return next(); +}); + +// ---------------------------------------------------------------- SPA + +/** + * Do index.html se za behu vklada base pro prohlizec. + * + * Prohlizec vidi adresu /apps//..., ale Vite build ma relativni cesty. + * Bez by se soubory na vnorenych cestach hledaly ve spatne slozce. + * Prefix se bere z ROOT_PATH, nikdy neni v kodu natvrdo. + */ +function renderIndexHtml(): string { + const file = path.join(webRoot, 'index.html'); + const html = fs.readFileSync(file, 'utf8'); + const base = `${config.rootPath}/`; + const injected = + `\n` + + ` `; + return html.replace('', `\n ${injected}`); +} + +let cachedIndexHtml: string | null = null; +const hasWebBuild = fs.existsSync(path.join(webRoot, 'index.html')); + +if (hasWebBuild) { + const serveStatic = express.static(webRoot, { + index: false, + // Soubory maji hash v nazvu, muzou se cachovat dlouho. index.html ne. + setHeaders(res, filePath) { + if (filePath.endsWith('.html')) res.setHeader('Cache-Control', 'no-cache'); + else res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); + }, }); -}); -app.get("/health", (_req, res) => { - res.json({ status: "ok" }); -}); + app.use(serveStatic); + if (config.rootPath) app.use(config.rootPath, serveStatic); -if (rootPath) { - app.get(rootPath, (_req, res) => { + // Vsechny ostatni cesty obsluhuje SPA, routovani si resi React Router. + app.get('*', (_req, res) => { + if (!cachedIndexHtml) cachedIndexHtml = renderIndexHtml(); + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.setHeader('Cache-Control', 'no-cache'); + res.send(cachedIndexHtml); + }); +} else { + // Bez buildu webu nesmi aplikace tise vracet prazdno. + console.warn(`[start] build webu nenalezen v ${webRoot}, bezi jen API`); + app.get('/', (_req, res) => { res.json({ - name: "csbot-prototype", - service: "csbot-prototype", - status: "ok" + name: 'csbot-prototype', + status: 'ok', + note: 'Build webu chybi, dostupne je jen API a /docs.', }); }); - - app.get(rootPath + "/health", (_req, res) => { - res.json({ status: "ok" }); - }); } -app.listen(port, "0.0.0.0", () => { - console.log("csbot-prototype listening on port " + port); +// Centralni error handler - nic nesmi propadnout bez logu. +app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => { + console.error('[error]', err); + const message = err instanceof Error ? err.message : 'Neznama chyba.'; + res.status(500).json({ + error: 'internal_error', + message: config.isProduction ? 'Interni chyba serveru.' : message, + }); +}); + +// Poslouchat na vsech rozhranich containeru, ne jen na localhost (AGENTS.md). +const server = app.listen(config.port, '0.0.0.0', () => { + console.info(`[start] csbot-prototype bezi na portu ${config.port}`); + console.info(`[start] ROOT_PATH: ${config.rootPath || '(neni nastaven)'}`); + console.info(`[start] health: ${config.rootPath}/health, docs: ${config.rootPath}/docs`); +}); + +server.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EADDRINUSE') { + console.error(`[start] Port ${config.port} je obsazeny.`); + } else { + console.error('[start] Aplikaci se nepodarilo spustit:', err); + } + process.exit(1); }); diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts new file mode 100644 index 0000000..7a999d7 --- /dev/null +++ b/src/middleware/auth.ts @@ -0,0 +1,52 @@ +import type { NextFunction, Request, Response } from 'express'; +import jwt from 'jsonwebtoken'; +import { config } from '../config.js'; +import { findUserById } from '../data/users.js'; +import type { JwtPayload, User } from '../types.js'; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Express { + interface Request { + user?: User; + } + } +} + +/** Overi Bearer token a doplni req.user. Bez tokenu / s neplatnym tokenem vraci 401. */ +export function requireAuth(req: Request, res: Response, next: NextFunction) { + const header = req.header('authorization') ?? ''; + const [scheme, token] = header.split(' '); + + if (scheme?.toLowerCase() !== 'bearer' || !token) { + return res.status(401).json({ error: 'unauthorized', message: 'Chybí přihlašovací token.' }); + } + + try { + const payload = jwt.verify(token, config.jwtSecret) as JwtPayload; + const user = findUserById(payload.sub); + if (!user) { + console.warn(`[auth] token s platnym podpisem, ale neexistujici uzivatel: ${payload.sub}`); + return res.status(401).json({ error: 'unauthorized', message: 'Uživatel neexistuje.' }); + } + req.user = user; + return next(); + } catch (err) { + console.warn('[auth] neplatny token:', err instanceof Error ? err.message : err); + return res.status(401).json({ error: 'unauthorized', message: 'Neplatný nebo expirovaný token.' }); + } +} + +/** Omezi pristup na konkretni role. Pouzivat vzdy AZ za requireAuth. */ +export function requireRole(...roles: User['role'][]) { + return (req: Request, res: Response, next: NextFunction) => { + if (!req.user) { + console.error('[auth] requireRole pouzito bez requireAuth'); + return res.status(401).json({ error: 'unauthorized' }); + } + if (!roles.includes(req.user.role)) { + return res.status(403).json({ error: 'forbidden', message: 'Nedostatečná oprávnění.' }); + } + return next(); + }; +} diff --git a/src/openapi.ts b/src/openapi.ts new file mode 100644 index 0000000..d60144b --- /dev/null +++ b/src/openapi.ts @@ -0,0 +1,600 @@ +import { config } from './config.js'; + +/** + * OpenAPI popis API. + * + * `servers` MUSI obsahovat prefix reverse proxy, jinak Swagger "Try it out" + * vola endpointy na root domene a dostane 404 (viz AGENTS.md). + * Prefix se bere z ROOT_PATH, nikdy se nehardcoduje. + */ +export function buildOpenApiDocument() { + const server = config.rootPath === '' ? '/' : config.rootPath; + + return { + openapi: '3.0.3', + info: { + title: 'Automia - portal a API', + version: '1.0.0', + description: + 'Webova prezentace a klientsky portal. Automatizace, voiceboti, integrace, ' + + 'tickety a incidenty. Aplikace bezi za reverse proxy AppFactory.', + }, + servers: [{ url: server, description: 'Verejna adresa vcetne prefixu proxy' }], + tags: [ + { name: 'Provoz', description: 'Health a zakladni informace' }, + { name: 'Autentizace', description: 'Prihlaseni do portalu' }, + { name: 'Dashboard', description: 'Data klientskeho portalu' }, + { name: 'Automatizace', description: 'Sprava automatizaci a stromu akci' }, + { name: 'Simulace', description: 'Vyvolani provoznich udalosti pro nahled' }, + { name: 'Webhook', description: 'Verejny prijem dat do automatizace' }, + { name: 'Kontakt', description: 'Poptavkovy formular z webu' }, + ], + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + description: 'Token z POST /api/auth/login. Vlozte samotny token bez slova Bearer.', + }, + }, + schemas: { + Error: { + type: 'object', + properties: { + error: { type: 'string', example: 'validation_error' }, + message: { type: 'string', example: 'Zadejte platny e-mail.' }, + }, + }, + User: { + type: 'object', + properties: { + id: { type: 'string', example: 'usr_1' }, + email: { type: 'string', example: 'admin@automia.cz' }, + name: { type: 'string', example: 'Jiri Uhlir' }, + role: { type: 'string', enum: ['admin', 'client'] }, + company: { type: 'string', example: 'Automia' }, + }, + }, + LoginRequest: { + type: 'object', + required: ['email', 'password'], + properties: { + email: { type: 'string', format: 'email', example: 'admin@automia.cz' }, + password: { type: 'string', format: 'password', example: 'demo1234' }, + }, + }, + LoginResponse: { + type: 'object', + properties: { + token: { type: 'string' }, + user: { $ref: '#/components/schemas/User' }, + }, + }, + Ticket: { + type: 'object', + properties: { + id: { type: 'string', example: 'TK-4821' }, + subject: { type: 'string' }, + requester: { type: 'string' }, + status: { type: 'string', enum: ['new', 'open', 'waiting', 'resolved'] }, + priority: { type: 'string', enum: ['low', 'normal', 'high', 'critical'] }, + assignee: { type: 'string', nullable: true }, + createdAt: { type: 'string', format: 'date-time' }, + updatedAt: { type: 'string', format: 'date-time' }, + }, + }, + Incident: { + type: 'object', + properties: { + id: { type: 'string', example: 'INC-231' }, + title: { type: 'string' }, + service: { type: 'string' }, + severity: { type: 'string', enum: ['sev1', 'sev2', 'sev3'] }, + status: { + type: 'string', + enum: ['investigating', 'identified', 'monitoring', 'resolved'], + }, + startedAt: { type: 'string', format: 'date-time' }, + resolvedAt: { type: 'string', format: 'date-time', nullable: true }, + }, + }, + TriggerField: { + type: 'object', + required: ['id', 'name', 'type', 'required'], + properties: { + id: { type: 'string', example: 'f_42' }, + name: { + type: 'string', + example: 'score', + description: 'Klic v prichozich datech, pismena, cislice a podtrzitko.', + }, + type: { type: 'string', enum: ['string', 'number', 'boolean', 'date'] }, + required: { type: 'boolean' }, + }, + }, + FlowStep: { + type: 'object', + description: + 'Krok stromu. Bud akce nad konektorem, nebo podminka se dvema vetvemi.', + properties: { + id: { type: 'string' }, + kind: { type: 'string', enum: ['action', 'condition'] }, + connectorId: { type: 'string', example: 'email' }, + operationId: { type: 'string', example: 'send' }, + fieldId: { type: 'string', example: 'f_42' }, + operator: { + type: 'string', + enum: [ + 'eq', + 'neq', + 'gt', + 'gte', + 'lt', + 'lte', + 'contains', + 'startsWith', + 'isEmpty', + 'isNotEmpty', + 'isTrue', + 'isFalse', + ], + }, + value: { type: 'string', example: '15' }, + yes: { type: 'array', items: { $ref: '#/components/schemas/FlowStep' } }, + no: { type: 'array', items: { $ref: '#/components/schemas/FlowStep' } }, + }, + }, + AutomationFlow: { + type: 'object', + properties: { + trigger: { + type: 'object', + nullable: true, + properties: { + connectorId: { type: 'string', example: 'webhook' }, + operationId: { type: 'string', example: 'received' }, + fields: { + type: 'array', + items: { $ref: '#/components/schemas/TriggerField' }, + }, + webhookToken: { + type: 'string', + readOnly: true, + description: 'Generuje vyhradne server, hodnota od klienta se ignoruje.', + }, + }, + }, + steps: { type: 'array', items: { $ref: '#/components/schemas/FlowStep' } }, + }, + }, + Automation: { + type: 'object', + properties: { + id: { type: 'string', example: 'AUT-01' }, + name: { type: 'string' }, + kind: { type: 'string', enum: ['workflow', 'voicebot', 'integrace', 'report'] }, + enabled: { type: 'boolean' }, + runsToday: { type: 'integer' }, + successRate: { type: 'number' }, + avgDurationMs: { type: 'integer' }, + lastRunAt: { type: 'string', format: 'date-time' }, + stepCount: { type: 'integer' }, + configured: { type: 'boolean' }, + issues: { + type: 'array', + items: { type: 'string' }, + description: 'Co chybi k zapnuti. Prazdne pole znamena hotovo.', + }, + }, + }, + AutomationDetail: { + allOf: [ + { $ref: '#/components/schemas/Automation' }, + { + type: 'object', + properties: { + flow: { $ref: '#/components/schemas/AutomationFlow' }, + createdAt: { type: 'string', format: 'date-time' }, + updatedAt: { type: 'string', format: 'date-time' }, + }, + }, + ], + }, + }, + }, + paths: { + '/health': { + get: { + tags: ['Provoz'], + summary: 'Health check', + description: 'Vraci 200, pokud je aplikace schopna prijimat provoz.', + responses: { + '200': { + description: 'Aplikace bezi', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + status: { type: 'string', example: 'ok' }, + uptimeSec: { type: 'integer', example: 42 }, + }, + }, + }, + }, + }, + }, + }, + }, + '/api/auth/login': { + post: { + tags: ['Autentizace'], + summary: 'Prihlaseni', + requestBody: { + required: true, + content: { + 'application/json': { schema: { $ref: '#/components/schemas/LoginRequest' } }, + }, + }, + responses: { + '200': { + description: 'Token a udaje uzivatele', + content: { + 'application/json': { schema: { $ref: '#/components/schemas/LoginResponse' } }, + }, + }, + '401': { + description: 'Nespravny e-mail nebo heslo', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, + }, + }, + }, + }, + '/api/auth/me': { + get: { + tags: ['Autentizace'], + summary: 'Prihlaseny uzivatel', + security: [{ bearerAuth: [] }], + responses: { + '200': { + description: 'Udaje uzivatele', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { user: { $ref: '#/components/schemas/User' } }, + }, + }, + }, + }, + '401': { description: 'Chybi nebo neplatny token' }, + }, + }, + }, + '/api/auth/logout': { + post: { + tags: ['Autentizace'], + summary: 'Odhlaseni', + security: [{ bearerAuth: [] }], + responses: { '204': { description: 'Odhlaseno' } }, + }, + }, + '/api/dashboard/summary': { + get: { + tags: ['Dashboard'], + summary: 'Souhrn pro prehled', + security: [{ bearerAuth: [] }], + responses: { '200': { description: 'Souhrnne metriky a casova rada' } }, + }, + }, + '/api/dashboard/tickets': { + get: { + tags: ['Dashboard'], + summary: 'Seznam ticketu', + security: [{ bearerAuth: [] }], + responses: { + '200': { + description: 'Tickety', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + items: { type: 'array', items: { $ref: '#/components/schemas/Ticket' } }, + }, + }, + }, + }, + }, + }, + }, + }, + '/api/dashboard/incidents': { + get: { + tags: ['Dashboard'], + summary: 'Seznam incidentu', + security: [{ bearerAuth: [] }], + responses: { + '200': { + description: 'Incidenty', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + items: { type: 'array', items: { $ref: '#/components/schemas/Incident' } }, + }, + }, + }, + }, + }, + }, + }, + }, + '/api/dashboard/stream': { + get: { + tags: ['Dashboard'], + summary: 'Zivy stream zmen (SSE)', + description: + 'Server-Sent Events. Drzi otevrene spojeni a posila udalosti, jakmile nastanou. ' + + 'Swagger UI streamovanou odpoved nezobrazi rozumne, testujte prohlizecem nebo curl.', + security: [{ bearerAuth: [] }], + responses: { '200': { description: 'Proud udalosti text/event-stream' } }, + }, + }, + '/api/dashboard/connectors': { + get: { + tags: ['Automatizace'], + summary: 'Katalog konektoru', + security: [{ bearerAuth: [] }], + responses: { '200': { description: 'Konektory, kategorie a operatory podminek' } }, + }, + }, + '/api/dashboard/automations': { + get: { + tags: ['Automatizace'], + summary: 'Seznam automatizaci', + security: [{ bearerAuth: [] }], + responses: { + '200': { + description: 'Automatizace', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + items: { type: 'array', items: { $ref: '#/components/schemas/Automation' } }, + }, + }, + }, + }, + }, + }, + }, + post: { + tags: ['Automatizace'], + summary: 'Zalozit automatizaci', + security: [{ bearerAuth: [] }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['name'], + properties: { name: { type: 'string', minLength: 3 } }, + }, + }, + }, + }, + responses: { + '201': { + description: 'Vytvoreno', + content: { + 'application/json': { schema: { $ref: '#/components/schemas/AutomationDetail' } }, + }, + }, + '400': { description: 'Neplatny nazev' }, + }, + }, + }, + '/api/dashboard/automations/{id}': { + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string' }, example: 'AUT-01' }, + ], + get: { + tags: ['Automatizace'], + summary: 'Detail vcetne stromu akci', + security: [{ bearerAuth: [] }], + responses: { + '200': { + description: 'Detail', + content: { + 'application/json': { schema: { $ref: '#/components/schemas/AutomationDetail' } }, + }, + }, + '404': { description: 'Neexistuje' }, + }, + }, + put: { + tags: ['Automatizace'], + summary: 'Ulozit automatizaci', + description: + 'Validuje strom proti katalogu konektoru. Nedokoncenou automatizaci server ' + + 'nezapne ani pri enabled=true, duvody vraci v poli issues.', + security: [{ bearerAuth: [] }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + name: { type: 'string', minLength: 3 }, + enabled: { type: 'boolean' }, + flow: { $ref: '#/components/schemas/AutomationFlow' }, + }, + }, + }, + }, + }, + responses: { + '200': { + description: 'Ulozeno', + content: { + 'application/json': { schema: { $ref: '#/components/schemas/AutomationDetail' } }, + }, + }, + '400': { description: 'Neplatny strom' }, + '404': { description: 'Neexistuje' }, + }, + }, + delete: { + tags: ['Automatizace'], + summary: 'Smazat automatizaci', + security: [{ bearerAuth: [] }], + responses: { '204': { description: 'Smazano' }, '404': { description: 'Neexistuje' } }, + }, + }, + '/api/dashboard/automations/{id}/webhook/regenerate': { + post: { + tags: ['Automatizace'], + summary: 'Nova adresa webhooku', + description: 'Stara adresa okamzite prestane fungovat.', + security: [{ bearerAuth: [] }], + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + responses: { + '200': { + description: 'Novy token', + content: { + 'application/json': { schema: { $ref: '#/components/schemas/AutomationDetail' } }, + }, + }, + '404': { description: 'Neexistuje nebo spoustecem neni webhook' }, + }, + }, + }, + '/api/simulate': { + post: { + tags: ['Simulace'], + summary: 'Vyvolat provozni udalost', + description: + 'Meni skutecna data, aby bylo videt, jak dashboard reaguje zive. ' + + 'Nevyplnena pole server doplni ukazkovou hodnotou.', + security: [{ bearerAuth: [] }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['action'], + properties: { + action: { + type: 'string', + enum: [ + 'ticket.created', + 'ticket.resolved', + 'incident.started', + 'incident.resolved', + 'automation.run', + ], + }, + subject: { type: 'string' }, + requester: { type: 'string' }, + priority: { type: 'string', enum: ['low', 'normal', 'high', 'critical'] }, + ticketId: { type: 'string' }, + title: { type: 'string' }, + service: { type: 'string' }, + severity: { type: 'string', enum: ['sev1', 'sev2', 'sev3'] }, + incidentId: { type: 'string' }, + automationId: { type: 'string' }, + ok: { type: 'boolean' }, + }, + }, + }, + }, + }, + responses: { + '200': { description: 'Udalost provedena' }, + '201': { description: 'Zaznam vytvoren' }, + '409': { description: 'Neni co provest' }, + }, + }, + }, + '/webhook/{token}': { + post: { + tags: ['Webhook'], + summary: 'Prijem dat do automatizace', + description: + 'Verejny endpoint bez prihlaseni. Autorizuje neuhodnutelny token v adrese. ' + + 'Telo se overuje proti parametrum deklarovanym u spoustece.', + parameters: [ + { + name: 'token', + in: 'path', + required: true, + schema: { type: 'string' }, + description: '32znakovy token vygenerovany serverem.', + }, + ], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { type: 'object', additionalProperties: true }, + example: { customer: 'Nordis', score: 18, comment: 'vse ok' }, + }, + }, + }, + responses: { + '202': { description: 'Prijato' }, + '400': { description: 'Chybi parametr nebo nesedi typ' }, + '404': { description: 'Neznamy token' }, + '409': { description: 'Automatizace je pozastavena' }, + }, + }, + }, + '/api/contact': { + post: { + tags: ['Kontakt'], + summary: 'Odeslat poptavku z webu', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + required: ['name', 'email', 'topic', 'message'], + properties: { + name: { type: 'string', minLength: 2 }, + email: { type: 'string', format: 'email' }, + company: { type: 'string' }, + phone: { type: 'string' }, + topic: { + type: 'string', + enum: [ + 'automatizace', + 'voicebot', + 'integrace', + 'dashboard', + 'podpora', + 'jine', + ], + }, + message: { type: 'string', minLength: 10 }, + }, + }, + }, + }, + }, + responses: { + '202': { description: 'Prijato' }, + '400': { description: 'Neplatny vstup' }, + }, + }, + }, + }, + }; +} diff --git a/src/routes/auth.ts b/src/routes/auth.ts new file mode 100644 index 0000000..991183b --- /dev/null +++ b/src/routes/auth.ts @@ -0,0 +1,63 @@ +import bcrypt from 'bcryptjs'; +import { Router } from 'express'; +import jwt from 'jsonwebtoken'; +import { z } from 'zod'; +import { config } from '../config.js'; +import { findUserByEmail } from '../data/users.js'; +import { requireAuth } from '../middleware/auth.js'; +import { toPublicUser, type JwtPayload } from '../types.js'; + +export const authRouter = Router(); + +const loginSchema = z.object({ + email: z.string().email('Zadejte platný e-mail.'), + password: z.string().min(1, 'Zadejte heslo.'), +}); + +authRouter.post('/login', async (req, res) => { + const parsed = loginSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ + error: 'validation_error', + message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.', + }); + } + + const { email, password } = parsed.data; + const user = findUserByEmail(email); + + // Stejna odpoved pro neexistujiciho uzivatele i spatne heslo (neprozrazujeme, ktery ucet existuje). + const invalid = () => + res.status(401).json({ error: 'invalid_credentials', message: 'Nesprávný e-mail nebo heslo.' }); + + if (!user) { + console.info(`[auth] neuspesne prihlaseni - neznamy e-mail: ${email}`); + return invalid(); + } + + const passwordOk = await bcrypt.compare(password, user.passwordHash); + if (!passwordOk) { + console.info(`[auth] neuspesne prihlaseni - spatne heslo: ${user.email}`); + return invalid(); + } + + const payload: JwtPayload = { sub: user.id, email: user.email, role: user.role }; + const token = jwt.sign(payload, config.jwtSecret, { + expiresIn: config.jwtExpiresIn as jwt.SignOptions['expiresIn'], + }); + + console.info(`[auth] prihlasen: ${user.email} (${user.role})`); + return res.json({ token, user: toPublicUser(user) }); +}); + +authRouter.get('/me', requireAuth, (req, res) => { + // requireAuth garantuje req.user + return res.json({ user: toPublicUser(req.user!) }); +}); + +authRouter.post('/logout', requireAuth, (req, res) => { + // Stateless JWT - odhlaseni resi klient zahozenim tokenu. + // Endpoint existuje kvuli auditu a budoucimu blacklistu / refresh tokenum. + console.info(`[auth] odhlasen: ${req.user!.email}`); + return res.status(204).end(); +}); diff --git a/src/routes/contact.ts b/src/routes/contact.ts new file mode 100644 index 0000000..5eafcab --- /dev/null +++ b/src/routes/contact.ts @@ -0,0 +1,38 @@ +import { Router } from 'express'; +import { z } from 'zod'; + +export const contactRouter = Router(); + +const contactSchema = z.object({ + name: z.string().min(2, 'Zadejte jméno.'), + email: z.string().email('Zadejte platný e-mail.'), + company: z.string().optional().default(''), + phone: z.string().optional().default(''), + topic: z.enum(['automatizace', 'voicebot', 'integrace', 'dashboard', 'podpora', 'jine']), + message: z.string().min(10, 'Napište prosím alespoň pár slov (min. 10 znaků).'), +}); + +/** + * PROTOTYP: zpravu jen zalogujeme. Realne odeslani (SMTP / ticket system) + * pribude pozdeji - viz docs/04-backend-api.md. + */ +contactRouter.post('/', (req, res) => { + const parsed = contactSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ + error: 'validation_error', + message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.', + issues: parsed.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message })), + }); + } + + const data = parsed.data; + console.info( + `[contact] nova poptavka: ${data.name} <${data.email}> tema=${data.topic} firma=${data.company || '-'}`, + ); + + return res.status(202).json({ + ok: true, + message: 'Děkujeme, ozveme se do jednoho pracovního dne.', + }); +}); diff --git a/src/routes/dashboard.ts b/src/routes/dashboard.ts new file mode 100644 index 0000000..acd0afc --- /dev/null +++ b/src/routes/dashboard.ts @@ -0,0 +1,251 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import { publicBaseUrl } from '../config.js'; +import { + createAutomation, + deleteAutomation, + getAutomation, + listAutomations, + regenerateWebhookToken, + updateAutomation, + type FlowStep, +} from '../data/automationStore.js'; +import { operatorAllowedForType, operatorsByType } from '../data/conditions.js'; +import { connectorCategories, connectors, findOperation } from '../data/connectors.js'; +import { listIncidents } from '../data/incidentStore.js'; +import { getSummary } from '../data/mock.js'; +import { listTickets } from '../data/ticketStore.js'; +import { requireAuth } from '../middleware/auth.js'; +import { streamRouter } from './stream.js'; + +export const dashboardRouter = Router(); + +// Cely dashboard je jen pro prihlasene. +dashboardRouter.use(requireAuth); + +dashboardRouter.get('/summary', (_req, res) => { + res.json(getSummary()); +}); + +dashboardRouter.get('/tickets', (_req, res) => { + res.json({ items: listTickets() }); +}); + +dashboardRouter.get('/incidents', (_req, res) => { + res.json({ items: listIncidents() }); +}); + +// Zivy stream zmen. Musi byt pred obecnymi cestami, aby ho nic neprebilo. +dashboardRouter.use('/stream', streamRouter); + +// ---------------------------------------------------------------- konektory + +dashboardRouter.get('/connectors', (_req, res) => { + res.json({ + categories: connectorCategories, + items: connectors, + // Frontend potrebuje vedet, jake operatory nabidnout ke kteremu typu, + // a jakou zakladni adresu ukazat u webhooku. + operatorsByType, + webhookBaseUrl: `${publicBaseUrl()}/webhook`, + }); +}); + +// ------------------------------------------------------------- automatizace + +/** + * Rekurzivni schema kroku. z.lazy je nutne, protoze podminka obsahuje + * dalsi kroky - bez toho by se typ odkazoval sam na sebe drive, nez existuje. + */ +const stepSchema: z.ZodType = z.lazy(() => + z.discriminatedUnion('kind', [ + z.object({ + id: z.string().min(1), + kind: z.literal('action'), + connectorId: z.string().min(1), + operationId: z.string().min(1), + }), + z.object({ + id: z.string().min(1), + kind: z.literal('condition'), + fieldId: z.string().min(1, 'Podmínka musí mít vybraný parametr.'), + operator: z.enum([ + 'eq', + 'neq', + 'gt', + 'gte', + 'lt', + 'lte', + 'contains', + 'startsWith', + 'isEmpty', + 'isNotEmpty', + 'isTrue', + 'isFalse', + ]), + value: z.string().optional(), + yes: z.array(stepSchema), + no: z.array(stepSchema), + }), + ]), +); + +const fieldSchema = z.object({ + id: z.string().min(1), + name: z + .string() + .trim() + .min(1, 'Parametr musí mít název.') + // Zamerne jen bezpecne znaky - nazev je klic v prichozim JSONu. + .regex(/^[A-Za-z_][A-Za-z0-9_]*$/, 'Název parametru: písmena, číslice a _ (nezačíná číslicí).'), + type: z.enum(['string', 'number', 'boolean', 'date']), + required: z.boolean(), +}); + +const flowSchema = z.object({ + trigger: z + .object({ + connectorId: z.string().min(1), + operationId: z.string().min(1), + fields: z.array(fieldSchema), + // Token generuje server. Cokoliv od klienta se ignoruje. + webhookToken: z.string().optional(), + }) + .nullable(), + steps: z.array(stepSchema), +}); + +const createSchema = z.object({ + name: z.string().trim().min(3, 'Název musí mít alespoň 3 znaky.'), +}); + +const updateSchema = z.object({ + name: z.string().trim().min(3, 'Název musí mít alespoň 3 znaky.').optional(), + enabled: z.boolean().optional(), + flow: flowSchema.optional(), +}); + +/** + * Overi, ze kazdy krok odkazuje na existujici konektor a operaci. + * Vraci seznam problemu - prazdny znamena, ze je strom v poradku. + */ +function validateFlowReferences(flow: z.infer): string[] { + const problems: string[] = []; + + if (!flow.trigger) return problems; + + const trigger = findOperation(flow.trigger.connectorId, flow.trigger.operationId, 'trigger'); + if (!trigger) { + problems.push( + `Spouštěč ${flow.trigger.connectorId}/${flow.trigger.operationId} neexistuje v katalogu.`, + ); + } + + // Nazvy parametru musi byt unikatni - jsou to klice v prichozich datech. + const names = new Set(); + for (const field of flow.trigger.fields) { + if (names.has(field.name)) { + problems.push(`Parametr „${field.name}" je uvedený dvakrát.`); + } + names.add(field.name); + } + + const fieldById = new Map(flow.trigger.fields.map((field) => [field.id, field])); + + const walk = (steps: FlowStep[]) => { + for (const step of steps) { + if (step.kind === 'condition') { + const field = fieldById.get(step.fieldId); + if (!field) { + problems.push(`Podmínka odkazuje na neexistující parametr (${step.fieldId}).`); + } else if (!operatorAllowedForType(step.operator, field.type)) { + problems.push( + `Operátor "${step.operator}" nelze použít na parametr „${field.name}" typu ${field.type}.`, + ); + } + walk(step.yes); + walk(step.no); + continue; + } + if (!findOperation(step.connectorId, step.operationId, 'action')) { + problems.push(`Akce ${step.connectorId}/${step.operationId} neexistuje v katalogu.`); + } + } + }; + walk(flow.steps); + + return problems; +} + +dashboardRouter.get('/automations', (_req, res) => { + res.json({ items: listAutomations() }); +}); + +dashboardRouter.get('/automations/:id', (req, res) => { + const automation = getAutomation(req.params.id); + if (!automation) { + return res.status(404).json({ error: 'not_found', message: 'Automatizace neexistuje.' }); + } + return res.json(automation); +}); + +dashboardRouter.post('/automations', (req, res) => { + const parsed = createSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ + error: 'validation_error', + message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.', + }); + } + + const automation = createAutomation(parsed.data.name); + return res.status(201).json(automation); +}); + +dashboardRouter.put('/automations/:id', (req, res) => { + const parsed = updateSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ + error: 'validation_error', + message: parsed.error.issues[0]?.message ?? 'Neplatný vstup.', + issues: parsed.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message })), + }); + } + + if (parsed.data.flow) { + const problems = validateFlowReferences(parsed.data.flow); + if (problems.length > 0) { + console.warn(`[automations] ${req.params.id}: neplatny strom - ${problems.join(' ')}`); + return res.status(400).json({ + error: 'validation_error', + message: problems[0], + issues: problems.map((message) => ({ path: 'flow', message })), + }); + } + } + + const updated = updateAutomation(req.params.id, parsed.data); + if (!updated) { + return res.status(404).json({ error: 'not_found', message: 'Automatizace neexistuje.' }); + } + return res.json(updated); +}); + +/** Nova adresa webhooku. Stara okamzite prestane fungovat - zamer, ne chyba. */ +dashboardRouter.post('/automations/:id/webhook/regenerate', (req, res) => { + const updated = regenerateWebhookToken(req.params.id); + if (!updated) { + return res.status(404).json({ + error: 'not_found', + message: 'Automatizace neexistuje, nebo jejím spouštěčem není webhook.', + }); + } + return res.json(updated); +}); + +dashboardRouter.delete('/automations/:id', (req, res) => { + if (!deleteAutomation(req.params.id)) { + return res.status(404).json({ error: 'not_found', message: 'Automatizace neexistuje.' }); + } + return res.status(204).end(); +}); diff --git a/src/routes/simulate.ts b/src/routes/simulate.ts new file mode 100644 index 0000000..47f8e51 --- /dev/null +++ b/src/routes/simulate.ts @@ -0,0 +1,153 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import { listAutomations, recordRun } from '../data/automationStore.js'; +import { + createIncident, + firstActiveIncident, + updateIncidentStatus, +} from '../data/incidentStore.js'; +import { createTicket, firstOpenTicket, updateTicketStatus } from '../data/ticketStore.js'; +import { requireAuth } from '../middleware/auth.js'; + +export const simulateRouter = Router(); + +simulateRouter.use(requireAuth); + +/** + * Simulace provoznich udalosti pro nahled zivého dashboardu. + * + * Zamerne MENI skutecna data v ulozisti, ne jen posila falesnou notifikaci. + * Diky tomu se zmena projevi i v seznamech a v souhrnu, ne jen v bublinach. + */ +const schema = z.discriminatedUnion('action', [ + z.object({ + action: z.literal('ticket.created'), + subject: z.string().trim().min(3).optional(), + requester: z.string().trim().min(2).optional(), + priority: z.enum(['low', 'normal', 'high', 'critical']).optional(), + }), + z.object({ + action: z.literal('ticket.resolved'), + ticketId: z.string().optional(), + }), + z.object({ + action: z.literal('incident.started'), + title: z.string().trim().min(3).optional(), + service: z.string().trim().min(2).optional(), + severity: z.enum(['sev1', 'sev2', 'sev3']).optional(), + }), + z.object({ + action: z.literal('incident.resolved'), + incidentId: z.string().optional(), + }), + z.object({ + action: z.literal('automation.run'), + automationId: z.string().optional(), + ok: z.boolean().optional(), + }), +]); + +const defaultSubjects = [ + 'Nefunguje export objednávek do skladu', + 'Voicebot nerozumí názvu ulice', + 'Faktura se nespárovala s platbou', + 'Chybí notifikace o nové poptávce', + 'Zákazník žádá změnu fakturačních údajů', +]; + +const defaultIncidents = [ + { title: 'Výpadek spojení s fakturačním API', service: 'Integrace / iDoklad' }, + { title: 'Zpoždění doručování webhooků', service: 'Webhook Router' }, + { title: 'Hlasová brána odmítá hovory', service: 'Voicebot Gateway' }, +]; + +/** Nahodny prvek - jen pro rozmanitost ukazkovych dat. */ +function pick(items: T[]): T { + return items[Math.floor(Math.random() * items.length)]; +} + +simulateRouter.post('/', (req, res) => { + const parsed = schema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ + error: 'validation_error', + message: parsed.error.issues[0]?.message ?? 'Neplatný vstup simulace.', + }); + } + + const input = parsed.data; + console.info(`[simulace] ${input.action} spustil ${req.user?.email}`); + + switch (input.action) { + case 'ticket.created': { + const ticket = createTicket({ + subject: input.subject ?? pick(defaultSubjects), + requester: input.requester ?? 'Firma s.r.o.', + priority: input.priority ?? 'normal', + }); + return res.status(201).json({ ok: true, ticket }); + } + + case 'ticket.resolved': { + const target = input.ticketId ? { id: input.ticketId } : firstOpenTicket(); + if (!target) { + return res.status(409).json({ + error: 'nothing_to_resolve', + message: 'Není co vyřešit, všechny tickety jsou hotové.', + }); + } + const ticket = updateTicketStatus(target.id, 'resolved'); + if (!ticket) { + return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' }); + } + return res.json({ ok: true, ticket }); + } + + case 'incident.started': { + const preset = pick(defaultIncidents); + const incident = createIncident({ + title: input.title ?? preset.title, + service: input.service ?? preset.service, + severity: input.severity ?? 'sev2', + }); + return res.status(201).json({ ok: true, incident }); + } + + case 'incident.resolved': { + const target = input.incidentId ? { id: input.incidentId } : firstActiveIncident(); + if (!target) { + return res.status(409).json({ + error: 'nothing_to_resolve', + message: 'Není co vyřešit, žádný incident neběží.', + }); + } + const incident = updateIncidentStatus(target.id, 'resolved'); + if (!incident) { + return res.status(404).json({ error: 'not_found', message: 'Incident neexistuje.' }); + } + return res.json({ ok: true, incident }); + } + + case 'automation.run': { + const automations = listAutomations().filter((a) => a.enabled); + const targetId = input.automationId ?? automations[0]?.id; + if (!targetId) { + return res.status(409).json({ + error: 'no_automation', + message: 'Není co spustit, žádná automatizace není aktivní.', + }); + } + const automation = recordRun(targetId, input.ok ?? true); + if (!automation) { + return res.status(404).json({ error: 'not_found', message: 'Automatizace neexistuje.' }); + } + return res.json({ ok: true, automation }); + } + + default: { + // Vetve jsou vycerpane, tohle je jen pojistka pri rozsireni schematu. + console.error('[simulace] neosetrena akce:', input); + return res.status(400).json({ error: 'unknown_action', message: 'Neznámá akce.' }); + } + } +}); diff --git a/src/routes/stream.ts b/src/routes/stream.ts new file mode 100644 index 0000000..ce2061d --- /dev/null +++ b/src/routes/stream.ts @@ -0,0 +1,62 @@ +import { Router } from 'express'; +import { listenerCount, recentEvents, subscribe, type DashboardEvent } from '../events/bus.js'; +import { requireAuth } from '../middleware/auth.js'; + +export const streamRouter = Router(); + +/** Jak casto poslat komentar, aby spojeni neuspalo proxy nebo prohlizec. */ +const HEARTBEAT_MS = 25_000; + +/** + * Server-Sent Events stream. Klient se pripoji jednou a dostava zmeny, + * misto aby se kazdych par sekund ptal. + * + * Pouziva se SSE, ne WebSocket, protoze tok dat je jednosmerny + * (server -> klient). Klient posila zmeny beznym REST volanim. + * + * Autorizace jde pres bezny Authorization header - klient se pripojuje + * pres fetch, ne pres EventSource, ktery hlavicky neumi. Diky tomu + * nekonci token v adrese a tedy ani v access logu. + */ +streamRouter.get('/', requireAuth, (req, res) => { + res.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); + res.setHeader('Cache-Control', 'no-cache, no-transform'); + res.setHeader('Connection', 'keep-alive'); + // Vypne bufferovani na pripadne reverzni proxy (nginx), jinak se nic nedoruci. + res.setHeader('X-Accel-Buffering', 'no'); + res.flushHeaders(); + + const email = req.user?.email ?? 'neznamy'; + console.info(`[stream] pripojen ${email} (celkem posluchacu: ${listenerCount() + 1})`); + + const send = (event: DashboardEvent) => { + res.write(`event: ${event.type}\n`); + res.write(`id: ${event.id}\n`); + res.write(`data: ${JSON.stringify(event)}\n\n`); + }; + + // Potvrzeni pripojeni, aby klient hned vedel, ze stream bezi. + res.write(`event: connected\ndata: ${JSON.stringify({ at: new Date().toISOString() })}\n\n`); + + // Kratka historie, aby klient nepresel o to, co se stalo tesne pred pripojenim. + for (const event of recentEvents(5).reverse()) send(event); + + const unsubscribe = subscribe(send); + + const heartbeat = setInterval(() => { + // Komentarovy radek - klient ho ignoruje, spojeni zustane zive. + res.write(': ping\n\n'); + }, HEARTBEAT_MS); + + const cleanup = () => { + clearInterval(heartbeat); + unsubscribe(); + console.info(`[stream] odpojen ${email} (zbyva posluchacu: ${listenerCount()})`); + }; + + req.on('close', cleanup); + res.on('error', (err) => { + console.warn('[stream] chyba spojeni:', err); + cleanup(); + }); +}); diff --git a/src/routes/webhook.ts b/src/routes/webhook.ts new file mode 100644 index 0000000..9387f25 --- /dev/null +++ b/src/routes/webhook.ts @@ -0,0 +1,107 @@ +import { Router } from 'express'; +import { findByWebhookToken, recordRun, type TriggerField } from '../data/automationStore.js'; +import type { FieldType } from '../data/conditions.js'; +import { publish } from '../events/bus.js'; + +export const webhookRouter = Router(); + +/** + * VEREJNY endpoint - zamerne BEZ prihlaseni. Autorizaci resi neodhadnutelny + * token v adrese (32 znaku, base64url), presne tak, jak to dela vetsina + * webhookovych sluzeb. + * + * Neznamy token vraci 404 a nikdy neprozradi, ze nejaka automatizace existuje. + */ +webhookRouter.post('/:token', (req, res) => { + const { token } = req.params; + const automation = findByWebhookToken(token); + + if (!automation || !automation.flow.trigger) { + console.warn(`[webhook] volani na neznamy token (${token.slice(0, 6)}…)`); + return res.status(404).json({ error: 'not_found', message: 'Webhook neexistuje.' }); + } + + if (!automation.enabled) { + console.info(`[webhook] ${automation.id}: volani na pozastavenou automatizaci`); + return res.status(409).json({ + error: 'automation_disabled', + message: 'Automatizace je pozastavená, požadavek nebyl zpracován.', + }); + } + + const payload = (req.body ?? {}) as Record; + const problems = validatePayload(automation.flow.trigger.fields, payload); + + if (problems.length > 0) { + console.warn(`[webhook] ${automation.id}: neplatna data - ${problems.join(' ')}`); + return res.status(400).json({ + error: 'validation_error', + message: problems[0], + issues: problems, + }); + } + + publish('webhook.received', `Webhook přijal data pro ${automation.id}`, { + automationId: automation.id, + fields: Object.keys(payload), + }); + recordRun(automation.id); + console.info( + `[webhook] ${automation.id}: prijato (${Object.keys(payload).join(', ') || 'bez dat'})`, + ); + + // PROTOTYP: strom se nevykonava, jen potvrdime prijem. + // Runtime je popsany v docs/08-automatizace-builder.md. + return res.status(202).json({ + accepted: true, + automationId: automation.id, + message: 'Požadavek přijat. Prototyp strom akcí nevykonává.', + }); +}); + +/** Overi prichozi data proti deklarovanym parametrum spoustece. */ +function validatePayload( + fields: TriggerField[], + payload: Record, +): string[] { + const problems: string[] = []; + + for (const field of fields) { + const value = payload[field.name]; + + if (value === undefined || value === null) { + if (field.required) problems.push(`Chybí povinný parametr „${field.name}".`); + continue; + } + + if (!matchesType(value, field.type)) { + problems.push(`Parametr „${field.name}" má mít typ ${field.type}.`); + } + } + + // Neznama pole jen zalogujeme - odmitat je by rozbilo odesilatele, + // kteri posilaji navic i sva vlastni data. + const declared = new Set(fields.map((f) => f.name)); + const extra = Object.keys(payload).filter((key) => !declared.has(key)); + if (extra.length > 0) { + console.info(`[webhook] nedeklarovane parametry navic: ${extra.join(', ')}`); + } + + return problems; +} + +function matchesType(value: unknown, type: FieldType): boolean { + switch (type) { + case 'string': + return typeof value === 'string'; + case 'number': + return typeof value === 'number' && Number.isFinite(value); + case 'boolean': + return typeof value === 'boolean'; + case 'date': + return typeof value === 'string' && !Number.isNaN(new Date(value).getTime()); + default: + console.warn(`[webhook] neznamy typ parametru: ${type}`); + return false; + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..33e27de --- /dev/null +++ b/src/types.ts @@ -0,0 +1,31 @@ +export type UserRole = 'admin' | 'client'; + +export interface User { + id: string; + email: string; + /** bcrypt hash - nikdy neposilat na klienta */ + passwordHash: string; + name: string; + role: UserRole; + company: string; +} + +/** Verze uzivatele bezpecna pro odeslani na klienta. */ +export interface PublicUser { + id: string; + email: string; + name: string; + role: UserRole; + company: string; +} + +export interface JwtPayload { + sub: string; + email: string; + role: UserRole; +} + +export function toPublicUser(user: User): PublicUser { + const { passwordHash: _passwordHash, ...rest } = user; + return rest; +} diff --git a/tsconfig.json b/tsconfig.json index 81a634e..570754a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,10 +1,22 @@ { "compilerOptions": { "target": "ES2022", - "module": "CommonJS", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2023"], + "types": ["node"], "outDir": "dist", "rootDir": "src", "strict": true, - "esModuleInterop": true - } + "noUnusedLocals": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "sourceMap": true, + "declaration": false + }, + "include": ["src/**/*.ts"], + "exclude": ["web", "dist", "node_modules"] } diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..c3c44a8 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,38 @@ +import tailwindcss from '@tailwindcss/vite'; +import react from '@vitejs/plugin-react'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vite'; + +const here = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + root: path.resolve(here, 'web'), + /** + * Relativni base. Aplikace bezi za reverse proxy na /apps/, ktery + * neni znamy pri buildu. Server proto do index.html vklada + * podle ROOT_PATH a relativni odkazy se podle nej slozi spravne. + */ + base: './', + plugins: [react(), tailwindcss()], + resolve: { + // Musi zustat v souladu s "paths" ve web/tsconfig.json + alias: { + '@': path.resolve(here, 'web/src'), + }, + }, + server: { + port: 5173, + proxy: { + // Lokalni vyvoj: web na 5173 vola API na 3000, bez reseni CORS. + '/api': { target: 'http://localhost:3000', changeOrigin: true }, + '/webhook': { target: 'http://localhost:3000', changeOrigin: true }, + }, + }, + build: { + // Server tuhle slozku obsluhuje jako statiku, viz src/index.ts. + outDir: path.resolve(here, 'dist/public'), + emptyOutDir: true, + sourcemap: true, + }, +}); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..8e9a80f --- /dev/null +++ b/web/index.html @@ -0,0 +1,24 @@ + + + + + + Automia — automatizace, voiceboti a integrace na míru + + + + + + + + +
+ + + diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 0000000..348297a --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..70a087a --- /dev/null +++ b/web/src/App.tsx @@ -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 ( + + {/* Verejny web */} + }> + } /> + } /> + } /> + } /> + } /> + + + {/* Prihlaseni - vlastni layout bez hlavicky a paticky */} + } /> + + {/* Klientsky portal */} + }> + + + + } + > + + + } + > + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + ); +} diff --git a/web/src/auth/AuthContext.tsx b/web/src/auth/AuthContext.tsx new file mode 100644 index 0000000..bcf1d3f --- /dev/null +++ b/web/src/auth/AuthContext.tsx @@ -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; + logout: () => Promise; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(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('/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( + () => ({ user, loading, login, logout }), + [user, loading, login, logout], + ); + + return {children}; +} + +export function useAuth(): AuthState { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error('useAuth musí být použit uvnitř .'); + return ctx; +} diff --git a/web/src/auth/RequireAuth.tsx b/web/src/auth/RequireAuth.tsx new file mode 100644 index 0000000..949ed5f --- /dev/null +++ b/web/src/auth/RequireAuth.tsx @@ -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 ( +
+ +
+ ); + } + + if (!user) { + return ; + } + + return ; +} diff --git a/web/src/components/dashboard/DashboardLayout.tsx b/web/src/components/dashboard/DashboardLayout.tsx new file mode 100644 index 0000000..ccfa6cb --- /dev/null +++ b/web/src/components/dashboard/DashboardLayout.tsx @@ -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. + + + + ); +} + +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 ( +
+ {/* Sidebar */} + + + {/* Prekryv pri otevrenem mobilnim sidebaru */} + {sidebarOpen && ( + + +
+

+ {user?.company ?? 'Klientský portál'} +

+

+ {user?.role === 'admin' ? 'Interní přístup' : 'Klientský přístup'} +

+
+ + + +
+
+

{user?.name}

+

{user?.email}

+
+ + {initials(user?.name)} + +
+ + +
+ +
+
+ + + setSimulationOpen(false)} /> + + ); +} + +function initials(name: string | undefined): string { + if (!name) return '?'; + return name + .split(' ') + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase() ?? '') + .join(''); +} diff --git a/web/src/components/dashboard/DataState.tsx b/web/src/components/dashboard/DataState.tsx new file mode 100644 index 0000000..fc9f8b7 --- /dev/null +++ b/web/src/components/dashboard/DataState.tsx @@ -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 ( +
+ +
+ ); + } + + if (error) { + return ( +
+

+ + {error} +

+ {onRetry && ( + + )} +
+ ); + } + + if (empty) { + return ( +
+ +

{emptyLabel}

+
+ ); + } + + return <>{children}; +} diff --git a/web/src/components/dashboard/EventStreamProvider.tsx b/web/src/components/dashboard/EventStreamProvider.tsx new file mode 100644 index 0000000..cb94889 --- /dev/null +++ b/web/src/components/dashboard/EventStreamProvider.tsx @@ -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({ + 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('connecting'); + const [events, setEvents] = useState([]); + 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( + () => ({ status, events, subscribe }), + [status, events, subscribe], + ); + + return {children}; +} + +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]); +} diff --git a/web/src/components/dashboard/EventToasts.tsx b/web/src/components/dashboard/EventToasts.tsx new file mode 100644 index 0000000..69aab26 --- /dev/null +++ b/web/src/components/dashboard/EventToasts.tsx @@ -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 = { + '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([]); + const [dismissed, setDismissed] = useState>(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 ( +
+ {visible.map((event) => { + const look = style[event.type] ?? { icon: Workflow, tone: 'text-white/60 bg-white/5' }; + const Icon = look.icon; + + return ( +
+ + + +

{event.message}

+ +
+ ); + })} +
+ ); +} diff --git a/web/src/components/dashboard/LiveIndicator.tsx b/web/src/components/dashboard/LiveIndicator.tsx new file mode 100644 index 0000000..174ce9f --- /dev/null +++ b/web/src/components/dashboard/LiveIndicator.tsx @@ -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 ( + + + {current.label} + + ); +} diff --git a/web/src/components/dashboard/RunsChart.tsx b/web/src/components/dashboard/RunsChart.tsx new file mode 100644 index 0000000..df12eed --- /dev/null +++ b/web/src/components/dashboard/RunsChart.tsx @@ -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

Zatím nemáme dost dat pro graf.

; + } + + 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 ( +
+
+
+

Spuštění automatizací

+

+ posledních {series.length} dní · celkem {formatNumber(total)} spuštění,{' '} + {formatNumber(failures)} chyb +

+
+

+ maximum {formatNumber(peak.runs)} · {formatDay(peak.date)} +

+
+ +
+ {/* Recesivni mrizka - jen tri linky, aby nepretahovaly pozornost */} +
+ {[0, 1, 2].map((line) => ( + + ))} +
+ +
+ {series.map((point) => { + const height = Math.max(4, Math.round((point.runs / max) * 100)); + const isPeak = point.date === peak.date; + + return ( +
+
+ + {/* Tooltip - vetsi hit area diky rodici pres celou vysku */} +
+
+

{formatDay(point.date)}

+

+ {formatNumber(point.runs)} spuštění +

+

{formatNumber(point.failures)} chyb

+
+
+
+ ); + })} +
+
+ +
+ {formatDay(series[0].date)} + {formatDay(series[series.length - 1].date)} +
+ + {/* Alternativa k barevnemu cteni grafu */} +
+ + Zobrazit data v tabulce + + + + + + + + + + + {series.map((point) => ( + + + + + + ))} + +
DenSpuštěníChyby
{formatDay(point.date)}{formatNumber(point.runs)}{formatNumber(point.failures)}
+
+
+ ); +} diff --git a/web/src/components/dashboard/SimulationModal.tsx b/web/src/components/dashboard/SimulationModal.tsx new file mode 100644 index 0000000..23d51f0 --- /dev/null +++ b/web/src/components/dashboard/SimulationModal.tsx @@ -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(null); + const [result, setResult] = useState(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 = {}) { + setRunning(action); + setResult(null); + try { + await apiFetch('/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) { + 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) { + event.preventDefault(); + void run('incident.started', { + ...(title.trim() ? { title: title.trim() } : {}), + ...(service.trim() ? { service: service.trim() } : {}), + severity, + }); + setTitle(''); + } + + return ( + +
+ +
+ setSubject(event.target.value)} + placeholder="Předmět (nepovinné, jinak se doplní ukázkový)" + className={inputClass} + /> +
+ setRequester(event.target.value)} + placeholder="Zadavatel" + className={cn(inputClass, 'min-w-0 flex-1')} + /> + +
+ +
+
+ + +
+ setTitle(event.target.value)} + placeholder="Popis (nepovinné, jinak se doplní ukázkový)" + className={inputClass} + /> +
+ setService(event.target.value)} + placeholder="Služba" + className={cn(inputClass, 'min-w-0 flex-1')} + /> + +
+ +
+
+ + +

+ Působí na první vhodný záznam, nemusíte nic vybírat. +

+
+ void run('ticket.resolved')} + disabled={running !== null} + busy={running === 'ticket.resolved'} + > + Vyřešit ticket + + void run('incident.resolved')} + disabled={running !== null} + busy={running === 'incident.resolved'} + > + Vyřešit incident + + void run('automation.run', { ok: true })} + disabled={running !== null} + busy={running === 'automation.run'} + > + Spustit automatizaci + + void run('automation.run', { ok: false })} + disabled={running !== null} + busy={running === 'automation.run'} + > + Neúspěšný běh + +
+
+ + {result && ( +

+ {result.ok ? ( + + ) : ( + + )} + {result.message} +

+ )} + +

+ 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. +

+
+
+ ); +} + +function Panel({ + icon: Icon, + title, + children, +}: { + icon: typeof LifeBuoy; + title: string; + children: ReactNode; +}) { + return ( +
+

+ + {title} +

+ {children} +
+ ); +} + +function QuickButton({ + onClick, + disabled, + busy, + children, +}: { + onClick: () => void; + disabled: boolean; + busy: boolean; + children: ReactNode; +}) { + return ( + + ); +} diff --git a/web/src/components/dashboard/StatTile.tsx b/web/src/components/dashboard/StatTile.tsx new file mode 100644 index 0000000..ab32dfe --- /dev/null +++ b/web/src/components/dashboard/StatTile.tsx @@ -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 ( +
+
+

{label}

+ + + +
+

{value}

+ {hint &&

{hint}

} +
+ ); +} diff --git a/web/src/components/dashboard/StatusBadge.tsx b/web/src/components/dashboard/StatusBadge.tsx new file mode 100644 index 0000000..6d629dd --- /dev/null +++ b/web/src/components/dashboard/StatusBadge.tsx @@ -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 = { + 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 = { + 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 = { + sev1: { label: 'SEV1 — kritický', tone: 'danger' }, + sev2: { label: 'SEV2 — vážný', tone: 'warn' }, + sev3: { label: 'SEV3 — menší', tone: 'neutral' }, +}; + +const incidentStatusMap: Record = { + 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 ( + + + {label} + + ); +} + +export function TicketPriorityBadge({ priority }: { priority: TicketPriority }) { + const { label, tone } = ticketPriorityMap[priority]; + return ( + + {(priority === 'critical' || priority === 'high') && } + {label} + + ); +} + +export function IncidentSeverityBadge({ severity }: { severity: IncidentSeverity }) { + const { label, tone } = incidentSeverityMap[severity]; + return ( + + + {label} + + ); +} + +export function IncidentStatusBadge({ status }: { status: IncidentStatus }) { + const { label, tone } = incidentStatusMap[status]; + const Icon = status === 'resolved' ? CheckCircle2 : status === 'investigating' ? Search : CircleDot; + return ( + + + {label} + + ); +} diff --git a/web/src/components/dashboard/flow/FlowCanvas.tsx b/web/src/components/dashboard/flow/FlowCanvas.tsx new file mode 100644 index 0000000..684e6f9 --- /dev/null +++ b/web/src/components/dashboard/flow/FlowCanvas.tsx @@ -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 ( +
+ + + {flow.trigger && ( + + )} +
+ ); +} + +/** 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 ( + + ); + } + + const resolved = resolveOperation( + connectors, + flow.trigger.connectorId, + flow.trigger.operationId, + 'trigger', + ); + + if (!resolved) { + return ( + + ); + } + + const Icon = connectorIcon(resolved.connector.icon); + + return ( +
+
+ + + +
+
+ + + Spouštěč + + {resolved.connector.name} +
+

{resolved.operation.name}

+

{resolved.operation.description}

+
+ +
+ + +
+ ); +} + +/** 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 ( +
+ {steps.map((step, index) => ( +
+ onAddStep(path, index)} compact={compact} /> + + {step.kind === 'action' ? ( + 0} + canMoveDown={index < steps.length - 1} + onRemove={() => onRemoveStep(step.id)} + onMove={(offset) => onMoveStep(step.id, offset)} + /> + ) : ( + 0} + canMoveDown={index < steps.length - 1} + {...callbacks} + /> + )} +
+ ))} + + onAddStep(path, steps.length)} compact={compact} last /> +
+ ); +} + +/** Spojnice + tlacitko "+". Tohle je to misto, kde se strom rozsiruje. */ +function AddStepButton({ + onClick, + compact = false, + last = false, +}: { + onClick: () => void; + compact?: boolean; + last?: boolean; +}) { + return ( +
+ + + {!last && } +
+ ); +} + +function ActionCard({ + step, + connectors, + canMoveUp, + canMoveDown, + onRemove, + onMove, +}: { + step: Extract; + connectors: Connector[]; + canMoveUp: boolean; + canMoveDown: boolean; + onRemove: () => void; + onMove: (offset: number) => void; +}) { + const resolved = resolveOperation(connectors, step.connectorId, step.operationId, 'action'); + + if (!resolved) { + return ( + + ); + } + + const Icon = connectorIcon(resolved.connector.icon); + + return ( + + + + +
+

{resolved.connector.name}

+

{resolved.operation.name}

+

{resolved.operation.description}

+
+ +
+ ); +} + +function ConditionCard({ + step, + path, + connectors, + fields, + canMoveUp, + canMoveDown, + ...callbacks +}: CanvasCallbacks & { + step: Extract; + 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 ( +
+
+ + + +
+
+

Podmínka

+ {valueMissing && Doplňte hodnotu} +
+ + {!field ? ( +

+ Parametr, na který se podmínka odkazovala, už neexistuje. Vyberte jiný, nebo + podmínku odeberte. +

+ ) : ( +

+ {describeCondition(field, step.operator, step.value)} +

+ )} + + {/* Vlastni editor: parametr - operator - hodnota */} +
+ + + + + {needsValue && field && ( + 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', + )} + /> + )} +
+
+ + onMoveStep(step.id, offset)} + onRemove={() => onRemoveStep(step.id)} + /> +
+ +
+ {(['yes', 'no'] as const).map((branch) => ( +
+

+ {branch === 'yes' ? 'Ano' : 'Ne'} +

+ {step[branch].length === 0 && ( +

+ Zatím prázdná větev — přidejte krok tlačítkem níž. +

+ )} + +
+ ))} +
+
+ ); +} + +function StepShell({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function StepControls({ + canMoveUp, + canMoveDown, + onMove, + onRemove, +}: { + canMoveUp: boolean; + canMoveDown: boolean; + onMove: (offset: number) => void; + onRemove: () => void; +}) { + return ( +
+ + + +
+ ); +} + +/** 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 ( +
+
+

{label}

+

{detail}

+
+ {onFix && ( + + )} + {onRemove && ( + + )} +
+ ); +} diff --git a/web/src/components/dashboard/flow/StepPicker.tsx b/web/src/components/dashboard/flow/StepPicker.tsx new file mode 100644 index 0000000..1066cf5 --- /dev/null +++ b/web/src/components/dashboard/flow/StepPicker.tsx @@ -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(null); + const [query, setQuery] = useState(''); + const [category, setCategory] = useState('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 ( + +
+
+ + 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" + /> +
+ + {!searching && !selected && visibleCategories.length > 1 && ( +
+ setCategory('all')}> + Vše + + {visibleCategories.map((cat) => ( + setCategory(cat.id)} + > + {cat.label} + + ))} +
+ )} +
+ +
+ {/* --- vysledky hledani napric konektory --- */} + {searching && ( + <> + {searchResults.length === 0 ? ( +

+ Nic jsme nenašli. Zkuste jiné slovo, nebo použijte konektor „HTTP požadavek". +

+ ) : ( +
    + {searchResults.map(({ connector, operation }) => ( +
  • + pick(connector, operation)} + /> +
  • + ))} +
+ )} + + )} + + {/* --- druhy krok: operace vybraneho konektoru --- */} + {!searching && selected && ( + <> + + +
+ +
+

{selected.name}

+

{selected.description}

+
+
+ +
    + {operationsOf(selected).map((operation) => ( +
  • + pick(selected, operation)} + /> +
  • + ))} +
+ + )} + + {/* --- prvni krok: vyber konektoru --- */} + {!searching && !selected && ( + <> + {mode === 'action' && onPickCondition && ( + + )} + +
+ {filtered.map((connector) => ( + setSelected(connector)} + /> + ))} +
+ + )} +
+
+ ); +} + +function CategoryChip({ + active, + onClick, + children, +}: { + active: boolean; + onClick: () => void; + children: ReactNode; +}) { + return ( + + ); +} + +function ConnectorGlyph({ connector }: { connector: Connector }) { + const Icon = connectorIcon(connector.icon); + return ( + + + + ); +} + +function ConnectorCard({ + connector, + operationCount, + onClick, +}: { + connector: Connector; + operationCount: number; + onClick: () => void; +}) { + const planned = connector.status === 'planned'; + + return ( + + ); +} + +function OperationRow({ + connector, + operation, + showConnectorName = false, + onClick, +}: { + connector: Connector; + operation: ConnectorOperation; + showConnectorName?: boolean; + onClick: () => void; +}) { + const planned = connector.status === 'planned'; + + return ( + + ); +} diff --git a/web/src/components/dashboard/flow/TriggerConfig.tsx b/web/src/components/dashboard/flow/TriggerConfig.tsx new file mode 100644 index 0000000..0eee46f --- /dev/null +++ b/web/src/components/dashboard/flow/TriggerConfig.tsx @@ -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) { + 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 ( +
+ {isWebhook && ( + + )} + +
+
+
+

Vstupní parametry

+

+ {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ě.'} +

+
+ +
+ + {trigger.fields.length === 0 ? ( +

+ Zatím žádné parametry. Bez nich nelze přidat podmínku — nebylo by podle čeho + se rozhodovat. +

+ ) : ( +
    + {trigger.fields.map((field) => { + const duplicate = field.name.trim().length > 0 && duplicates.has(field.name.trim()); + + return ( +
  • + 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', + )} + /> + + + + + + + + {duplicate && ( +

    + Tento název už je použitý — parametry musí být unikátní. +

    + )} +
  • + ); + })} +
+ )} +
+
+ ); +} + +/** 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 ( +
+

+ + Adresa se vygeneruje při prvním uložení. +

+
+ ); + } + + 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 ( +
+
+

+ + Adresa webhooku +

+ + + Zaregistrováno + +
+ +
+ + POST {url} + + +
+ +

+ Token v adrese je jediná ochrana — kdo ji zná, může automatizaci spustit. + Nesdílejte ji veřejně a neposílejte ji v odkazech. +

+ + {confirming ? ( +
+

+ Vygenerovat novou adresu? Ta stará okamžitě + přestane fungovat a musíte ji přenastavit všude, odkud se volá. +

+
+ + +
+
+ ) : ( + + )} +
+ ); +} diff --git a/web/src/components/home/CallToAction.tsx b/web/src/components/home/CallToAction.tsx new file mode 100644 index 0000000..b691cc2 --- /dev/null +++ b/web/src/components/home/CallToAction.tsx @@ -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 ( +
+ +
+
+

+ Máte proces, který vás žere? Pojďme se na něj podívat. +

+

+ 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ů. +

+ +
+ + Napsat nám + + + + + {brand.phone} + +
+ +

+ + {brand.email} +

+
+ +
+ ); +} diff --git a/web/src/components/home/Hero.tsx b/web/src/components/home/Hero.tsx new file mode 100644 index 0000000..662aca6 --- /dev/null +++ b/web/src/components/home/Hero.tsx @@ -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 ( +
+ {/* Dekorativni pozadi */} +
+
+
+
+
+ + +
+
+ + + Automatizace · Voiceboti · Integrace + + +

+ Vítejte. Děláme z ruční práce{' '} + procesy, které běží samy. +

+ +

+ 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. +

+ +
+ + Nezávazná konzultace + + + + Co umíme + +
+ +
    + {['Nasazení v týdnech, ne kvartálech', 'Provoz a podpora v ceně', 'Bez vendor lock-inu'].map( + (item) => ( +
  • + + {item} +
  • + ), + )} +
+
+ + +
+
+
+ ); +} + +/** 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 ( +
+
+
+
+ + + +
+

Voicebot — příjem poptávek

+

AUT-02 · běží

+
+
+ + + live + +
+ +
    + {steps.map((step) => ( +
  1. + + {step.done ? ( + + ) : ( + + )} + + {step.label} + {step.time} +
  2. + ))} +
+ +
+ {[ + { value: '137', label: 'hovorů dnes' }, + { value: '96,1 %', label: 'úspěšnost' }, + { value: '1,2 s', label: 'odezva' }, + ].map((stat) => ( +
+

{stat.value}

+

{stat.label}

+
+ ))} +
+
+ + {/* Male "odlepene" karticky pro hloubku */} +
+

Ušetřeno tento měsíc

+

312 hodin

+
+
+ ); +} diff --git a/web/src/components/home/LogoCloud.tsx b/web/src/components/home/LogoCloud.tsx new file mode 100644 index 0000000..04b3e95 --- /dev/null +++ b/web/src/components/home/LogoCloud.tsx @@ -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 ( +
+ +

+ Pracujeme pro firmy, které nemají čas na ruční práci +

+
+
+
+ {items.map((name, index) => ( + + {name} + + ))} +
+
+
+ ); +} diff --git a/web/src/components/home/Process.tsx b/web/src/components/home/Process.tsx new file mode 100644 index 0000000..635cc50 --- /dev/null +++ b/web/src/components/home/Process.tsx @@ -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 ( +
+ + Od prvního hovoru do provozu za pár týdnů + + } + subtitle="Žádné půlroční analýzy. Nejdřív malý funkční celek, který přinese úsporu, potom rozšiřování." + /> + +
    + {steps.map((step) => ( +
  1. + + {step.number} + +

    {step.title}

    +

    {step.text}

    +

    + {step.duration} +

    +
  2. + ))} +
+
+ ); +} diff --git a/web/src/components/home/Products.tsx b/web/src/components/home/Products.tsx new file mode 100644 index 0000000..39503ac --- /dev/null +++ b/web/src/components/home/Products.tsx @@ -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 ( +
+ + Šest věcí, které umíme dodat a udržet + + } + subtitle="Každou z nich umíme nasadit samostatně, nebo je pospojovat do jednoho celku. Ceny a rozsah vždy podle skutečného provozu." + /> + +
+ {products.map((product) => ( + + ))} +
+
+ ); +} + +export function ProductCard({ product }: { product: Product }) { + const Icon = product.icon; + + return ( + +
+ + + +
+

{product.metric.value}

+

{product.metric.label}

+
+
+ +

{product.name}

+

{product.tagline}

+

{product.description}

+ +
    + {product.features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+ + + Detail služby + + +
+ ); +} diff --git a/web/src/components/home/References.tsx b/web/src/components/home/References.tsx new file mode 100644 index 0000000..2864975 --- /dev/null +++ b/web/src/components/home/References.tsx @@ -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 ( +
+ + Co říkají firmy, se kterými to běží + + } + subtitle="Ukázka spoluprací napříč obory. Čísla vždy měříme před nasazením a po něm — jinak by to byl jen marketing." + /> + +
+ {references.map((reference) => ( + + ))} +
+ +

+ Reference jsou v této verzi webu ukázkové (mockup) a slouží k náhledu layoutu. +

+
+ ); +} + +function ReferenceCard({ reference }: { reference: Reference }) { + return ( + +
+
+ + {reference.initials} + +
+

{reference.company}

+

{reference.industry}

+
+
+ +
+ +
+ „{reference.quote}“ +
+ +
+
+

{reference.author}

+

{reference.role}

+
+
+ + {reference.result.value} + {reference.result.label} +
+
+
+ ); +} diff --git a/web/src/components/home/Stats.tsx b/web/src/components/home/Stats.tsx new file mode 100644 index 0000000..c173873 --- /dev/null +++ b/web/src/components/home/Stats.tsx @@ -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 ( +
+
+ +
+ {stats.map((stat) => ( +
+
{stat.label}
+
+

{stat.value}

+

{stat.label}

+
+
+ ))} +
+
+
+ ); +} diff --git a/web/src/components/layout/Footer.tsx b/web/src/components/layout/Footer.tsx new file mode 100644 index 0000000..3acc174 --- /dev/null +++ b/web/src/components/layout/Footer.tsx @@ -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 ( +
+ +
+
+ +

{brand.claim}

+
+ + {footerNav.map((column) => ( +
+

{column.title}

+
    + {column.items.map((item) => ( +
  • + + {item.label} + +
  • + ))} +
+
+ ))} + +
+

Kontakt

+ +
+
+ +
+

+ © {year} {brand.legalName} · IČO {brand.ico} +

+

+ Prototyp webu — obsah a reference jsou ukázkové (mockup). +

+
+
+
+ ); +} diff --git a/web/src/components/layout/Logo.tsx b/web/src/components/layout/Logo.tsx new file mode 100644 index 0000000..ed712cf --- /dev/null +++ b/web/src/components/layout/Logo.tsx @@ -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 ( + + + A + + {brand.name} + + ); +} diff --git a/web/src/components/layout/Navbar.tsx b/web/src/components/layout/Navbar.tsx new file mode 100644 index 0000000..9d13ac4 --- /dev/null +++ b/web/src/components/layout/Navbar.tsx @@ -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 ( +
+ +
+ + + + +
+ {user ? ( + + + Dashboard + + ) : ( + <> + + + Přihlášení + + + Nezávazná konzultace + + + )} +
+ + +
+
+ + {mobileOpen && ( +
+ + {mainNav.map((item) => ( + + 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} + + ))} +
+ {user ? ( + + + Dashboard + + ) : ( + <> + + + Přihlášení + + Nezávazná konzultace + + )} +
+
+
+ )} +
+ ); +} diff --git a/web/src/components/layout/PublicLayout.tsx b/web/src/components/layout/PublicLayout.tsx new file mode 100644 index 0000000..e70d85d --- /dev/null +++ b/web/src/components/layout/PublicLayout.tsx @@ -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 ( +
+ +
+ +
+
+
+ ); +} diff --git a/web/src/components/ui/Badge.tsx b/web/src/components/ui/Badge.tsx new file mode 100644 index 0000000..077718f --- /dev/null +++ b/web/src/components/ui/Badge.tsx @@ -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 = { + 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 ( + + {children} + + ); +} diff --git a/web/src/components/ui/Button.tsx b/web/src/components/ui/Button.tsx new file mode 100644 index 0000000..1228aa4 --- /dev/null +++ b/web/src/components/ui/Button.tsx @@ -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 = { + 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 = { + 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) { + return ( + + ); +} + +/** 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 ( + + {children} + + ); + } + + return ( + + {children} + + ); +} diff --git a/web/src/components/ui/Card.tsx b/web/src/components/ui/Card.tsx new file mode 100644 index 0000000..1fa772e --- /dev/null +++ b/web/src/components/ui/Card.tsx @@ -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 ( +
+ {children} +
+ ); +} diff --git a/web/src/components/ui/Container.tsx b/web/src/components/ui/Container.tsx new file mode 100644 index 0000000..19cd63f --- /dev/null +++ b/web/src/components/ui/Container.tsx @@ -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
{children}
; +} diff --git a/web/src/components/ui/Modal.tsx b/web/src/components/ui/Modal.tsx new file mode 100644 index 0000000..e56dc70 --- /dev/null +++ b/web/src/components/ui/Modal.tsx @@ -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(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( + '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 ( +
+
+ +
+
+
+

{title}

+ {description &&

{description}

} +
+ +
+ + {children} +
+
+ ); +} diff --git a/web/src/components/ui/PageHeader.tsx b/web/src/components/ui/PageHeader.tsx new file mode 100644 index 0000000..9a94250 --- /dev/null +++ b/web/src/components/ui/PageHeader.tsx @@ -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 ( +
+
+
+
+
+ + {eyebrow && ( +

+ {eyebrow} +

+ )} +

{title}

+ {subtitle && ( +

{subtitle}

+ )} +
+
+ ); +} diff --git a/web/src/components/ui/Section.tsx b/web/src/components/ui/Section.tsx new file mode 100644 index 0000000..f1f1322 --- /dev/null +++ b/web/src/components/ui/Section.tsx @@ -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 ( +
+ {children} +
+ ); +} + +/** 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 ( +
+ {eyebrow && ( +

+ {eyebrow} +

+ )} +

{title}

+ {subtitle &&

{subtitle}

} +
+ ); +} diff --git a/web/src/components/ui/Spinner.tsx b/web/src/components/ui/Spinner.tsx new file mode 100644 index 0000000..a0ced43 --- /dev/null +++ b/web/src/components/ui/Spinner.tsx @@ -0,0 +1,10 @@ +import { cn } from '@/lib/cn'; + +export function Spinner({ label, className }: { label?: string; className?: string }) { + return ( +
+ + {label && {label}} +
+ ); +} diff --git a/web/src/config/brand.ts b/web/src/config/brand.ts new file mode 100644 index 0000000..dde3eae --- /dev/null +++ b/web/src/config/brand.ts @@ -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; diff --git a/web/src/data/navigation.ts b/web/src/data/navigation.ts new file mode 100644 index 0000000..040672f --- /dev/null +++ b/web/src/data/navigation.ts @@ -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' }, + ], + }, +]; diff --git a/web/src/data/products.ts b/web/src/data/products.ts new file mode 100644 index 0000000..bb26f00 --- /dev/null +++ b/web/src/data/products.ts @@ -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' }, + }, +]; diff --git a/web/src/data/references.ts b/web/src/data/references.ts new file mode 100644 index 0000000..1bb3fbf --- /dev/null +++ b/web/src/data/references.ts @@ -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', +]; diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 0000000..cf90f50 --- /dev/null +++ b/web/src/index.css @@ -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); +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts new file mode 100644 index 0000000..0272dda --- /dev/null +++ b/web/src/lib/api.ts @@ -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 { + body?: unknown; + /** true = pridat Authorization hlavicku (default pro vse krome loginu) */ + auth?: boolean; +} + +export async function apiFetch(path: string, options: RequestOptions = {}): Promise { + 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; +} diff --git a/web/src/lib/cn.ts b/web/src/lib/cn.ts new file mode 100644 index 0000000..7149a9b --- /dev/null +++ b/web/src/lib/cn.ts @@ -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(' '); +} diff --git a/web/src/lib/connectorIcons.ts b/web/src/lib/connectorIcons.ts new file mode 100644 index 0000000..a87ab97 --- /dev/null +++ b/web/src/lib/connectorIcons.ts @@ -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 = { + 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; +} diff --git a/web/src/lib/eventStream.ts b/web/src/lib/eventStream.ts new file mode 100644 index 0000000..6276cf4 --- /dev/null +++ b/web/src/lib/eventStream.ts @@ -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'); + }; +} diff --git a/web/src/lib/flow.ts b/web/src/lib/flow.ts new file mode 100644 index 0000000..1a9f8cd --- /dev/null +++ b/web/src/lib/flow.ts @@ -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 = { + 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 = { + 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> = { + 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 = { + 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 }; +} diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts new file mode 100644 index 0000000..07a4734 --- /dev/null +++ b/web/src/lib/format.ts @@ -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`; +} diff --git a/web/src/lib/useApiQuery.ts b/web/src/lib/useApiQuery.ts new file mode 100644 index 0000000..ab0b405 --- /dev/null +++ b/web/src/lib/useApiQuery.ts @@ -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 { + 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(path: string, options: QueryOptions = {}): QueryState { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [tick, setTick] = useState(0); + + const reload = useCallback(() => setTick((value) => value + 1), []); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(null); + + apiFetch(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(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 }; +} diff --git a/web/src/lib/usePageMeta.ts b/web/src/lib/usePageMeta.ts new file mode 100644 index 0000000..da7916b --- /dev/null +++ b/web/src/lib/usePageMeta.ts @@ -0,0 +1,19 @@ +import { useEffect } from 'react'; + +/** + * Nastavi 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]); +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..3111c8b --- /dev/null +++ b/web/src/main.tsx @@ -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>, +); diff --git a/web/src/pages/About.tsx b/web/src/pages/About.tsx new file mode 100644 index 0000000..5c916e6 --- /dev/null +++ b/web/src/pages/About.tsx @@ -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 /> + </> + ); +} diff --git a/web/src/pages/Contact.tsx b/web/src/pages/Contact.tsx new file mode 100644 index 0000000..f29cb5d --- /dev/null +++ b/web/src/pages/Contact.tsx @@ -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> + ); +} diff --git a/web/src/pages/Home.tsx b/web/src/pages/Home.tsx new file mode 100644 index 0000000..4d68fe2 --- /dev/null +++ b/web/src/pages/Home.tsx @@ -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 /> + </> + ); +} diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx new file mode 100644 index 0000000..08cd012 --- /dev/null +++ b/web/src/pages/Login.tsx @@ -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> + ); +} diff --git a/web/src/pages/NotFound.tsx b/web/src/pages/NotFound.tsx new file mode 100644 index 0000000..72ab9ce --- /dev/null +++ b/web/src/pages/NotFound.tsx @@ -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> + ); +} diff --git a/web/src/pages/Services.tsx b/web/src/pages/Services.tsx new file mode 100644 index 0000000..92fbadd --- /dev/null +++ b/web/src/pages/Services.tsx @@ -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 /> + </> + ); +} diff --git a/web/src/pages/dashboard/AutomationDetail.tsx b/web/src/pages/dashboard/AutomationDetail.tsx new file mode 100644 index 0000000..3d7ed58 --- /dev/null +++ b/web/src/pages/dashboard/AutomationDetail.tsx @@ -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> + ); +} diff --git a/web/src/pages/dashboard/Automations.tsx b/web/src/pages/dashboard/Automations.tsx new file mode 100644 index 0000000..70bd52a --- /dev/null +++ b/web/src/pages/dashboard/Automations.tsx @@ -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> + ); +} diff --git a/web/src/pages/dashboard/Connectors.tsx b/web/src/pages/dashboard/Connectors.tsx new file mode 100644 index 0000000..c78bec7 --- /dev/null +++ b/web/src/pages/dashboard/Connectors.tsx @@ -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> + ); +} diff --git a/web/src/pages/dashboard/Incidents.tsx b/web/src/pages/dashboard/Incidents.tsx new file mode 100644 index 0000000..93b8a0c --- /dev/null +++ b/web/src/pages/dashboard/Incidents.tsx @@ -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> + ); +} diff --git a/web/src/pages/dashboard/Overview.tsx b/web/src/pages/dashboard/Overview.tsx new file mode 100644 index 0000000..b48d8a4 --- /dev/null +++ b/web/src/pages/dashboard/Overview.tsx @@ -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> + ); +} diff --git a/web/src/pages/dashboard/Settings.tsx b/web/src/pages/dashboard/Settings.tsx new file mode 100644 index 0000000..c2fe66f --- /dev/null +++ b/web/src/pages/dashboard/Settings.tsx @@ -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> + ); +} diff --git a/web/src/pages/dashboard/Tickets.tsx b/web/src/pages/dashboard/Tickets.tsx new file mode 100644 index 0000000..637a67a --- /dev/null +++ b/web/src/pages/dashboard/Tickets.tsx @@ -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> + ); +} diff --git a/web/src/types/dashboard.ts b/web/src/types/dashboard.ts new file mode 100644 index 0000000..b9efb81 --- /dev/null +++ b/web/src/types/dashboard.ts @@ -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[]; +} diff --git a/web/src/types/events.ts b/web/src/types/events.ts new file mode 100644 index 0000000..20ef579 --- /dev/null +++ b/web/src/types/events.ts @@ -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>; +} diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 0000000..05dd671 --- /dev/null +++ b/web/src/vite-env.d.ts @@ -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 {}; diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..e8dbb3f --- /dev/null +++ b/web/tsconfig.json @@ -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"] +}