Nahrazeni sablony kompletnim webem a klientskym portalem

Web a portal Automia v jednom containeru. Express obsluhuje API
i zbuildovanou React aplikaci z dist/public.

Obsah:
- verejny web: homepage, sluzby, o nas, kontakt, 404
- prihlaseni pres JWT, demo ucty
- portal: prehled s grafem, tickety, incidenty, automatizace, konektory
- builder automatizaci: strom akci, vetveni podminkou
- katalog 25 konektoru v 8 kategoriich
- webhook s registrovanou adresou, token generuje server
- zivy dashboard pres SSE vcetne simulace provozu
- Swagger UI na /docs a OpenAPI na /openapi.json

Soulad s AGENTS.md:
- ROOT_PATH z prostredi, prefix proxy nikde nehardcodovan
- mount na koren i na prefix, funguje s handle_path i bez nej
- base tag a window.__BASE_PATH__ vkladane do index.html za behu
- OpenAPI servers obsahuje prefix, Try it out vola spravnou adresu
- povinne /health a /docs, port 3000, naslouchani na 0.0.0.0
- secrets jen z environment variables, nikdy v logu

Dokumentace ve slozce documentation/.
This commit is contained in:
JiriUhlir
2026-07-31 17:00:37 +02:00
parent 46f2f0b07e
commit 7b045a9f20
100 changed files with 15409 additions and 35 deletions
+4
View File
@@ -1,3 +1,7 @@
node_modules/
dist/
.git/
documentation/
*.log
.env
.env.*
+16
View File
@@ -0,0 +1,16 @@
node_modules/
dist/
build/
.env
.env.local
.env.*.local
*.log
npm-debug.log*
.vite/
coverage/
.DS_Store
Thumbs.db
.idea/
+2
View File
@@ -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 ./
+65 -4
View File
@@ -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/<app-id>`.
- 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/<app-id>/health` a `/apps/<app-id>/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).
+45
View File
@@ -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).
+103
View File
@@ -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/<app-id>/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
<base href="/apps/<app-id>/">
<script>window.__BASE_PATH__ = "/apps/<app-id>";</script>
```
- `<base>` zajisti, ze se relativni odkazy na CSS a JS slozi spravne i na vnorene
ceste jako `/apps/<app-id>/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 `<base>` by prohlizec hledal soubory v `/apps/<app-id>/dashboard/assets/...`
a dostal by HTML aplikace misto skriptu.
## Povinne endpointy
| Verejna cesta | Vraci |
| ------------------------------ | -------------------------------------- |
| `/apps/<app-id>/health` | `{"status":"ok","uptimeSec":N}` |
| `/apps/<app-id>/docs` | presmeruje na `/docs/` |
| `/apps/<app-id>/docs/` | Swagger UI |
| `/apps/<app-id>/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/<app-id>/health
curl -i https://services.csbot.cz/apps/<app-id>/docs/
curl -s https://services.csbot.cz/apps/<app-id>/openapi.json | head -40
```
Ve Swagger UI zkontrolovat, ze Try it out vola adresu s `/apps/<app-id>`.
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.
@@ -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.
+124
View File
@@ -0,0 +1,124 @@
# 04 - API
Interaktivni dokumentace je na `/apps/<app-id>/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 <token>`:
| 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/<app-id>/webhook/<token> \
-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.
+119
View File
@@ -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 <verejna-adresa>/webhook/<token>`. 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 |
+59
View File
@@ -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 `<base>` 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 `<base>`,
- 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.
+4547
View File
File diff suppressed because it is too large Load Diff
+38 -5
View File
@@ -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"
}
}
+58
View File
@@ -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}`;
}
+582
View File
@@ -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<string, StoredAutomation>();
let idCounter = 0;
function nextId(): string {
idCounter += 1;
return `AUT-${String(idCounter).padStart(2, '0')}`;
}
function seed(automation: Omit<StoredAutomation, 'id' | 'createdAt' | 'updatedAt' | 'kind'>) {
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;
}
+48
View File
@@ -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<FieldType, ConditionOperator[]> = {
string: ['eq', 'neq', 'contains', 'startsWith', 'isEmpty', 'isNotEmpty'],
number: ['eq', 'neq', 'gt', 'gte', 'lt', 'lte'],
boolean: ['isTrue', 'isFalse'],
date: ['eq', 'gt', 'lt'],
};
/** Operatory, ktere nepotrebuji hodnotu k porovnani. */
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);
}
+699
View File
@@ -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);
}
+115
View File
@@ -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');
}
+48
View File
@@ -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,
};
}
+148
View File
@@ -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');
}
+37
View File
@@ -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);
}
+80
View File
@@ -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<string, unknown>;
}
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<string, unknown>,
): 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);
}
+173 -23
View File
@@ -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/<app-id>/..., ale Vite build ma relativni cesty.
* Bez <base> 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 =
`<base href="${base}">\n` +
` <script>window.__BASE_PATH__ = ${JSON.stringify(config.rootPath)};</script>`;
return html.replace('<head>', `<head>\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);
});
+52
View File
@@ -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();
};
}
+600
View File
@@ -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' },
},
},
},
},
};
}
+63
View File
@@ -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();
});
+38
View File
@@ -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.',
});
});
+251
View File
@@ -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<FlowStep> = 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<typeof flowSchema>): 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<string>();
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();
});
+153
View File
@@ -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<T>(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.' });
}
}
});
+62
View File
@@ -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();
});
});
+107
View File
@@ -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<string, unknown>;
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, unknown>,
): 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;
}
}
+31
View File
@@ -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;
}
+15 -3
View File
@@ -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"]
}
+38
View File
@@ -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/<app-id>, ktery
* neni znamy pri buildu. Server proto do index.html vklada <base href>
* 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,
},
});
+24
View File
@@ -0,0 +1,24 @@
<!doctype html>
<html lang="cs" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Automia — automatizace, voiceboti a integrace na míru</title>
<meta
name="description"
content="Automatizujeme procesy, stavíme voiceboty a propojujeme systémy. K tomu dashboardy, tickety a incident management pod jednou střechou."
/>
<meta name="theme-color" content="#070b16" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+18
View File
@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#22d3ee" />
<stop offset="1" stop-color="#8b5cf6" />
</linearGradient>
</defs>
<rect width="64" height="64" rx="16" fill="#070b16" />
<path
d="M20 44 32 18l12 26"
fill="none"
stroke="url(#g)"
stroke-width="5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path d="M25 36h14" fill="none" stroke="url(#g)" stroke-width="5" stroke-linecap="round" />
</svg>

After

Width:  |  Height:  |  Size: 596 B

+71
View File
@@ -0,0 +1,71 @@
import { Suspense, lazy } from 'react';
import { Route, Routes } from 'react-router-dom';
import { RequireAuth } from '@/auth/RequireAuth';
import { DashboardLayout } from '@/components/dashboard/DashboardLayout';
import { PublicLayout } from '@/components/layout/PublicLayout';
import { Spinner } from '@/components/ui/Spinner';
// Verejny web se nacita hned, dashboard az po prihlaseni (mensi initial bundle).
import Home from '@/pages/Home';
import About from '@/pages/About';
import Contact from '@/pages/Contact';
import Login from '@/pages/Login';
import NotFound from '@/pages/NotFound';
import Services from '@/pages/Services';
const Overview = lazy(() => import('@/pages/dashboard/Overview'));
const Automations = lazy(() => import('@/pages/dashboard/Automations'));
const AutomationDetail = lazy(() => import('@/pages/dashboard/AutomationDetail'));
const Connectors = lazy(() => import('@/pages/dashboard/Connectors'));
const Tickets = lazy(() => import('@/pages/dashboard/Tickets'));
const Incidents = lazy(() => import('@/pages/dashboard/Incidents'));
const Settings = lazy(() => import('@/pages/dashboard/Settings'));
/**
* Routovani. Cesty jsou zamerne cesky (SEO + citelnost URL),
* mapa vsech cest je v docs/03-frontend.md.
*/
export default function App() {
return (
<Routes>
{/* Verejny web */}
<Route element={<PublicLayout />}>
<Route path="/" element={<Home />} />
<Route path="/sluzby" element={<Services />} />
<Route path="/o-nas" element={<About />} />
<Route path="/kontakt" element={<Contact />} />
<Route path="*" element={<NotFound />} />
</Route>
{/* Prihlaseni - vlastni layout bez hlavicky a paticky */}
<Route path="/prihlaseni" element={<Login />} />
{/* Klientsky portal */}
<Route element={<RequireAuth />}>
<Route
path="/dashboard"
element={
<Suspense
fallback={
<div className="grid min-h-screen place-items-center bg-ink-950">
<Spinner label="Načítám portál…" />
</div>
}
>
<DashboardLayout />
</Suspense>
}
>
<Route index element={<Overview />} />
<Route path="automatizace" element={<Automations />} />
<Route path="automatizace/:id" element={<AutomationDetail />} />
<Route path="konektory" element={<Connectors />} />
<Route path="tickety" element={<Tickets />} />
<Route path="incidenty" element={<Incidents />} />
<Route path="nastaveni" element={<Settings />} />
<Route path="*" element={<NotFound />} />
</Route>
</Route>
</Routes>
);
}
+89
View File
@@ -0,0 +1,89 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { apiFetch, getToken, setToken } from '@/lib/api';
export interface AuthUser {
id: string;
email: string;
name: string;
role: 'admin' | 'client';
company: string;
}
interface AuthState {
user: AuthUser | null;
/** true dokud probiha prvotni overeni ulozeneho tokenu */
loading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthState | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null);
const [loading, setLoading] = useState(true);
// Pri startu zkusime obnovit session z ulozeneho tokenu.
useEffect(() => {
let cancelled = false;
async function restore() {
if (!getToken()) {
setLoading(false);
return;
}
try {
const data = await apiFetch<{ user: AuthUser }>('/api/auth/me');
if (!cancelled) setUser(data.user);
} catch (err) {
// Expirovany/neplatny token - zahodime ho a pokracujeme jako neprihlaseny.
console.warn('[auth] obnoveni session selhalo, mazu token:', err);
setToken(null);
if (!cancelled) setUser(null);
} finally {
if (!cancelled) setLoading(false);
}
}
void restore();
return () => {
cancelled = true;
};
}, []);
const login = useCallback(async (email: string, password: string) => {
const data = await apiFetch<{ token: string; user: AuthUser }>('/api/auth/login', {
method: 'POST',
auth: false,
body: { email, password },
});
setToken(data.token);
setUser(data.user);
}, []);
const logout = useCallback(async () => {
try {
await apiFetch<void>('/api/auth/logout', { method: 'POST' });
} catch (err) {
// Odhlaseni na klientovi musi projit i kdyz server nedostupny.
console.warn('[auth] logout na serveru selhal, odhlasuji lokalne:', err);
} finally {
setToken(null);
setUser(null);
}
}, []);
const value = useMemo<AuthState>(
() => ({ user, loading, login, logout }),
[user, loading, login, logout],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthState {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth musí být použit uvnitř <AuthProvider>.');
return ctx;
}
+23
View File
@@ -0,0 +1,23 @@
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { useAuth } from '@/auth/AuthContext';
import { Spinner } from '@/components/ui/Spinner';
/** Obalka pro chranene routy. Neprihlaseneho posle na /prihlaseni a zapamatuje cil. */
export function RequireAuth() {
const { user, loading } = useAuth();
const location = useLocation();
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center bg-ink-900">
<Spinner label="Načítám session…" />
</div>
);
}
if (!user) {
return <Navigate to="/prihlaseni" state={{ from: location.pathname }} replace />;
}
return <Outlet />;
}
@@ -0,0 +1,199 @@
import {
AlarmClock,
ExternalLink,
FlaskConical,
LayoutDashboard,
LifeBuoy,
LogOut,
Menu,
Plug,
Settings,
Workflow,
X,
} from 'lucide-react';
import { useEffect, useState } from 'react';
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '@/auth/AuthContext';
import { EventStreamProvider } from '@/components/dashboard/EventStreamProvider';
import { EventToasts } from '@/components/dashboard/EventToasts';
import { LiveIndicator } from '@/components/dashboard/LiveIndicator';
import { SimulationModal } from '@/components/dashboard/SimulationModal';
import { Logo } from '@/components/layout/Logo';
import { cn } from '@/lib/cn';
const nav = [
{ to: '/dashboard', label: 'Přehled', icon: LayoutDashboard, end: true },
{ to: '/dashboard/automatizace', label: 'Automatizace', icon: Workflow, end: false },
{ to: '/dashboard/konektory', label: 'Konektory', icon: Plug, end: false },
{ to: '/dashboard/tickety', label: 'Tickety', icon: LifeBuoy, end: false },
{ to: '/dashboard/incidenty', label: 'Incidenty', icon: AlarmClock, end: false },
{ to: '/dashboard/nastaveni', label: 'Nastavení', icon: Settings, end: false },
];
/**
* Shell klientskeho portalu: fixni sidebar (desktop) / vysouvaci (mobil) + obsah.
* Sem se budou pripojovat dalsi moduly dashboardu - viz docs/06-dashboard.md.
*/
export function DashboardLayout() {
return (
// Jedno spojeni na stream pro cely portal, proto az tady nahore.
<EventStreamProvider>
<DashboardShell />
</EventStreamProvider>
);
}
function DashboardShell() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const [sidebarOpen, setSidebarOpen] = useState(false);
const [simulationOpen, setSimulationOpen] = useState(false);
useEffect(() => {
setSidebarOpen(false);
}, [location.pathname]);
async function handleLogout() {
await logout();
navigate('/', { replace: true });
}
return (
<div className="min-h-screen bg-ink-950">
{/* Sidebar */}
<aside
className={cn(
'fixed inset-y-0 left-0 z-50 flex w-64 flex-col border-r border-ink-600/60 bg-ink-900 transition-transform duration-300 lg:translate-x-0',
sidebarOpen ? 'translate-x-0' : '-translate-x-full',
)}
>
<div className="flex h-18 items-center justify-between border-b border-ink-600/60 px-5">
<Logo to="/dashboard" />
<button
type="button"
onClick={() => setSidebarOpen(false)}
className="grid size-9 place-items-center rounded-lg text-white/50 hover:bg-white/5 hover:text-white lg:hidden"
aria-label="Zavřít menu"
>
<X className="size-5" />
</button>
</div>
<nav className="flex-1 space-y-1 overflow-y-auto p-3">
{nav.map((item) => {
const Icon = item.icon;
return (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
cn(
'flex items-center gap-3 rounded-xl px-3.5 py-2.5 text-sm font-medium transition-colors',
isActive
? 'bg-brand-500/12 text-brand-200'
: 'text-white/55 hover:bg-white/5 hover:text-white',
)
}
>
<Icon className="size-4 shrink-0" />
{item.label}
</NavLink>
);
})}
{/* Nastroj pro nahled zivého dashboardu, proto az za beznou navigaci. */}
<button
type="button"
onClick={() => setSimulationOpen(true)}
className="mt-2 flex w-full items-center gap-3 rounded-xl border border-dashed border-ink-600/70 px-3.5 py-2.5 text-sm font-medium text-white/55 transition-colors hover:border-accent-400/60 hover:text-white"
>
<FlaskConical className="size-4 shrink-0" />
Simulace
</button>
</nav>
<div className="border-t border-ink-600/60 p-3">
<Link
to="/"
className="flex items-center gap-3 rounded-xl px-3.5 py-2.5 text-sm text-white/45 transition-colors hover:bg-white/5 hover:text-white"
>
<ExternalLink className="size-4 shrink-0" />
Veřejný web
</Link>
<button
type="button"
onClick={handleLogout}
className="flex w-full items-center gap-3 rounded-xl px-3.5 py-2.5 text-sm text-white/45 transition-colors hover:bg-danger-500/10 hover:text-danger-400"
>
<LogOut className="size-4 shrink-0" />
Odhlásit se
</button>
</div>
</aside>
{/* Prekryv pri otevrenem mobilnim sidebaru */}
{sidebarOpen && (
<button
type="button"
aria-label="Zavřít menu"
onClick={() => setSidebarOpen(false)}
className="fixed inset-0 z-40 bg-ink-950/70 backdrop-blur-sm lg:hidden"
/>
)}
<div className="lg:pl-64">
{/* Topbar */}
<header className="sticky top-0 z-30 flex h-18 items-center gap-4 border-b border-ink-600/60 bg-ink-900/85 px-5 backdrop-blur-xl sm:px-7">
<button
type="button"
onClick={() => setSidebarOpen(true)}
className="grid size-10 place-items-center rounded-lg text-white/60 hover:bg-white/5 hover:text-white lg:hidden"
aria-label="Otevřít menu"
>
<Menu className="size-5" />
</button>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-white">
{user?.company ?? 'Klientský portál'}
</p>
<p className="truncate text-xs text-white/40">
{user?.role === 'admin' ? 'Interní přístup' : 'Klientský přístup'}
</p>
</div>
<LiveIndicator className="hidden sm:inline-flex" />
<div className="flex items-center gap-3">
<div className="hidden text-right sm:block">
<p className="text-sm font-medium text-white">{user?.name}</p>
<p className="text-xs text-white/40">{user?.email}</p>
</div>
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-gradient-to-br from-brand-400/25 to-accent-500/25 font-mono text-sm font-bold text-brand-200">
{initials(user?.name)}
</span>
</div>
</header>
<main className="px-5 py-7 sm:px-7">
<Outlet />
</main>
</div>
<EventToasts />
<SimulationModal open={simulationOpen} onClose={() => setSimulationOpen(false)} />
</div>
);
}
function initials(name: string | undefined): string {
if (!name) return '?';
return name
.split(' ')
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? '')
.join('');
}
@@ -0,0 +1,60 @@
import { AlertCircle, Inbox, RefreshCw } from 'lucide-react';
import type { ReactNode } from 'react';
import { Button } from '@/components/ui/Button';
import { Spinner } from '@/components/ui/Spinner';
/**
* Jednotne stavy pro data v dashboardu: nacitani / chyba / prazdno.
* Chyba se vzdy zobrazi uzivateli, nikdy se nespolkne.
*/
export function DataState({
loading,
error,
empty,
onRetry,
children,
emptyLabel = 'Žádná data k zobrazení.',
}: {
loading: boolean;
error: string | null;
empty?: boolean;
onRetry?: () => void;
children: ReactNode;
emptyLabel?: string;
}) {
if (loading) {
return (
<div className="grid min-h-40 place-items-center">
<Spinner label="Načítám data…" />
</div>
);
}
if (error) {
return (
<div className="flex min-h-40 flex-col items-center justify-center gap-4 text-center">
<p className="flex items-center gap-2 text-sm text-danger-400">
<AlertCircle className="size-4 shrink-0" />
{error}
</p>
{onRetry && (
<Button variant="secondary" size="sm" onClick={onRetry}>
<RefreshCw className="size-4" />
Zkusit znovu
</Button>
)}
</div>
);
}
if (empty) {
return (
<div className="flex min-h-40 flex-col items-center justify-center gap-2 text-center text-white/45">
<Inbox className="size-6" />
<p className="text-sm">{emptyLabel}</p>
</div>
);
}
return <>{children}</>;
}
@@ -0,0 +1,94 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { connectEventStream, type StreamStatus } from '@/lib/eventStream';
import type { DashboardEvent, DashboardEventType } from '@/types/events';
interface EventStreamState {
status: StreamStatus;
/** Poslednich par udalosti, nejnovejsi prvni. */
events: DashboardEvent[];
/** Prihlaseni k odberu. Vraci funkci pro odhlaseni. */
subscribe: (listener: (event: DashboardEvent) => void) => () => void;
}
const noop = () => () => {};
const EventStreamContext = createContext<EventStreamState>({
status: 'closed',
events: [],
subscribe: () => {
console.warn('[stream] subscribe mimo EventStreamProvider, udalosti nedorazi');
return noop();
},
});
const MAX_KEPT = 30;
/**
* Drzi jedno spojeni na SSE stream pro cely dashboard a rozesila udalosti
* vsem, kdo o ne stoji. Jedno spojeni na aplikaci, ne jedno na komponentu.
*/
export function EventStreamProvider({ children }: { children: ReactNode }) {
const [status, setStatus] = useState<StreamStatus>('connecting');
const [events, setEvents] = useState<DashboardEvent[]>([]);
const listeners = useRef(new Set<(event: DashboardEvent) => void>());
useEffect(() => {
const close = connectEventStream('/api/dashboard/stream', {
onStatus: setStatus,
onEvent: (event) => {
setEvents((previous) => [event, ...previous].slice(0, MAX_KEPT));
for (const listener of listeners.current) {
try {
listener(event);
} catch (err) {
// Chyba jednoho odberatele nesmi shodit rozesilani ostatnim.
console.error('[stream] chyba v posluchaci udalosti:', err);
}
}
},
});
return close;
}, []);
const subscribe = useCallback((listener: (event: DashboardEvent) => void) => {
listeners.current.add(listener);
return () => {
listeners.current.delete(listener);
};
}, []);
const value = useMemo<EventStreamState>(
() => ({ status, events, subscribe }),
[status, events, subscribe],
);
return <EventStreamContext.Provider value={value}>{children}</EventStreamContext.Provider>;
}
export function useEventStream(): EventStreamState {
return useContext(EventStreamContext);
}
/** Zavola callback jen pri udalostech uvedenych typu. */
export function useEventListener(
types: DashboardEventType[],
handler: (event: DashboardEvent) => void,
) {
const { subscribe } = useEventStream();
const handlerRef = useRef(handler);
handlerRef.current = handler;
// Klic z pole, aby se odber neobnovoval pri kazdem prekresleni.
const key = types.join(',');
useEffect(() => {
if (types.length === 0) return;
const wanted = new Set(key.split(','));
return subscribe((event) => {
if (wanted.has(event.type)) handlerRef.current(event);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [subscribe, key]);
}
@@ -0,0 +1,88 @@
import { AlarmClock, CheckCircle2, LifeBuoy, Webhook, Workflow, X } from 'lucide-react';
import { useEffect, useState } from 'react';
import type { LucideIcon } from 'lucide-react';
import { useEventStream } from '@/components/dashboard/EventStreamProvider';
import { cn } from '@/lib/cn';
import type { DashboardEvent, DashboardEventType } from '@/types/events';
/** Jak dlouho bublina zustane, nez sama zmizi. */
const LIFETIME_MS = 6_000;
const MAX_VISIBLE = 4;
const style: Record<DashboardEventType, { icon: LucideIcon; tone: string }> = {
'ticket.created': { icon: LifeBuoy, tone: 'text-brand-300 bg-brand-500/12' },
'ticket.updated': { icon: LifeBuoy, tone: 'text-brand-300 bg-brand-500/12' },
'ticket.resolved': { icon: CheckCircle2, tone: 'text-ok-400 bg-ok-500/12' },
'incident.started': { icon: AlarmClock, tone: 'text-danger-400 bg-danger-500/12' },
'incident.updated': { icon: AlarmClock, tone: 'text-warn-400 bg-warn-500/12' },
'incident.resolved': { icon: CheckCircle2, tone: 'text-ok-400 bg-ok-500/12' },
'automation.created': { icon: Workflow, tone: 'text-brand-300 bg-brand-500/12' },
'automation.updated': { icon: Workflow, tone: 'text-brand-300 bg-brand-500/12' },
'automation.deleted': { icon: Workflow, tone: 'text-white/50 bg-white/5' },
'automation.run': { icon: Workflow, tone: 'text-ok-400 bg-ok-500/12' },
'webhook.received': { icon: Webhook, tone: 'text-accent-300 bg-accent-500/12' },
};
/** Bubliny o tom, co se prave stalo. Bez nich by zive zmeny nebyly poznat. */
export function EventToasts() {
const { events } = useEventStream();
const [visible, setVisible] = useState<DashboardEvent[]>([]);
const [dismissed, setDismissed] = useState<Set<string>>(new Set());
// Nove udalosti pridame mezi viditelne a po chvili je odebereme.
useEffect(() => {
const newest = events[0];
if (!newest || dismissed.has(newest.id)) return;
setVisible((previous) => {
if (previous.some((e) => e.id === newest.id)) return previous;
return [newest, ...previous].slice(0, MAX_VISIBLE);
});
const timer = window.setTimeout(() => {
setVisible((previous) => previous.filter((e) => e.id !== newest.id));
}, LIFETIME_MS);
return () => window.clearTimeout(timer);
}, [events, dismissed]);
function dismiss(id: string) {
setDismissed((previous) => new Set(previous).add(id));
setVisible((previous) => previous.filter((e) => e.id !== id));
}
if (visible.length === 0) return null;
return (
<div
className="pointer-events-none fixed right-4 bottom-4 z-50 flex w-full max-w-sm flex-col gap-2"
role="status"
aria-live="polite"
>
{visible.map((event) => {
const look = style[event.type] ?? { icon: Workflow, tone: 'text-white/60 bg-white/5' };
const Icon = look.icon;
return (
<div
key={event.id}
className="animate-rise glass pointer-events-auto flex items-start gap-3 rounded-xl p-3.5 shadow-xl"
>
<span className={cn('grid size-8 shrink-0 place-items-center rounded-lg', look.tone)}>
<Icon className="size-4" />
</span>
<p className="min-w-0 flex-1 text-sm text-white/80">{event.message}</p>
<button
type="button"
onClick={() => dismiss(event.id)}
className="grid size-6 shrink-0 place-items-center rounded text-white/35 transition-colors hover:bg-white/5 hover:text-white"
aria-label="Zavřít oznámení"
>
<X className="size-3.5" />
</button>
</div>
);
})}
</div>
);
}
@@ -0,0 +1,33 @@
import { useEventStream } from '@/components/dashboard/EventStreamProvider';
import { cn } from '@/lib/cn';
const meta = {
connecting: { label: 'Připojuji', dot: 'bg-warn-400', text: 'text-warn-400', pulse: true },
open: { label: 'Živě', dot: 'bg-ok-400', text: 'text-ok-400', pulse: true },
reconnecting: { label: 'Obnovuji spojení', dot: 'bg-warn-400', text: 'text-warn-400', pulse: true },
closed: { label: 'Odpojeno', dot: 'bg-white/40', text: 'text-white/45', pulse: false },
} as const;
/** Stav spojeni se streamem. Uzivatel musi poznat, ze data nejsou zive. */
export function LiveIndicator({ className }: { className?: string }) {
const { status } = useEventStream();
const current = meta[status];
return (
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-full border border-ink-600/70 px-2.5 py-1 text-xs font-medium',
current.text,
className,
)}
title={
status === 'open'
? 'Dashboard dostává změny okamžitě, bez obnovování stránky.'
: 'Změny se teď nemusí projevovat okamžitě.'
}
>
<span className={cn('size-1.5 rounded-full', current.dot, current.pulse && 'animate-pulse')} />
{current.label}
</span>
);
}
+113
View File
@@ -0,0 +1,113 @@
import { formatDay, formatNumber } from '@/lib/format';
import type { SeriesPoint } from '@/types/dashboard';
/**
* Sloupcovy graf spusteni automatizaci za poslednich 14 dni.
*
* Zamerne JEDNA serie (pocet spusteni) a jedna osa - chybovost je radove
* mensi cislo a druha osa by graf jen zkreslila. Chyby jsou v tooltipu
* a jako zvyrazneni sloupce, kde jich bylo nejvic.
* Pod grafem je tabulka pro cteni bez barev (screen readery, tisk).
*/
export function RunsChart({ series }: { series: SeriesPoint[] }) {
if (series.length === 0) {
console.warn('[chart] RunsChart dostal prazdnou serii');
return <p className="text-sm text-white/45">Zatím nemáme dost dat pro graf.</p>;
}
const max = Math.max(...series.map((point) => point.runs));
const peak = series.reduce((best, point) => (point.runs > best.runs ? point : best), series[0]);
const total = series.reduce((sum, point) => sum + point.runs, 0);
const failures = series.reduce((sum, point) => sum + point.failures, 0);
return (
<figure className="m-0">
<figcaption className="mb-5 flex flex-wrap items-end justify-between gap-3">
<div>
<h3 className="font-semibold text-white">Spuštění automatizací</h3>
<p className="mt-0.5 text-xs text-white/45">
posledních {series.length} dní · celkem {formatNumber(total)} spuštění,{' '}
{formatNumber(failures)} chyb
</p>
</div>
<p className="text-right text-xs text-white/35">
maximum {formatNumber(peak.runs)} · {formatDay(peak.date)}
</p>
</figcaption>
<div
className="relative h-44"
role="img"
aria-label={`Sloupcový graf spuštění automatizací za posledních ${series.length} dní. Celkem ${formatNumber(total)} spuštění, maximum ${formatNumber(peak.runs)} dne ${formatDay(peak.date)}.`}
>
{/* Recesivni mrizka - jen tri linky, aby nepretahovaly pozornost */}
<div aria-hidden className="absolute inset-0 flex flex-col justify-between">
{[0, 1, 2].map((line) => (
<span key={line} className="h-px w-full bg-ink-600/40" />
))}
</div>
<div className="relative flex h-full items-end gap-[2px]">
{series.map((point) => {
const height = Math.max(4, Math.round((point.runs / max) * 100));
const isPeak = point.date === peak.date;
return (
<div key={point.date} className="group relative flex h-full flex-1 items-end">
<div
className={
isPeak
? 'w-full rounded-t bg-brand-400 transition-opacity group-hover:opacity-100'
: 'w-full rounded-t bg-brand-500/55 transition-colors group-hover:bg-brand-400'
}
style={{ height: `${height}%` }}
/>
{/* Tooltip - vetsi hit area diky rodici pres celou vysku */}
<div className="pointer-events-none absolute bottom-full left-1/2 z-10 mb-2 hidden -translate-x-1/2 group-hover:block">
<div className="rounded-lg border border-ink-600 bg-ink-950/95 px-3 py-2 whitespace-nowrap shadow-xl">
<p className="text-xs font-semibold text-white">{formatDay(point.date)}</p>
<p className="mt-0.5 text-xs text-white/60">
{formatNumber(point.runs)} spuštění
</p>
<p className="text-xs text-white/60">{formatNumber(point.failures)} chyb</p>
</div>
</div>
</div>
);
})}
</div>
</div>
<div className="mt-2 flex justify-between text-xs text-white/35">
<span>{formatDay(series[0].date)}</span>
<span>{formatDay(series[series.length - 1].date)}</span>
</div>
{/* Alternativa k barevnemu cteni grafu */}
<details className="mt-4 text-xs text-white/40">
<summary className="cursor-pointer transition-colors hover:text-white/70">
Zobrazit data v tabulce
</summary>
<table className="mt-3 w-full text-left">
<thead className="text-white/35">
<tr>
<th className="py-1 font-medium">Den</th>
<th className="py-1 text-right font-medium">Spuštění</th>
<th className="py-1 text-right font-medium">Chyby</th>
</tr>
</thead>
<tbody className="font-mono text-white/55">
{series.map((point) => (
<tr key={point.date} className="border-t border-ink-600/40">
<td className="py-1">{formatDay(point.date)}</td>
<td className="py-1 text-right tabular-nums">{formatNumber(point.runs)}</td>
<td className="py-1 text-right tabular-nums">{formatNumber(point.failures)}</td>
</tr>
))}
</tbody>
</table>
</details>
</figure>
);
}
@@ -0,0 +1,281 @@
import { AlarmClock, AlertCircle, CheckCircle2, LifeBuoy, Play, Workflow } from 'lucide-react';
import { useState } from 'react';
import type { FormEvent, ReactNode } from 'react';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/cn';
type Action =
| 'ticket.created'
| 'ticket.resolved'
| 'incident.started'
| 'incident.resolved'
| 'automation.run';
interface Result {
ok: boolean;
message: string;
}
const priorities = [
{ value: 'low', label: 'Nízká' },
{ value: 'normal', label: 'Běžná' },
{ value: 'high', label: 'Vysoká' },
{ value: 'critical', label: 'Kritická' },
] as const;
const severities = [
{ value: 'sev3', label: 'SEV3 - menší' },
{ value: 'sev2', label: 'SEV2 - vážný' },
{ value: 'sev1', label: 'SEV1 - kritický' },
] as const;
const inputClass =
'w-full rounded-xl border border-ink-600/70 bg-ink-850/70 px-3.5 py-2.5 text-sm text-white placeholder:text-white/30 focus:border-brand-400/70 focus:outline-none';
/**
* Simulace provoznich udalosti. Slouzi k nahledu zivého dashboardu bez toho,
* aby se muselo cekat na skutecny provoz.
*
* Nejde o falesne notifikace - kazda akce opravdu zmeni data na serveru,
* takze se projevi i v seznamech a v souhrnu.
*/
export function SimulationModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const [running, setRunning] = useState<Action | null>(null);
const [result, setResult] = useState<Result | null>(null);
const [subject, setSubject] = useState('');
const [requester, setRequester] = useState('');
const [priority, setPriority] = useState<(typeof priorities)[number]['value']>('normal');
const [title, setTitle] = useState('');
const [service, setService] = useState('');
const [severity, setSeverity] = useState<(typeof severities)[number]['value']>('sev2');
async function run(action: Action, body: Record<string, unknown> = {}) {
setRunning(action);
setResult(null);
try {
await apiFetch<unknown>('/api/simulate', {
method: 'POST',
body: { action, ...body },
});
setResult({ ok: true, message: 'Hotovo. Změna se objevila v dashboardu.' });
} catch (err) {
const message = err instanceof Error ? err.message : 'Simulaci se nepodařilo spustit.';
console.error('[simulace] selhalo:', err);
setResult({ ok: false, message });
} finally {
setRunning(null);
}
}
function submitTicket(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
void run('ticket.created', {
// Prazdna pole neposilame, server pak doplni ukazkovou hodnotu.
...(subject.trim() ? { subject: subject.trim() } : {}),
...(requester.trim() ? { requester: requester.trim() } : {}),
priority,
});
setSubject('');
}
function submitIncident(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
void run('incident.started', {
...(title.trim() ? { title: title.trim() } : {}),
...(service.trim() ? { service: service.trim() } : {}),
severity,
});
setTitle('');
}
return (
<Modal
open={open}
onClose={onClose}
title="Simulace provozu"
description="Vyvolá skutečnou událost, aby bylo vidět, jak dashboard reaguje živě."
>
<div className="max-h-[65vh] space-y-5 overflow-y-auto p-5">
<Panel icon={LifeBuoy} title="Nový ticket">
<form onSubmit={submitTicket} className="space-y-3">
<input
value={subject}
onChange={(event) => setSubject(event.target.value)}
placeholder="Předmět (nepovinné, jinak se doplní ukázkový)"
className={inputClass}
/>
<div className="flex flex-wrap gap-3">
<input
value={requester}
onChange={(event) => setRequester(event.target.value)}
placeholder="Zadavatel"
className={cn(inputClass, 'min-w-0 flex-1')}
/>
<select
value={priority}
onChange={(event) =>
setPriority(event.target.value as (typeof priorities)[number]['value'])
}
aria-label="Priorita"
className={cn(inputClass, 'w-40')}
>
{priorities.map((item) => (
<option key={item.value} value={item.value} className="bg-ink-850">
{item.label}
</option>
))}
</select>
</div>
<Button type="submit" size="sm" disabled={running !== null}>
<Play className="size-4" />
{running === 'ticket.created' ? 'Zakládám...' : 'Založit ticket'}
</Button>
</form>
</Panel>
<Panel icon={AlarmClock} title="Nový incident">
<form onSubmit={submitIncident} className="space-y-3">
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="Popis (nepovinné, jinak se doplní ukázkový)"
className={inputClass}
/>
<div className="flex flex-wrap gap-3">
<input
value={service}
onChange={(event) => setService(event.target.value)}
placeholder="Služba"
className={cn(inputClass, 'min-w-0 flex-1')}
/>
<select
value={severity}
onChange={(event) =>
setSeverity(event.target.value as (typeof severities)[number]['value'])
}
aria-label="Závažnost"
className={cn(inputClass, 'w-44')}
>
{severities.map((item) => (
<option key={item.value} value={item.value} className="bg-ink-850">
{item.label}
</option>
))}
</select>
</div>
<Button type="submit" size="sm" disabled={running !== null}>
<Play className="size-4" />
{running === 'incident.started' ? 'Zakládám...' : 'Vyvolat incident'}
</Button>
</form>
</Panel>
<Panel icon={Workflow} title="Rychlé akce">
<p className="mb-3 text-sm text-white/50">
Působí na první vhodný záznam, nemusíte nic vybírat.
</p>
<div className="flex flex-wrap gap-2">
<QuickButton
onClick={() => void run('ticket.resolved')}
disabled={running !== null}
busy={running === 'ticket.resolved'}
>
Vyřešit ticket
</QuickButton>
<QuickButton
onClick={() => void run('incident.resolved')}
disabled={running !== null}
busy={running === 'incident.resolved'}
>
Vyřešit incident
</QuickButton>
<QuickButton
onClick={() => void run('automation.run', { ok: true })}
disabled={running !== null}
busy={running === 'automation.run'}
>
Spustit automatizaci
</QuickButton>
<QuickButton
onClick={() => void run('automation.run', { ok: false })}
disabled={running !== null}
busy={running === 'automation.run'}
>
Neúspěšný běh
</QuickButton>
</div>
</Panel>
{result && (
<p
className={cn(
'flex items-center gap-2 rounded-xl border px-4 py-3 text-sm',
result.ok
? 'border-ok-400/30 bg-ok-500/10 text-ok-400'
: 'border-danger-400/30 bg-danger-500/10 text-danger-400',
)}
>
{result.ok ? (
<CheckCircle2 className="size-4 shrink-0" />
) : (
<AlertCircle className="size-4 shrink-0" />
)}
{result.message}
</p>
)}
<p className="rounded-xl border border-ink-600/60 px-4 py-3 text-xs leading-relaxed text-white/40">
Simulace zapisuje do stejných dat jako běžný provoz. Data jsou v paměti API,
takže se restartem serveru vrátí do výchozího stavu.
</p>
</div>
</Modal>
);
}
function Panel({
icon: Icon,
title,
children,
}: {
icon: typeof LifeBuoy;
title: string;
children: ReactNode;
}) {
return (
<section className="rounded-xl border border-ink-600/60 bg-ink-850/40 p-4">
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-white">
<Icon className="size-4 text-brand-300" />
{title}
</h3>
{children}
</section>
);
}
function QuickButton({
onClick,
disabled,
busy,
children,
}: {
onClick: () => void;
disabled: boolean;
busy: boolean;
children: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className="rounded-lg border border-ink-600/70 px-3.5 py-2 text-sm text-white/70 transition-colors hover:border-brand-400/60 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
>
{busy ? 'Spouštím...' : children}
</button>
);
}
+42
View File
@@ -0,0 +1,42 @@
import type { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/cn';
/**
* Dlazdice s jednim cislem. Hodnota je hero prvek, popisek pod ni,
* ikona jen jako orientacni znacka - text vzdy nese informaci.
*/
export function StatTile({
icon: Icon,
label,
value,
hint,
tone = 'brand',
className,
}: {
icon: LucideIcon;
label: string;
value: string;
hint?: string;
tone?: 'brand' | 'ok' | 'warn' | 'danger';
className?: string;
}) {
const tones = {
brand: 'bg-brand-500/12 text-brand-300',
ok: 'bg-ok-500/12 text-ok-400',
warn: 'bg-warn-500/12 text-warn-400',
danger: 'bg-danger-500/12 text-danger-400',
} as const;
return (
<div className={cn('glass rounded-card p-5', className)}>
<div className="flex items-start justify-between gap-3">
<p className="text-sm text-white/50">{label}</p>
<span className={cn('grid size-8 shrink-0 place-items-center rounded-lg', tones[tone])}>
<Icon className="size-4" />
</span>
</div>
<p className="mt-3 text-3xl font-extrabold text-white tabular-nums">{value}</p>
{hint && <p className="mt-1 text-xs text-white/40">{hint}</p>}
</div>
);
}
@@ -0,0 +1,82 @@
import { AlertTriangle, CheckCircle2, CircleDot, Clock, Search, ShieldAlert } from 'lucide-react';
import { Badge, type BadgeTone } from '@/components/ui/Badge';
import type {
IncidentSeverity,
IncidentStatus,
TicketPriority,
TicketStatus,
} from '@/types/dashboard';
/**
* Stavove odznaky. Barva NIKDY nenese informaci sama - vzdy je s ni
* text i ikona (pristupnost, viz docs/05-design-system.md).
*/
const ticketStatusMap: Record<TicketStatus, { label: string; tone: BadgeTone }> = {
new: { label: 'Nový', tone: 'brand' },
open: { label: 'V řešení', tone: 'warn' },
waiting: { label: 'Čeká na klienta', tone: 'neutral' },
resolved: { label: 'Vyřešeno', tone: 'ok' },
};
const ticketPriorityMap: Record<TicketPriority, { label: string; tone: BadgeTone }> = {
low: { label: 'Nízká', tone: 'neutral' },
normal: { label: 'Běžná', tone: 'neutral' },
high: { label: 'Vysoká', tone: 'warn' },
critical: { label: 'Kritická', tone: 'danger' },
};
const incidentSeverityMap: Record<IncidentSeverity, { label: string; tone: BadgeTone }> = {
sev1: { label: 'SEV1 — kritický', tone: 'danger' },
sev2: { label: 'SEV2 — vážný', tone: 'warn' },
sev3: { label: 'SEV3 — menší', tone: 'neutral' },
};
const incidentStatusMap: Record<IncidentStatus, { label: string; tone: BadgeTone }> = {
investigating: { label: 'Zkoumáme', tone: 'warn' },
identified: { label: 'Příčina známa', tone: 'warn' },
monitoring: { label: 'Sledujeme', tone: 'brand' },
resolved: { label: 'Vyřešeno', tone: 'ok' },
};
export function TicketStatusBadge({ status }: { status: TicketStatus }) {
const { label, tone } = ticketStatusMap[status];
const Icon = status === 'resolved' ? CheckCircle2 : status === 'waiting' ? Clock : CircleDot;
return (
<Badge tone={tone}>
<Icon className="size-3.5" />
{label}
</Badge>
);
}
export function TicketPriorityBadge({ priority }: { priority: TicketPriority }) {
const { label, tone } = ticketPriorityMap[priority];
return (
<Badge tone={tone}>
{(priority === 'critical' || priority === 'high') && <AlertTriangle className="size-3.5" />}
{label}
</Badge>
);
}
export function IncidentSeverityBadge({ severity }: { severity: IncidentSeverity }) {
const { label, tone } = incidentSeverityMap[severity];
return (
<Badge tone={tone}>
<ShieldAlert className="size-3.5" />
{label}
</Badge>
);
}
export function IncidentStatusBadge({ status }: { status: IncidentStatus }) {
const { label, tone } = incidentStatusMap[status];
const Icon = status === 'resolved' ? CheckCircle2 : status === 'investigating' ? Search : CircleDot;
return (
<Badge tone={tone}>
<Icon className="size-3.5" />
{label}
</Badge>
);
}
@@ -0,0 +1,567 @@
import { ChevronDown, ChevronUp, GitBranch, Plus, Trash2, Zap } from 'lucide-react';
import type { ReactNode } from 'react';
import { TriggerConfig } from '@/components/dashboard/flow/TriggerConfig';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/cn';
import { connectorIcon } from '@/lib/connectorIcons';
import {
defaultOperatorFor,
describeCondition,
isUnaryOperator,
operatorLabel,
operatorsByType,
resolveOperation,
type FlowPath,
} from '@/lib/flow';
import type {
AutomationFlow,
ConditionOperator,
Connector,
FlowStep,
TriggerField,
} from '@/types/dashboard';
/**
* Vizualizace stromu akci. Sama nic nemeni - vsechny zmeny hlasi nahoru
* pres callbacky, stav drzi stranka (AutomationDetail).
*
* Struktura: karta spoustece -> sekvence kroku. Podminka rozdeluje beh
* na vetev ANO a NE, kazda ma vlastni sekvenci a vlastni "+".
*/
interface CanvasCallbacks {
onAddStep: (path: FlowPath, index: number) => void;
onRemoveStep: (stepId: string) => void;
onUpdateCondition: (
stepId: string,
patch: { fieldId?: string; operator?: ConditionOperator; value?: string },
) => void;
onMoveStep: (stepId: string, offset: number) => void;
}
export function FlowCanvas({
flow,
connectors,
webhookBaseUrl,
regenerating,
onPickTrigger,
onChangeFields,
onRegenerateToken,
...callbacks
}: CanvasCallbacks & {
flow: AutomationFlow;
connectors: Connector[];
webhookBaseUrl: string;
regenerating: boolean;
onPickTrigger: () => void;
onChangeFields: (fields: TriggerField[]) => void;
onRegenerateToken: () => void;
}) {
return (
<div className="flex flex-col items-stretch">
<TriggerCard
flow={flow}
connectors={connectors}
webhookBaseUrl={webhookBaseUrl}
regenerating={regenerating}
onPick={onPickTrigger}
onChangeFields={onChangeFields}
onRegenerateToken={onRegenerateToken}
/>
{flow.trigger && (
<StepSequence
steps={flow.steps}
path={[]}
connectors={connectors}
fields={flow.trigger.fields}
{...callbacks}
/>
)}
</div>
);
}
/** Prvni karta stromu - dokud neni vybrany spoustec, je to vyzva ke kliknuti. */
function TriggerCard({
flow,
connectors,
webhookBaseUrl,
regenerating,
onPick,
onChangeFields,
onRegenerateToken,
}: {
flow: AutomationFlow;
connectors: Connector[];
webhookBaseUrl: string;
regenerating: boolean;
onPick: () => void;
onChangeFields: (fields: TriggerField[]) => void;
onRegenerateToken: () => void;
}) {
if (!flow.trigger) {
return (
<button
type="button"
onClick={onPick}
className="group flex items-center gap-4 rounded-card border border-dashed border-brand-400/40 bg-brand-500/5 p-5 text-left transition-colors hover:border-brand-400/80 hover:bg-brand-500/10"
>
<span className="grid size-11 shrink-0 place-items-center rounded-xl bg-brand-500/15 text-brand-300 transition-transform group-hover:scale-105">
<Zap className="size-5" />
</span>
<span>
<span className="block font-semibold text-white">Vyberte spouštěč</span>
<span className="mt-0.5 block text-sm text-white/55">
Webhook, plánovač, formulář, nebo událost v napojené službě.
</span>
</span>
</button>
);
}
const resolved = resolveOperation(
connectors,
flow.trigger.connectorId,
flow.trigger.operationId,
'trigger',
);
if (!resolved) {
return (
<BrokenCard
label="Spouštěč odkazuje na neznámý konektor"
detail={`${flow.trigger.connectorId} / ${flow.trigger.operationId}`}
onFix={onPick}
/>
);
}
const Icon = connectorIcon(resolved.connector.icon);
return (
<div className="rounded-card border border-brand-400/35 bg-gradient-to-br from-brand-500/12 to-transparent p-5">
<div className="flex items-start gap-4">
<span className="grid size-11 shrink-0 place-items-center rounded-xl bg-brand-500/20 text-brand-200">
<Icon className="size-5" />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<Badge tone="brand">
<Zap className="size-3" />
Spouštěč
</Badge>
<span className="text-xs text-white/40">{resolved.connector.name}</span>
</div>
<p className="mt-2 font-semibold text-white">{resolved.operation.name}</p>
<p className="mt-0.5 text-sm text-white/55">{resolved.operation.description}</p>
</div>
<button
type="button"
onClick={onPick}
className="shrink-0 rounded-lg px-3 py-1.5 text-xs font-medium text-white/50 transition-colors hover:bg-white/5 hover:text-white"
>
Změnit
</button>
</div>
<TriggerConfig
trigger={flow.trigger}
connector={resolved.connector}
webhookBaseUrl={webhookBaseUrl}
onChangeFields={onChangeFields}
onRegenerateToken={onRegenerateToken}
regenerating={regenerating}
/>
</div>
);
}
/** Sekvence kroku s "+" na konci i mezi kroky. */
function StepSequence({
steps,
path,
connectors,
fields,
compact = false,
...callbacks
}: CanvasCallbacks & {
steps: FlowStep[];
path: FlowPath;
connectors: Connector[];
fields: TriggerField[];
compact?: boolean;
}) {
const { onAddStep, onRemoveStep, onMoveStep } = callbacks;
return (
<div className="flex flex-col items-stretch">
{steps.map((step, index) => (
<div key={step.id} className="flex flex-col items-stretch">
<AddStepButton onClick={() => onAddStep(path, index)} compact={compact} />
{step.kind === 'action' ? (
<ActionCard
step={step}
connectors={connectors}
canMoveUp={index > 0}
canMoveDown={index < steps.length - 1}
onRemove={() => onRemoveStep(step.id)}
onMove={(offset) => onMoveStep(step.id, offset)}
/>
) : (
<ConditionCard
step={step}
path={path}
connectors={connectors}
fields={fields}
canMoveUp={index > 0}
canMoveDown={index < steps.length - 1}
{...callbacks}
/>
)}
</div>
))}
<AddStepButton onClick={() => onAddStep(path, steps.length)} compact={compact} last />
</div>
);
}
/** Spojnice + tlacitko "+". Tohle je to misto, kde se strom rozsiruje. */
function AddStepButton({
onClick,
compact = false,
last = false,
}: {
onClick: () => void;
compact?: boolean;
last?: boolean;
}) {
return (
<div className="flex flex-col items-center">
<span className={cn('w-px bg-ink-600', compact ? 'h-3' : 'h-4')} aria-hidden />
<button
type="button"
onClick={onClick}
title="Přidat krok"
className={cn(
'group grid place-items-center rounded-full border border-dashed border-ink-600 bg-ink-850 text-white/40 transition-all hover:scale-110 hover:border-brand-400 hover:bg-brand-500/15 hover:text-brand-300',
compact ? 'size-7' : 'size-8',
)}
>
<Plus className={compact ? 'size-3.5' : 'size-4'} />
<span className="sr-only">Přidat krok</span>
</button>
{!last && <span className={cn('w-px bg-ink-600', compact ? 'h-3' : 'h-4')} aria-hidden />}
</div>
);
}
function ActionCard({
step,
connectors,
canMoveUp,
canMoveDown,
onRemove,
onMove,
}: {
step: Extract<FlowStep, { kind: 'action' }>;
connectors: Connector[];
canMoveUp: boolean;
canMoveDown: boolean;
onRemove: () => void;
onMove: (offset: number) => void;
}) {
const resolved = resolveOperation(connectors, step.connectorId, step.operationId, 'action');
if (!resolved) {
return (
<BrokenCard
label="Krok odkazuje na neznámou akci"
detail={`${step.connectorId} / ${step.operationId}`}
onRemove={onRemove}
/>
);
}
const Icon = connectorIcon(resolved.connector.icon);
return (
<StepShell>
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-ink-700/70 text-brand-300">
<Icon className="size-4.5" />
</span>
<div className="min-w-0 flex-1">
<p className="text-xs text-white/40">{resolved.connector.name}</p>
<p className="mt-0.5 font-semibold text-white">{resolved.operation.name}</p>
<p className="mt-0.5 text-sm text-white/50">{resolved.operation.description}</p>
</div>
<StepControls
canMoveUp={canMoveUp}
canMoveDown={canMoveDown}
onMove={onMove}
onRemove={onRemove}
/>
</StepShell>
);
}
function ConditionCard({
step,
path,
connectors,
fields,
canMoveUp,
canMoveDown,
...callbacks
}: CanvasCallbacks & {
step: Extract<FlowStep, { kind: 'condition' }>;
path: FlowPath;
connectors: Connector[];
fields: TriggerField[];
canMoveUp: boolean;
canMoveDown: boolean;
}) {
const { onRemoveStep, onMoveStep, onUpdateCondition } = callbacks;
const field = fields.find((f) => f.id === step.fieldId);
const allowedOperators = field ? operatorsByType[field.type] : [];
const needsValue = !isUnaryOperator(step.operator);
const valueMissing = needsValue && (step.value ?? '').trim().length === 0;
/** Zmena parametru muze zneplatnit operator - v tom pripade ho prepneme. */
function changeField(fieldId: string) {
const next = fields.find((f) => f.id === fieldId);
if (!next) {
console.warn(`[flow] podminka: neznamy parametr ${fieldId}`);
return;
}
const operatorStillValid = operatorsByType[next.type].includes(step.operator);
onUpdateCondition(step.id, {
fieldId,
operator: operatorStillValid ? step.operator : defaultOperatorFor(next.type),
value: operatorStillValid ? step.value : '',
});
}
return (
<div className="rounded-card border border-accent-400/30 bg-accent-500/6 p-4">
<div className="flex items-start gap-3">
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-accent-500/20 text-accent-300">
<GitBranch className="size-4.5" />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-xs text-white/40">Podmínka</p>
{valueMissing && <Badge tone="warn">Doplňte hodnotu</Badge>}
</div>
{!field ? (
<p className="mt-1 text-sm text-danger-400">
Parametr, na který se podmínka odkazovala, neexistuje. Vyberte jiný, nebo
podmínku odeberte.
</p>
) : (
<p className="mt-0.5 font-semibold text-white">
{describeCondition(field, step.operator, step.value)}
</p>
)}
{/* Vlastni editor: parametr - operator - hodnota */}
<div className="mt-2.5 flex flex-wrap items-center gap-2">
<select
value={field ? step.fieldId : ''}
onChange={(event) => changeField(event.target.value)}
aria-label="Parametr"
className="rounded-lg border border-ink-600/70 bg-ink-900/70 px-3 py-1.5 font-mono text-sm text-white focus:border-accent-400/70 focus:outline-none"
>
{!field && (
<option value="" className="bg-ink-850">
vyberte parametr
</option>
)}
{fields.map((option) => (
<option key={option.id} value={option.id} className="bg-ink-850">
{option.name || '(bez názvu)'}
</option>
))}
</select>
<select
value={step.operator}
onChange={(event) =>
onUpdateCondition(step.id, {
operator: event.target.value as ConditionOperator,
})
}
disabled={!field}
aria-label="Operátor"
className="rounded-lg border border-ink-600/70 bg-ink-900/70 px-3 py-1.5 text-sm text-white focus:border-accent-400/70 focus:outline-none disabled:opacity-50"
>
{allowedOperators.map((operator) => (
<option key={operator} value={operator} className="bg-ink-850">
{field ? operatorLabel(operator, field.type) : operator}
</option>
))}
</select>
{needsValue && field && (
<input
value={step.value ?? ''}
onChange={(event) => onUpdateCondition(step.id, { value: event.target.value })}
type={field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : 'text'}
placeholder={field.type === 'number' ? '15' : 'hodnota'}
aria-label="Hodnota"
className={cn(
'w-32 rounded-lg border bg-ink-900/70 px-3 py-1.5 text-sm text-white placeholder:text-white/25 focus:outline-none',
valueMissing
? 'border-warn-400/60'
: 'border-ink-600/70 focus:border-accent-400/70',
)}
/>
)}
</div>
</div>
<StepControls
canMoveUp={canMoveUp}
canMoveDown={canMoveDown}
onMove={(offset) => onMoveStep(step.id, offset)}
onRemove={() => onRemoveStep(step.id)}
/>
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-2">
{(['yes', 'no'] as const).map((branch) => (
<div
key={branch}
className={cn(
'rounded-xl border p-3',
branch === 'yes'
? 'border-ok-400/25 bg-ok-500/5'
: 'border-danger-400/25 bg-danger-500/5',
)}
>
<p
className={cn(
'mb-1 text-xs font-semibold tracking-wide uppercase',
branch === 'yes' ? 'text-ok-400' : 'text-danger-400',
)}
>
{branch === 'yes' ? 'Ano' : 'Ne'}
</p>
{step[branch].length === 0 && (
<p className="mb-1 text-xs text-white/35">
Zatím prázdná větev přidejte krok tlačítkem níž.
</p>
)}
<StepSequence
steps={step[branch]}
path={[...path, { stepId: step.id, branch }]}
connectors={connectors}
fields={fields}
compact
{...callbacks}
/>
</div>
))}
</div>
</div>
);
}
function StepShell({ children }: { children: ReactNode }) {
return (
<div className="flex items-start gap-3 rounded-card border border-ink-600/60 bg-ink-800/60 p-4 transition-colors hover:border-ink-600">
{children}
</div>
);
}
function StepControls({
canMoveUp,
canMoveDown,
onMove,
onRemove,
}: {
canMoveUp: boolean;
canMoveDown: boolean;
onMove: (offset: number) => void;
onRemove: () => void;
}) {
return (
<div className="flex shrink-0 items-center gap-0.5">
<button
type="button"
onClick={() => onMove(-1)}
disabled={!canMoveUp}
title="Posunout výš"
className="grid size-8 place-items-center rounded-lg text-white/35 transition-colors hover:bg-white/5 hover:text-white disabled:pointer-events-none disabled:opacity-25"
>
<ChevronUp className="size-4" />
<span className="sr-only">Posunout výš</span>
</button>
<button
type="button"
onClick={() => onMove(1)}
disabled={!canMoveDown}
title="Posunout níž"
className="grid size-8 place-items-center rounded-lg text-white/35 transition-colors hover:bg-white/5 hover:text-white disabled:pointer-events-none disabled:opacity-25"
>
<ChevronDown className="size-4" />
<span className="sr-only">Posunout níž</span>
</button>
<button
type="button"
onClick={onRemove}
title="Odebrat krok"
className="grid size-8 place-items-center rounded-lg text-white/35 transition-colors hover:bg-danger-500/12 hover:text-danger-400"
>
<Trash2 className="size-3.5" />
<span className="sr-only">Odebrat krok</span>
</button>
</div>
);
}
/** Krok odkazuje na neco, co v katalogu neexistuje - nesmi to tise zmizet. */
function BrokenCard({
label,
detail,
onFix,
onRemove,
}: {
label: string;
detail: string;
onFix?: () => void;
onRemove?: () => void;
}) {
return (
<div className="flex items-start gap-3 rounded-card border border-danger-400/40 bg-danger-500/8 p-4">
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-danger-400">{label}</p>
<p className="mt-0.5 font-mono text-xs text-white/45">{detail}</p>
</div>
{onFix && (
<button
type="button"
onClick={onFix}
className="shrink-0 rounded-lg px-3 py-1.5 text-xs font-medium text-white/60 hover:bg-white/5 hover:text-white"
>
Vybrat znovu
</button>
)}
{onRemove && (
<button
type="button"
onClick={onRemove}
className="grid size-8 shrink-0 place-items-center rounded-lg text-white/40 hover:bg-danger-500/12 hover:text-danger-400"
title="Odebrat krok"
>
<Trash2 className="size-3.5" />
<span className="sr-only">Odebrat krok</span>
</button>
)}
</div>
);
}
@@ -0,0 +1,370 @@
import { ArrowLeft, ChevronRight, GitBranch, Search } from 'lucide-react';
import { useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { Badge } from '@/components/ui/Badge';
import { Modal } from '@/components/ui/Modal';
import { cn } from '@/lib/cn';
import { connectorIcon } from '@/lib/connectorIcons';
import type { Connector, ConnectorCategory, ConnectorOperation } from '@/types/dashboard';
/**
* Vyber toho, co se ma pridat do stromu.
*
* mode = 'trigger' -> nabizi jen udalosti, kterymi muze automatizace zacit
* mode = 'action' -> nabizi akce konektoru + moznost rozvetvit podminkou
*
* Prvni krok = vyber konektoru, druhy = vyber operace. Kdyz uzivatel zacne
* hledat, preskoci se rovnou na plochy seznam operaci napric konektory.
*/
export function StepPicker({
open,
mode,
connectors,
categories,
onClose,
onPickOperation,
onPickCondition,
canAddCondition,
}: {
open: boolean;
mode: 'trigger' | 'action';
connectors: Connector[];
categories: Array<{ id: ConnectorCategory; label: string }>;
onClose: () => void;
onPickOperation: (connectorId: string, operationId: string) => void;
onPickCondition?: () => void;
/** false = spoustec nema zadne parametry, nebylo by podle ceho se rozhodovat */
canAddCondition?: boolean;
}) {
const [selected, setSelected] = useState<Connector | null>(null);
const [query, setQuery] = useState('');
const [category, setCategory] = useState<ConnectorCategory | 'all'>('all');
const operationsOf = (connector: Connector) =>
mode === 'trigger' ? connector.triggers : connector.actions;
// Konektory, ktere v tomhle rezimu vubec maji co nabidnout.
const usable = useMemo(
() => connectors.filter((connector) => operationsOf(connector).length > 0),
[connectors, mode],
);
const visibleCategories = useMemo(
() => categories.filter((cat) => usable.some((connector) => connector.category === cat.id)),
[categories, usable],
);
const filtered = useMemo(
() => (category === 'all' ? usable : usable.filter((c) => c.category === category)),
[usable, category],
);
const trimmedQuery = query.trim().toLowerCase();
const searching = trimmedQuery.length >= 2;
// Ploche vysledky hledani - hleda v nazvu konektoru i v nazvech operaci.
const searchResults = useMemo(() => {
if (!searching) return [];
const results: Array<{ connector: Connector; operation: ConnectorOperation }> = [];
for (const connector of usable) {
for (const operation of operationsOf(connector)) {
const haystack = `${connector.name} ${operation.name} ${operation.description}`.toLowerCase();
if (haystack.includes(trimmedQuery)) results.push({ connector, operation });
}
}
return results;
}, [usable, trimmedQuery, searching, mode]);
function close() {
setSelected(null);
setQuery('');
setCategory('all');
onClose();
}
function pick(connector: Connector, operation: ConnectorOperation) {
if (connector.status === 'planned') {
console.warn(`[picker] konektor ${connector.id} je na roadmape, nelze pouzit`);
return;
}
onPickOperation(connector.id, operation.id);
setSelected(null);
setQuery('');
}
return (
<Modal
open={open}
onClose={close}
title={mode === 'trigger' ? 'Čím má automatizace začít?' : 'Co se má stát?'}
description={
mode === 'trigger'
? 'Vyberte spouštěč — webhook, čas, nebo událost ve službě, kterou máte napojenou.'
: 'Vyberte službu nebo konektor a pak konkrétní akci.'
}
>
<div className="border-b border-ink-600/60 p-5">
<div className="relative">
<Search className="pointer-events-none absolute top-1/2 left-3.5 size-4 -translate-y-1/2 text-white/35" />
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={mode === 'trigger' ? 'Hledat spouštěč…' : 'Hledat akci nebo službu…'}
className="w-full rounded-xl border border-ink-600/70 bg-ink-850/70 py-2.5 pr-4 pl-10 text-sm text-white placeholder:text-white/30 focus:border-brand-400/70 focus:outline-none"
/>
</div>
{!searching && !selected && visibleCategories.length > 1 && (
<div className="mt-4 flex flex-wrap gap-2">
<CategoryChip active={category === 'all'} onClick={() => setCategory('all')}>
Vše
</CategoryChip>
{visibleCategories.map((cat) => (
<CategoryChip
key={cat.id}
active={category === cat.id}
onClick={() => setCategory(cat.id)}
>
{cat.label}
</CategoryChip>
))}
</div>
)}
</div>
<div className="max-h-[55vh] overflow-y-auto p-5">
{/* --- vysledky hledani napric konektory --- */}
{searching && (
<>
{searchResults.length === 0 ? (
<p className="py-8 text-center text-sm text-white/45">
Nic jsme nenašli. Zkuste jiné slovo, nebo použijte konektor HTTP požadavek".
</p>
) : (
<ul className="space-y-2">
{searchResults.map(({ connector, operation }) => (
<li key={`${connector.id}.${operation.id}`}>
<OperationRow
connector={connector}
operation={operation}
showConnectorName
onClick={() => pick(connector, operation)}
/>
</li>
))}
</ul>
)}
</>
)}
{/* --- druhy krok: operace vybraneho konektoru --- */}
{!searching && selected && (
<>
<button
type="button"
onClick={() => setSelected(null)}
className="mb-4 inline-flex items-center gap-2 text-sm text-white/50 transition-colors hover:text-white"
>
<ArrowLeft className="size-4" />
Zpět na výběr služby
</button>
<div className="mb-4 flex items-center gap-3">
<ConnectorGlyph connector={selected} />
<div>
<p className="font-semibold text-white">{selected.name}</p>
<p className="text-xs text-white/45">{selected.description}</p>
</div>
</div>
<ul className="space-y-2">
{operationsOf(selected).map((operation) => (
<li key={operation.id}>
<OperationRow
connector={selected}
operation={operation}
onClick={() => pick(selected, operation)}
/>
</li>
))}
</ul>
</>
)}
{/* --- prvni krok: vyber konektoru --- */}
{!searching && !selected && (
<>
{mode === 'action' && onPickCondition && (
<button
type="button"
onClick={() => {
onPickCondition();
close();
}}
disabled={canAddCondition === false}
className={cn(
'mb-5 flex w-full items-start gap-3 rounded-xl border border-accent-400/30 bg-accent-500/8 p-4 text-left transition-colors',
canAddCondition === false
? 'cursor-not-allowed opacity-50'
: 'hover:border-accent-400/60 hover:bg-accent-500/14',
)}
>
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-accent-500/20 text-accent-300">
<GitBranch className="size-5" />
</span>
<span className="min-w-0 flex-1">
<span className="block font-semibold text-white">Rozvětvit podmínkou</span>
<span className="mt-0.5 block text-sm text-white/55">
{canAddCondition === false
? 'Nejdřív u spouštěče nadeklarujte vstupní parametry — bez nich není podle čeho se rozhodovat.'
: 'Porovná hodnotu vstupního parametru (např. score ≥ 15) a rozdělí běh na větev ANO a NE.'}
</span>
</span>
<ChevronRight className="mt-2.5 size-4 shrink-0 text-white/30" />
</button>
)}
<div className="grid gap-2 sm:grid-cols-2">
{filtered.map((connector) => (
<ConnectorCard
key={connector.id}
connector={connector}
operationCount={operationsOf(connector).length}
onClick={() => setSelected(connector)}
/>
))}
</div>
</>
)}
</div>
</Modal>
);
}
function CategoryChip({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'rounded-full border px-3 py-1.5 text-xs font-medium transition-colors',
active
? 'border-brand-400/50 bg-brand-500/15 text-brand-200'
: 'border-ink-600/70 text-white/50 hover:border-white/20 hover:text-white',
)}
>
{children}
</button>
);
}
function ConnectorGlyph({ connector }: { connector: Connector }) {
const Icon = connectorIcon(connector.icon);
return (
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-gradient-to-br from-brand-500/20 to-accent-500/20 text-brand-300">
<Icon className="size-5" />
</span>
);
}
function ConnectorCard({
connector,
operationCount,
onClick,
}: {
connector: Connector;
operationCount: number;
onClick: () => void;
}) {
const planned = connector.status === 'planned';
return (
<button
type="button"
onClick={onClick}
disabled={planned}
className={cn(
'flex w-full items-start gap-3 rounded-xl border border-ink-600/60 bg-ink-850/50 p-4 text-left transition-colors',
planned
? 'cursor-not-allowed opacity-50'
: 'hover:border-brand-400/50 hover:bg-ink-800/70',
)}
>
<ConnectorGlyph connector={connector} />
<span className="min-w-0 flex-1">
<span className="flex items-center gap-2">
<span className="truncate font-semibold text-white">{connector.name}</span>
{connector.status === 'available' && <Badge tone="neutral">Umíme napojit</Badge>}
{planned && <Badge tone="warn">Na roadmapě</Badge>}
</span>
<span className="mt-1 block line-clamp-2 text-xs text-white/50">
{connector.description}
</span>
<span className="mt-1.5 block text-xs text-white/30">
{operationCount} {operationCount === 1 ? 'možnost' : 'možnosti'}
</span>
</span>
<ChevronRight className="mt-2.5 size-4 shrink-0 text-white/25" />
</button>
);
}
function OperationRow({
connector,
operation,
showConnectorName = false,
onClick,
}: {
connector: Connector;
operation: ConnectorOperation;
showConnectorName?: boolean;
onClick: () => void;
}) {
const planned = connector.status === 'planned';
return (
<button
type="button"
onClick={onClick}
disabled={planned}
className={cn(
'flex w-full items-start gap-3 rounded-xl border border-ink-600/50 bg-ink-850/40 p-4 text-left transition-colors',
planned ? 'cursor-not-allowed opacity-50' : 'hover:border-brand-400/50 hover:bg-ink-800/70',
)}
>
{showConnectorName && <ConnectorGlyph connector={connector} />}
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-white">{operation.name}</span>
{showConnectorName && (
<span className="text-xs text-white/40">· {connector.name}</span>
)}
{planned && <Badge tone="warn">Na roadmapě</Badge>}
</span>
<span className="mt-1 block text-sm text-white/55">{operation.description}</span>
{operation.fields && operation.fields.length > 0 && (
<span className="mt-2 flex flex-wrap gap-1.5">
{operation.fields.map((field) => (
<span
key={field}
className="rounded-md border border-ink-600/60 px-2 py-0.5 font-mono text-[0.7rem] text-white/40"
>
{field}
</span>
))}
</span>
)}
</span>
<ChevronRight className="mt-1 size-4 shrink-0 text-white/25" />
</button>
);
}
@@ -0,0 +1,277 @@
import { AlertTriangle, Check, Copy, KeyRound, Plus, RefreshCw, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/cn';
import { fieldTypeLabels, newFieldId } from '@/lib/flow';
import type { Connector, FieldType, FlowTrigger, TriggerField } from '@/types/dashboard';
const fieldTypes: FieldType[] = ['string', 'number', 'boolean', 'date'];
/**
* Nastaveni spoustece: registrovana adresa webhooku a deklarace parametru,
* ktere na ni budou chodit. Podminky ve strome se pak odkazuji prave na ne.
*/
export function TriggerConfig({
trigger,
connector,
webhookBaseUrl,
onChangeFields,
onRegenerateToken,
regenerating,
}: {
trigger: FlowTrigger;
connector: Connector | undefined;
webhookBaseUrl: string;
onChangeFields: (fields: TriggerField[]) => void;
onRegenerateToken: () => void;
regenerating: boolean;
}) {
const isWebhook = trigger.connectorId === 'webhook';
const operation = connector?.triggers.find((t) => t.id === trigger.operationId);
const editable = operation?.customPayload === true;
function addField() {
onChangeFields([
...trigger.fields,
{ id: newFieldId(), name: '', type: 'string', required: true },
]);
}
function updateField(id: string, patch: Partial<TriggerField>) {
onChangeFields(trigger.fields.map((f) => (f.id === id ? { ...f, ...patch } : f)));
}
function removeField(id: string) {
onChangeFields(trigger.fields.filter((f) => f.id !== id));
}
const duplicates = new Set(
trigger.fields
.map((f) => f.name.trim())
.filter((name, index, all) => name.length > 0 && all.indexOf(name) !== index),
);
return (
<div className="mt-4 space-y-4 border-t border-ink-600/50 pt-4">
{isWebhook && (
<WebhookAddress
token={trigger.webhookToken}
baseUrl={webhookBaseUrl}
onRegenerate={onRegenerateToken}
regenerating={regenerating}
/>
)}
<div>
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="text-sm font-semibold text-white">Vstupní parametry</h3>
<p className="mt-0.5 text-xs text-white/45">
{editable
? 'Co bude na spouštěč přicházet. Podle těchto hodnot pak stavíte podmínky.'
: 'Tato služba předává vlastní data. Parametry pro podmínky si můžete doplnit ručně.'}
</p>
</div>
<button
type="button"
onClick={addField}
className="inline-flex items-center gap-1.5 rounded-lg border border-ink-600/70 px-3 py-1.5 text-xs font-medium text-white/70 transition-colors hover:border-brand-400/60 hover:text-white"
>
<Plus className="size-3.5" />
Přidat parametr
</button>
</div>
{trigger.fields.length === 0 ? (
<p className="mt-3 rounded-xl border border-dashed border-ink-600/70 px-4 py-3 text-xs text-white/40">
Zatím žádné parametry. Bez nich nelze přidat podmínku nebylo by podle čeho
se rozhodovat.
</p>
) : (
<ul className="mt-3 space-y-2">
{trigger.fields.map((field) => {
const duplicate = field.name.trim().length > 0 && duplicates.has(field.name.trim());
return (
<li
key={field.id}
className="flex flex-wrap items-center gap-2 rounded-xl border border-ink-600/50 bg-ink-850/50 p-2.5"
>
<input
value={field.name}
onChange={(event) => updateField(field.id, { name: event.target.value })}
placeholder="nazev_parametru"
aria-label="Název parametru"
className={cn(
'min-w-0 flex-1 rounded-lg border bg-ink-900/70 px-3 py-1.5 font-mono text-sm text-white placeholder:text-white/25 focus:outline-none',
duplicate
? 'border-danger-400/60'
: 'border-ink-600/70 focus:border-brand-400/70',
)}
/>
<select
value={field.type}
onChange={(event) =>
updateField(field.id, { type: event.target.value as FieldType })
}
aria-label="Typ parametru"
className="rounded-lg border border-ink-600/70 bg-ink-900/70 px-3 py-1.5 text-sm text-white focus:border-brand-400/70 focus:outline-none"
>
{fieldTypes.map((type) => (
<option key={type} value={type} className="bg-ink-850">
{fieldTypeLabels[type]}
</option>
))}
</select>
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-white/55">
<input
type="checkbox"
checked={field.required}
onChange={(event) =>
updateField(field.id, { required: event.target.checked })
}
className="size-3.5 accent-cyan-400"
/>
povinný
</label>
<button
type="button"
onClick={() => removeField(field.id)}
title="Odebrat parametr"
className="grid size-8 place-items-center rounded-lg text-white/35 transition-colors hover:bg-danger-500/12 hover:text-danger-400"
>
<Trash2 className="size-3.5" />
<span className="sr-only">Odebrat parametr</span>
</button>
{duplicate && (
<p className="w-full text-xs text-danger-400">
Tento název je použitý parametry musí být unikátní.
</p>
)}
</li>
);
})}
</ul>
)}
</div>
</div>
);
}
/** Registrovana adresa webhooku - token generuje server, tady se jen ukazuje. */
function WebhookAddress({
token,
baseUrl,
onRegenerate,
regenerating,
}: {
token: string | undefined;
baseUrl: string;
onRegenerate: () => void;
regenerating: boolean;
}) {
const [copied, setCopied] = useState(false);
const [confirming, setConfirming] = useState(false);
if (!token) {
return (
<div className="rounded-xl border border-warn-400/35 bg-warn-500/8 p-4">
<p className="flex items-center gap-2 text-sm text-warn-400">
<AlertTriangle className="size-4 shrink-0" />
Adresa se vygeneruje při prvním uložení.
</p>
</div>
);
}
const url = `${baseUrl}/${token}`;
async function copy() {
try {
await navigator.clipboard.writeText(url);
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
} catch (err) {
// Clipboard API muze byt zakazane (http, oprávnění) - reklame to uzivateli.
console.warn('[webhook] kopirovani do schranky selhalo:', err);
window.prompt('Zkopírujte adresu ručně:', url);
}
}
return (
<div className="rounded-xl border border-ink-600/60 bg-ink-850/60 p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="flex items-center gap-2 text-sm font-semibold text-white">
<KeyRound className="size-4 text-brand-300" />
Adresa webhooku
</h3>
<Badge tone="ok">
<Check className="size-3" />
Zaregistrováno
</Badge>
</div>
<div className="mt-3 flex items-stretch gap-2">
<code className="min-w-0 flex-1 overflow-x-auto rounded-lg border border-ink-600/70 bg-ink-900 px-3 py-2.5 font-mono text-xs whitespace-nowrap text-brand-200">
POST {url}
</code>
<button
type="button"
onClick={() => void copy()}
title="Kopírovat adresu"
className="grid w-10 shrink-0 place-items-center rounded-lg border border-ink-600/70 text-white/55 transition-colors hover:border-brand-400/60 hover:text-white"
>
{copied ? <Check className="size-4 text-ok-400" /> : <Copy className="size-4" />}
<span className="sr-only">Kopírovat adresu</span>
</button>
</div>
<p className="mt-2.5 text-xs leading-relaxed text-white/45">
Token v adrese je jediná ochrana kdo ji zná, může automatizaci spustit.
Nesdílejte ji veřejně a neposílejte ji v odkazech.
</p>
{confirming ? (
<div className="mt-3 rounded-lg border border-danger-400/40 bg-danger-500/8 p-3">
<p className="text-xs text-white/70">
Vygenerovat novou adresu? <strong className="text-danger-400">Ta stará okamžitě
přestane fungovat</strong> a musíte ji přenastavit všude, odkud se volá.
</p>
<div className="mt-2.5 flex gap-2">
<button
type="button"
onClick={() => {
onRegenerate();
setConfirming(false);
}}
disabled={regenerating}
className="rounded-lg bg-danger-500/20 px-3 py-1.5 text-xs font-semibold text-danger-400 transition-colors hover:bg-danger-500/30 disabled:opacity-50"
>
{regenerating ? 'Generuji…' : 'Ano, vygenerovat novou'}
</button>
<button
type="button"
onClick={() => setConfirming(false)}
className="rounded-lg px-3 py-1.5 text-xs text-white/55 hover:text-white"
>
Zrušit
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setConfirming(true)}
className="mt-3 inline-flex items-center gap-1.5 text-xs font-medium text-white/45 transition-colors hover:text-white"
>
<RefreshCw className="size-3.5" />
Vygenerovat novou adresu
</button>
)}
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
import { ArrowRight, Mail, Phone } from 'lucide-react';
import { ButtonLink } from '@/components/ui/Button';
import { Container } from '@/components/ui/Container';
import { brand } from '@/config/brand';
/** Zaverecna vyzva k akci pred paticku. */
export function CallToAction() {
return (
<section className="py-20 sm:py-24">
<Container>
<div className="relative overflow-hidden rounded-3xl border border-brand-400/25 bg-gradient-to-br from-brand-500/12 via-ink-800/60 to-accent-500/12 px-6 py-14 text-center sm:px-14">
<div
aria-hidden
className="animate-pulse-slow pointer-events-none absolute -top-24 left-1/2 size-96 -translate-x-1/2 rounded-full bg-brand-500/20 blur-[100px]"
/>
<h2 className="relative text-3xl font-bold text-white sm:text-4xl">
Máte proces, který vás žere? <span className="text-gradient">Pojďme se na něj podívat.</span>
</h2>
<p className="relative mx-auto mt-4 max-w-xl text-white/60">
Půlhodinový hovor, ve kterém si řekneme, co se automatizovat hned, co později a co
nemá smysl vůbec. Bez závazku a bez prezentací na 40 slidů.
</p>
<div className="relative mt-9 flex flex-wrap justify-center gap-3">
<ButtonLink to="/kontakt" size="lg">
Napsat nám
<ArrowRight className="size-4" />
</ButtonLink>
<ButtonLink to={`tel:${brand.phoneHref}`} variant="secondary" size="lg">
<Phone className="size-4" />
{brand.phone}
</ButtonLink>
</div>
<p className="relative mt-6 inline-flex items-center gap-2 text-sm text-white/45">
<Mail className="size-4" />
{brand.email}
</p>
</div>
</Container>
</section>
);
}
+134
View File
@@ -0,0 +1,134 @@
import { ArrowRight, CheckCircle2, PhoneCall, Sparkles } from 'lucide-react';
import { ButtonLink } from '@/components/ui/Button';
import { Container } from '@/components/ui/Container';
/** Krátké uvítání + hlavní CTA + mockup živého provozu. */
export function Hero() {
return (
<section className="relative overflow-hidden pt-16 pb-20 sm:pt-24 sm:pb-28">
{/* Dekorativni pozadi */}
<div aria-hidden className="pointer-events-none absolute inset-0 -z-10">
<div className="absolute inset-0 bg-grid mask-fade-b opacity-40" />
<div className="animate-pulse-slow absolute -top-40 left-1/2 size-[38rem] -translate-x-1/2 rounded-full bg-brand-500/18 blur-[120px]" />
<div className="animate-pulse-slow absolute top-40 -right-32 size-[30rem] rounded-full bg-accent-500/16 blur-[120px]" />
</div>
<Container>
<div className="grid items-center gap-14 lg:grid-cols-[1.05fr_0.95fr]">
<div className="animate-rise">
<span className="glass inline-flex items-center gap-2 rounded-full px-4 py-1.5 text-xs font-medium text-brand-300">
<Sparkles className="size-3.5" />
Automatizace · Voiceboti · Integrace
</span>
<h1 className="mt-6 text-4xl leading-[1.08] font-extrabold text-white sm:text-5xl lg:text-6xl">
Vítejte. Děláme z ruční práce{' '}
<span className="text-gradient">procesy, které běží samy</span>.
</h1>
<p className="mt-6 max-w-xl text-lg leading-relaxed text-white/65">
Jsme malý tým, který firmám staví automatizace, hlasové asistenty a propojení systémů
a pak je i provozuje. Vy vidíte výsledky v dashboardu, my držíme provoz.
</p>
<div className="mt-9 flex flex-wrap items-center gap-3">
<ButtonLink to="/kontakt" size="lg">
Nezávazná konzultace
<ArrowRight className="size-4" />
</ButtonLink>
<ButtonLink to="/sluzby" variant="secondary" size="lg">
Co umíme
</ButtonLink>
</div>
<ul className="mt-9 flex flex-wrap gap-x-6 gap-y-2.5 text-sm text-white/55">
{['Nasazení v týdnech, ne kvartálech', 'Provoz a podpora v ceně', 'Bez vendor lock-inu'].map(
(item) => (
<li key={item} className="flex items-center gap-2">
<CheckCircle2 className="size-4 shrink-0 text-ok-400" />
{item}
</li>
),
)}
</ul>
</div>
<HeroPanel />
</div>
</Container>
</section>
);
}
/** Mockup panelu "z provozu" - jen ilustrace, zadna realna data. */
function HeroPanel() {
const steps = [
{ label: 'Příchozí hovor rozpoznán', time: '0,4 s', done: true },
{ label: 'Poptávka založena v CRM', time: '1,1 s', done: true },
{ label: 'Nabídka vygenerována', time: '2,8 s', done: true },
{ label: 'Předáno obchodníkovi', time: 'právě teď', done: false },
];
return (
<div className="animate-rise relative [animation-delay:150ms]">
<div className="animate-float glass rounded-2xl p-5 shadow-2xl shadow-brand-500/10">
<div className="flex items-center justify-between border-b border-ink-600/60 pb-4">
<div className="flex items-center gap-2.5">
<span className="grid size-9 place-items-center rounded-xl bg-brand-500/15 text-brand-300">
<PhoneCall className="size-4" />
</span>
<div>
<p className="text-sm font-semibold text-white">Voicebot příjem poptávek</p>
<p className="font-mono text-xs text-white/40">AUT-02 · běží</p>
</div>
</div>
<span className="inline-flex items-center gap-1.5 rounded-full border border-ok-400/35 bg-ok-500/12 px-2.5 py-1 text-xs font-medium text-ok-400">
<span className="size-1.5 animate-pulse rounded-full bg-ok-400" />
live
</span>
</div>
<ol className="mt-4 space-y-3">
{steps.map((step) => (
<li key={step.label} className="flex items-center gap-3">
<span
className={
step.done
? 'grid size-5 shrink-0 place-items-center rounded-full bg-ok-500/20 text-ok-400'
: 'grid size-5 shrink-0 place-items-center rounded-full bg-brand-500/20 text-brand-300'
}
>
{step.done ? (
<CheckCircle2 className="size-3.5" />
) : (
<span className="size-1.5 animate-pulse rounded-full bg-brand-300" />
)}
</span>
<span className="flex-1 text-sm text-white/75">{step.label}</span>
<span className="font-mono text-xs text-white/35">{step.time}</span>
</li>
))}
</ol>
<div className="mt-5 grid grid-cols-3 gap-3 border-t border-ink-600/60 pt-4">
{[
{ value: '137', label: 'hovorů dnes' },
{ value: '96,1 %', label: 'úspěšnost' },
{ value: '1,2 s', label: 'odezva' },
].map((stat) => (
<div key={stat.label}>
<p className="text-lg font-bold text-white">{stat.value}</p>
<p className="text-xs text-white/45">{stat.label}</p>
</div>
))}
</div>
</div>
{/* Male "odlepene" karticky pro hloubku */}
<div className="glass animate-float absolute -bottom-6 -left-6 hidden rounded-xl px-4 py-3 shadow-xl [animation-delay:1.2s] sm:block">
<p className="text-xs text-white/45">Ušetřeno tento měsíc</p>
<p className="text-base font-bold text-white">312 hodin</p>
</div>
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { Container } from '@/components/ui/Container';
import { clientLogos } from '@/data/references';
/** Nekonecny pas nazvu klientu (mockup misto realnych log). */
export function LogoCloud() {
// Seznam zdvojujeme, aby marquee navazovalo bez skoku (posun je -50 %).
const items = [...clientLogos, ...clientLogos];
return (
<section className="border-y border-ink-600/50 bg-ink-950/50 py-10">
<Container>
<p className="mb-6 text-center text-xs font-semibold tracking-[0.18em] text-white/35 uppercase">
Pracujeme pro firmy, které nemají čas na ruční práci
</p>
</Container>
<div className="mask-fade-x overflow-hidden">
<div className="animate-marquee flex w-max items-center gap-14">
{items.map((name, index) => (
<span
key={`${name}-${index}`}
className="text-lg font-semibold whitespace-nowrap text-white/25 transition-colors hover:text-white/50"
>
{name}
</span>
))}
</div>
</div>
</section>
);
}
+63
View File
@@ -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: '12 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: '23 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: '24 týdny',
},
{
number: '04',
title: 'Provoz a rozvoj',
text: 'Držíme SLA, řešíme tickety a incidenty, průběžně přidáváme, co se v provozu ukáže jako potřebné.',
duration: 'trvale',
},
];
/** Jak spoluprace probiha - casova osa ve 4 krocich. */
export function Process() {
return (
<Section id="postup">
<SectionHeading
eyebrow="Jak to probíhá"
title={
<>
Od prvního hovoru <span className="text-gradient">do provozu za pár týdnů</span>
</>
}
subtitle="Žádné půlroční analýzy. Nejdřív malý funkční celek, který přinese úsporu, potom rozšiřování."
/>
<ol className="grid gap-5 md:grid-cols-2 lg:grid-cols-4">
{steps.map((step) => (
<li
key={step.number}
className="group relative rounded-card border border-ink-600/60 bg-ink-800/40 p-6 transition-colors hover:border-brand-400/40"
>
<span className="font-mono text-3xl font-bold text-brand-400/25 transition-colors group-hover:text-brand-400/45">
{step.number}
</span>
<h3 className="mt-3 font-semibold text-white">{step.title}</h3>
<p className="mt-2 text-sm leading-relaxed text-white/55">{step.text}</p>
<p className="mt-4 text-xs font-medium tracking-wide text-brand-300/70 uppercase">
{step.duration}
</p>
</li>
))}
</ol>
</Section>
);
}
+74
View File
@@ -0,0 +1,74 @@
import { ArrowRight, Check } from 'lucide-react';
import { Link } from 'react-router-dom';
import { Card } from '@/components/ui/Card';
import { Section, SectionHeading } from '@/components/ui/Section';
import { products, type Product } from '@/data/products';
import { cn } from '@/lib/cn';
/** Mockup produktu / sluzeb - grid karet. */
export function Products() {
return (
<Section id="produkty">
<SectionHeading
eyebrow="Produkty"
title={
<>
Šest věcí, které <span className="text-gradient">umíme dodat a udržet</span>
</>
}
subtitle="Každou z nich umíme nasadit samostatně, nebo je pospojovat do jednoho celku. Ceny a rozsah vždy podle skutečného provozu."
/>
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{products.map((product) => (
<ProductCard key={product.slug} product={product} />
))}
</div>
</Section>
);
}
export function ProductCard({ product }: { product: Product }) {
const Icon = product.icon;
return (
<Card
interactive
className={cn(
'flex flex-col',
product.featured && 'border-brand-400/35 bg-gradient-to-b from-brand-500/8 to-transparent',
)}
>
<div className="flex items-start justify-between gap-3">
<span className="grid size-11 place-items-center rounded-xl bg-gradient-to-br from-brand-500/20 to-accent-500/20 text-brand-300">
<Icon className="size-5" />
</span>
<div className="text-right">
<p className="text-lg font-bold text-white">{product.metric.value}</p>
<p className="text-[0.7rem] text-white/40">{product.metric.label}</p>
</div>
</div>
<h3 className="mt-5 text-lg font-semibold text-white">{product.name}</h3>
<p className="mt-1 text-sm font-medium text-brand-300/80">{product.tagline}</p>
<p className="mt-3 text-sm leading-relaxed text-white/60">{product.description}</p>
<ul className="mt-5 space-y-2">
{product.features.map((feature) => (
<li key={feature} className="flex items-start gap-2 text-sm text-white/55">
<Check className="mt-0.5 size-3.5 shrink-0 text-ok-400" />
{feature}
</li>
))}
</ul>
<Link
to={`/sluzby#${product.slug}`}
className="mt-6 inline-flex items-center gap-1.5 text-sm font-semibold text-brand-300 transition-colors hover:text-brand-200"
>
Detail služby
<ArrowRight className="size-3.5" />
</Link>
</Card>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { Quote, TrendingUp } from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Section, SectionHeading } from '@/components/ui/Section';
import { references, type Reference } from '@/data/references';
/** Mockup referenci - citaty klientu + merutelny vysledek. */
export function References() {
return (
<Section id="reference" className="bg-ink-950/40">
<SectionHeading
eyebrow="Reference"
title={
<>
Co říkají firmy, <span className="text-gradient">se kterými to běží</span>
</>
}
subtitle="Ukázka spoluprací napříč obory. Čísla vždy měříme před nasazením a po něm — jinak by to byl jen marketing."
/>
<div className="grid gap-5 md:grid-cols-2">
{references.map((reference) => (
<ReferenceCard key={reference.id} reference={reference} />
))}
</div>
<p className="mt-8 text-center text-xs text-white/30">
Reference jsou v této verzi webu ukázkové (mockup) a slouží k náhledu layoutu.
</p>
</Section>
);
}
function ReferenceCard({ reference }: { reference: Reference }) {
return (
<Card interactive className="flex h-full flex-col">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<span className="grid size-11 shrink-0 place-items-center rounded-xl border border-ink-600/70 bg-ink-700/60 font-mono text-sm font-bold text-brand-300">
{reference.initials}
</span>
<div>
<p className="font-semibold text-white">{reference.company}</p>
<p className="text-xs text-white/40">{reference.industry}</p>
</div>
</div>
<Quote className="size-6 shrink-0 text-brand-400/25" />
</div>
<blockquote className="mt-5 flex-1 text-[0.975rem] leading-relaxed text-white/70">
{reference.quote}
</blockquote>
<div className="mt-6 flex flex-wrap items-end justify-between gap-4 border-t border-ink-600/60 pt-4">
<div>
<p className="text-sm font-medium text-white">{reference.author}</p>
<p className="text-xs text-white/40">{reference.role}</p>
</div>
<div className="flex items-center gap-2 rounded-lg border border-ok-400/25 bg-ok-500/10 px-3 py-1.5">
<TrendingUp className="size-4 text-ok-400" />
<span className="text-sm font-bold text-ok-400">{reference.result.value}</span>
<span className="text-xs text-white/45">{reference.result.label}</span>
</div>
</div>
</Card>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { Container } from '@/components/ui/Container';
const stats = [
{ value: '120+', label: 'nasazených automatizací' },
{ value: '99,98 %', label: 'dostupnost provozovaných služeb' },
{ value: '11 min', label: 'medián první reakce podpory' },
{ value: '7 let', label: 'na trhu' },
];
/** Pas s cisly mezi sekcemi - drzi pozornost a rozdeluje obsah. */
export function Stats() {
return (
<section className="relative overflow-hidden py-14">
<div
aria-hidden
className="pointer-events-none absolute inset-0 -z-10 bg-gradient-to-r from-brand-500/8 via-transparent to-accent-500/8"
/>
<Container>
<dl className="grid grid-cols-2 gap-8 lg:grid-cols-4">
{stats.map((stat) => (
<div key={stat.label} className="text-center">
<dt className="sr-only">{stat.label}</dt>
<dd>
<p className="text-3xl font-extrabold text-white sm:text-4xl">{stat.value}</p>
<p className="mt-2 text-sm text-white/50">{stat.label}</p>
</dd>
</div>
))}
</dl>
</Container>
</section>
);
}
+82
View File
@@ -0,0 +1,82 @@
import { Mail, MapPin, Phone } from 'lucide-react';
import { Link } from 'react-router-dom';
import { Logo } from '@/components/layout/Logo';
import { Container } from '@/components/ui/Container';
import { brand } from '@/config/brand';
import { footerNav } from '@/data/navigation';
export function Footer() {
const year = new Date().getFullYear();
return (
<footer className="border-t border-ink-600/60 bg-ink-950/80">
<Container className="py-14">
<div className="grid gap-10 md:grid-cols-[1.4fr_1fr_1fr_1.2fr]">
<div>
<Logo />
<p className="mt-4 max-w-xs text-sm leading-relaxed text-white/55">{brand.claim}</p>
</div>
{footerNav.map((column) => (
<div key={column.title}>
<h3 className="mb-4 text-sm font-semibold text-white">{column.title}</h3>
<ul className="space-y-2.5">
{column.items.map((item) => (
<li key={item.to + item.label}>
<Link
to={item.to}
className="text-sm text-white/55 transition-colors hover:text-brand-300"
>
{item.label}
</Link>
</li>
))}
</ul>
</div>
))}
<div>
<h3 className="mb-4 text-sm font-semibold text-white">Kontakt</h3>
<ul className="space-y-3 text-sm text-white/55">
<li>
<a
href={`mailto:${brand.email}`}
className="inline-flex items-center gap-2 transition-colors hover:text-brand-300"
>
<Mail className="size-4 shrink-0 text-brand-400" />
{brand.email}
</a>
</li>
<li>
<a
href={`tel:${brand.phoneHref}`}
className="inline-flex items-center gap-2 transition-colors hover:text-brand-300"
>
<Phone className="size-4 shrink-0 text-brand-400" />
{brand.phone}
</a>
</li>
<li className="flex items-start gap-2">
<MapPin className="mt-0.5 size-4 shrink-0 text-brand-400" />
<span>
{brand.address.street}
<br />
{brand.address.zip} {brand.address.city}
</span>
</li>
</ul>
</div>
</div>
<div className="mt-12 flex flex-col gap-3 border-t border-ink-600/50 pt-6 text-xs text-white/40 sm:flex-row sm:items-center sm:justify-between">
<p>
© {year} {brand.legalName} · IČO {brand.ico}
</p>
<p className="font-mono">
Prototyp webu obsah a reference jsou ukázkové (mockup).
</p>
</div>
</Container>
</footer>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { Link } from 'react-router-dom';
import { brand } from '@/config/brand';
import { cn } from '@/lib/cn';
export function Logo({ className, to = '/' }: { className?: string; to?: string }) {
return (
<Link to={to} className={cn('group inline-flex items-center gap-2.5', className)}>
<span className="relative grid size-9 place-items-center rounded-xl bg-gradient-to-br from-brand-400 to-accent-500 shadow-lg shadow-brand-500/25 transition-transform duration-300 group-hover:scale-105">
<span className="font-mono text-sm font-bold text-ink-950">A</span>
</span>
<span className="text-lg font-bold tracking-tight text-white">{brand.name}</span>
</Link>
);
}
+129
View File
@@ -0,0 +1,129 @@
import { LayoutDashboard, LogIn, Menu, X } from 'lucide-react';
import { useEffect, useState } from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import { useAuth } from '@/auth/AuthContext';
import { Logo } from '@/components/layout/Logo';
import { ButtonLink } from '@/components/ui/Button';
import { Container } from '@/components/ui/Container';
import { mainNav } from '@/data/navigation';
import { cn } from '@/lib/cn';
export function Navbar() {
const [scrolled, setScrolled] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const location = useLocation();
const { user } = useAuth();
// Sticky hlavicka ztmavne po odscrollovani.
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 12);
onScroll();
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, []);
// Zavreni mobilniho menu pri zmene stranky.
useEffect(() => {
setMobileOpen(false);
}, [location.pathname]);
return (
<header
className={cn(
'fixed inset-x-0 top-0 z-50 transition-all duration-300',
scrolled ? 'border-b border-ink-600/60 bg-ink-900/85 backdrop-blur-xl' : 'bg-transparent',
)}
>
<Container>
<div className="flex h-18 items-center justify-between gap-4">
<Logo />
<nav className="hidden items-center gap-1 md:flex">
{mainNav.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) =>
cn(
'rounded-full px-4 py-2 text-sm font-medium transition-colors',
isActive ? 'bg-white/8 text-white' : 'text-white/60 hover:text-white',
)
}
>
{item.label}
</NavLink>
))}
</nav>
<div className="hidden items-center gap-3 md:flex">
{user ? (
<ButtonLink to="/dashboard" size="sm">
<LayoutDashboard className="size-4" />
Dashboard
</ButtonLink>
) : (
<>
<ButtonLink to="/prihlaseni" variant="ghost" size="sm">
<LogIn className="size-4" />
Přihlášení
</ButtonLink>
<ButtonLink to="/kontakt" size="sm">
Nezávazná konzultace
</ButtonLink>
</>
)}
</div>
<button
type="button"
className="grid size-10 place-items-center rounded-lg text-white/70 hover:bg-white/5 hover:text-white md:hidden"
onClick={() => setMobileOpen((open) => !open)}
aria-label={mobileOpen ? 'Zavřít menu' : 'Otevřít menu'}
aria-expanded={mobileOpen}
>
{mobileOpen ? <X className="size-5" /> : <Menu className="size-5" />}
</button>
</div>
</Container>
{mobileOpen && (
<div className="border-t border-ink-600/60 bg-ink-900/98 backdrop-blur-xl md:hidden">
<Container className="flex flex-col gap-1 py-4">
{mainNav.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) =>
cn(
'rounded-lg px-4 py-3 text-base font-medium transition-colors',
isActive ? 'bg-white/8 text-white' : 'text-white/70 hover:bg-white/5',
)
}
>
{item.label}
</NavLink>
))}
<div className="mt-3 flex flex-col gap-2">
{user ? (
<ButtonLink to="/dashboard">
<LayoutDashboard className="size-4" />
Dashboard
</ButtonLink>
) : (
<>
<ButtonLink to="/prihlaseni" variant="secondary">
<LogIn className="size-4" />
Přihlášení
</ButtonLink>
<ButtonLink to="/kontakt">Nezávazná konzultace</ButtonLink>
</>
)}
</div>
</Container>
</div>
)}
</header>
);
}
@@ -0,0 +1,32 @@
import { useEffect } from 'react';
import { Outlet, useLocation } from 'react-router-dom';
import { Footer } from '@/components/layout/Footer';
import { Navbar } from '@/components/layout/Navbar';
/** Layout verejneho webu: sticky hlavicka + obsah + paticka. */
export function PublicLayout() {
const { pathname, hash } = useLocation();
// Pri prechodu na jinou stranku scrollujeme nahoru, pri #odkazu na cilovou sekci.
useEffect(() => {
if (hash) {
const target = document.querySelector(hash);
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
return;
}
console.warn(`[layout] cil hashe ${hash} na strance neexistuje`);
}
window.scrollTo({ top: 0, behavior: 'instant' as ScrollBehavior });
}, [pathname, hash]);
return (
<div className="flex min-h-screen flex-col bg-ink-900">
<Navbar />
<main className="flex-1 pt-18">
<Outlet />
</main>
<Footer />
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
import type { ReactNode } from 'react';
import { cn } from '@/lib/cn';
export type BadgeTone = 'neutral' | 'brand' | 'ok' | 'warn' | 'danger';
const tones: Record<BadgeTone, string> = {
neutral: 'border-white/12 bg-white/5 text-white/70',
brand: 'border-brand-400/35 bg-brand-500/12 text-brand-300',
ok: 'border-ok-400/35 bg-ok-500/12 text-ok-400',
warn: 'border-warn-400/35 bg-warn-500/12 text-warn-400',
danger: 'border-danger-400/35 bg-danger-500/12 text-danger-400',
};
export function Badge({
tone = 'neutral',
className,
children,
}: {
tone?: BadgeTone;
className?: string;
children: ReactNode;
}) {
return (
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium whitespace-nowrap',
tones[tone],
className,
)}
>
{children}
</span>
);
}
+74
View File
@@ -0,0 +1,74 @@
import { Link } from 'react-router-dom';
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import { cn } from '@/lib/cn';
type Variant = 'primary' | 'secondary' | 'ghost';
type Size = 'sm' | 'md' | 'lg';
const base =
'inline-flex items-center justify-center gap-2 rounded-full font-semibold transition-all duration-200 disabled:cursor-not-allowed disabled:opacity-55';
const variants: Record<Variant, string> = {
primary:
'bg-gradient-to-r from-brand-400 to-accent-500 text-ink-950 shadow-lg shadow-brand-500/20 hover:shadow-xl hover:shadow-brand-500/30 hover:brightness-110',
secondary: 'glass text-white hover:border-brand-400/60 hover:bg-ink-700/70',
ghost: 'text-white/70 hover:bg-white/5 hover:text-white',
};
const sizes: Record<Size, string> = {
sm: 'h-9 px-4 text-sm',
md: 'h-11 px-6 text-[0.95rem]',
lg: 'h-13 px-8 text-base',
};
interface CommonProps {
variant?: Variant;
size?: Size;
className?: string;
children: ReactNode;
}
export function Button({
variant = 'primary',
size = 'md',
className,
children,
...rest
}: CommonProps & ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button className={cn(base, variants[variant], sizes[size], className)} {...rest}>
{children}
</button>
);
}
/** Stejny vzhled jako Button, ale routovaci odkaz. */
export function ButtonLink({
to,
variant = 'primary',
size = 'md',
className,
children,
}: CommonProps & { to: string }) {
const isExternal = /^(https?:|mailto:|tel:)/.test(to);
const classes = cn(base, variants[variant], sizes[size], className);
if (isExternal) {
return (
<a
href={to}
className={classes}
target={to.startsWith('http') ? '_blank' : undefined}
rel={to.startsWith('http') ? 'noreferrer' : undefined}
>
{children}
</a>
);
}
return (
<Link to={to} className={classes}>
{children}
</Link>
);
}
+23
View File
@@ -0,0 +1,23 @@
import type { ReactNode } from 'react';
import { cn } from '@/lib/cn';
interface CardProps {
className?: string;
children: ReactNode;
/** Zvyrazni kartu pri hoveru - pro klikatelne / produktove karty. */
interactive?: boolean;
}
export function Card({ className, children, interactive = false }: CardProps) {
return (
<div
className={cn(
'glass rounded-card p-6 transition-all duration-300',
interactive && 'hover:-translate-y-1 hover:border-brand-400/50 hover:shadow-2xl hover:shadow-brand-500/10',
className,
)}
>
{children}
</div>
);
}
+7
View File
@@ -0,0 +1,7 @@
import type { ReactNode } from 'react';
import { cn } from '@/lib/cn';
/** Jednotna sirka obsahu a horizontalni odsazeni pro cely web. */
export function Container({ className, children }: { className?: string; children: ReactNode }) {
return <div className={cn('mx-auto w-full max-w-6xl px-5 sm:px-8', className)}>{children}</div>;
}
+90
View File
@@ -0,0 +1,90 @@
import { X } from 'lucide-react';
import { useEffect, useRef } from 'react';
import type { ReactNode } from 'react';
import { cn } from '@/lib/cn';
/**
* Dialog nad obsahem. Zavira se Esc, klikem na pozadi i krizkem.
* Pri otevreni zamkne scroll stranky a preda focus dovnitr.
*/
export function Modal({
open,
onClose,
title,
description,
children,
className,
}: {
open: boolean;
onClose: () => void;
title: string;
description?: string;
children: ReactNode;
className?: string;
}) {
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose();
};
document.addEventListener('keydown', onKeyDown);
// Zamek scrollu, aby se pod dialogem nescrollovalo.
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
// Focus do dialogu - jinak by tabulator pokracoval na strance za nim.
const firstFocusable = panelRef.current?.querySelector<HTMLElement>(
'input, button, [tabindex]:not([tabindex="-1"])',
);
firstFocusable?.focus();
return () => {
document.removeEventListener('keydown', onKeyDown);
document.body.style.overflow = previousOverflow;
};
}, [open, onClose]);
if (!open) return null;
return (
<div className="fixed inset-0 z-[60] flex items-start justify-center overflow-y-auto p-4 sm:items-center sm:p-6">
<div
className="fixed inset-0 bg-ink-950/80 backdrop-blur-sm"
onClick={onClose}
aria-hidden
/>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label={title}
className={cn(
'relative my-auto w-full max-w-3xl rounded-2xl border border-ink-600/70 bg-ink-900 shadow-2xl',
className,
)}
>
<div className="flex items-start justify-between gap-4 border-b border-ink-600/60 p-5">
<div>
<h2 className="text-lg font-bold text-white">{title}</h2>
{description && <p className="mt-1 text-sm text-white/50">{description}</p>}
</div>
<button
type="button"
onClick={onClose}
className="grid size-9 shrink-0 place-items-center rounded-lg text-white/50 transition-colors hover:bg-white/5 hover:text-white"
aria-label="Zavřít"
>
<X className="size-5" />
</button>
</div>
{children}
</div>
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
import type { ReactNode } from 'react';
import { Container } from '@/components/ui/Container';
/** Hlavicka podstranky - jednotny vzhled pro O nas, Sluzby, Kontakt. */
export function PageHeader({
eyebrow,
title,
subtitle,
}: {
eyebrow?: string;
title: ReactNode;
subtitle?: ReactNode;
}) {
return (
<section className="relative overflow-hidden border-b border-ink-600/50 py-16 sm:py-20">
<div aria-hidden className="pointer-events-none absolute inset-0 -z-10">
<div className="absolute inset-0 bg-grid mask-fade-b opacity-30" />
<div className="absolute -top-32 left-1/3 size-96 rounded-full bg-brand-500/12 blur-[110px]" />
</div>
<Container>
{eyebrow && (
<p className="mb-3 text-xs font-semibold tracking-[0.18em] text-brand-300 uppercase">
{eyebrow}
</p>
)}
<h1 className="max-w-3xl text-3xl font-extrabold text-white sm:text-5xl">{title}</h1>
{subtitle && (
<p className="mt-5 max-w-2xl text-lg leading-relaxed text-white/60">{subtitle}</p>
)}
</Container>
</section>
);
}
+53
View File
@@ -0,0 +1,53 @@
import type { ReactNode } from 'react';
import { Container } from '@/components/ui/Container';
import { cn } from '@/lib/cn';
/** Sekce s jednotnym vertikalnim rytmem. */
export function Section({
id,
className,
children,
}: {
id?: string;
className?: string;
children: ReactNode;
}) {
return (
<section id={id} className={cn('relative py-20 sm:py-28', className)}>
<Container>{children}</Container>
</section>
);
}
/** Nadpis sekce: mala popiska + titulek + podtitulek. */
export function SectionHeading({
eyebrow,
title,
subtitle,
align = 'center',
className,
}: {
eyebrow?: string;
title: ReactNode;
subtitle?: ReactNode;
align?: 'center' | 'left';
className?: string;
}) {
return (
<div
className={cn(
'mb-14 max-w-2xl',
align === 'center' ? 'mx-auto text-center' : 'text-left',
className,
)}
>
{eyebrow && (
<p className="mb-3 text-xs font-semibold tracking-[0.18em] text-brand-300 uppercase">
{eyebrow}
</p>
)}
<h2 className="text-3xl font-bold text-white sm:text-4xl">{title}</h2>
{subtitle && <p className="mt-4 text-base leading-relaxed text-white/60">{subtitle}</p>}
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { cn } from '@/lib/cn';
export function Spinner({ label, className }: { label?: string; className?: string }) {
return (
<div className={cn('flex items-center gap-3 text-sm text-white/60', className)} role="status">
<span className="size-5 animate-spin rounded-full border-2 border-white/15 border-t-brand-400" />
{label && <span>{label}</span>}
</div>
);
}
+32
View File
@@ -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: 'PoPá 8:0018:00',
sla: 'SLA 24/7 pro klienty s tarifem Provoz',
},
} as const;
+33
View File
@@ -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' },
],
},
];
+90
View File
@@ -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' },
},
];
+75
View File
@@ -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',
];
+190
View File
@@ -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);
}
+92
View File
@@ -0,0 +1,92 @@
/**
* Tenka vrstva nad fetch. Vsechny volani API jdou pres ni,
* aby se autorizace a chybove hlaseni resily na jednom miste.
*/
const TOKEN_KEY = 'automia.token';
/**
* Prefix reverse proxy, napr. "/apps/csbot-prototype".
* Server ho vklada do index.html podle ROOT_PATH, viz src/index.ts.
* Bez nej by requesty smerovaly na koren domeny, kam aplikace nepatri.
*/
export function basePath(): string {
const value = window.__BASE_PATH__;
return typeof value === 'string' ? value : '';
}
/** Slozi absolutni cestu vcetne prefixu proxy. */
export function apiUrl(path: string): string {
return `${basePath()}${path}`;
}
export class ApiError extends Error {
constructor(
message: string,
readonly status: number,
readonly code?: string,
) {
super(message);
this.name = 'ApiError';
}
}
export function getToken(): string | null {
try {
return localStorage.getItem(TOKEN_KEY);
} catch (err) {
// Privatni rezim / zakazane storage - nesmi to spadnout, ale chceme vedet.
console.warn('[api] localStorage neni dostupne:', err);
return null;
}
}
export function setToken(token: string | null) {
try {
if (token) localStorage.setItem(TOKEN_KEY, token);
else localStorage.removeItem(TOKEN_KEY);
} catch (err) {
console.warn('[api] token nelze ulozit:', err);
}
}
interface RequestOptions extends Omit<RequestInit, 'body'> {
body?: unknown;
/** true = pridat Authorization hlavicku (default pro vse krome loginu) */
auth?: boolean;
}
export async function apiFetch<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { body, auth = true, headers, ...rest } = options;
const token = auth ? getToken() : null;
const response = await fetch(apiUrl(path), {
...rest,
headers: {
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...headers,
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (response.status === 204) return undefined as T;
const isJson = response.headers.get('content-type')?.includes('application/json');
const payload = isJson ? await response.json().catch(() => null) : await response.text();
if (!response.ok) {
const message =
(isJson && payload && typeof payload === 'object' && 'message' in payload
? String((payload as { message: unknown }).message)
: null) ?? `Požadavek selhal (HTTP ${response.status}).`;
const code =
isJson && payload && typeof payload === 'object' && 'error' in payload
? String((payload as { error: unknown }).error)
: undefined;
console.error(`[api] ${path} -> ${response.status} ${code ?? ''} ${message}`);
throw new ApiError(message, response.status, code);
}
return payload as T;
}
+9
View File
@@ -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(' ');
}
+71
View File
@@ -0,0 +1,71 @@
import {
BarChart3,
Building2,
Clock,
FileAudio,
FileInput,
Globe,
Hash,
Landmark,
LifeBuoy,
Mail,
Megaphone,
MessageSquare,
MousePointer,
MousePointerClick,
Plug,
PhoneCall,
Receipt,
ScrollText,
Search,
ShoppingCart,
Shuffle,
Sparkles,
Timer,
Truck,
Users,
Webhook,
type LucideIcon,
} from 'lucide-react';
/**
* API posila u konektoru jen klic ikony (string) - React komponentu poslat nemuze.
* Mapovani je tady. Neznamy klic se zaloguje a dostane obecnou ikonu,
* aby chybejici zapis nerozbil celou stranku.
*/
const icons: Record<string, LucideIcon> = {
BarChart3,
Building2,
Clock,
FileAudio,
FileInput,
Globe,
Hash,
Landmark,
LifeBuoy,
Mail,
Megaphone,
MessageSquare,
MousePointer,
MousePointerClick,
PhoneCall,
Receipt,
ScrollText,
Search,
ShoppingCart,
Shuffle,
Sparkles,
Timer,
Truck,
Users,
Webhook,
};
export function connectorIcon(key: string): LucideIcon {
const icon = icons[key];
if (!icon) {
console.warn(`[connectorIcons] neznamy klic ikony "${key}", pouzivam zastupnou`);
return Plug;
}
return icon;
}
+112
View File
@@ -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');
};
}
+244
View File
@@ -0,0 +1,244 @@
import type {
ConditionOperator,
Connector,
ConnectorOperation,
FieldType,
FlowStep,
TriggerField,
} from '@/types/dashboard';
/**
* Ciste funkce pro praci se stromem akci. Zadny React, zadny stav -
* vsechno vraci novy strom, aby se React prekreslil a slo to snadno testovat.
*
* Popis modelu: docs/08-automatizace-builder.md
*/
/**
* Cesta k jedne sekvenci kroku ve strome.
* Prazdne pole = hlavni (korenova) sekvence.
* `[{ stepId: 'st_3', branch: 'yes' }]` = vetev ANO podminky st_3.
*/
export type FlowPath = Array<{ stepId: string; branch: 'yes' | 'no' }>;
let idCounter = 0;
/** Docasne ID kroku na klientovi. Server si ho pri ulozeni prevezme. */
export function newStepId(): string {
idCounter += 1;
return `st_${Date.now().toString(36)}_${idCounter}`;
}
export function createActionStep(connectorId: string, operationId: string): FlowStep {
return { id: newStepId(), kind: 'action', connectorId, operationId };
}
export function createConditionStep(field: TriggerField): FlowStep {
return {
id: newStepId(),
kind: 'condition',
fieldId: field.id,
operator: defaultOperatorFor(field.type),
value: '',
yes: [],
no: [],
};
}
export function newFieldId(): string {
idCounter += 1;
return `f_${Date.now().toString(36)}_${idCounter}`;
}
// ---------------------------------------------------------------- podminky
/**
* Ktere operatory maji smysl pro ktery typ parametru.
* POZOR: stejna tabulka je na serveru v apps/api/src/data/conditions.ts.
* Server je autorita - tady je jen proto, aby UI nenabidlo nesmysl.
*/
export const operatorsByType: Record<FieldType, ConditionOperator[]> = {
string: ['eq', 'neq', 'contains', 'startsWith', 'isEmpty', 'isNotEmpty'],
number: ['eq', 'neq', 'gt', 'gte', 'lt', 'lte'],
boolean: ['isTrue', 'isFalse'],
date: ['eq', 'gt', 'lt'],
};
/** Operatory, ktere nepotrebuji hodnotu k porovnani. */
const unaryOperators: ConditionOperator[] = ['isEmpty', 'isNotEmpty', 'isTrue', 'isFalse'];
export function isUnaryOperator(operator: ConditionOperator): boolean {
return unaryOperators.includes(operator);
}
export function defaultOperatorFor(type: FieldType): ConditionOperator {
const first = operatorsByType[type][0];
if (!first) {
console.warn(`[flow] typ ${type} nema zadny operator, pouzivam "eq"`);
return 'eq';
}
return first;
}
const operatorLabels: Record<ConditionOperator, string> = {
eq: 'je rovno',
neq: 'není rovno',
gt: 'je větší než',
gte: 'je větší nebo rovno',
lt: 'je menší než',
lte: 'je menší nebo rovno',
contains: 'obsahuje',
startsWith: 'začíná na',
isEmpty: 'je prázdné',
isNotEmpty: 'není prázdné',
isTrue: 'je splněno',
isFalse: 'není splněno',
};
/** U datumu zni porovnani prirozeneji jinak nez u cisel. */
const dateOperatorLabels: Partial<Record<ConditionOperator, string>> = {
gt: 'je po',
lt: 'je před',
eq: 'je přesně',
};
export function operatorLabel(operator: ConditionOperator, type: FieldType): string {
if (type === 'date' && dateOperatorLabels[operator]) return dateOperatorLabels[operator];
return operatorLabels[operator];
}
export const fieldTypeLabels: Record<FieldType, string> = {
string: 'text',
number: 'číslo',
boolean: 'ano/ne',
date: 'datum',
};
/** Lidsky citelny zapis podminky, napr. „score je větší nebo rovno 15". */
export function describeCondition(
field: TriggerField | undefined,
operator: ConditionOperator,
value: string | undefined,
): string {
if (!field) return 'Neznámý parametr';
const label = operatorLabel(operator, field.type);
if (isUnaryOperator(operator)) return `${field.name} ${label}`;
return `${field.name} ${label} ${value?.trim() || '…'}`;
}
/** Zmena vlastnosti podminky kdekoliv ve strome. */
export function updateCondition(
steps: FlowStep[],
stepId: string,
patch: { fieldId?: string; operator?: ConditionOperator; value?: string },
): FlowStep[] {
return steps.map((step) => {
if (step.kind !== 'condition') return step;
if (step.id === stepId) return { ...step, ...patch };
return {
...step,
yes: updateCondition(step.yes, stepId, patch),
no: updateCondition(step.no, stepId, patch),
};
});
}
/** Vloz krok do sekvence na dane cesta+pozice. */
export function insertStep(
steps: FlowStep[],
path: FlowPath,
index: number,
step: FlowStep,
): FlowStep[] {
if (path.length === 0) {
const next = [...steps];
next.splice(index, 0, step);
return next;
}
const [head, ...rest] = path;
return steps.map((current) => {
if (current.id !== head.stepId) return current;
if (current.kind !== 'condition') {
console.warn(`[flow] cesta vede pres krok ${current.id}, ktery neni podminka`);
return current;
}
if (head.branch === 'yes') {
return { ...current, yes: insertStep(current.yes, rest, index, step) };
}
return { ...current, no: insertStep(current.no, rest, index, step) };
});
}
/** Odstran krok podle ID kdekoliv ve strome (vcetne jeho vetvi). */
export function removeStep(steps: FlowStep[], stepId: string): FlowStep[] {
return steps
.filter((step) => step.id !== stepId)
.map((step) =>
step.kind === 'condition'
? { ...step, yes: removeStep(step.yes, stepId), no: removeStep(step.no, stepId) }
: step,
);
}
/** Posun krok v ramci jeho sekvence o `offset` pozic. */
export function moveStep(steps: FlowStep[], stepId: string, offset: number): FlowStep[] {
const index = steps.findIndex((step) => step.id === stepId);
if (index !== -1) {
const target = index + offset;
if (target < 0 || target >= steps.length) return steps;
const next = [...steps];
const [moved] = next.splice(index, 1);
next.splice(target, 0, moved);
return next;
}
return steps.map((step) =>
step.kind === 'condition'
? { ...step, yes: moveStep(step.yes, stepId, offset), no: moveStep(step.no, stepId, offset) }
: step,
);
}
/** Pocet vsech kroku vcetne vnorenych vetvi. */
export function countSteps(steps: FlowStep[]): number {
return steps.reduce(
(sum, step) =>
step.kind === 'condition'
? sum + 1 + countSteps(step.yes) + countSteps(step.no)
: sum + 1,
0,
);
}
interface ResolvedOperation {
connector: Connector;
operation: ConnectorOperation;
}
/**
* Najde konektor a operaci pro krok. Vraci null, pokud je odkaz rozbity
* (napr. konektor byl z katalogu odebran) - volajici to musi zobrazit.
*/
export function resolveOperation(
connectors: Connector[],
connectorId: string,
operationId: string,
type: 'trigger' | 'action',
): ResolvedOperation | null {
const connector = connectors.find((c) => c.id === connectorId);
if (!connector) {
console.warn(`[flow] neznamy konektor ${connectorId}`);
return null;
}
const pool = type === 'trigger' ? connector.triggers : connector.actions;
const operation = pool.find((op) => op.id === operationId);
if (!operation) {
console.warn(`[flow] konektor ${connectorId} nema ${type} ${operationId}`);
return null;
}
return { connector, operation };
}
+59
View File
@@ -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`;
}
+79
View File
@@ -0,0 +1,79 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useEventStream } from '@/components/dashboard/EventStreamProvider';
import { apiFetch } from '@/lib/api';
import type { DashboardEventType } from '@/types/events';
interface QueryState<T> {
data: T | null;
loading: boolean;
error: string | null;
reload: () => void;
}
interface QueryOptions {
/**
* Typy udalosti, po kterych se maji data znovu nacist.
* Diky tomu je dashboard zivy bez pravidelneho dotazovani serveru.
*/
refetchOn?: DashboardEventType[];
}
/**
* Minimalisticky data-fetching hook pro dashboard.
* Az bude dashboard vetsi, nahradit TanStack Query.
*/
export function useApiQuery<T>(path: string, options: QueryOptions = {}): QueryState<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tick, setTick] = useState(0);
const reload = useCallback(() => setTick((value) => value + 1), []);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
apiFetch<T>(path)
.then((result) => {
if (!cancelled) setData(result);
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : 'Data se nepodařilo načíst.';
console.error(`[query] ${path} selhalo:`, err);
if (!cancelled) setError(message);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [path, tick]);
// Obnoveni dat pri prichozi udalosti ze streamu.
const { subscribe } = useEventStream();
const refetchKey = (options.refetchOn ?? []).join(',');
const debounce = useRef<number | undefined>(undefined);
useEffect(() => {
if (refetchKey.length === 0) return;
const wanted = new Set(refetchKey.split(','));
const unsubscribe = subscribe((event) => {
if (!wanted.has(event.type)) return;
// Kdyz prijde vic udalosti tesne po sobe, nacitame data jen jednou.
if (debounce.current) window.clearTimeout(debounce.current);
debounce.current = window.setTimeout(reload, 150);
});
return () => {
unsubscribe();
if (debounce.current) window.clearTimeout(debounce.current);
};
}, [subscribe, refetchKey, reload]);
return { data, loading, error, reload };
}
+19
View File
@@ -0,0 +1,19 @@
import { useEffect } from 'react';
/**
* Nastavi <title> a meta description pro danou stranku.
* Zamerne bez knihovny (react-helmet) - pro prototyp staci.
*/
export function usePageMeta({ title, description }: { title: string; description?: string }) {
useEffect(() => {
document.title = title;
if (!description) return;
const tag = document.querySelector('meta[name="description"]');
if (!tag) {
console.warn('[meta] tag <meta name="description"> v index.html chybi');
return;
}
tag.setAttribute('content', description);
}, [title, description]);
}
+24
View File
@@ -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>,
);
+145
View File
@@ -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 skoro každý jen mu každý ří
jinak. Od 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 />
</>
);
}
+247
View File
@@ -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>
);
}
+28
View File
@@ -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 />
</>
);
}
+147
View File
@@ -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>
);
}
+23
View File
@@ -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>
);
}
+86
View File
@@ -0,0 +1,86 @@
import { Check } from 'lucide-react';
import { CallToAction } from '@/components/home/CallToAction';
import { PageHeader } from '@/components/ui/PageHeader';
import { Section } from '@/components/ui/Section';
import { products } from '@/data/products';
import { usePageMeta } from '@/lib/usePageMeta';
import { cn } from '@/lib/cn';
export default function Services() {
usePageMeta({
title: 'Služby — Automia',
description:
'Automatizace procesů, voiceboti, integrace systémů, dashboardy, tickety a incident management.',
});
return (
<>
<PageHeader
eyebrow="Služby"
title={
<>
Co pro vás <span className="text-gradient">umíme postavit</span>
</>
}
subtitle="Nabídku skládáme podle toho, co firmě reálně chybí. Nejčastěji ale řešíme těchto šest oblastí."
/>
<Section className="py-16! sm:py-20!">
<div className="space-y-6">
{products.map((product, index) => {
const Icon = product.icon;
const reversed = index % 2 === 1;
return (
<article
key={product.slug}
id={product.slug}
className="glass scroll-mt-28 rounded-card p-6 sm:p-8"
>
<div
className={cn(
'grid gap-8 lg:grid-cols-[1.4fr_1fr] lg:items-center',
reversed && 'lg:[&>*:first-child]:order-2',
)}
>
<div>
<div className="flex items-center gap-3">
<span className="grid size-11 place-items-center rounded-xl bg-gradient-to-br from-brand-500/20 to-accent-500/20 text-brand-300">
<Icon className="size-5" />
</span>
<div>
<h2 className="text-xl font-bold text-white">{product.name}</h2>
<p className="text-sm text-brand-300/80">{product.tagline}</p>
</div>
</div>
<p className="mt-5 leading-relaxed text-white/65">{product.description}</p>
<ul className="mt-5 grid gap-2 sm:grid-cols-2">
{product.features.map((feature) => (
<li key={feature} className="flex items-start gap-2 text-sm text-white/55">
<Check className="mt-0.5 size-4 shrink-0 text-ok-400" />
{feature}
</li>
))}
</ul>
</div>
<div className="rounded-2xl border border-ink-600/60 bg-ink-850/60 p-6 text-center">
<p className="text-4xl font-extrabold text-white">{product.metric.value}</p>
<p className="mt-2 text-sm text-white/45">{product.metric.label}</p>
<p className="mt-5 border-t border-ink-600/60 pt-4 font-mono text-xs text-white/30">
ukázková metrika
</p>
</div>
</div>
</article>
);
})}
</div>
</Section>
<CallToAction />
</>
);
}
@@ -0,0 +1,416 @@
import { AlertCircle, ArrowLeft, Check, Info, Pause, Play, Save, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { DataState } from '@/components/dashboard/DataState';
import { FlowCanvas } from '@/components/dashboard/flow/FlowCanvas';
import { StepPicker } from '@/components/dashboard/flow/StepPicker';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { apiFetch } from '@/lib/api';
import {
countSteps,
createActionStep,
createConditionStep,
insertStep,
moveStep,
removeStep,
updateCondition,
type FlowPath,
} from '@/lib/flow';
import { formatDateTime } from '@/lib/format';
import { useApiQuery } from '@/lib/useApiQuery';
import { usePageMeta } from '@/lib/usePageMeta';
import type {
AutomationDetail as Detail,
AutomationFlow,
ConnectorCatalog,
TriggerField,
} from '@/types/dashboard';
/** Kam se ma vlozit dalsi krok - null znamena, ze vyber neni otevreny. */
interface PickerTarget {
mode: 'trigger' | 'action';
path: FlowPath;
index: number;
}
export default function AutomationDetail() {
const { id = '' } = useParams();
const navigate = useNavigate();
const automation = useApiQuery<Detail>(`/api/dashboard/automations/${id}`);
const catalog = useApiQuery<ConnectorCatalog>('/api/dashboard/connectors');
const [name, setName] = useState('');
const [flow, setFlow] = useState<AutomationFlow>({ trigger: null, steps: [] });
const [enabled, setEnabled] = useState(false);
const [picker, setPicker] = useState<PickerTarget | null>(null);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [savedAt, setSavedAt] = useState<string | null>(null);
const [dirty, setDirty] = useState(false);
const [regenerating, setRegenerating] = useState(false);
/** Co podle serveru chybi k zapnuti. Prepocitava ho server pri kazdem ulozeni. */
const [issues, setIssues] = useState<string[]>([]);
usePageMeta({ title: `${name || 'Automatizace'} — portál Automia` });
// Prevzeti dat ze serveru do editovatelneho stavu.
useEffect(() => {
if (!automation.data) return;
setName(automation.data.name);
setFlow(automation.data.flow);
setEnabled(automation.data.enabled);
setIssues(automation.data.issues);
setDirty(false);
}, [automation.data]);
const connectors = catalog.data?.items ?? [];
const categories = catalog.data?.categories ?? [];
const stepCount = useMemo(() => countSteps(flow.steps), [flow.steps]);
/** Kazda zmena stromu jde pres tohle, aby se drzel priznak "neulozeno". */
const changeFlow = useCallback((next: AutomationFlow) => {
setFlow(next);
setDirty(true);
setSavedAt(null);
}, []);
function handlePick(connectorId: string, operationId: string) {
if (!picker) {
console.warn('[builder] vyber potvrzen bez otevreneho cile');
return;
}
if (picker.mode === 'trigger') {
// Pri zmene spoustece drzime uz nadeklarovane parametry, aby se nezahodila prace.
changeFlow({
...flow,
trigger: {
connectorId,
operationId,
fields: flow.trigger?.fields ?? [],
webhookToken: flow.trigger?.webhookToken,
},
});
} else {
changeFlow({
...flow,
steps: insertStep(
flow.steps,
picker.path,
picker.index,
createActionStep(connectorId, operationId),
),
});
}
setPicker(null);
}
function handlePickCondition() {
if (!picker || picker.mode !== 'action') {
console.warn('[builder] podminku lze vlozit jen jako krok');
return;
}
const firstField = flow.trigger?.fields[0];
if (!firstField) {
console.warn('[builder] podminku nelze pridat - spoustec nema zadne parametry');
return;
}
changeFlow({
...flow,
steps: insertStep(flow.steps, picker.path, picker.index, createConditionStep(firstField)),
});
setPicker(null);
}
function handleChangeFields(fields: TriggerField[]) {
if (!flow.trigger) {
console.warn('[builder] zmena parametru bez spoustece');
return;
}
changeFlow({ ...flow, trigger: { ...flow.trigger, fields } });
}
async function handleRegenerateToken() {
setRegenerating(true);
setSaveError(null);
try {
// Token mení server; napřed uložíme rozdělanou práci, ať se nepřepíše.
if (dirty) {
await apiFetch<Detail>(`/api/dashboard/automations/${id}`, {
method: 'PUT',
body: { name, enabled, flow },
});
}
const updated = await apiFetch<Detail>(
`/api/dashboard/automations/${id}/webhook/regenerate`,
{ method: 'POST' },
);
setFlow(updated.flow);
setIssues(updated.issues);
setDirty(false);
setSavedAt(updated.updatedAt);
} catch (err) {
const message = err instanceof Error ? err.message : 'Novou adresu se nepodařilo vytvořit.';
console.error('[builder] regenerace tokenu selhala:', err);
setSaveError(message);
} finally {
setRegenerating(false);
}
}
async function save(nextEnabled = enabled) {
setSaving(true);
setSaveError(null);
try {
const updated = await apiFetch<Detail>(`/api/dashboard/automations/${id}`, {
method: 'PUT',
body: { name, enabled: nextEnabled, flow },
});
setEnabled(updated.enabled);
setFlow(updated.flow);
setIssues(updated.issues);
setDirty(false);
setSavedAt(updated.updatedAt);
// Server muze zapnuti odmitnout - rekneme presne proc, ne obecnou hlasku.
if (nextEnabled && !updated.enabled) {
setSaveError(
updated.issues.length > 0
? `Automatizaci nelze zapnout: ${updated.issues.join(' ')}`
: 'Automatizaci se nepodařilo zapnout.',
);
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Uložení se nepodařilo.';
console.error('[builder] ulozeni selhalo:', err);
setSaveError(message);
} finally {
setSaving(false);
}
}
async function handleDelete() {
if (!window.confirm(`Smazat automatizaci „${name}"? Tuto akci nelze vzít zpět.`)) return;
try {
await apiFetch<void>(`/api/dashboard/automations/${id}`, { method: 'DELETE' });
navigate('/dashboard/automatizace', { replace: true });
} catch (err) {
const message = err instanceof Error ? err.message : 'Smazání se nepodařilo.';
console.error('[builder] smazani selhalo:', err);
setSaveError(message);
}
}
return (
<div className="space-y-6">
<Link
to="/dashboard/automatizace"
className="inline-flex items-center gap-2 text-sm text-white/45 transition-colors hover:text-white"
>
<ArrowLeft className="size-4" />
Zpět na automatizace
</Link>
<DataState
loading={automation.loading || catalog.loading}
error={automation.error ?? catalog.error}
onRetry={() => {
automation.reload();
catalog.reload();
}}
>
<div className="space-y-6">
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<input
value={name}
onChange={(event) => {
setName(event.target.value);
setDirty(true);
setSavedAt(null);
}}
aria-label="Název automatizace"
className="w-full rounded-lg border border-transparent bg-transparent px-2 py-1 -mx-2 text-2xl font-bold text-white transition-colors hover:border-ink-600/70 focus:border-brand-400/70 focus:bg-ink-850/60 focus:outline-none"
/>
<div className="mt-2 flex flex-wrap items-center gap-2 px-0.5">
<span className="font-mono text-xs text-white/35">{id}</span>
{flow.trigger ? (
<Badge tone={enabled ? 'ok' : 'neutral'}>
<span
className={
enabled ? 'size-1.5 rounded-full bg-ok-400' : 'size-1.5 rounded-full bg-white/40'
}
/>
{enabled ? 'Aktivní' : 'Pozastaveno'}
</Badge>
) : (
<Badge tone="warn">Koncept chybí spouštěč</Badge>
)}
<span className="text-xs text-white/40">
{stepCount} {stepCount === 1 ? 'krok' : stepCount >= 2 && stepCount <= 4 ? 'kroky' : 'kroků'}
</span>
{dirty && <Badge tone="warn">Neuložené změny</Badge>}
{!dirty && savedAt && (
<span className="inline-flex items-center gap-1.5 text-xs text-ok-400">
<Check className="size-3.5" />
Uloženo {formatDateTime(savedAt)}
</span>
)}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => void save(!enabled)}
disabled={saving || !flow.trigger}
title={!flow.trigger ? 'Nejdřív vyberte spouštěč' : undefined}
>
{enabled ? <Pause className="size-4" /> : <Play className="size-4" />}
{enabled ? 'Pozastavit' : 'Zapnout'}
</Button>
<Button size="sm" onClick={() => void save()} disabled={saving || !dirty}>
<Save className="size-4" />
{saving ? 'Ukládám…' : 'Uložit'}
</Button>
<button
type="button"
onClick={() => void handleDelete()}
title="Smazat automatizaci"
className="grid size-9 place-items-center rounded-lg text-white/35 transition-colors hover:bg-danger-500/12 hover:text-danger-400"
>
<Trash2 className="size-4" />
<span className="sr-only">Smazat automatizaci</span>
</button>
</div>
</header>
{saveError && (
<p className="flex items-center gap-2 rounded-xl border border-danger-400/30 bg-danger-500/10 px-4 py-3 text-sm text-danger-400">
<AlertCircle className="size-4 shrink-0" />
{saveError}
</p>
)}
{/* Rozdelana prace se ulozi vzdy, jen se nesmi pustit do provozu. */}
{issues.length > 0 && (
<div className="rounded-xl border border-warn-400/30 bg-warn-500/8 px-4 py-3">
<p className="flex items-center gap-2 text-sm font-semibold text-warn-400">
<AlertCircle className="size-4 shrink-0" />
Než půjde zapnout, chybí doplnit
</p>
<ul className="mt-2 space-y-1 pl-6 text-sm text-white/60">
{issues.map((issue) => (
<li key={issue} className="list-disc">
{issue}
</li>
))}
</ul>
</div>
)}
<div className="grid gap-6 xl:grid-cols-[1fr_18rem]">
<section className="glass rounded-card p-5 sm:p-6">
<div className="mb-6 flex items-center justify-between gap-3">
<h2 className="font-semibold text-white">Strom akcí</h2>
<p className="text-xs text-white/35">
Klikněte na <span className="font-mono">+</span> a vyberte, co se 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>
);
}
+239
View File
@@ -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>
);
}
+270
View File
@@ -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 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>
);
}
+72
View File
@@ -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>
);
}
+180
View File
@@ -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>
);
}
+46
View File
@@ -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>
);
}
+75
View File
@@ -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>
);
}
+183
View File
@@ -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[];
}
+25
View File
@@ -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>;
}
+13
View File
@@ -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 {};
+25
View File
@@ -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"]
}