1 Commits

Author SHA1 Message Date
batmanisko 2e8db88f07 feat: úhrada za všechny jednou osobou (issue #29, SINGLE_PAYMENT)
Přidává možnost, aby jeden strávník zaplatil celý účet v restauraci a ostatní
obdrželi QR kód pro refundaci.

Prerekvizita — podpora více QR kódů na (příjemce, den):
- PendingQr.id (UUID) nahrazuje deduplikaci podle data; každý QR má vlastní klíč
- QR obrázky uloženy do Redis/storage (base64) místo tmpdir — přežijí redeploy
- GET /api/qr vyžaduje ?id= parametr; dismissQr přijímá {id} místo {date}

Feature:
- Ikona 'Zaplatit za všechny' v choices-table pro každou LunchChoice (kromě
  PIZZA/NEOBEDVAM/ROZHODUJI); viditelná jen při ≥2 strávnících a vyplněném účtu
- PayForAllModal: tabulka strávníků s prefillovanými cenami z menu, příplatky
  per-diner, celkové dýško rozpočtené rovnoměrně, generování QR přes POST /api/qr/generate
- parsePriceCzk() helper pro parsing 'N Kč' → number

Co se nemění: POST /api/qr/generate API kontrakt, PizzaOrder.hasQr boolean

Co se mění v OpenAPI: PendingQr.id (required), getPizzaQr ?id param, dismissQr body

Co-Authored-By: opmrdkazkrtkaus <opmrdkazkrtkaus@melancholik.eu>
2026-04-28 22:35:15 +02:00
192 changed files with 1685 additions and 10601 deletions
-261
View File
@@ -1,261 +0,0 @@
name: CI
on:
push:
branches:
- "**"
pull_request:
concurrency:
group: ${{ github.ref }}
cancel-in-progress: true
jobs:
# ─── 1. Generate OpenAPI types ────────────────────────────────────────────
generate-types:
name: Generate TypeScript types
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
- run: corepack enable
- run: cd types && yarn install --frozen-lockfile && yarn openapi-ts
- uses: actions/upload-artifact@v3
with:
name: types-gen
path: types/gen
# ─── 2a. Server unit tests ────────────────────────────────────────────────
server-test:
name: Server unit tests
runs-on: ubuntu-latest
needs: generate-types
env:
NODE_ENV: test
JWT_SECRET: test-secret-min-32-chars-aaaaaaa!
MOCK_DATA: "true"
STORAGE: json
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
- run: corepack enable
- uses: actions/download-artifact@v3
with:
name: types-gen
path: types/gen
- run: cd server && yarn install --frozen-lockfile && yarn test
# ─── 2b. Build server ─────────────────────────────────────────────────────
server-build:
name: Build server
runs-on: ubuntu-latest
needs: generate-types
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
- run: corepack enable
- uses: actions/download-artifact@v3
with:
name: types-gen
path: types/gen
- run: cd types && yarn install --frozen-lockfile
- run: cd server && yarn install --frozen-lockfile && yarn build
- uses: actions/upload-artifact@v3
with:
name: server-dist
path: server/dist
# ─── 2c. Build client ─────────────────────────────────────────────────────
client-build:
name: Build client
runs-on: ubuntu-latest
needs: generate-types
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
- run: corepack enable
- uses: actions/download-artifact@v3
with:
name: types-gen
path: types/gen
- run: cd types && yarn install --frozen-lockfile
- run: cd client && yarn install --frozen-lockfile && yarn build
- uses: actions/upload-artifact@v3
with:
name: client-dist
path: client/dist
# ─── 3. Playwright E2E tests ──────────────────────────────────────────────
e2e:
name: Playwright E2E tests
runs-on: ubuntu-latest
needs: [ server-build, client-build ]
container: mcr.microsoft.com/playwright:v1.59.1-jammy
services:
redis:
image: redis/redis-stack-server:7.4.0-v1
env:
REDIS_ARGS: "--save '' --loglevel warning"
env:
CI: "true"
NODE_ENV: test
JWT_SECRET: test-secret-min-32-chars-aaaaaaa!
MOCK_DATA: "true"
STORAGE: redis
REDIS_HOST: redis
REDIS_PORT: "6379"
HTTP_REMOTE_USER_ENABLED: "true"
HTTP_REMOTE_USER_HEADER_NAME: remote-user
HTTP_REMOTE_TRUSTED_IPS: "127.0.0.1,::1,::ffff:127.0.0.1"
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v3
with:
name: server-dist
path: server/dist
- uses: actions/download-artifact@v3
with:
name: client-dist
path: client/dist
- name: Install server dependencies
run: cd server && yarn install --frozen-lockfile
- name: Copy client build into server/public
run: cp -r client/dist server/public
- name: Install e2e dependencies and browsers
run: cd e2e && yarn install --frozen-lockfile && yarn playwright install firefox
--with-deps
- name: Run Playwright tests
run: cd e2e && yarn test
- uses: actions/upload-artifact@v3
if: failure()
with:
name: playwright-report
path: |
e2e/playwright-report
e2e/test-results
# ─── 4. Build and push Docker image (master only) ─────────────────────────
docker-build:
name: Build and push Docker image
runs-on: ubuntu-latest
needs: [ server-build, client-build, server-test, e2e ]
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
- run: corepack enable
- uses: actions/download-artifact@v3
with:
name: server-dist
path: server/dist
- uses: actions/download-artifact@v3
with:
name: client-dist
path: client/dist
- name: Install server production dependencies
run: cd server && yarn install --frozen-lockfile --production
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ${{ secrets.REPO_URL }}
username: ${{ secrets.REPO_USERNAME }}
password: ${{ secrets.REPO_PASSWORD }}
- uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile
target: runner-prebuilt
platforms: linux/amd64
push: true
tags: ${{ secrets.REPO_URL }}/${{ secrets.REPO_NAME }}:latest
# ─── 5. Notifications ────────────────────────
notify:
name: Notify
runs-on: ubuntu-latest
needs: [ server-build, client-build, server-test, e2e, docker-build ]
if: always() && github.event_name == 'push'
steps:
- name: Send webhook
env:
DISCORD_WEBHOOK_ID: ${{ secrets.DISCORD_WEBHOOK_ID }}
DISCORD_WEBHOOK_TOKEN: ${{ secrets.DISCORD_WEBHOOK_TOKEN }}
NTFY_URL: ${{ secrets.NTFY_URL }}
BUILD_RESULT: ${{ needs.docker-build.result }}
RUN_NUMBER: ${{ github.run_number }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{
github.run_id }}
COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
COMMIT_AUTHOR: ${{ github.event.head_commit.author.name }}
run: |
if [ "$BUILD_RESULT" = "success" ]; then
MSG="✅ Sestavení #${RUN_NUMBER} proběhlo úspěšně."
NTFY_TAGS="white_check_mark"
else
MSG="❌ Sestavení #${RUN_NUMBER} selhalo."
NTFY_TAGS="x"
fi
FULL_MSG="$(printf '%s\n\nPipeline: %s\nPoslední commit: %sAutor: %s' \
"$MSG" "$RUN_URL" "$COMMIT_MESSAGE" "$COMMIT_AUTHOR")"
curl -s -X POST \
"https://discord.com/api/webhooks/${DISCORD_WEBHOOK_ID}/${DISCORD_WEBHOOK_TOKEN}" \
-H "Content-Type: application/json" \
--data "$(jq -n --arg content "$FULL_MSG" '{content: $content}')"
curl -s -X POST "${NTFY_URL}" \
-H "Title: Luncher CI #${RUN_NUMBER}" \
-H "Tags: ${NTFY_TAGS}" \
-H "Click: ${RUN_URL}" \
-d "${FULL_MSG}"
-6
View File
@@ -1,9 +1,3 @@
node_modules
types/gen
**.DS_Store
.mcp.json
.claude/settings.local.json
server/public/
.claude/*.lock
.claude/worktrees
.playwright-mcp
-32
View File
@@ -1,32 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Server (ts-node, debug)",
"type": "node",
"request": "launch",
"cwd": "${workspaceFolder}/server",
"runtimeArgs": ["-r", "ts-node/register"],
"program": "${workspaceFolder}/server/src/index.ts",
"env": { "NODE_ENV": "development" },
"console": "integratedTerminal",
"skipFiles": ["<node_internals>/**"],
"preLaunchTask": "types: openapi-ts"
},
{
"name": "Client (vite + Edge)",
"type": "msedge",
"request": "launch",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}/client",
"preLaunchTask": "client: vite"
}
],
"compounds": [
{
"name": "Dev: server + client",
"configurations": ["Server (ts-node, debug)", "Client (vite + Edge)"],
"stopAll": true
}
]
}
-67
View File
@@ -1,67 +0,0 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "types: openapi-ts",
"type": "shell",
"command": "yarn openapi-ts",
"options": {
"cwd": "${workspaceFolder}/types"
},
"presentation": {
"reveal": "silent",
"panel": "dedicated"
},
"problemMatcher": []
},
{
"label": "server: startReload",
"type": "shell",
"command": "yarn startReload",
"options": {
"cwd": "${workspaceFolder}/server",
"env": {
"NODE_ENV": "development"
}
},
"isBackground": true,
"presentation": {
"reveal": "always",
"panel": "dedicated",
"group": "dev"
},
"problemMatcher": []
},
{
"label": "client: vite",
"type": "shell",
"command": "yarn start",
"options": {
"cwd": "${workspaceFolder}/client"
},
"isBackground": true,
"presentation": {
"reveal": "always",
"panel": "dedicated",
"group": "dev"
},
"problemMatcher": []
},
{
"label": "dev: server+client",
"dependsOn": [
"server: startReload",
"client: vite"
]
},
{
"label": "dev: all",
"dependsOrder": "sequence",
"dependsOn": [
"types: openapi-ts",
"dev: server+client"
],
"problemMatcher": []
}
]
}
+64
View File
@@ -0,0 +1,64 @@
variables:
- &node_image "node:22-alpine"
- &branch "master"
when:
- event: push
branch: *branch
steps:
- name: Generate TypeScript types
image: *node_image
commands:
- cd types
- yarn install --frozen-lockfile
- yarn openapi-ts
- name: Install server dependencies
image: *node_image
commands:
- cd server
- yarn install --frozen-lockfile
depends_on: [Generate TypeScript types]
- name: Install client dependencies
image: *node_image
commands:
- cd client
- yarn install --frozen-lockfile
depends_on: [Generate TypeScript types]
- name: Build server
depends_on: [Install server dependencies]
image: *node_image
commands:
- cd server
- yarn build
- name: Build client
depends_on: [Install client dependencies]
image: *node_image
commands:
- cd client
- yarn build
- name: Build Docker image
depends_on: [Build server, Build client]
image: woodpeckerci/plugin-docker-buildx
settings:
dockerfile: Dockerfile-Woodpecker
platforms: linux/amd64
registry:
from_secret: REPO_URL
username:
from_secret: REPO_USERNAME
password:
from_secret: REPO_PASSWORD
repo:
from_secret: REPO_NAME
- name: Discord notification - build
image: appleboy/drone-discord
depends_on: [Build Docker image]
when:
- status: [success, failure]
settings:
webhook_id:
from_secret: DISCORD_WEBHOOK_ID
webhook_token:
from_secret: DISCORD_WEBHOOK_TOKEN
message: "{{#success build.status}}✅ Sestavení {{build.number}} proběhlo úspěšně.{{else}}❌ Sestavení {{build.number}} selhalo.{{/success}}\n\nPipeline: {{build.link}}\nPoslední commit: {{commit.message}}Autor: {{commit.author}}"
+23 -40
View File
@@ -12,12 +12,9 @@ Luncher is a lunch management app for teams — daily restaurant menus, food ord
types/ → Shared OpenAPI-generated TypeScript types (source of truth: types/api.yml)
server/ → Express 5 backend (Node.js 22, ts-node)
client/ → React 19 frontend (Vite 7, React Bootstrap)
e2e/ → Playwright E2E tests (separate package)
```
Each of the four directories has its own `package.json`. Package manager: **Yarn Classic**.
Deployment files at repo root: `Dockerfile` (multi-stage, dva runner targety: výchozí `runner` pro lokální build, `runner-prebuilt` pro CI s předem sestavenými artefakty), `compose.yml`, `compose-traefik.yml`.
Each directory has its own `package.json` and `tsconfig.json`. Package manager: **Yarn Classic**.
## Development Commands
@@ -26,7 +23,6 @@ Deployment files at repo root: `Dockerfile` (multi-stage, dva runner targety: v
cd types && yarn install && yarn openapi-ts # Generate API types first
cd ../server && yarn install
cd ../client && yarn install
cd ../e2e && yarn install
```
### Running dev environment
@@ -48,62 +44,38 @@ cd client && yarn build # tsc --noEmit + vite build → client/dist
### Tests
```bash
# Server unit tests (Jest)
cd server && yarn test # All tests in server/src/tests/
cd server && yarn test dates # Run one file by name
cd server && yarn test -t "name" # Run by test name pattern
# E2E (Playwright) — requires prebuilt server
cd server && yarn build
cd e2e && yarn test # chromium + firefox, baseURL 127.0.0.1:3001
cd e2e && yarn test:ui # interactive UI mode
cd e2e && yarn report # open last HTML report
cd server && yarn test # Jest (tests in server/src/tests/)
```
Jest setup (`server/src/tests/setupEnv.ts`) forces `STORAGE=memory`, deletes `MOCK_DATA`, and sets a fixed `JWT_SECRET`. Playwright auto-starts the prebuilt server and authenticates via the `remote-user: e2e-user` trusted-header path; locally uses `STORAGE=json` + `MOCK_DATA=true`, CI uses `STORAGE=redis`.
### CI pipeline
Gitea Actions — `.gitea/workflows/ci.yaml` (no `.github/` equivalent):
1. `generate-types` — runs `yarn openapi-ts`, uploads artifact
2. `server-test` — Jest
3. `server-build` + `client-build` — parallel tsc/vite builds
4. `e2e` — Playwright in `mcr.microsoft.com/playwright:v1.59.1-jammy` with a Redis service container; only Firefox installed in CI
5. `docker-build` — master branch only, uses `Dockerfile` with `--target runner-prebuilt` (skládá image z artefaktů `server-build` + `client-build`)
6. `notify` — Discord + ntfy webhooks
### Formatting
Prettier is installed in `client/` (devDependency only, no script or config) — invoke via `yarn prettier <path>` with defaults.
```bash
# Prettier available in client (no config file — uses defaults)
```
## Architecture
### API Types (types/)
- OpenAPI 3.0 spec in `types/api.yml` — all API endpoints and DTOs defined here
- `api.yml` is a thin aggregator — actual endpoint specs live in `types/paths/<domain>/*.yml`, shared schemas in `types/schemas/_index.yml`
- `yarn openapi-ts` generates `types/gen/` (client.gen.ts, sdk.gen.ts, types.gen.ts)
- Both server and client import from these generated types
- **When changing API contracts: update api.yml first, then regenerate**
### Server (server/src/)
- **Entry:** `index.ts` — Express app + Socket.io setup
- **Routes:** `routes/` 9 modular Express route handlers (food, pizzaDay, voting, notifications, qr, stats, easterEgg, dev, changelog)
- **Routes:** `routes/` — modular Express route handlers (food, pizzaDay, voting, notifications, qr, stats, easterEgg, dev)
- **Services:** domain logic files at root level (`service.ts`, `pizza.ts`, `restaurants.ts`, `chefie.ts`, `voting.ts`, `stats.ts`, `qr.ts`, `notifikace.ts`)
- **Helpers:** `mock.ts` (fake menu data for `MOCK_DATA=true`), `pushReminder.ts` (push notification reminders), `utils.ts` (shared utilities)
- **Auth:** `auth.ts` — JWT + optional trusted-header authentication
- **Storage:** `storage/index.ts` factory selects implementation; backends: `json.ts` (file-based, dev), `redis.ts` (production), `memory.ts` (tests). Data keyed by date (YYYY-MM-DD).
- **Storage:** `storage/StorageInterface.ts` defines the interface; implementations in `storage/json.ts` (file-based, dev) and `storage/redis.ts` (production). Data keyed by date (YYYY-MM-DD).
- **Restaurant scrapers:** Cheerio-based HTML parsing for daily menus from multiple restaurants
- **Pizza Chefie integration:** `chefie.ts` scrapes pizzachefie.cz for Pizza day menus and salads (only active when a Pizza day is open)
- **WebSocket:** `websocket.ts` — Socket.io for real-time client updates
- **Mock mode:** `MOCK_DATA=true` env var returns fake menus (useful for weekend/holiday dev)
- **Config:** `.env.development` / `.env.production` (see `.env.template` for all options)
### Client (client/src/)
- **Entry:** `index.tsx``App.tsx``AppRoutes.tsx`; `Login.tsx` is the auth screen; `FallingLeaves.tsx` is a seasonal visual effect
- **Entry:** `index.tsx``App.tsx``AppRoutes.tsx`
- **Pages:** `pages/` (StatsPage)
- **Components:** `components/` (Header, Footer, Loader, PizzaOrderList, PizzaOrderRow, and modals in `components/modals/`)
- **Context providers:** `context/``auth.tsx`, `settings.tsx`, `socket.js`, `eggs.tsx` (note: `socket.js` is the only non-TSX context file)
- **Hooks:** `hooks/` (`usePushReminder.ts`)
- **Utils:** `utils/` (`parsePrice.ts`)
- **Components:** `components/` (Header, Footer, Loader, modals/, PizzaOrderList, PizzaOrderRow)
- **Context providers:** `context/`AuthContext, SettingsContext, SocketContext, EasterEggContext
- **Styling:** Bootstrap 5 + React Bootstrap + custom SCSS files (co-located with components)
- **API calls:** use OpenAPI-generated SDK from `types/gen/`
- **Routing:** React Router DOM v7
@@ -117,7 +89,7 @@ Prettier is installed in `client/` (devDependency only, no script or config) —
## Environment
- **Server env files:** `server/.env.development`, `server/.env.production` (see `server/.env.template`)
- Key vars: `JWT_SECRET`, `STORAGE` (json/redis/memory), `MOCK_DATA`, `REDIS_HOST`, `REDIS_PORT`
- Key vars: `JWT_SECRET`, `STORAGE` (json/redis), `MOCK_DATA`, `REDIS_HOST`, `REDIS_PORT`
- **Docker:** multi-stage Dockerfile, `compose.yml` for app + Redis. Timezone: Europe/Prague.
## Conventions
@@ -125,4 +97,15 @@ Prettier is installed in `client/` (devDependency only, no script or config) —
- Czech naming for domain variables and UI strings; English for infrastructure code
- TypeScript strict mode in both client and server
- Server module resolution: Node16; Client: ESNext/bundler
- `TODO.md` tracks open bugs and roadmap items — worth scanning before starting non-trivial work
## Code Search Strategy
When searching through the project for information, use the Task tool to spawn
subagents. Each subagent should read the relevant files and return a brief
summary of what it found (not the full file contents). This keeps the main
context window small and saves tokens. Only pull in full file contents once
you've identified the specific files that matter.
When using subagents to search, each subagent should return:
- File path
- Whether it's relevant (yes/no)
- 1-3 sentence summary of what's in the file
Do NOT return full file contents in subagent responses.
+8 -36
View File
@@ -1,6 +1,6 @@
ARG NODE_VERSION="node:22-alpine"
# ─── Builder ──────────────────────────────────────────────────────────────────
# Builder
FROM ${NODE_VERSION} AS builder
WORKDIR /build
@@ -62,9 +62,8 @@ RUN yarn build
WORKDIR /build/client
RUN yarn build
# ─── Runner base ──────────────────────────────────────────────────────────────
# Společný základ pro oba runner targety nastaví prostředí a metadata běhu.
FROM ${NODE_VERSION} AS runner-base
# Runner
FROM ${NODE_VERSION}
RUN apk add --no-cache tzdata
ENV TZ=Europe/Prague \
@@ -73,17 +72,6 @@ ENV TZ=Europe/Prague \
WORKDIR /app
# Export /data/db.json do složky /data
VOLUME ["/data"]
EXPOSE 3001
CMD [ "node", "./server/src/index.js" ]
# ─── Runner (default) ─────────────────────────────────────────────────────────
# Použití: docker build . (lokální sestavení vše se buildí uvnitř image)
FROM runner-base AS runner
# Vykopírování sestaveného serveru
COPY --from=builder /build/server/node_modules ./server/node_modules
COPY --from=builder /build/server/dist ./
@@ -94,28 +82,12 @@ COPY --from=builder /build/client/dist ./public
# Zkopírování produkčních .env serveru
COPY /server/.env.production ./server
# Zkopírování changelogů (seznamu novinek)
COPY /server/changelogs ./server/changelogs
# Zkopírování konfigurace easter eggů
RUN if [ -f ./server/.easter-eggs.json ]; then cp ./server/.easter-eggs.json ./server/; fi
RUN if [ -f /server/.easter-eggs.json ]; then cp /server/.easter-eggs.json ./server/; fi
# ─── Runner (prebuilt) ────────────────────────────────────────────────────────
# Použití: docker build --target runner-prebuilt .
# Očekává předem sestavené artefakty v build kontextu (server/dist,
# client/dist, server/node_modules) využívá Gitea Actions, kde se
# server i klient buildí v separátních jobech a sem se jen kopírují.
FROM runner-base AS runner-prebuilt
# Export /data/db.json do složky /data
VOLUME ["/data"]
# Vykopírování sestaveného serveru
COPY ./server/node_modules ./server/node_modules
COPY ./server/dist ./
EXPOSE 3000
# Vykopírování sestaveného klienta
COPY ./client/dist ./public
# Zkopírování changelogů (seznamu novinek)
COPY ./server/changelogs ./server/changelogs
# Zkopírování konfigurace easter eggů
RUN if [ -f ./server/.easter-eggs.json ]; then cp ./server/.easter-eggs.json ./server/; fi
CMD [ "node", "./server/src/index.js" ]
+26
View File
@@ -0,0 +1,26 @@
ARG NODE_VERSION="node:22-alpine"
FROM ${NODE_VERSION}
RUN apk add --no-cache tzdata
ENV TZ=Europe/Prague \
LC_ALL=cs_CZ.UTF-8 \
NODE_ENV=production
WORKDIR /app
# Vykopírování sestaveného serveru
COPY ./server/node_modules ./server/node_modules
COPY ./server/dist ./
# TODO tohle není dobře, má to být součástí serveru
# COPY ./server/resources ./resources
# Vykopírování sestaveného klienta
COPY ./client/dist ./public
# Zkopírování konfigurace easter eggů
RUN if [ -f ./server/.easter-eggs.json ]; then cp ./server/.easter-eggs.json ./server/; fi
EXPOSE 3000
CMD [ "node", "./server/src/index.js" ]
-5
View File
@@ -1,9 +1,4 @@
# TODO
## HA / multi-replica follow-ups
- [ ] `foodRoutes.ts` per-pod rate limiter (`rateLimits` map) — s více replikami může uživatel překročit limit ~N× rychleji; přesunout do Redis (např. `INCR` + `EXPIRE`)
- [ ] `easterEggRoutes.ts` — náhodně generované URL easter eggů jsou per-pod; URL funguje pouze na podu, který ji vygeneroval; zvážit deterministické seedy nebo sdílení přes Redis
- [ ] `service.ts` — komplexní víceúrovňové funkce (`addChoice`, `removeChoiceIfPresent`) provádějí více po sobě jdoucích zápisů do stejného Redis klíče; pro plnou atomicitu je potřeba per-klíčový distribuovaný zámek (Redlock nebo `SET NX EX`) nebo sloučení logiky do jednoho `updateData` volání
- [ ] HTTP_REMOTE_TRUSTED_IPS se nikde nevalidují, hlavičky jsou přijímány odkudkoli
- [ ] V případě zapnutí přihlašování přes trusted headers nefunguje standardní přihlášení (nevrátí žádnou odpověď)
- [ ] Nemělo by se jít dostat na přihlašovací formulář (měla by tam být nanejvýš hláška nebo přesměrování)
-1
View File
@@ -18,7 +18,6 @@
"bootstrap": "^5.3.8",
"react": "^19.2.0",
"react-bootstrap": "^2.10.10",
"react-datepicker": "^9.1.0",
"react-dom": "^19.2.0",
"react-jwt": "^1.3.0",
"react-modal": "^3.16.3",
+7 -6
View File
@@ -7,7 +7,6 @@ self.addEventListener('push', (event) => {
body: data.body,
icon: '/favicon.ico',
tag: 'lunch-reminder',
data: { login: data.login, token: data.token },
actions: [
{ action: 'neobedvam', title: 'Mám vlastní/neobědvám' },
],
@@ -19,26 +18,28 @@ self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'neobedvam') {
const { login, token } = event.notification.data ?? {};
if (login && token) {
event.waitUntil(
fetch('/api/notifications/push/quickChoice', {
self.registration.pushManager.getSubscription().then((subscription) => {
if (!subscription) return;
return fetch('/api/notifications/push/quickChoice', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ login, token }),
body: JSON.stringify({ endpoint: subscription.endpoint }),
});
})
);
}
return;
}
event.waitUntil(
self.clients.matchAll({ type: 'window' }).then((clientList) => {
// Pokud je již otevřené okno, zaostříme na něj
for (const client of clientList) {
if (client.url.includes(self.location.origin) && 'focus' in client) {
return client.focus();
}
}
// Jinak otevřeme nové
return self.clients.openWindow('/');
})
);
-100
View File
@@ -226,7 +226,6 @@ body {
&:hover svg {
transform: rotate(15deg);
}
}
// ============================================
@@ -279,105 +278,6 @@ body {
}
}
// Varianta navigace mezi dny na stránce objednávání šipky kolem date pickeru
.order-day-navigator {
margin-bottom: 16px;
gap: 16px;
// react-datepicker obaluje input do wrapperu necháme ho zabrat jen potřebnou šířku
.react-datepicker-wrapper {
width: auto;
}
.order-date-input {
width: 160px;
cursor: pointer;
}
}
// Zvýraznění dnů, ve kterých existuje alespoň jedna objednávka tečka pod číslem dne
.react-datepicker__day.luncher-order-day {
position: relative;
font-weight: 700;
&::after {
content: "";
position: absolute;
left: 50%;
bottom: 2px;
transform: translateX(-50%);
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--luncher-primary, #0d6efd);
}
// U vybraného dne (tmavé pozadí) je tečka světlá, aby byla vidět
&.react-datepicker__day--selected::after,
&.react-datepicker__day--keyboard-selected::after {
background: #fff;
}
}
// Vybraný den používá akcentovou barvu aplikace (v obou režimech), místo výchozí modré
.react-datepicker__day--selected,
.react-datepicker__day--keyboard-selected {
background-color: var(--luncher-primary);
color: #fff;
&:hover {
background-color: var(--luncher-primary-hover);
}
}
// Tmavý režim kalendáře (react-datepicker) navázáno na CSS proměnné motivu
[data-bs-theme="dark"] {
.react-datepicker {
background-color: var(--luncher-bg-card);
border-color: var(--luncher-border);
color: var(--luncher-text);
}
.react-datepicker__header {
background-color: var(--luncher-bg-hover);
border-bottom-color: var(--luncher-border);
}
.react-datepicker__current-month,
.react-datepicker__day-name,
.react-datepicker__day,
.react-datepicker-year-header {
color: var(--luncher-text);
}
.react-datepicker__day:hover,
.react-datepicker__month-text:hover {
background-color: var(--luncher-bg-hover);
}
.react-datepicker__day--today {
color: var(--luncher-primary);
}
.react-datepicker__day--disabled,
.react-datepicker__day--outside-month {
color: var(--luncher-text-muted);
}
// Šipky pro přepínání měsíců
.react-datepicker__navigation-icon::before {
border-color: var(--luncher-text-secondary);
}
// Špička popoveru (SVG) míří do hlavičky sladíme barvy.
// !important kvůli vyšší specificitě knihovního pravidla [data-placement].
.react-datepicker__triangle {
fill: var(--luncher-bg-hover) !important;
color: var(--luncher-bg-hover) !important;
stroke: var(--luncher-border) !important;
}
}
// ============================================
// FOOD TABLES - CARD STYLE
// ============================================
+71 -126
View File
@@ -1,6 +1,6 @@
import React, { useContext, useEffect, useMemo, useRef, useState, useCallback } from 'react';
import 'bootstrap/dist/css/bootstrap.min.css';
import { EVENT_DISCONNECT, EVENT_MESSAGE, EVENT_PENDING_QR, SocketContext } from './context/socket';
import { EVENT_DISCONNECT, EVENT_MESSAGE, SocketContext } from './context/socket';
import { useAuth } from './context/auth';
import Login from './Login';
import { Alert, Button, Col, Form, Row, Table } from 'react-bootstrap';
@@ -13,15 +13,13 @@ import './App.scss';
import { faCircleCheck, faNoteSticky, faTrashCan, faComment } from '@fortawesome/free-regular-svg-icons';
import { useSettings } from './context/settings';
import Footer from './components/Footer';
import { faArrowUpRightFromSquare, faBasketShopping, faChainBroken, faChevronLeft, faChevronRight, faGear, faMoneyBillTransfer, faSatelliteDish, faSearch, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons';
import { useNavigate } from 'react-router-dom';
import { faBasketShopping, faChainBroken, faChevronLeft, faChevronRight, faGear, faMoneyBillTransfer, faSatelliteDish, faSearch, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons';
import Loader from './components/Loader';
import { getHumanDateTime, isInTheFuture } from './Utils';
import { getHumanDateTime, isInTheFuture, formatDateString } from './Utils';
import NoteModal from './components/modals/NoteModal';
import PayForAllModal from './components/modals/PayForAllModal';
import PendingPayments from './components/PendingPayments';
import { useEasterEgg } from './context/eggs';
import { ClientData, Food, MealSlot, PendingQr, PizzaOrder, DepartureTime, PizzaDayState, Restaurant, RestaurantDayMenu, RestaurantDayMenuMap, LunchChoice, LocationLunchChoicesMap, UserLunchChoice, PizzaVariant, getData, getEasterEggImage, addPizza, removePizza, updatePizzaDayNote, createPizzaDay, deletePizzaDay, lockPizzaDay, unlockPizzaDay, finishOrder, finishDelivery, addChoice, jdemeObed, removeChoices, removeChoice, updateNote, changeDepartureTime, setBuyer, generateQr } from '../../types';
import { ClientData, Food, PizzaOrder, DepartureTime, PizzaDayState, Restaurant, RestaurantDayMenu, RestaurantDayMenuMap, LunchChoice, LocationLunchChoicesMap, UserLunchChoice, PizzaVariant, getData, getEasterEggImage, addPizza, removePizza, updatePizzaDayNote, createPizzaDay, deletePizzaDay, lockPizzaDay, unlockPizzaDay, finishOrder, finishDelivery, addChoice, jdemeObed, removeChoices, removeChoice, updateNote, changeDepartureTime, setBuyer, dismissQr, generateQr } from '../../types';
import { getLunchChoiceName } from './enums';
// import FallingLeaves, { LEAF_PRESETS, LEAF_COLOR_THEMES } from './FallingLeaves';
// import './FallingLeaves.scss';
@@ -61,7 +59,6 @@ const EASTER_EGG_DEFAULT_DURATION = 0.75;
function App() {
const auth = useAuth();
const settings = useSettings();
const navigate = useNavigate();
const [easterEgg, _] = useEasterEgg(auth);
const [isConnected, setIsConnected] = useState<boolean>(false);
const [data, setData] = useState<ClientData>();
@@ -129,46 +126,19 @@ function App() {
});
socket.on(EVENT_MESSAGE, (newData: ClientData) => {
// console.log("Přijata nová data ze socketu", newData);
if (newData.slot === MealSlot.EXTRA) return;
// Aktualizujeme pouze, pokud jsme dostali data pro den, který máme aktuálně zobrazený
if (dayIndexRef.current == null || newData.dayIndex === dayIndexRef.current) {
setData(newData);
}
});
socket.on(EVENT_PENDING_QR, (pendingQr: PendingQr) => {
setData(prev => prev ? { ...prev, pendingQrs: [...(prev.pendingQrs ?? []), pendingQr] } : prev);
});
return () => {
socket.off(EVENT_CONNECT);
socket.off(EVENT_DISCONNECT);
socket.off(EVENT_MESSAGE);
socket.off(EVENT_PENDING_QR);
}
}, [socket]);
// Připojení do osobní socket místnosti po přihlášení
useEffect(() => {
if (auth?.login) {
socket.emit('join', auth.login);
}
}, [auth?.login, socket]);
// Po znovupřipojení socketu znovu vstoupit do místnosti a obnovit data
useEffect(() => {
const onReconnect = () => {
if (auth?.login) socket.emit('join', auth.login);
getData({ query: { dayIndex: dayIndexRef.current } }).then(response => {
if (response.data) {
setData(response.data);
setFood(response.data.menus);
}
});
};
socket.io.on('reconnect', onReconnect);
return () => { socket.io.off('reconnect', onReconnect); };
}, [socket, auth?.login]);
useEffect(() => {
if (!auth?.login || !data?.choices) {
return
@@ -319,7 +289,6 @@ function App() {
try {
await addChoice({ body: { locationKey: location, foodIndex, dayIndex } });
await tryAutoSelectDepartureTime();
} catch (error: any) {
alert(`Chyba při změně volby: ${error.message || error}`);
}
@@ -346,10 +315,6 @@ function App() {
foodChoiceRef.current.value = "";
}
choiceRef.current?.blur();
// Automatický výběr času odchodu pouze pro restaurace s menu
if (Object.keys(Restaurant).includes(locationKey)) {
await tryAutoSelectDepartureTime();
}
} catch (error: any) {
alert(`Chyba při změně volby: ${error.message || error}`);
// Reset výběru zpět
@@ -374,7 +339,6 @@ function App() {
const locationKey = choiceRef.current.value as LunchChoice;
if (auth?.login) {
await addChoice({ body: { locationKey, foodIndex: Number(event.target.value), dayIndex } });
await tryAutoSelectDepartureTime();
}
}
}
@@ -423,82 +387,34 @@ function App() {
}
}
const handleCreatePizzaDay = async () => {
if (!window.confirm('Opravdu chcete založit Pizza day?')) return;
setLoadingPizzaDay(true);
await createPizzaDay().then(() => setLoadingPizzaDay(false));
}
const handleDeletePizzaDay = async () => {
if (!window.confirm('Opravdu chcete smazat Pizza day? Budou smazány i všechny dosud zadané objednávky.')) return;
await deletePizzaDay();
}
const handleLockPizzaDay = async () => {
if (!window.confirm('Opravdu chcete uzamknout objednávky? Po uzamčení nebude možné přidávat ani odebírat objednávky.')) return;
await lockPizzaDay();
}
const handleUnlockPizzaDay = async () => {
if (!window.confirm('Opravdu chcete odemknout objednávky? Uživatelé budou moci opět upravovat své objednávky.')) return;
await unlockPizzaDay();
}
const handleFinishOrder = async () => {
if (!window.confirm('Opravdu chcete označit objednávky jako objednané? Objednávky zůstanou zamčeny.')) return;
await finishOrder();
}
const handleReturnToLocked = async () => {
if (!window.confirm('Opravdu chcete vrátit stav zpět do "uzamčeno" (před objednáním)?')) return;
await lockPizzaDay();
}
const handleFinishDelivery = async () => {
if (!window.confirm(`Opravdu chcete označit pizzy jako doručené?${settings?.bankAccount && settings?.holderName ? ' Uživatelům bude vygenerován QR kód pro platbu.' : ''}`)) return;
await finishDelivery({ body: { bankAccount: settings?.bankAccount, bankAccountHolder: settings?.holderName } });
}
const pizzaSuggestions = useMemo(() => {
if (!data?.pizzaList && !data?.salatList) {
if (!data?.pizzaList) {
return [];
}
const suggestions: SelectSearchOption[] = [];
data.pizzaList?.forEach((pizza, index) => {
data.pizzaList.forEach((pizza, index) => {
const group: SelectSearchOption = { name: pizza.name, type: "group", items: [] }
pizza.sizes.forEach((size, sizeIndex) => {
const name = `${size.size} (${size.price / 100} Kč)`;
const value = `pizza|${index}|${sizeIndex}`;
const name = `${size.size} (${size.price} Kč)`;
const value = `${index}|${sizeIndex}`;
group.items?.push({ name, value });
})
suggestions.push(group);
});
if (data.salatList?.length) {
const salatGroup: SelectSearchOption = { name: "Saláty", type: "group", items: [] }
data.salatList.forEach((salat, index) => {
salatGroup.items?.push({ name: `${salat.name} (${salat.price / 100} Kč)`, value: `salat|${index}` });
});
suggestions.push(salatGroup);
}
})
return suggestions;
}, [data?.pizzaList, data?.salatList]);
}, [data?.pizzaList]);
const handlePizzaChange = async (value: SelectedOptionValue | SelectedOptionValue[]) => {
if (auth?.login) {
if (auth?.login && data?.pizzaList) {
if (typeof value !== 'string') {
throw new TypeError('Nepodporovaný typ hodnoty: ' + typeof value);
}
const s = value.split('|');
if (s[0] === 'salat') {
const salatIndex = Number.parseInt(s[1]);
await addPizza({ body: { salatIndex } });
} else {
const pizzaIndex = Number.parseInt(s[1]);
const pizzaSizeIndex = Number.parseInt(s[2]);
const pizzaIndex = Number.parseInt(s[0]);
const pizzaSizeIndex = Number.parseInt(s[1]);
await addPizza({ body: { pizzaIndex, pizzaSizeIndex } });
}
}
}
const handlePizzaDelete = async (pizzaOrder: PizzaVariant) => {
await removePizza({ body: { pizzaOrder } });
@@ -518,16 +434,6 @@ function App() {
}
}
// Automaticky nastaví preferovaný čas odchodu 10:45, pokud tento čas ještě nenastal (nebo jde o budoucí den)
const tryAutoSelectDepartureTime = async () => {
const preferredTime = "10:45" as DepartureTime;
const isToday = dayIndex === data?.todayDayIndex;
if ((!isToday || isInTheFuture(preferredTime)) && departureChoiceRef.current) {
departureChoiceRef.current.value = preferredTime;
await changeDepartureTime({ body: { time: preferredTime, dayIndex } });
}
}
const handleDayChange = async (dayIndex: number) => {
setDayIndex(dayIndex);
dayIndexRef.current = dayIndex;
@@ -678,7 +584,7 @@ function App() {
</Form.Select>
<small>Je možné vybrat jen jednu možnost. Výběr jiné odstraní předchozí.</small>
{foodChoiceList && !closed && <>
<p className="mt-3">Na co dobrého?</p>
<p className="mt-3">Na co dobrého? <small style={{ color: 'var(--luncher-text-muted)' }}>(nepovinné)</small></p>
<Form.Select ref={foodChoiceRef} onChange={doAddFoodChoice}>
<option value="">Vyber jídlo...</option>
{foodChoiceList.map((food, index) => <option key={food.name} value={index}>{food.name}</option>)}
@@ -689,7 +595,7 @@ function App() {
<Form.Select ref={departureChoiceRef} onChange={handleChangeDepartureTime}>
<option value="">Vyber čas...</option>
{Object.values(DepartureTime)
.filter(time => dayIndex !== data.todayDayIndex || isInTheFuture(time))
.filter(time => isInTheFuture(time))
.map(time => <option key={time} value={time}>{time}</option>)}
</Form.Select>
</>}
@@ -750,9 +656,6 @@ function App() {
markAsBuyer();
}} icon={faBasketShopping} className={isBuyer ? 'buyer-icon' : 'action-icon'} style={{ cursor: 'pointer' }} />
</span>}
{login === auth.login && locationKey === LunchChoice.OBJEDNAVAM && <span title='Přejít na stránku objednávek'>
<FontAwesomeIcon onClick={() => navigate('/objednani')} icon={faArrowUpRightFromSquare} className='action-icon' style={{ cursor: 'pointer' }} />
</span>}
{login !== auth.login && locationKey === LunchChoice.OBJEDNAVAM && isBuyer && <span title='Objednávající'>
<FontAwesomeIcon onClick={() => {
copyNote(userPayload.note!);
@@ -819,7 +722,10 @@ function App() {
</span>
:
<div>
<Button onClick={handleCreatePizzaDay}>Založit Pizza day</Button>
<Button onClick={async () => {
setLoadingPizzaDay(true);
await createPizzaDay().then(() => setLoadingPizzaDay(false));
}}>Založit Pizza day</Button>
<Button variant="outline-primary" onClick={doJdemeObed}>Jdeme na oběd!</Button>
</div>
}
@@ -838,8 +744,12 @@ function App() {
{
data.pizzaDay.creator === auth.login &&
<div className="mb-4">
<Button variant="danger" title="Smaže kompletně pizza day, včetně dosud zadaných objednávek." onClick={handleDeletePizzaDay}>Smazat Pizza day</Button>
<Button title={noOrders ? "Nelze uzamknout - neexistuje žádná objednávka" : "Zamezí přidávat/odebírat objednávky. Použij před samotným objednáním, aby již nemohlo docházet ke změnám."} disabled={noOrders} onClick={handleLockPizzaDay}>Uzamknout</Button>
<Button variant="danger" title="Smaže kompletně pizza day, včetně dosud zadaných objednávek." onClick={async () => {
await deletePizzaDay();
}}>Smazat Pizza day</Button>
<Button title={noOrders ? "Nelze uzamknout - neexistuje žádná objednávka" : "Zamezí přidávat/odebírat objednávky. Použij před samotným objednáním, aby již nemohlo docházet ke změnám."} disabled={noOrders} onClick={async () => {
await lockPizzaDay();
}}>Uzamknout</Button>
</div>
}
</>
@@ -850,8 +760,12 @@ function App() {
<p>Objednávky jsou uzamčeny uživatelem <strong>{data.pizzaDay.creator}</strong></p>
{data.pizzaDay.creator === auth.login &&
<div className="mb-4">
<Button variant="secondary" title="Umožní znovu editovat objednávky." onClick={handleUnlockPizzaDay}>Odemknout</Button>
<Button title={noOrders ? "Nelze objednat - neexistuje žádná objednávka" : "Použij po objednání. Objednávky zůstanou zamčeny."} disabled={noOrders} onClick={handleFinishOrder}>Objednáno</Button>
<Button variant="secondary" title="Umožní znovu editovat objednávky." onClick={async () => {
await unlockPizzaDay();
}}>Odemknout</Button>
<Button title={noOrders ? "Nelze objednat - neexistuje žádná objednávka" : "Použij po objednání. Objednávky zůstanou zamčeny."} disabled={noOrders} onClick={async () => {
await finishOrder();
}}>Objednáno</Button>
</div>
}
</>
@@ -862,8 +776,12 @@ function App() {
<p>Pizzy byly objednány uživatelem <strong>{data.pizzaDay.creator}</strong></p>
{data.pizzaDay.creator === auth.login &&
<div className="mb-4">
<Button variant="secondary" title="Vrátí stav do předchozího kroku (před objednáním)." onClick={handleReturnToLocked}>Vrátit do "uzamčeno"</Button>
<Button title="Nastaví stav na 'Doručeno' - koncový stav." onClick={handleFinishDelivery}>Doručeno</Button>
<Button variant="secondary" title="Vrátí stav do předchozího kroku (před objednáním)." onClick={async () => {
await lockPizzaDay();
}}>Vrátit do "uzamčeno"</Button>
<Button title="Nastaví stav na 'Doručeno' - koncový stav." onClick={async () => {
await finishDelivery({ body: { bankAccount: settings?.bankAccount, bankAccountHolder: settings?.holderName } });
}}>Doručeno</Button>
</div>
}
</>
@@ -880,7 +798,7 @@ function App() {
<SelectSearch
search={true}
options={pizzaSuggestions}
placeholder='Vyhledat pizzu nebo salát...'
placeholder='Vyhledat pizzu...'
onChange={handlePizzaChange}
onBlur={_ => { }}
onFocus={_ => { }}
@@ -902,21 +820,48 @@ function App() {
</div>
}
<PizzaOrderList state={data.pizzaDay.state!} orders={data.pizzaDay.orders!} onDelete={handlePizzaDelete} creator={data.pizzaDay.creator!} />
{
data.pizzaDay.state === PizzaDayState.DELIVERED && myOrder?.hasQr && (() => {
const pizzaQr = data.pendingQrs?.find(qr => qr.creator === data.pizzaDay?.creator);
return pizzaQr ? (
<div className='qr-code'>
<h3>QR platba</h3>
<img src={`/api/qr?login=${auth.login}&id=${pizzaQr.id}`} alt='QR kód' />
</div>
) : null;
})()
}
</>
}
</div>
}
</div>
<PendingPayments
pendingQrs={data.pendingQrs}
login={auth.login}
onDismissed={async () => {
{data.pendingQrs && data.pendingQrs.length > 0 &&
<div className='pizza-section fade-in mt-4'>
<h3>Nevyřízené platby</h3>
<p>Máte neuhrazené platby.</p>
{data.pendingQrs.map(qr => (
<div key={qr.id} className='qr-code mb-3'>
<p>
<strong>{formatDateString(qr.date)}</strong> {qr.creator} ({qr.totalPrice} )
{qr.purpose && <><br /><span className="text-muted">{qr.purpose}</span></>}
</p>
<img src={`/api/qr?login=${auth.login}&id=${qr.id}`} alt='QR kód' />
<div className='mt-2'>
<Button variant="success" onClick={async () => {
await dismissQr({ body: { id: qr.id } });
const response = await getData({ query: { dayIndex } });
if (response.data) {
setData(response.data);
}
}}
/>
}}>
Zaplatil jsem
</Button>
</div>
</div>
))}
</div>
}
</>
</div>
{/* <FallingLeaves
-17
View File
@@ -5,31 +5,14 @@ import { SnowOverlay } from 'react-snow-overlay';
import { ToastContainer } from "react-toastify";
import { SocketContext, socket } from "./context/socket";
import StatsPage from "./pages/StatsPage";
import OrderGroupsPage from "./pages/OrderGroupsPage";
import SuggestionsPage from "./pages/SuggestionsPage";
import App from "./App";
export const STATS_URL = '/stats';
export const OBJEDNANI_URL = '/objednani';
export const NAVRHY_URL = '/navrhy';
export default function AppRoutes() {
return (
<Routes>
<Route path={STATS_URL} element={<StatsPage />} />
<Route path={NAVRHY_URL} element={
<ProvideSettings>
<SuggestionsPage />
</ProvideSettings>
} />
<Route path={OBJEDNANI_URL} element={
<ProvideSettings>
<SocketContext.Provider value={socket}>
<OrderGroupsPage />
<ToastContainer />
</SocketContext.Provider>
</ProvideSettings>
} />
<Route path="/" element={
<ProvideSettings>
<SocketContext.Provider value={socket}>
-13
View File
@@ -110,16 +110,3 @@ export function formatDateString(dateString: string): string {
const [year, month, day] = dateString.split('-');
return `${day}.${month}.${year}`;
}
/**
* Očistí zprávu (účel platby) pro QR platbu musí odpovídat serverové logice (qr.ts):
* transliteruje diakritiku na základní písmena (š→s, č→c, ...), odstraní znaky mimo
* ISO 8859-1 a hvězdičku (oddělovač polí v QR platbě) a ořízne na max. 60 znaků.
*/
export function sanitizeQrMessage(message: string): string {
const sanitized = message
.normalize('NFD').replace(/[\u0300-\u036f]/g, '') // diakritika → základní písmeno
.replace(/[^\x00-\xff]/g, '') // znaky mimo ISO 8859-1
.replace(/\*/g, ''); // '*' je v QR platbě oddělovač
return sanitized.length > 60 ? sanitized.substring(0, 60) : sanitized;
}
@@ -1,70 +0,0 @@
.bolt-progress {
display: flex;
align-items: flex-start;
max-width: 400px;
.bolt-step {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
min-width: 64px;
.bolt-dot {
position: relative;
z-index: 1;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--luncher-border, #ced4da);
}
.bolt-label {
margin-top: 2px;
font-size: 0.7em;
color: var(--luncher-text-muted, #6c757d);
white-space: nowrap;
}
// Spojnice k předchozímu kroku
&:not(:first-child)::before {
content: '';
position: absolute;
top: 5px;
right: 50%;
width: 100%;
height: 2px;
background: var(--luncher-border, #ced4da);
}
&.done {
.bolt-dot {
background: var(--bs-success, #198754);
}
&:not(:first-child)::before {
background: var(--bs-success, #198754);
}
}
&.active .bolt-label {
font-weight: 600;
color: inherit;
}
}
// Pulzování aktivního kroku, dokud sledování běží
&.live .bolt-step.active .bolt-dot {
animation: bolt-pulse 2s ease-in-out infinite;
}
}
@keyframes bolt-pulse {
0%, 100% {
box-shadow: 0 0 0 0 rgba(25, 135, 84, 0.5);
}
50% {
box-shadow: 0 0 0 5px rgba(25, 135, 84, 0);
}
}
@@ -1,89 +0,0 @@
import { OverlayTrigger, Tooltip } from 'react-bootstrap';
import './BoltOrderProgress.scss';
const STEPS = ['Přijato', 'Příprava', 'Na cestě', 'Doručeno'];
/**
* Známé stavy objednávky z Bolt API → index kroku ve stepperu.
* Pozor: waiting_delivery znamená "jídlo čeká v podniku na vyzvednutí",
* nikoli "na cestě" — tu signalizuje až stav kurýra (picked_up apod.).
*/
const ORDER_STATE_TO_STEP: Record<string, number> = {
created: 0,
pending: 0,
accepted: 0,
waiting_preparation: 0,
preparing: 1,
waiting_delivery: 1,
ready_for_pickup: 1,
waiting_courier: 1,
waiting_pickup: 1,
picked_up: 2,
in_delivery: 2,
delivering: 2,
heading_to_client: 2,
delivered: 3,
finished: 3,
};
/** Stavy kurýra z Bolt API → index kroku. Kurýr u podniku ještě neznamená "na cestě". */
const COURIER_STATE_TO_STEP: Record<string, number> = {
heading_to_provider: 1,
arrived_to_provider: 1,
picked_up: 2,
heading_to_client: 2,
delivering: 2,
arrived_to_client: 2,
delivered: 3,
};
/** Neznámé stavy se mapují heuristicky podle klíčových slov. */
function stepForOrderState(state: string): number | 'cancelled' {
const s = state.toLowerCase();
if (s in ORDER_STATE_TO_STEP) return ORDER_STATE_TO_STEP[s];
if (/cancel|reject|fail/.test(s)) return 'cancelled';
if (/delivered|finished/.test(s)) return 3;
if (/^waiting|prepar|ready|cook/.test(s)) return 1;
if (/picked|delivering|heading_to_client|transport/.test(s)) return 2;
return 0;
}
function stepForCourierState(state?: string): number {
if (!state) return 0;
const s = state.toLowerCase();
if (s in COURIER_STATE_TO_STEP) return COURIER_STATE_TO_STEP[s];
if (/picked|client|delivering|transport/.test(s)) return 2;
return 0;
}
interface Props {
/** Raw order_state z Bolt API (např. waiting_preparation) */
state: string;
/** Raw courier.state z Bolt API (např. arrived_to_provider) */
courierState?: string;
/** Zda sledování stále běží (skupina má boltTrackingToken) */
tracking: boolean;
}
/** Mini progress stepper se stavem objednávky Bolt Food. */
export default function BoltOrderProgress({ state, courierState, tracking }: Props) {
const orderStep = stepForOrderState(state);
if (orderStep === 'cancelled') {
return <small className="text-danger">Objednávka Bolt byla zrušena</small>;
}
// Stav kurýra může krok jen zpřesnit dopředu (např. waiting_delivery + picked_up → Na cestě)
const step = Math.max(orderStep, stepForCourierState(courierState));
const rawInfo = courierState ? `${state} / kurýr: ${courierState}` : state;
return (
<OverlayTrigger overlay={<Tooltip>Stav z Bolt Food: {rawInfo}</Tooltip>}>
<div className={`bolt-progress${tracking && step < 3 ? ' live' : ''}`}>
{STEPS.map((label, i) => (
<div key={label} className={`bolt-step${i <= step ? ' done' : ''}${i === step ? ' active' : ''}`}>
<div className="bolt-dot" />
<div className="bolt-label">{label}</div>
</div>
))}
</div>
</OverlayTrigger>
);
}
+60 -49
View File
@@ -3,20 +3,24 @@ import { Navbar, Nav, NavDropdown, Modal, Button } from "react-bootstrap";
import { useAuth } from "../context/auth";
import SettingsModal from "./modals/SettingsModal";
import { useSettings, ThemePreference } from "../context/settings";
import HuePicker from "./HuePicker";
import FeaturesVotingModal from "./modals/FeaturesVotingModal";
import PizzaCalculatorModal from "./modals/PizzaCalculatorModal";
import RefreshMenuModal from "./modals/RefreshMenuModal";
import GenerateQrModal from "./modals/GenerateQrModal";
import GenerateMockDataModal from "./modals/GenerateMockDataModal";
import ClearMockDataModal from "./modals/ClearMockDataModal";
import { useNavigate } from "react-router";
import { STATS_URL, OBJEDNANI_URL, NAVRHY_URL } from "../AppRoutes";
import { LunchChoices, getChangelogs } from "../../../types";
import { STATS_URL } from "../AppRoutes";
import { FeatureRequest, getVotes, updateVote, LunchChoices } from "../../../types";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons";
import { formatDateString } from "../Utils";
const LAST_SEEN_CHANGELOG_KEY = "lastChangelogDate";
const CHANGELOG = [
"Nový moderní design aplikace",
"Oprava parsování Sladovnické a TechTower",
"Možnost označit se jako objednávající u volby \"budu objednávat\"",
"Možnost generovat QR kódy pro platby (i mimo Pizza day)",
];
const IS_DEV = process.env.NODE_ENV === 'development';
@@ -30,33 +34,51 @@ export default function Header({ choices, dayIndex }: Props) {
const settings = useSettings();
const navigate = useNavigate();
const [settingsModalOpen, setSettingsModalOpen] = useState<boolean>(false);
const [votingModalOpen, setVotingModalOpen] = useState<boolean>(false);
const [pizzaModalOpen, setPizzaModalOpen] = useState<boolean>(false);
const [refreshMenuModalOpen, setRefreshMenuModalOpen] = useState<boolean>(false);
const [changelogModalOpen, setChangelogModalOpen] = useState<boolean>(false);
const [changelogEntries, setChangelogEntries] = useState<Record<string, string[]>>({});
const [qrModalOpen, setQrModalOpen] = useState<boolean>(false);
const [generateMockModalOpen, setGenerateMockModalOpen] = useState<boolean>(false);
const [clearMockModalOpen, setClearMockModalOpen] = useState<boolean>(false);
const [featureVotes, setFeatureVotes] = useState<FeatureRequest[] | undefined>([]);
const effectiveDark = settings?.effectiveDark ?? false;
// Zjistíme aktuální efektivní téma (pro zobrazení správné ikony)
const [effectiveTheme, setEffectiveTheme] = useState<'light' | 'dark'>('light');
useEffect(() => {
if (!auth?.login) return;
const lastSeen = localStorage.getItem(LAST_SEEN_CHANGELOG_KEY) ?? undefined;
getChangelogs({ query: lastSeen ? { since: lastSeen } : {} }).then(response => {
const entries = response.data;
if (!entries || Object.keys(entries).length === 0) return;
setChangelogEntries(entries);
setChangelogModalOpen(true);
const newestDate = Object.keys(entries).sort((a, b) => b.localeCompare(a))[0];
localStorage.setItem(LAST_SEEN_CHANGELOG_KEY, newestDate);
});
const updateEffectiveTheme = () => {
if (settings?.themePreference === 'system') {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
setEffectiveTheme(isDark ? 'dark' : 'light');
} else {
setEffectiveTheme(settings?.themePreference || 'light');
}
};
updateEffectiveTheme();
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', updateEffectiveTheme);
return () => mediaQuery.removeEventListener('change', updateEffectiveTheme);
}, [settings?.themePreference]);
useEffect(() => {
if (auth?.login) {
getVotes().then(response => {
setFeatureVotes(response.data);
})
}
}, [auth?.login]);
const closeSettingsModal = () => {
setSettingsModalOpen(false);
}
const closeVotingModal = () => {
setVotingModalOpen(false);
}
const closePizzaModal = () => {
setPizzaModalOpen(false);
}
@@ -78,7 +100,8 @@ export default function Header({ choices, dayIndex }: Props) {
}
const toggleTheme = () => {
const newTheme: ThemePreference = effectiveDark ? 'light' : 'dark';
// Přepínáme mezi light a dark (ignorujeme system pro jednoduchost)
const newTheme: ThemePreference = effectiveTheme === 'dark' ? 'light' : 'dark';
settings?.setThemePreference(newTheme);
}
@@ -143,6 +166,17 @@ export default function Header({ choices, dayIndex }: Props) {
closeSettingsModal();
}
const saveFeatureVote = async (option: FeatureRequest, active: boolean) => {
await updateVote({ body: { option, active } });
const votes = [...featureVotes || []];
if (active) {
votes.push(option);
} else {
votes.splice(votes.indexOf(option), 1);
}
setFeatureVotes(votes);
}
return <Navbar variant='dark' expand="lg">
<Navbar.Brand href="/">Luncher</Navbar.Brand>
<Navbar.Toggle aria-controls="basic-navbar-nav" />
@@ -151,35 +185,19 @@ export default function Header({ choices, dayIndex }: Props) {
<button
className="theme-toggle"
onClick={toggleTheme}
title={effectiveDark ? 'Přepnout na světlý režim' : 'Přepnout na tmavý režim'}
aria-label="Přepnout světlý/tmavý režim"
title={effectiveTheme === 'dark' ? 'Přepnout na světlý režim' : 'Přepnout na tmavý režim'}
aria-label="Přepnout barevný motiv"
>
<FontAwesomeIcon icon={effectiveDark ? faSun : faMoon} />
<FontAwesomeIcon icon={effectiveTheme === 'dark' ? faSun : faMoon} />
</button>
<HuePicker
accentHue={settings?.accentHue ?? 142}
isDark={effectiveDark}
onChange={hue => settings?.setAccentHue(hue)}
/>
<NavDropdown align="end" title={auth?.login} id="basic-nav-dropdown">
<NavDropdown.Item onClick={() => setSettingsModalOpen(true)}>Nastavení</NavDropdown.Item>
<NavDropdown.Item onClick={() => setRefreshMenuModalOpen(true)}>Přenačtení menu</NavDropdown.Item>
<NavDropdown.Item onClick={() => navigate(NAVRHY_URL)}>Návrhy na vylepšení</NavDropdown.Item>
<NavDropdown.Item onClick={() => setVotingModalOpen(true)}>Hlasovat o nových funkcích</NavDropdown.Item>
<NavDropdown.Item onClick={() => setPizzaModalOpen(true)}>Pizza kalkulačka</NavDropdown.Item>
<NavDropdown.Item onClick={handleQrMenuClick}>Generování QR kódů</NavDropdown.Item>
<NavDropdown.Item onClick={() => navigate(STATS_URL)}>Statistiky</NavDropdown.Item>
<NavDropdown.Item onClick={() => navigate(OBJEDNANI_URL)}>Objednání</NavDropdown.Item>
<NavDropdown.Item onClick={() => {
getChangelogs().then(response => {
const entries = response.data ?? {};
setChangelogEntries(entries);
setChangelogModalOpen(true);
const dates = Object.keys(entries).sort((a, b) => b.localeCompare(a));
if (dates.length > 0) {
localStorage.setItem(LAST_SEEN_CHANGELOG_KEY, dates[0]);
}
});
}}>Novinky</NavDropdown.Item>
<NavDropdown.Item onClick={() => setChangelogModalOpen(true)}>Novinky</NavDropdown.Item>
{IS_DEV && (
<>
<NavDropdown.Divider />
@@ -194,6 +212,7 @@ export default function Header({ choices, dayIndex }: Props) {
</Navbar.Collapse>
<SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} />
<RefreshMenuModal isOpen={refreshMenuModalOpen} onClose={closeRefreshMenuModal} />
<FeaturesVotingModal isOpen={votingModalOpen} onClose={closeVotingModal} onChange={saveFeatureVote} initialValues={featureVotes} />
<PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} />
{choices && settings?.bankAccount && settings?.holderName && (
<GenerateQrModal
@@ -218,24 +237,16 @@ export default function Header({ choices, dayIndex }: Props) {
/>
</>
)}
<Modal show={changelogModalOpen} onHide={() => setChangelogModalOpen(false)} size="lg">
<Modal show={changelogModalOpen} onHide={() => setChangelogModalOpen(false)}>
<Modal.Header closeButton>
<Modal.Title><h2>Novinky</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
{Object.keys(changelogEntries).sort((a, b) => b.localeCompare(a)).map(date => (
<div key={date}>
<strong>{formatDateString(date)}</strong>
<ul>
{changelogEntries[date].map((item, index) => (
{CHANGELOG.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</div>
))}
{Object.keys(changelogEntries).length === 0 && (
<p>Žádné novinky.</p>
)}
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={() => setChangelogModalOpen(false)}>
-138
View File
@@ -1,138 +0,0 @@
.hue-picker-dropdown {
.dropdown-toggle {
background: transparent !important;
border: none !important;
color: var(--luncher-navbar-text) !important;
padding: 8px 12px;
font-size: 1.1rem;
display: flex;
align-items: center;
cursor: pointer;
border-radius: var(--luncher-radius-sm);
transition: var(--luncher-transition);
&::after {
display: none;
}
&:hover {
background: rgba(255, 255, 255, 0.1) !important;
transform: scale(1.1);
}
&:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.3) !important;
}
}
}
.hue-picker-panel {
padding: 0 !important;
min-width: 240px;
.hue-picker-inner {
padding: 14px 16px;
}
.hue-picker-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--luncher-text-secondary);
margin-bottom: 12px;
}
}
.hue-slider {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 12px;
border-radius: 6px;
background: linear-gradient(
to right,
hsl(0 70% 50%), hsl(30 70% 50%), hsl(60 70% 50%), hsl(90 70% 50%),
hsl(120 70% 50%), hsl(150 70% 50%), hsl(180 70% 50%), hsl(210 70% 50%),
hsl(240 70% 50%), hsl(270 70% 50%), hsl(300 70% 50%), hsl(330 70% 50%), hsl(360 70% 50%)
);
outline: none;
cursor: pointer;
margin-bottom: 14px;
&::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: white;
border: 2px solid rgba(0, 0, 0, 0.25);
cursor: pointer;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
transition: transform 0.15s ease;
&:hover {
transform: scale(1.15);
}
}
&::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: white;
border: 2px solid rgba(0, 0, 0, 0.25);
cursor: pointer;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
}
}
.hue-presets {
display: flex;
gap: 8px;
margin-bottom: 14px;
.hue-swatch {
width: 26px;
height: 26px;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
padding: 0;
transition: transform 0.15s ease, border-color 0.15s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
&:hover {
transform: scale(1.15);
}
&.active {
border-color: var(--luncher-text);
transform: scale(1.1);
}
}
}
.hue-preview {
display: flex;
align-items: center;
gap: 10px;
padding-top: 4px;
border-top: 1px solid var(--luncher-border);
.hue-preview-chip {
width: 32px;
height: 32px;
border-radius: var(--luncher-radius-sm);
flex-shrink: 0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
transition: background 0.2s ease;
}
span {
font-size: 0.8rem;
color: var(--luncher-text-secondary);
}
}
-71
View File
@@ -1,71 +0,0 @@
import { Dropdown } from 'react-bootstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPalette } from '@fortawesome/free-solid-svg-icons';
import './HuePicker.scss';
const PRESETS = [
{ hue: 142, label: 'Zelená' },
{ hue: 217, label: 'Modrá' },
{ hue: 263, label: 'Fialová' },
{ hue: 0, label: 'Červená' },
{ hue: 28, label: 'Oranžová' },
{ hue: 340, label: 'Růžová' },
];
type Props = {
accentHue: number;
isDark: boolean;
onChange: (hue: number) => void;
};
function swatchColor(hue: number, isDark: boolean): string {
return `hsl(${hue} 70% ${isDark ? 55 : 38}%)`;
}
export default function HuePicker({ accentHue, isDark, onChange }: Props) {
return (
<Dropdown align="end" autoClose="outside" className="hue-picker-dropdown">
<Dropdown.Toggle
as="button"
className="theme-toggle"
aria-label="Barva zvýraznění"
title="Barva zvýraznění"
>
<FontAwesomeIcon icon={faPalette} />
</Dropdown.Toggle>
<Dropdown.Menu className="hue-picker-panel">
<div className="hue-picker-inner">
<div className="hue-picker-label">Barva zvýraznění</div>
<input
type="range"
min={0}
max={360}
value={accentHue}
onChange={e => onChange(parseInt(e.target.value, 10))}
className="hue-slider"
aria-label="Odstín barvy zvýraznění"
/>
<div className="hue-presets">
{PRESETS.map(p => (
<button
key={p.hue}
className={`hue-swatch${accentHue === p.hue ? ' active' : ''}`}
style={{ background: swatchColor(p.hue, isDark) }}
title={p.label}
onClick={() => onChange(p.hue)}
aria-label={p.label}
/>
))}
</div>
<div className="hue-preview">
<div
className="hue-preview-chip"
style={{ background: swatchColor(accentHue, isDark) }}
/>
<span>Aktuální barva zvýraznění</span>
</div>
</div>
</Dropdown.Menu>
</Dropdown>
);
}
-102
View File
@@ -1,102 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { Button, Modal } from 'react-bootstrap';
import { PendingQr, dismissQr } from '../../../types';
import { formatDateString } from '../Utils';
import ConfirmModal from './modals/ConfirmModal';
type Props = {
pendingQrs?: PendingQr[];
login?: string;
// Zavolá se po úspěšném potvrzení platby, aby si rodič mohl znovu načíst data
onDismissed?: () => void | Promise<void>;
};
// Sekce "Nevyřízené platby" zobrazí QR kódy neuhrazených plateb přihlášeného uživatele
// včetně tlačítka "Zaplatil jsem" a potvrzovacího dialogu. Sdíleno hlavní stránkou i stránkou objednávek.
// Při příchodu nových nevyřízených plateb se navíc automaticky otevře modální dialog,
// aby si uživatel QR kódů určitě všiml (často si jich nevšimnou, protože sekce je dole na stránce).
export default function PendingPayments({ pendingQrs, login, onDismissed }: Readonly<Props>) {
const [dismissQrId, setDismissQrId] = useState<string | null>(null);
const [modalOpen, setModalOpen] = useState(false);
// ID QR kódů, pro které už byl v rámci tohoto načtení stránky automaticky zobrazen
// modální dialog. Drží se jen v paměti (ne v sessionStorage), takže se při každém
// ručním přenačtení stránky vynuluje a dialog se znovu otevře, dokud uživatel platby
// neuhradí. Zároveň se nepřekrývá při pouhém obnovení dat či příchodu už zobrazeného QR.
const autoShownQrIds = useRef<Set<string>>(new Set());
const qrIdsKey = (pendingQrs ?? []).map(qr => qr.id).join(',');
// Automaticky otevřeme modální dialog, jakmile přijdou nové (dosud nezobrazené) platby.
useEffect(() => {
const ids = (pendingQrs ?? []).map(qr => qr.id);
if (ids.length === 0) return;
const unseen = ids.filter(id => !autoShownQrIds.current.has(id));
if (unseen.length > 0) {
setModalOpen(true);
unseen.forEach(id => autoShownQrIds.current.add(id));
}
}, [qrIdsKey, pendingQrs]);
if (!pendingQrs || pendingQrs.length === 0) return null;
// Vykreslení jednoho QR kódu i s tlačítkem "Zaplatil jsem" sdíleno sekcí i modálem.
const renderQr = (qr: PendingQr) => (
<div key={qr.id} className='qr-code mb-3'>
<p>
<strong>{formatDateString(qr.date)}</strong> {qr.creator} ({qr.totalPrice / 100} )
{qr.purpose && <><br /><span className="text-muted">{qr.purpose}</span></>}
</p>
<img src={`/api/qr?login=${login}&id=${qr.id}`} alt='QR kód' />
<div className='mt-2'>
<Button variant="success" onClick={() => setDismissQrId(qr.id)}>
Zaplatil jsem
</Button>
</div>
</div>
);
return (
<>
<div className='pizza-section fade-in mt-4'>
<h3>Nevyřízené platby</h3>
<p>
Máte neuhrazené platby.{' '}
<Button variant="link" className="p-0 align-baseline" onClick={() => setModalOpen(true)}>
Zobrazit QR kódy
</Button>
</p>
{pendingQrs.map(renderQr)}
</div>
<Modal show={modalOpen} onHide={() => setModalOpen(false)} centered scrollable>
<Modal.Header closeButton>
<Modal.Title>Nevyřízené platby</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>Máte neuhrazené platby. Naskenujte QR kód pro zaplacení.</p>
{pendingQrs.map(renderQr)}
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={() => setModalOpen(false)}>
Zavřít
</Button>
</Modal.Footer>
</Modal>
<ConfirmModal
isOpen={dismissQrId !== null}
title="Potvrzení platby"
message="Opravdu jste zaplatili? QR kód bude odstraněn."
confirmLabel="Zaplatil jsem"
confirmVariant="success"
onClose={() => setDismissQrId(null)}
onConfirm={async () => {
if (!dismissQrId) return;
const id = dismissQrId;
setDismissQrId(null);
await dismissQr({ body: { id } });
await onDismissed?.();
}}
/>
</>
);
}
+1 -1
View File
@@ -48,7 +48,7 @@ export default function PizzaOrderList({ state, orders, onDelete, creator }: Rea
borderTop: '2px solid var(--luncher-border)'
}}>
<td colSpan={4} style={{ padding: '16px 20px', border: 'none' }}>Celkem</td>
<td style={{ padding: '16px 20px', border: 'none', textAlign: 'right', color: 'var(--luncher-primary)' }}>{`${total / 100}`}</td>
<td style={{ padding: '16px 20px', border: 'none', textAlign: 'right', color: 'var(--luncher-primary)' }}>{`${total}`}</td>
</tr>
</tbody>
</Table>
+4 -4
View File
@@ -26,7 +26,7 @@ export default function PizzaOrderRow({ creator, order, state, onDelete, onFeeMo
<td>{order.customer}</td>
<td>{order.pizzaList!.map<React.ReactNode>(pizzaOrder =>
<span key={pizzaOrder.name}>
{`${pizzaOrder.name}, ${pizzaOrder.size} (${pizzaOrder.price / 100} Kč)`}
{`${pizzaOrder.name}, ${pizzaOrder.size} (${pizzaOrder.price} Kč)`}
{auth?.login === order.customer && state === PizzaDayState.CREATED &&
<span title='Odstranit'>
<FontAwesomeIcon onClick={() => {
@@ -38,10 +38,10 @@ export default function PizzaOrderRow({ creator, order, state, onDelete, onFeeMo
.reduce((prev, curr, index) => [prev, <br key={`br-${index}`} />, curr])}
</td>
<td style={{ maxWidth: "200px" }}>{order.note ?? '-'}</td>
<td style={{ maxWidth: "200px" }}>{order.fee?.price ? `${order.fee.price / 100}${order.fee.text ? ` (${order.fee.text})` : ''}` : '-'}</td>
<td style={{ maxWidth: "200px" }}>{order.fee?.price ? `${order.fee.price}${order.fee.text ? ` (${order.fee.text})` : ''}` : '-'}</td>
<td>
{order.totalPrice / 100} {auth?.login === creator && state === PizzaDayState.CREATED && <span title='Nastavit příplatek'><FontAwesomeIcon onClick={() => { setIsFeeModalOpen(true) }} className='action-icon' icon={faMoneyBill1} /></span>}
{order.totalPrice} {auth?.login === creator && state === PizzaDayState.CREATED && <span title='Nastavit příplatek'><FontAwesomeIcon onClick={() => { setIsFeeModalOpen(true) }} className='action-icon' icon={faMoneyBill1} /></span>}
</td>
<PizzaAdditionalFeeModal customerName={order.customer} isOpen={isFeeModalOpen} onClose={() => setIsFeeModalOpen(false)} onSave={saveFees} initialValues={{ text: order.fee?.text, price: order.fee?.price != null ? String(order.fee.price / 100) : undefined }} />
<PizzaAdditionalFeeModal customerName={order.customer} isOpen={isFeeModalOpen} onClose={() => setIsFeeModalOpen(false)} onSave={saveFees} initialValues={{ text: order.fee?.text, price: order.fee?.price?.toString() }} />
</>
}
@@ -1,79 +0,0 @@
import { useState } from "react";
import { Modal, Button, Form } from "react-bootstrap";
type Props = {
isOpen: boolean;
onClose: () => void;
onSubmit: (title: string, description: string) => Promise<void>;
};
/** Modální dialog pro přidání nového návrhu na vylepšení. */
export default function AddSuggestionModal({ isOpen, onClose, onSubmit }: Readonly<Props>) {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [submitting, setSubmitting] = useState(false);
const reset = () => {
setTitle("");
setDescription("");
};
const handleClose = () => {
reset();
onClose();
};
const handleSubmit = async () => {
if (!title.trim() || !description.trim()) return;
setSubmitting(true);
try {
await onSubmit(title.trim(), description.trim());
reset();
onClose();
} finally {
setSubmitting(false);
}
};
return (
<Modal show={isOpen} onHide={handleClose} size="lg">
<Modal.Header closeButton>
<Modal.Title><h2>Nový návrh na vylepšení</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
<Form.Group className="mb-3">
<Form.Label>Název</Form.Label>
<Form.Control
type="text"
placeholder="Stručný název návrhu"
value={title}
maxLength={120}
onChange={e => setTitle(e.target.value)}
onKeyDown={e => e.stopPropagation()}
autoFocus
/>
<Form.Text className="text-muted">Krátký, výstižný název navrhované úpravy.</Form.Text>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Popis</Form.Label>
<Form.Control
as="textarea"
rows={5}
placeholder="Detailní popis navrhované úpravy, řešení apod."
value={description}
onChange={e => setDescription(e.target.value)}
onKeyDown={e => e.stopPropagation()}
/>
</Form.Group>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={handleClose}>
Storno
</Button>
<Button onClick={handleSubmit} disabled={submitting || !title.trim() || !description.trim()}>
Přidat
</Button>
</Modal.Footer>
</Modal>
);
}
@@ -1,26 +0,0 @@
import { Modal, Button } from "react-bootstrap";
type Props = {
isOpen: boolean;
title: string;
message: string;
confirmLabel?: string;
confirmVariant?: string;
onConfirm: () => void;
onClose: () => void;
};
export default function ConfirmModal({ isOpen, title, message, confirmLabel = "Potvrdit", confirmVariant = "primary", onConfirm, onClose }: Readonly<Props>) {
return (
<Modal show={isOpen} onHide={onClose}>
<Modal.Header closeButton>
<Modal.Title>{title}</Modal.Title>
</Modal.Header>
<Modal.Body>{message}</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={onClose}>Zrušit</Button>
<Button variant={confirmVariant} onClick={onConfirm}>{confirmLabel}</Button>
</Modal.Footer>
</Modal>
);
}
@@ -1,193 +0,0 @@
import { useState, useEffect } from "react";
import { Modal, Button, Form, Table, Alert } from "react-bootstrap";
import { updateGroupFees, OrderGroup, OrderGroupMember } from "../../../../types";
import { computeFeeShare, computeMemberTotal, countActiveMembers, isActiveMember } from "../../utils/groupFees";
type Props = {
isOpen: boolean;
onClose: () => void;
group: OrderGroup;
onSaved: (data: any) => void;
};
function parseHal(s: string): number {
const n = parseFloat(s.replace(',', '.'));
return isNaN(n) || n < 0 ? 0 : Math.round(n * 100);
}
function parsePercent(s: string): number {
const n = parseFloat(s.replace(',', '.'));
return isNaN(n) || n < 0 ? 0 : Math.round(n);
}
export default function EditGroupFeesModal({ isOpen, onClose, group, onSaved }: Readonly<Props>) {
const [fees, setFees] = useState('');
const [shipping, setShipping] = useState('');
const [tip, setTip] = useState('');
const [discountType, setDiscountType] = useState<'percent' | 'fixed'>('percent');
const [discountValue, setDiscountValue] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!isOpen) return;
setFees(group.fees ? String(group.fees / 100) : '');
setShipping(group.shipping ? String(group.shipping / 100) : '');
setTip(group.tip ? String(group.tip / 100) : '');
setDiscountType((group.discountType as 'percent' | 'fixed') ?? 'percent');
setDiscountValue(group.discountValue
? ((group.discountType as string) === 'fixed' ? String(group.discountValue / 100) : String(group.discountValue))
: '');
setError(null);
}, [isOpen, group]);
const memberEntries = Object.entries(group.members) as [string, OrderGroupMember][];
// Poplatky se dělí jen mezi aktivní strávníky (kdo si reálně něco objednal).
const activeCount = countActiveMembers(group.members);
const feesNum = parseHal(fees);
const shippingNum = parseHal(shipping);
const tipNum = parseHal(tip);
const discountNum = discountType === 'percent' ? parsePercent(discountValue) : parseHal(discountValue);
const totalFees = feesNum + shippingNum + tipNum;
const feeShare = computeFeeShare(totalFees, activeCount);
const feeParams = { totalFees, discountType, discountValue: discountNum };
const handleSave = async () => {
setError(null);
setLoading(true);
try {
const res = await updateGroupFees({
body: {
id: group.id,
fees: feesNum,
shipping: shippingNum,
tip: tipNum,
discountType: discountNum > 0 ? discountType : undefined,
discountValue: discountNum > 0 ? discountNum : undefined,
}
});
if (res.error) {
setError((res.error as any).error || 'Nastala chyba');
} else {
onSaved(res.data);
onClose();
}
} catch (e: any) {
setError(e.message || 'Nastala chyba');
} finally {
setLoading(false);
}
};
return (
<Modal show={isOpen} onHide={onClose} size="lg">
<Modal.Header closeButton>
<Modal.Title><h2>Poplatky skupiny {group.name}</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
{error && (
<Alert variant="danger" onClose={() => setError(null)} dismissible>{error}</Alert>
)}
<div className="d-flex gap-3 flex-wrap mb-3">
<Form.Group>
<Form.Label>Poplatky ()</Form.Label>
<Form.Control
type="number" min={0} step={0.01}
value={fees} onChange={e => setFees(e.target.value)}
placeholder="0" style={{ width: 110 }}
onKeyDown={e => e.stopPropagation()}
/>
</Form.Group>
<Form.Group>
<Form.Label>Doprava ()</Form.Label>
<Form.Control
type="number" min={0} step={0.01}
value={shipping} onChange={e => setShipping(e.target.value)}
placeholder="0" style={{ width: 110 }}
onKeyDown={e => e.stopPropagation()}
/>
</Form.Group>
<Form.Group>
<Form.Label>Spropitné ()</Form.Label>
<Form.Control
type="number" min={0} step={0.01}
value={tip} onChange={e => setTip(e.target.value)}
placeholder="0" style={{ width: 110 }}
onKeyDown={e => e.stopPropagation()}
/>
</Form.Group>
</div>
<div className="d-flex gap-3 align-items-end flex-wrap mb-3">
<Form.Group>
<Form.Label>Sleva</Form.Label>
<div className="d-flex gap-2 align-items-center">
<Form.Select
value={discountType}
onChange={e => setDiscountType(e.target.value as 'percent' | 'fixed')}
style={{ width: 160 }}
>
<option value="percent">Procentuální (%)</option>
<option value="fixed">Pevná částka ()</option>
</Form.Select>
<Form.Control
type="number" min={0} step={discountType === 'percent' ? 1 : 0.01}
value={discountValue} onChange={e => setDiscountValue(e.target.value)}
placeholder="0" style={{ width: 100 }}
onKeyDown={e => e.stopPropagation()}
/>
<span className="text-muted">{discountType === 'percent' ? '%' : 'Kč'}</span>
</div>
</Form.Group>
</div>
<hr />
<h6>Náhled celkových částek ({activeCount} {activeCount === 1 ? 'strávník' : 'strávníků'} s objednávkou, {feeShare > 0 ? `poplatek ${feeShare / 100} Kč/os.` : 'bez poplatku'})</h6>
<Table size="sm" bordered>
<thead>
<tr>
<th>Člen</th>
<th className="text-end">Základ</th>
<th className="text-end">Příplatek</th>
<th className="text-end">Poplatek</th>
<th className="text-end">Sleva</th>
<th className="text-end fw-bold">Celkem</th>
</tr>
</thead>
<tbody>
{memberEntries.map(([login, member]) => {
const base = member.amount ?? 0;
const surcharge = member.surchargeAmount ?? 0;
const active = isActiveMember(member);
const total = computeMemberTotal(member, feeParams, feeShare, activeCount);
// Sleva i poplatek se týkají jen aktivních strávníků.
const discount = active && discountNum > 0
? (discountType === 'percent'
? Math.round((base + surcharge) * discountNum / 100)
: Math.round(discountNum / activeCount))
: 0;
return (
<tr key={login} className={active ? '' : 'text-muted'}>
<td><strong>{login}</strong>{!active && <small className="ms-1">(jen objednává)</small>}</td>
<td className="text-end">{base > 0 ? `${base / 100}` : '—'}</td>
<td className="text-end">{surcharge > 0 ? `${surcharge / 100}` : '—'}</td>
<td className="text-end">{active && feeShare > 0 ? `${feeShare / 100}` : '—'}</td>
<td className="text-end text-danger">{discount > 0 ? `-${discount / 100}` : '—'}</td>
<td className="text-end fw-bold">{total > 0 ? `${total / 100}` : '—'}</td>
</tr>
);
})}
</tbody>
</Table>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={onClose} disabled={loading}>Storno</Button>
<Button variant="primary" onClick={handleSave} disabled={loading}>
{loading ? 'Ukládám...' : 'Uložit'}
</Button>
</Modal.Footer>
</Modal>
);
}
@@ -0,0 +1,45 @@
import { Modal, Button, Form } from "react-bootstrap"
import { FeatureRequest } from "../../../../types";
type Props = {
isOpen: boolean,
onClose: () => void,
onChange: (option: FeatureRequest, active: boolean) => void,
initialValues?: FeatureRequest[],
}
/** Modální dialog pro hlasování o nových funkcích. */
export default function FeaturesVotingModal({ isOpen, onClose, onChange, initialValues }: Readonly<Props>) {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.currentTarget.value as FeatureRequest, e.currentTarget.checked);
}
return <Modal show={isOpen} onHide={onClose} size="lg">
<Modal.Header closeButton>
<Modal.Title>
Hlasujte pro nové funkce
<p style={{ fontSize: '12px' }}>Je možno vybrat maximálně 4 možnosti</p>
</Modal.Title>
</Modal.Header>
<Modal.Body>
{(Object.keys(FeatureRequest) as Array<keyof typeof FeatureRequest>).map(key => {
return <Form.Check
key={key}
type='checkbox'
id={key}
label={FeatureRequest[key]}
onChange={handleChange}
value={key}
defaultChecked={initialValues?.includes(key as FeatureRequest)}
/>
})}
<p className="mt-3" style={{ fontSize: '12px' }}>Něco jiného? Dejte vědět.</p>
</Modal.Body>
<Modal.Footer>
<Button variant="primary" onClick={onClose}>
Zavřít
</Button>
</Modal.Footer>
</Modal>
}
+25 -22
View File
@@ -33,7 +33,9 @@ function parseAmount(s: string): number | null {
if (!s || s.trim().length === 0) return null;
const n = parseFloat(s);
if (isNaN(n) || n < 0) return null;
return Math.round(n * 100);
const parts = s.split('.');
if (parts.length === 2 && parts[1].length > 2) return null;
return Math.round(n * 100) / 100;
}
export default function PayForAllModal({ isOpen, onClose, locationName, locationChoices, menu, payerLogin, bankAccount, bankAccountHolder }: Readonly<Props>) {
@@ -53,11 +55,11 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
let baseAmountParseFailed = false;
if (menu) {
for (const idx of selectedFoods) {
const priceKc = parsePriceCzk(menu.food?.[idx]?.price);
if (priceKc === null) {
const price = parsePriceCzk(menu.food?.[idx]?.price);
if (price === null) {
baseAmountParseFailed = true;
} else {
baseAmount += Math.round(priceKc * 100);
baseAmount += price;
}
}
}
@@ -82,19 +84,13 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
if (includedDiners.length === 0) return 0;
const tip = parseAmount(tipTotal);
if (tip === null || tip === 0) return 0;
const totalPeople = includedDiners.length + 1;
return Math.round(tip / totalPeople);
})();
const payerTipShare = (() => {
const tip = parseAmount(tipTotal);
if (!tip) return 0;
return tip - tipPerPerson * includedDiners.length;
return Math.round((tip / includedDiners.length) * 100) / 100;
})();
const getTotal = (d: DinerEntry): number => {
const surcharge = parseAmount(d.surchargeAmount) ?? 0;
const tip = d.login === payerLogin ? payerTipShare : tipPerPerson;
return d.baseAmount + surcharge + tip;
const tip = d.included && d.login !== payerLogin ? tipPerPerson : 0;
return Math.round((d.baseAmount + surcharge + tip) * 100) / 100;
};
const handleInclude = useCallback((login: string, checked: boolean) => {
@@ -120,6 +116,11 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
setError(`Celková částka pro ${d.login} musí být kladná`);
return;
}
const amountStr = total.toString();
if (amountStr.includes('.') && amountStr.split('.')[1].length > 2) {
setError(`Částka pro ${d.login} má více než 2 desetinná místa`);
return;
}
const foods = d.selectedFoods.map(i => menu?.food?.[i]?.name).filter(Boolean).join(', ');
const purposeBase = `Oběd ${locationName}${foods ? `${foods}` : ''}`;
recipients.push({
@@ -166,7 +167,7 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
</Alert>
) : (
<>
<p>Zaplatili jste za skupinu v restauraci. Nastavte příplatky a společné poplatky, poté vygenerujte QR kódy pro ostatní.</p>
<p>Zaplatili jste za skupinu v restauraci. Nastavte příplatky a dýško, poté vygenerujte QR kódy pro ostatní.</p>
{!hasMenu && (
<Alert variant="info">
@@ -193,7 +194,7 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
<th>Strávník</th>
<th>Jídla</th>
<th style={{ width: 220 }}>Příplatek</th>
<th style={{ width: 90 }}>Poplatek</th>
<th style={{ width: 90 }}>Dýško</th>
<th style={{ width: 90 }}>Celkem</th>
</tr>
</thead>
@@ -219,18 +220,19 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
<td>
<small>
{foodNames || <span className="text-muted">—</span>}
{hasMenu && d.baseAmount > 0 && <span className="text-muted"> ({d.baseAmount / 100} Kč)</span>}
{hasMenu && d.baseAmount > 0 && <span className="text-muted"> ({d.baseAmount} Kč)</span>}
{d.baseAmountParseFailed && <span className="text-warning"> ⚠</span>}
</small>
</td>
<td>
{!isPayer && (
<div className="d-flex gap-1">
<Form.Control
type="text"
placeholder="popis"
value={d.surchargeText}
onChange={e => handleSurchargeText(d.login, e.target.value)}
disabled={!isPayer && !d.included}
disabled={!d.included}
size="sm"
onKeyDown={e => e.stopPropagation()}
/>
@@ -239,18 +241,19 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
placeholder=""
value={d.surchargeAmount}
onChange={e => handleSurchargeAmount(d.login, e.target.value)}
disabled={!isPayer && !d.included}
disabled={!d.included}
size="sm"
style={{ width: 70 }}
onKeyDown={e => e.stopPropagation()}
/>
</div>
)}
</td>
<td className="text-end">
{(() => { const s = isPayer ? payerTipShare : tipPerPerson; return s > 0 ? `${s / 100} Kč` : '—'; })()}
{!isPayer && d.included ? `${tipPerPerson} Kč` : '—'}
</td>
<td className="text-end fw-bold">
{`${total / 100} Kč`}
{!isPayer ? `${total} Kč` : '—'}
</td>
</tr>
);
@@ -259,7 +262,7 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
</Table>
<div className="d-flex align-items-center gap-2 mt-2">
<label className="mb-0 text-nowrap">Poplatky celkem (Kč):</label>
<label className="mb-0 text-nowrap">Dýško celkem (Kč):</label>
<Form.Control
type="text"
placeholder="0"
@@ -271,7 +274,7 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
/>
<small className="text-muted">
{includedDiners.length > 0 && tipPerPerson > 0
? `(${tipPerPerson / 100} Kč / osoba)`
? `(${tipPerPerson} Kč / osoba)`
: ''}
</small>
</div>
@@ -1,220 +0,0 @@
import { useState, useEffect } from "react";
import { Modal, Button, Form, Table, Alert } from "react-bootstrap";
import { generateQr, OrderGroup, OrderGroupMember, QrRecipient } from "../../../../types";
import { sanitizeQrMessage } from "../../Utils";
import { computeFeeShare, computeMemberTotal, countActiveMembers, isActiveMember } from "../../utils/groupFees";
type Props = {
isOpen: boolean;
onClose: () => void;
onSuccess?: () => void;
group: OrderGroup;
payerLogin: string;
bankAccount: string;
bankAccountHolder: string;
groupId?: string;
};
type DinerEntry = {
login: string;
member: OrderGroupMember;
included: boolean;
};
export default function PayForGroupModal({ isOpen, onClose, onSuccess, group, payerLogin, bankAccount, bankAccountHolder, groupId }: Readonly<Props>) {
const [diners, setDiners] = useState<DinerEntry[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
useEffect(() => {
if (!isOpen) return;
const entries: DinerEntry[] = (Object.entries(group.members) as [string, OrderGroupMember][]).map(([login, member]) => ({
login,
member,
// Standardně zahrnout všechny, kdo nejsou plátce a něco si objednali.
included: login !== payerLogin && isActiveMember(member),
}));
setDiners(entries);
setError(null);
setSuccess(false);
}, [isOpen, group, payerLogin]);
const fees = group.fees ?? 0;
const shipping = group.shipping ?? 0;
const tip = group.tip ?? 0;
const totalFees = fees + shipping + tip;
// Poplatky se dělí jen mezi aktivní strávníky (kdo si reálně něco objednal).
const activeCount = countActiveMembers(group.members);
const feeShare = computeFeeShare(totalFees, activeCount);
const feeParams = { totalFees, discountType: group.discountType, discountValue: group.discountValue ?? 0 };
const getMemberTotal = (entry: DinerEntry): number =>
computeMemberTotal(entry.member, feeParams, feeShare, activeCount);
const includedNonPayers = diners.filter(d => d.included && d.login !== payerLogin);
const handleInclude = (login: string, checked: boolean) => {
setDiners(prev => prev.map(d => d.login === login ? { ...d, included: checked } : d));
};
const handleGenerate = async () => {
setError(null);
const recipients: QrRecipient[] = [];
for (const d of diners) {
if (!d.included || d.login === payerLogin) continue;
const total = getMemberTotal(d);
if (total <= 0) {
setError(`Celková částka pro ${d.login} musí být kladná`);
return;
}
const note = d.member.note?.trim();
recipients.push({
login: d.login,
purpose: sanitizeQrMessage(note || `Objednávka ${group.name}`),
amount: total,
});
}
if (recipients.length === 0) {
setError("Nebyl vybrán žádný příjemce");
return;
}
setLoading(true);
try {
const response = await generateQr({
body: { recipients, bankAccount, bankAccountHolder, ...(groupId ? { groupId } : {}) },
});
if (response.error) {
setError((response.error as any).error || 'Nastala chyba při generování QR kódů');
} else {
setSuccess(true);
onSuccess?.();
setTimeout(() => onClose(), 2000);
}
} catch (e: any) {
setError(e.message || 'Nastala chyba při generování QR kódů');
} finally {
setLoading(false);
}
};
const hasFees = totalFees > 0;
return (
<Modal show={isOpen} onHide={onClose} size="lg">
<Modal.Header closeButton>
<Modal.Title><h2>Generovat QR {group.name}</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
{success ? (
<Alert variant="success">
QR kódy byly úspěšně vygenerovány! Uživatelé je uvidí v sekci Nevyřízené platby".
</Alert>
) : (
<>
<p>Zaplatili jste za skupinu. Vyberte, komu vygenerovat QR kód k úhradě.</p>
{error && (
<Alert variant="danger" onClose={() => setError(null)} dismissible>
{error}
</Alert>
)}
{hasFees && (
<div className="d-flex gap-3 mb-2 text-muted" style={{ fontSize: '0.9em' }}>
{fees > 0 && <span>Poplatky: <strong>{fees / 100} Kč</strong></span>}
{shipping > 0 && <span>Doprava: <strong>{shipping / 100} Kč</strong></span>}
{tip > 0 && <span>Spropitné: <strong>{tip / 100} Kč</strong></span>}
<span>→ {feeShare / 100} Kč/os.</span>
</div>
)}
{group.discountValue != null && group.discountValue > 0 && (
<div className="mb-2 text-success" style={{ fontSize: '0.9em' }}>
Sleva: {group.discountType === 'percent' ? `${group.discountValue}%` : `${group.discountValue / 100} Kč`}
</div>
)}
<Table striped bordered hover responsive size="sm">
<thead>
<tr>
<th style={{ width: 40 }}></th>
<th>Člen</th>
<th style={{ width: 90 }} className="text-end">Základ</th>
<th style={{ width: 90 }} className="text-end">Příplatek</th>
{hasFees && <th style={{ width: 90 }} className="text-end">Poplatek</th>}
<th style={{ width: 90 }} className="text-end fw-bold">Celkem</th>
</tr>
</thead>
<tbody>
{diners.map(d => {
const isPayer = d.login === payerLogin;
const active = isActiveMember(d.member);
const total = getMemberTotal(d);
const surcharge = d.member.surchargeAmount ?? 0;
return (
<tr key={d.login} className={(!d.included && !isPayer) || !active ? 'text-muted' : ''}>
<td className="text-center">
{isPayer ? (
<small className="text-muted">plátce</small>
) : !active ? (
<small className="text-muted">jen objednává</small>
) : (
<Form.Check
type="checkbox"
checked={d.included}
onChange={e => handleInclude(d.login, e.target.checked)}
/>
)}
</td>
<td>
<strong>{d.login}</strong>
{d.member.surchargeText && (
<small className="text-muted ms-1">({d.member.surchargeText})</small>
)}
</td>
<td className="text-end">
{(d.member.amount ?? 0) > 0 ? `${d.member.amount! / 100} Kč` : <span className="text-muted">—</span>}
</td>
<td className="text-end">
{surcharge > 0 ? `${surcharge / 100} Kč` : <span className="text-muted">—</span>}
</td>
{hasFees && (
<td className="text-end">
{active && feeShare > 0 ? `${feeShare / 100} Kč` : '—'}
</td>
)}
<td className="text-end fw-bold">
{total > 0 ? `${total / 100} Kč` : <span className="text-muted">—</span>}
</td>
</tr>
);
})}
</tbody>
</Table>
</>
)}
</Modal.Body>
<Modal.Footer>
{!success && (
<>
<span className="me-auto text-muted">Příjemci: {includedNonPayers.length}</span>
<Button variant="secondary" onClick={onClose} disabled={loading}>Storno</Button>
<Button
variant="primary"
onClick={handleGenerate}
disabled={loading || includedNonPayers.length === 0}
>
{loading ? 'Generuji...' : 'Vygenerovat QR'}
</Button>
</>
)}
{success && (
<Button variant="secondary" onClick={onClose}>Zavřít</Button>
)}
</Modal.Footer>
</Modal>
);
}
@@ -15,12 +15,12 @@ export default function PizzaAdditionalFeeModal({ customerName, isOpen, onClose,
const priceRef = useRef<HTMLInputElement>(null);
const doSubmit = () => {
onSave(customerName, textRef.current?.value, Math.round(Number.parseFloat(priceRef.current?.value ?? "0") * 100));
onSave(customerName, textRef.current?.value, Number.parseInt(priceRef.current?.value ?? "0"));
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
onSave(customerName, textRef.current?.value, Math.round(Number.parseFloat(priceRef.current?.value ?? "0") * 100));
onSave(customerName, textRef.current?.value, Number.parseInt(priceRef.current?.value ?? "0"));
}
}
@@ -1,137 +0,0 @@
import { useState } from "react";
import { Modal, Button, Form, ListGroup, Alert } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faTrashCan } from "@fortawesome/free-regular-svg-icons";
import { faUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
import { addStore, deleteStore, Store } from "../../../../types";
type Props = {
isOpen: boolean;
onClose: () => void;
stores: Store[];
onStoresChanged: (stores: Store[]) => void;
};
export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChanged }: Readonly<Props>) {
const [newName, setNewName] = useState('');
const [newUrl, setNewUrl] = useState('');
const [heslo, setHeslo] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleAdd = async () => {
if (!newName.trim()) return;
setError(null);
setLoading(true);
try {
const res = await addStore({ body: { name: newName.trim(), url: newUrl.trim() || undefined, heslo } });
if (res.error) {
setError((res.error as any).error || 'Nastala chyba');
} else if (res.data) {
onStoresChanged(res.data as Store[]);
setNewName('');
setNewUrl('');
}
} catch (e: any) {
setError(e.message || 'Nastala chyba');
} finally {
setLoading(false);
}
};
const handleRemove = async (name: string) => {
setError(null);
setLoading(true);
try {
const res = await deleteStore({ body: { name, heslo } });
if (res.error) {
setError((res.error as any).error || 'Nastala chyba');
} else if (res.data) {
onStoresChanged(res.data as Store[]);
}
} catch (e: any) {
setError(e.message || 'Nastala chyba');
} finally {
setLoading(false);
}
};
return (
<Modal show={isOpen} onHide={onClose}>
<Modal.Header closeButton>
<Modal.Title><h2>Správa obchodů</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
{error && (
<Alert variant="danger" onClose={() => setError(null)} dismissible>
{error}
</Alert>
)}
<Form.Group className="mb-3">
<Form.Label>Admin heslo</Form.Label>
<Form.Control
type="password"
placeholder="Heslo"
value={heslo}
onChange={e => setHeslo(e.target.value)}
onKeyDown={e => e.stopPropagation()}
/>
</Form.Group>
<hr />
<h6>Přidat obchod</h6>
<Form.Control
className="mb-2"
type="text"
placeholder="Název obchodu"
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleAdd(); }}
/>
<div className="d-flex gap-2 mb-3">
<Form.Control
type="url"
placeholder="URL na nabídku (volitelné, např. Bolt Food/Wolt)"
value={newUrl}
onChange={e => setNewUrl(e.target.value)}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleAdd(); }}
/>
<Button variant="primary" onClick={handleAdd} disabled={loading || !newName.trim() || !heslo}>
Přidat
</Button>
</div>
<h6>Aktuální seznam</h6>
{stores.length === 0 ? (
<p className="text-muted">Žádné obchody v seznamu</p>
) : (
<ListGroup>
{stores.map(s => (
<ListGroup.Item key={s.name} className="d-flex justify-content-between align-items-center">
<span>
{s.name}
{s.url && /^https?:\/\//i.test(s.url) && (
<a href={s.url} target="_blank" rel="noopener noreferrer" className="ms-2" title="Otevřít nabídku v nové záložce">
<FontAwesomeIcon icon={faUpRightFromSquare} />
</a>
)}
</span>
<FontAwesomeIcon
icon={faTrashCan}
className="action-icon"
title="Odebrat"
onClick={() => handleRemove(s.name)}
style={{ cursor: 'pointer' }}
/>
</ListGroup.Item>
))}
</ListGroup>
)}
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={onClose}>Zavřít</Button>
</Modal.Footer>
</Modal>
);
}
@@ -1,30 +0,0 @@
import { Modal, Button } from "react-bootstrap";
import { Suggestion } from "../../../../types";
type Props = {
suggestion?: Suggestion;
onClose: () => void;
};
/** Modální dialog zobrazující celý detail návrhu na vylepšení. */
export default function SuggestionDetailModal({ suggestion, onClose }: Readonly<Props>) {
return (
<Modal show={!!suggestion} onHide={onClose} size="lg">
<Modal.Header closeButton>
<Modal.Title><h2>{suggestion?.title}</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
<p className="text-muted mb-3">
Navrhovatel: <strong>{suggestion?.author}</strong> · Hlasy: <strong>{suggestion?.voteScore}</strong>
{suggestion?.resolved && <> · <strong>Vyřešeno</strong></>}
</p>
<p style={{ whiteSpace: "pre-wrap" }}>{suggestion?.description}</p>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={onClose}>
Zavřít
</Button>
</Modal.Footer>
</Modal>
);
}
+12 -90
View File
@@ -4,8 +4,6 @@ const BANK_ACCOUNT_NUMBER_KEY = 'bank_account_number';
const BANK_ACCOUNT_HOLDER_KEY = 'bank_account_holder_name';
const HIDE_SOUPS_KEY = 'hide_soups';
const THEME_KEY = 'theme_preference';
const ACCENT_HUE_KEY = 'accent_hue';
const LEGACY_COLOR_THEME_KEY = 'color_theme';
export type ThemePreference = 'system' | 'light' | 'dark';
@@ -14,13 +12,10 @@ export type SettingsContextProps = {
holderName?: string,
hideSoups?: boolean,
themePreference: ThemePreference,
accentHue: number,
effectiveDark: boolean,
setBankAccountNumber: (accountNumber?: string) => void,
setBankAccountHolderName: (holderName?: string) => void,
setHideSoupsOption: (hideSoups?: boolean) => void,
setThemePreference: (theme: ThemePreference) => void,
setAccentHue: (hue: number) => void,
}
type ContextProps = {
@@ -50,74 +45,11 @@ function getInitialTheme(): ThemePreference {
return 'system';
}
function getInitialAccentHue(): number {
try {
const saved = localStorage.getItem(ACCENT_HUE_KEY);
if (saved !== null) {
const n = parseInt(saved, 10);
if (!isNaN(n) && n >= 0 && n <= 360) return n;
}
// Migrace ze starého string formátu (green/blue/purple)
const old = localStorage.getItem(LEGACY_COLOR_THEME_KEY);
if (old === 'blue') return 217;
if (old === 'purple') return 263;
} catch {
// localStorage nedostupný
}
return 142;
}
// Převod HSL na relativní jas dle WCAG (pro výpočet kontrastu s bílým textem)
function hslToRelativeLuminance(h: number, s: number, l: number): number {
const sn = s / 100, ln = l / 100;
const a = sn * Math.min(ln, 1 - ln);
const ch = (n: number) => {
const k = (n + h / 30) % 12;
return ln - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
};
const toLinear = (c: number) => c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
return 0.2126 * toLinear(ch(0)) + 0.7152 * toLinear(ch(8)) + 0.0722 * toLinear(ch(4));
}
// Najde nejnižší světlost, při které má barva dostatečný kontrast s bílým textem (WCAG AA 4.5:1)
function adjustedL(hue: number, sat: number, targetL: number): number {
let l = targetL;
while (l >= 5) {
const lum = hslToRelativeLuminance(hue, sat, l);
if (1.05 / (lum + 0.05) >= 4.5) return l;
l -= 1;
}
return l;
}
function applyAccentColors(hue: number, isDark: boolean): void {
const sat = 70;
const baseL = adjustedL(hue, sat, isDark ? 55 : 38);
const hoverL = isDark ? Math.min(baseL + 10, 80) : Math.max(baseL - 10, 10);
const root = document.documentElement;
root.style.setProperty('--luncher-primary', `hsl(${hue} ${sat}% ${baseL}%)`);
root.style.setProperty('--luncher-primary-hover', `hsl(${hue} ${sat}% ${hoverL}%)`);
root.style.setProperty('--luncher-primary-light', isDark
? `hsl(${hue} 60% 12%)`
: `hsl(${hue} 60% 92%)`);
root.style.setProperty('--luncher-action-icon', `hsl(${hue} ${sat}% ${baseL}%)`);
root.style.setProperty('--luncher-success', `hsl(${hue} ${sat}% ${baseL}%)`);
}
function useProvideSettings(): SettingsContextProps {
const [bankAccount, setBankAccount] = useState<string | undefined>();
const [holderName, setHolderName] = useState<string | undefined>();
const [hideSoups, setHideSoups] = useState<boolean | undefined>();
const [themePreference, setTheme] = useState<ThemePreference>(getInitialTheme);
const [accentHue, setHue] = useState<number>(getInitialAccentHue);
const [effectiveDark, setEffectiveDark] = useState<boolean>(() => {
try {
const pref = localStorage.getItem(THEME_KEY) as ThemePreference | null;
if (pref === 'dark') return true;
if (pref === 'light') return false;
} catch { /* noop */ }
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
});
useEffect(() => {
const accountNumber = localStorage.getItem(BANK_ACCOUNT_NUMBER_KEY);
@@ -163,27 +95,24 @@ function useProvideSettings(): SettingsContextProps {
}, [themePreference]);
useEffect(() => {
const applyTheme = (dark: boolean) => {
document.documentElement.setAttribute('data-bs-theme', dark ? 'dark' : 'light');
setEffectiveDark(dark);
const applyTheme = (theme: 'light' | 'dark') => {
document.documentElement.setAttribute('data-bs-theme', theme);
};
if (themePreference === 'system') {
const mq = window.matchMedia('(prefers-color-scheme: dark)');
applyTheme(mq.matches);
const handler = (e: MediaQueryListEvent) => applyTheme(e.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
applyTheme(mediaQuery.matches ? 'dark' : 'light');
const handler = (e: MediaQueryListEvent) => {
applyTheme(e.matches ? 'dark' : 'light');
};
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
} else {
applyTheme(themePreference === 'dark');
applyTheme(themePreference);
}
}, [themePreference]);
// Aplikuje accent barvy při změně hue nebo přepnutí světlý/tmavý
useEffect(() => {
localStorage.setItem(ACCENT_HUE_KEY, String(accentHue));
applyAccentColors(accentHue, effectiveDark);
}, [accentHue, effectiveDark]);
function setBankAccountNumber(bankAccount?: string) {
setBankAccount(bankAccount);
}
@@ -200,21 +129,14 @@ function useProvideSettings(): SettingsContextProps {
setTheme(theme);
}
function setAccentHue(hue: number) {
setHue(hue);
}
return {
bankAccount,
holderName,
hideSoups,
themePreference,
accentHue,
effectiveDark,
setBankAccountNumber,
setBankAccountHolderName,
setHideSoupsOption,
setThemePreference,
setAccentHue,
}
}
+1 -15
View File
@@ -8,27 +8,13 @@ if (process.env.NODE_ENV === 'development') {
socketPath = undefined;
} else {
socketUrl = `${globalThis.location.host}`;
socketPath = '/socket.io';
socketPath = `${globalThis.location.pathname}socket.io`;
}
export const socket = socketio.connect(socketUrl, { path: socketPath, transports: ["websocket"] });
export const SocketContext = React.createContext();
// Prohlížeče throttlují setTimeout v neaktivních tabech, což zdržuje automatické
// znovupřipojení socket.io. Po návratu do tabu nebo focusu okna se připojíme hned.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && !socket.connected) {
socket.connect();
}
});
window.addEventListener('focus', () => {
if (!socket.connected) {
socket.connect();
}
});
// Konstanty websocket eventů, musí odpovídat těm na serveru!
export const EVENT_CONNECT = 'connect';
export const EVENT_DISCONNECT = 'disconnect';
export const EVENT_MESSAGE = 'message';
export const EVENT_PENDING_QR = 'pendingQr';
-879
View File
@@ -1,879 +0,0 @@
import { useCallback, useContext, useEffect, useRef, useState } from 'react';
import { Alert, Badge, Button, Card, Form, Modal, OverlayTrigger, Table, Tooltip } from 'react-bootstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTrashCan } from '@fortawesome/free-regular-svg-icons';
import { faBasketShopping, faChevronLeft, faChevronRight, faCircleCheck, faClockRotateLeft, faGear, faLock, faLockOpen, faPen, faSearch, faUserPlus } from '@fortawesome/free-solid-svg-icons';
import DatePicker, { registerLocale } from 'react-datepicker';
import { cs } from 'date-fns/locale';
import 'react-datepicker/dist/react-datepicker.css';
import {
ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember, PendingQr,
getData, createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, setBoltTracking, getOrderDates,
} from '../../../types';
import { computeFeeShare, computeMemberTotal, countActiveMembers } from '../utils/groupFees';
import { EVENT_MESSAGE, EVENT_PENDING_QR, SocketContext } from '../context/socket';
import { useAuth } from '../context/auth';
import { useSettings } from '../context/settings';
import { formatDate, formatDateString } from '../Utils';
import Login from '../Login';
import Header from '../components/Header';
import Footer from '../components/Footer';
import Loader from '../components/Loader';
import StoreAdminModal from '../components/modals/StoreAdminModal';
import PayForGroupModal from '../components/modals/PayForGroupModal';
import EditGroupFeesModal from '../components/modals/EditGroupFeesModal';
import PendingPayments from '../components/PendingPayments';
import BoltOrderProgress from '../components/BoltOrderProgress';
const SLOT = MealSlot.EXTRA;
const TIME_REGEX = /^([01]\d|2[0-3]):[0-5]\d$/;
const BOLT_SHARE_URL_PREFIX = 'https://food.bolt.eu/sharedActiveOrder/';
const BOLT_TOKEN_REGEX = /^[0-9a-f]{64}$/i;
/** Vytáhne sledovací token ze sdílecí URL Bolt Food, nebo přijme samotný token. Null = neplatný vstup. */
function extractBoltToken(input: string): string | null {
const trimmed = input.trim();
if (!trimmed) return null;
if (BOLT_TOKEN_REGEX.test(trimmed)) return trimmed;
try {
const segments = new URL(trimmed).pathname.split('/').filter(Boolean);
const last = segments[segments.length - 1];
return last && BOLT_TOKEN_REGEX.test(last) ? last : null;
} catch {
return null;
}
}
/** Zkrátí dlouhý odkaz pro zobrazení v řádku (zachová začátek i konec). */
function shortenUrl(url: string, max = 48): string {
if (url.length <= max) return url;
const head = Math.ceil((max - 1) / 2);
const tail = Math.floor((max - 1) / 2);
return `${url.slice(0, head)}${url.slice(url.length - tail)}`;
}
// Český lokál pro date picker (názvy měsíců/dnů, pondělí jako první den)
registerLocale('cs', cs);
/** Vrátí ISO datum (YYYY-MM-DD) posunuté o předaný počet dní. */
function shiftIsoDate(iso: string, days: number): string {
const date = new Date(`${iso}T00:00:00`);
date.setDate(date.getDate() + days);
return formatDate(date);
}
/** Převede ISO datum (YYYY-MM-DD) na lokální Date (půlnoc), nebo null. */
function isoToDate(iso?: string): Date | null {
return iso ? new Date(`${iso}T00:00:00`) : null;
}
function stateBadge(state: GroupState) {
const map: Record<GroupState, { bg: string; label: string }> = {
[GroupState.OPEN]: { bg: 'success', label: 'Otevřeno' },
[GroupState.LOCKED]: { bg: 'warning', label: 'Uzamčeno' },
[GroupState.ORDERED]: { bg: 'secondary', label: 'Objednáno' },
};
const { bg, label } = map[state] ?? { bg: 'light', label: state };
return <Badge bg={bg}>{label}</Badge>;
}
export default function OrderGroupsPage() {
const auth = useAuth();
const settings = useSettings();
const socket = useContext(SocketContext);
const [data, setData] = useState<ClientData | undefined>();
const [failure, setFailure] = useState(false);
// Vybrané datum pro zobrazení historie (undefined = aktuální den)
const [selectedDate, setSelectedDate] = useState<string | undefined>();
// ISO datum dnešního dne dle serveru (horní hranice navigace), zjištěné při prvním načtení
const [todayIso, setTodayIso] = useState<string | undefined>();
// Ref pro socket handler aby věděl, zda se zobrazuje historie (na ní se živé aktualizace neaplikují)
const selectedDateRef = useRef<string | undefined>(undefined);
// ISO data dnů, ve kterých existuje aspoň jedna objednávka (pro zvýraznění v date pickeru)
const [orderDates, setOrderDates] = useState<string[]>([]);
const [newGroupName, setNewGroupName] = useState('');
const [creating, setCreating] = useState(false);
const [adminModalOpen, setAdminModalOpen] = useState(false);
const [editAmounts, setEditAmounts] = useState<Record<string, string>>({});
const [editNotes, setEditNotes] = useState<Record<string, string>>({});
const [editSurcharges, setEditSurcharges] = useState<Record<string, { text: string; amount: string }>>({});
const [editTimes, setEditTimes] = useState<Record<string, { orderedAt: string; deliveryAt: string; boltUrl: string }>>({});
const [payModal, setPayModal] = useState<OrderGroup | null>(null);
const [feesModal, setFeesModal] = useState<OrderGroup | null>(null);
const [confirmOrderGroup, setConfirmOrderGroup] = useState<OrderGroup | null>(null);
const [pageError, setPageError] = useState<string | null>(null);
const fetchData = async (date?: string) => {
try {
const r = await getData({ query: { slot: SLOT, date } });
if (r.data) {
setData(r.data);
// Při zobrazení aktuálního dne si zapamatujeme dnešní ISO datum jako horní hranici navigace
if (!date && r.data.isoDate) setTodayIso(r.data.isoDate);
}
} catch {
setFailure(true);
}
};
// Načte dny s objednávkou pro zvýraznění v date pickeru
const fetchOrderDates = async () => {
const r = await getOrderDates();
if (r.data?.dates) setOrderDates(r.data.dates);
};
useEffect(() => {
selectedDateRef.current = selectedDate;
}, [selectedDate]);
useEffect(() => {
if (!auth?.login) return;
fetchData(selectedDate);
}, [auth?.login, selectedDate]);
useEffect(() => {
if (!auth?.login) return;
fetchOrderDates();
}, [auth?.login]);
useEffect(() => {
socket.on(EVENT_MESSAGE, (newData: ClientData) => {
// Živé aktualizace se týkají vždy dneška při zobrazení historie je ignorujeme
if (selectedDateRef.current) return;
if (newData.slot === SLOT) setData(prev => ({
...newData,
stores: newData.stores ?? prev?.stores,
}));
});
// Nová nevyřízená platba (QR kód) připojíme do dat, aby se zobrazila i bez znovunačtení stránky
socket.on(EVENT_PENDING_QR, (pendingQr: PendingQr) => {
if (selectedDateRef.current) return;
setData(prev => prev ? { ...prev, pendingQrs: [...(prev.pendingQrs ?? []), pendingQr] } : prev);
});
return () => { socket.off(EVENT_MESSAGE); socket.off(EVENT_PENDING_QR); };
}, [socket]);
// Připojení do osobní socket místnosti po přihlášení bez toho nechodí události
// o nových nevyřízených platbách (QR kódy se posílají do místnosti user:<login>)
useEffect(() => {
if (auth?.login) {
socket.emit('join', auth.login);
}
}, [auth?.login, socket]);
useEffect(() => {
// Po znovupřipojení socketu znovu vstoupíme do osobní místnosti a načteme aktuálně
// zobrazený den (mohli jsme přijít o živé aktualizace)
const onReconnect = () => {
if (auth?.login) socket.emit('join', auth.login);
fetchData(selectedDateRef.current);
};
socket.io.on('reconnect', onReconnect);
return () => { socket.io.off('reconnect', onReconnect); };
}, [socket, auth?.login]);
// Navigace mezi dny pomocí klávesových šipek (←/→), obdobně jako na hlavní stránce
const handleKeyDown = useCallback((e: KeyboardEvent) => {
// Ignorujeme, pokud uživatel právě píše do formulářového pole
const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') return;
const currentIso = data?.isoDate;
if (!currentIso) return;
if (e.keyCode === 37) {
// Předchozí den do minulosti bez omezení
setSelectedDate(shiftIsoDate(currentIso, -1));
} else if (e.keyCode === 39 && todayIso != null && currentIso < todayIso) {
// Následující den nejvýše po dnešek (na dnešek přes undefined kvůli živým aktualizacím)
const target = shiftIsoDate(currentIso, 1);
setSelectedDate(target >= todayIso ? undefined : target);
}
}, [data?.isoDate, todayIso]);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
const refresh = async (fn: () => Promise<any>): Promise<boolean> => {
setPageError(null);
const result = await fn();
if (result?.error) {
setPageError((result.error as any).error || 'Nastala chyba');
await fetchData();
return false;
}
if (result?.data) {
setData(result.data);
socket.emit?.('message', result.data as ClientData);
}
// Sada dnů s objednávkou se mohla změnit (vytvoření/smazání skupiny)
fetchOrderDates();
return true;
};
const handleCreate = async () => {
if (!newGroupName || !auth?.login) return;
setCreating(true);
const ok = await refresh(() => createGroup({ body: { name: newGroupName } }));
if (ok) setNewGroupName('');
setCreating(false);
};
const handleJoin = (groupId: string) =>
refresh(() => addGroupMember({ body: { id: groupId } }));
const handleToggleLock = (group: OrderGroup) => {
const next = group.state === GroupState.OPEN ? GroupState.LOCKED : GroupState.OPEN;
return refresh(() => setGroupState({ body: { id: group.id, state: next } }));
};
const handleConfirmOrdered = async (group: OrderGroup) => {
setConfirmOrderGroup(null);
await refresh(() => setGroupState({ body: { id: group.id, state: GroupState.ORDERED } }));
};
const handleRevertOrdered = (group: OrderGroup) =>
refresh(() => setGroupState({ body: { id: group.id, state: GroupState.LOCKED } }));
const handleDelete = (groupId: string) =>
refresh(() => deleteGroup({ body: { id: groupId } }));
const handleSaveAmount = async (groupId: string, login: string) => {
const key = `${groupId}:${login}`;
const raw = editAmounts[key];
const n = parseFloat(raw ?? '');
if (!raw || isNaN(n) || n < 0) {
setPageError('Zadejte platnou kladnou částku');
return;
}
const ok = await refresh(() => updateGroupMember({ body: { id: groupId, login, amount: Math.round(n * 100) } }));
if (ok) setEditAmounts(prev => { const next = { ...prev }; delete next[key]; return next; });
};
const handleSaveNote = async (groupId: string, login: string) => {
const key = `${groupId}:${login}`;
const note = editNotes[key] ?? '';
const ok = await refresh(() => updateGroupMember({ body: { id: groupId, login, note } }));
if (ok) setEditNotes(prev => { const next = { ...prev }; delete next[key]; return next; });
};
const handleSaveSurcharge = async (groupId: string, login: string) => {
const key = `${groupId}:${login}`;
const surchargeText = editSurcharges[key]?.text ?? '';
const rawAmount = editSurcharges[key]?.amount ?? '';
const surchargeAmount = rawAmount === '' ? 0 : parseFloat(rawAmount.replace(',', '.'));
if (rawAmount !== '' && (isNaN(surchargeAmount) || surchargeAmount < 0)) {
setPageError('Zadejte platnou výši příplatku');
return;
}
const ok = await refresh(() => updateGroupMember({ body: { id: groupId, login, surchargeText, surchargeAmount: rawAmount === '' ? 0 : Math.round(surchargeAmount * 100) } }));
if (ok) setEditSurcharges(prev => { const next = { ...prev }; delete next[key]; return next; });
};
const handleSaveTimes = async (group: OrderGroup) => {
const times = editTimes[group.id];
if (!times) return;
const { orderedAt, deliveryAt, boltUrl } = times;
if (orderedAt && !TIME_REGEX.test(orderedAt)) {
setPageError('Čas objednání musí být ve formátu HH:MM');
return;
}
if (deliveryAt && !TIME_REGEX.test(deliveryAt)) {
setPageError('Čas doručení musí být ve formátu HH:MM');
return;
}
// Bolt odkaz se odesílá jen při změně oproti aktuálnímu tokenu skupiny
const boltToken = boltUrl.trim() ? extractBoltToken(boltUrl) : null;
if (boltUrl.trim() && !boltToken) {
setPageError('Neplatný odkaz Bolt (očekávána URL sdílení objednávky)');
return;
}
const boltChanged = (boltToken ?? undefined) !== group.boltTrackingToken;
let ok = await refresh(() => updateGroupTimes({ body: { id: group.id, orderedAt, deliveryAt } }));
if (ok && boltChanged) {
ok = await refresh(() => setBoltTracking({ body: { id: group.id, shareUrl: boltUrl.trim() } }));
}
if (ok) setEditTimes(prev => { const next = { ...prev }; delete next[group.id]; return next; });
};
// Pozn.: tyto funkce se volají až v renderu, kde je k dispozici `selectedDate`.
// Historie (jiný než aktuální den) je vždy read-only.
const canEditMember = (group: OrderGroup, targetLogin: string) => {
if (selectedDate) return false;
if (group.state === GroupState.ORDERED) return false;
if (auth?.login === group.creatorLogin) return true;
if (auth?.login === targetLogin && group.state === GroupState.OPEN) return true;
return false;
};
const canManageMembers = (group: OrderGroup) => {
if (selectedDate) return false;
if (group.state === GroupState.ORDERED) return false;
if (auth?.login === group.creatorLogin) return true;
return group.state === GroupState.OPEN;
};
if (!auth?.login) return <Login />;
if (failure) return (
<Loader icon={faSearch} description="Nepodařilo se načíst data" animation="fa-beat" />
);
if (!data) return (
<Loader icon={faSearch} description="Načítám..." animation="fa-bounce" />
);
const stores = data.stores ?? [];
const groups = data.groups ?? [];
// Zobrazené datum a režim historie (vše read-only, pokud nejde o aktuální den)
const displayedIso = data.isoDate;
const isToday = !selectedDate || (todayIso != null && displayedIso === todayIso);
const isReadOnly = !isToday;
const canGoNext = todayIso != null && displayedIso != null && displayedIso < todayIso;
const goToDay = (offset: number) => {
if (!displayedIso) return;
const target = shiftIsoDate(displayedIso, offset);
// Na dnešek (či dál) se vracíme přes undefined, aby se obnovily živé aktualizace
setSelectedDate(todayIso != null && target >= todayIso ? undefined : target);
};
const handleDatePick = (value: string) => {
if (!value) return;
setSelectedDate(todayIso != null && value >= todayIso ? undefined : value);
};
// Dny s objednávkou jako Date objekty pro zvýraznění v kalendáři
const highlightedOrderDates = orderDates
.map(d => isoToDate(d))
.filter((d): d is Date => d != null);
return (
<div className="app-container">
<Header choices={data.choices} />
<div className="wrapper">
<div className="d-flex align-items-center justify-content-between mb-1">
<h1 className="title mb-0">Objednání</h1>
<Button variant="outline-secondary" size="sm" onClick={() => setAdminModalOpen(true)} title="Správa obchodů">
<FontAwesomeIcon icon={faGear} />
</Button>
</div>
<p style={{ color: 'var(--luncher-text-muted)' }}>Skupinové objednávky z obchodů a restaurací</p>
{/* Navigace mezi dny šipky kolem výběru data (i klávesami ←/→) */}
<div className="day-navigator order-day-navigator">
<span title="Předchozí den">
<FontAwesomeIcon icon={faChevronLeft} onClick={() => goToDay(-1)} />
</span>
<DatePicker
selected={isoToDate(displayedIso)}
onChange={(d: Date | null) => handleDatePick(d ? formatDate(d) : '')}
maxDate={isoToDate(todayIso) ?? undefined}
highlightDates={[{ 'luncher-order-day': highlightedOrderDates }]}
locale="cs"
dateFormat="d. M. yyyy"
calendarStartDay={1}
popperPlacement="bottom"
className={`form-control text-center fw-semibold order-date-input ${isReadOnly ? 'text-muted' : ''}`}
/>
<span title="Následující den">
<FontAwesomeIcon
icon={faChevronRight}
style={{ visibility: canGoNext ? 'visible' : 'hidden' }}
onClick={() => canGoNext && goToDay(1)}
/>
</span>
</div>
{isReadOnly && (
<Alert variant="secondary" className="d-flex align-items-center gap-2 py-2">
<FontAwesomeIcon icon={faClockRotateLeft} />
<span>
Prohlížíte historii ze dne <strong>{displayedIso ? formatDateString(displayedIso) : data.date}</strong> data jsou pouze pro čtení.
</span>
<Button variant="link" size="sm" className="p-0 ms-auto" onClick={() => setSelectedDate(undefined)}>
Zpět na dnešek
</Button>
</Alert>
)}
{pageError && (
<Alert variant="danger" dismissible onClose={() => setPageError(null)} className="mt-2">
{pageError}
</Alert>
)}
<div className="content-wrapper">
<div className="content" style={{ maxWidth: 1200 }}>
{/* Vytvoření nové skupiny pouze pro aktuální den */}
{!isReadOnly && (
<div className="choice-section fade-in mb-4">
<h5>Vytvořit skupinu</h5>
{stores.length === 0 ? (
<p className="text-muted">
Nejsou přidány žádné obchody.{' '}
<Button variant="link" size="sm" className="p-0" onClick={() => setAdminModalOpen(true)}>
Přidat obchod
</Button>
</p>
) : (
<div className="d-flex gap-2 align-items-end flex-wrap">
<Form.Select
value={newGroupName}
onChange={e => setNewGroupName(e.target.value)}
style={{ maxWidth: 260 }}
>
<option value=""> vyberte obchod </option>
{stores.map(s => <option key={s.name} value={s.name}>{s.name}</option>)}
</Form.Select>
<Button variant="primary" onClick={handleCreate} disabled={creating || !newGroupName}>
Vytvořit skupinu
</Button>
</div>
)}
</div>
)}
{/* Seznam skupin */}
{groups.length === 0 && (
<p className="text-muted fade-in">
{isReadOnly ? 'Pro tento den nejsou žádné skupiny.' : 'Zatím žádné skupiny pro dnešní den.'}
</p>
)}
{groups.map(group => {
const login = auth!.login ?? '';
const isCreator = login === group.creatorLogin;
const isMember = login in group.members;
const isOrdered = group.state === GroupState.ORDERED;
const isLocked = group.state === GroupState.LOCKED;
const memberEntries = Object.entries(group.members) as [string, OrderGroupMember][];
const editingTimes = group.id in editTimes;
// URL na nabídku podniku (pokud ji má dohledatelný obchod vyplněnou).
// Povolíme jen http(s), aby odkaz nemohl být zneužit (např. javascript:).
const rawStoreUrl = stores.find(s => s.name === group.name)?.url;
const storeUrl = rawStoreUrl && /^https?:\/\//i.test(rawStoreUrl) ? rawStoreUrl : undefined;
const totalFees = (group.fees ?? 0) + (group.shipping ?? 0) + (group.tip ?? 0);
// Poplatky se dělí jen mezi aktivní strávníky (kdo si reálně něco objednal).
const activeCount = countActiveMembers(group.members);
const feeShare = computeFeeShare(totalFees, activeCount);
const feeParams = { totalFees, discountType: group.discountType, discountValue: group.discountValue ?? 0 };
const getMemberTotal = (m: OrderGroupMember) =>
computeMemberTotal(m, feeParams, feeShare, activeCount);
return (
<Card key={group.id} className="mb-3 fade-in">
<Card.Header className="d-flex justify-content-between align-items-center">
<div className="d-flex align-items-center gap-2">
{storeUrl ? (
<strong>
<a href={storeUrl} target="_blank" rel="noopener noreferrer" title="Otevřít nabídku v nové záložce">
{group.name}
</a>
</strong>
) : (
<strong>{group.name}</strong>
)}
{stateBadge(group.state)}
<small className="text-muted">zakladatel: {group.creatorLogin}</small>
</div>
<div className="d-flex gap-2">
{!isReadOnly && isCreator && !isOrdered && (
<>
<Button variant="outline-info" size="sm" onClick={() => setFeesModal(group)} title="Upravit poplatky a slevu">
Poplatky
</Button>
<Button variant="outline-secondary" size="sm" onClick={() => handleToggleLock(group)} title={isLocked ? 'Odemknout' : 'Uzamknout'}>
<FontAwesomeIcon icon={isLocked ? faLockOpen : faLock} />
</Button>
{isLocked && (
<Button variant="outline-primary" size="sm" onClick={() => setConfirmOrderGroup(group)}>
Objednáno
</Button>
)}
<Button variant="outline-danger" size="sm" onClick={() => handleDelete(group.id)} title="Smazat skupinu">
<FontAwesomeIcon icon={faTrashCan} />
</Button>
</>
)}
{!isReadOnly && isCreator && isOrdered && (
<>
{settings?.bankAccount && settings?.holderName && !group.qrGenerated && (
<Button variant="primary" size="sm" onClick={() => setPayModal(group)}>
<FontAwesomeIcon icon={faBasketShopping} className="me-1" />
Generovat QR
</Button>
)}
<Button variant="outline-warning" size="sm" onClick={() => handleRevertOrdered(group)} title="Vrátit na Uzamčeno (smaže QR kódy)">
<FontAwesomeIcon icon={faLockOpen} />
</Button>
</>
)}
{!isReadOnly && !isMember && !isOrdered && !isLocked && (
<Button variant="outline-success" size="sm" onClick={() => handleJoin(group.id)}>
<FontAwesomeIcon icon={faUserPlus} className="me-1" />
Přidat se
</Button>
)}
</div>
</Card.Header>
<Card.Body className="p-0">
<Table className="mb-0" size="sm">
<thead>
<tr>
<th>Člen</th>
<th style={{ width: 180 }}>Částka (bez slev)</th>
<th style={{ width: 220 }}>Příplatek</th>
<th>Poznámka</th>
<th style={{ width: 160 }}>Celkem (s poplatky)</th>
<th style={{ width: 40 }}></th>
</tr>
</thead>
<tbody>
{memberEntries.map(([memberLogin, member]) => {
const key = `${group.id}:${memberLogin}`;
const editingAmount = key in editAmounts;
const editingNote = key in editNotes;
const editingSurcharge = key in editSurcharges;
const canEdit = canEditMember(group, memberLogin);
const memberTotal = getMemberTotal(member);
return (
<tr key={memberLogin}>
<td>
<span className="user-info">
<strong>{memberLogin}</strong>
{memberLogin === group.creatorLogin && (
<OverlayTrigger placement="top" overlay={<Tooltip>Zakladatel / objednávající</Tooltip>}>
<span className="ms-1"><FontAwesomeIcon icon={faBasketShopping} className="buyer-icon" /></span>
</OverlayTrigger>
)}
{member.paid && (
<OverlayTrigger placement="top" overlay={<Tooltip>Zaplaceno</Tooltip>}>
<span className="ms-1"><FontAwesomeIcon icon={faCircleCheck} className="text-success" /></span>
</OverlayTrigger>
)}
</span>
</td>
<td>
{canEdit && editingAmount ? (
<div className="d-flex gap-1">
<Form.Control
type="number"
size="sm"
value={editAmounts[key]}
onChange={e => setEditAmounts(prev => ({ ...prev, [key]: e.target.value }))}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveAmount(group.id, memberLogin); if (e.key === 'Escape') setEditAmounts(prev => { const n = { ...prev }; delete n[key]; return n; }); }}
style={{ width: 95 }}
autoFocus
/>
<Button size="sm" variant="outline-success" onClick={() => handleSaveAmount(group.id, memberLogin)}></Button>
</div>
) : (
<span
style={{ cursor: canEdit ? 'pointer' : undefined }}
onClick={() => canEdit && setEditAmounts(prev => ({ ...prev, [key]: member.amount != null ? String(member.amount / 100) : '' }))}
title={canEdit ? 'Klikněte pro úpravu' : undefined}
>
{member.amount != null ? `${member.amount / 100}` : <span className="text-muted"></span>}
</span>
)}
</td>
<td>
{canEdit && editingSurcharge ? (
<div className="d-flex gap-1">
<Form.Control
type="text"
size="sm"
placeholder="popis"
value={editSurcharges[key]?.text ?? ''}
onChange={e => setEditSurcharges(prev => ({ ...prev, [key]: { ...prev[key], text: e.target.value } }))}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveSurcharge(group.id, memberLogin); if (e.key === 'Escape') setEditSurcharges(prev => { const n = { ...prev }; delete n[key]; return n; }); }}
style={{ width: 80 }}
autoFocus
/>
<Form.Control
type="number"
size="sm"
placeholder="Kč"
value={editSurcharges[key]?.amount ?? ''}
onChange={e => setEditSurcharges(prev => ({ ...prev, [key]: { ...prev[key], amount: e.target.value } }))}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveSurcharge(group.id, memberLogin); if (e.key === 'Escape') setEditSurcharges(prev => { const n = { ...prev }; delete n[key]; return n; }); }}
style={{ width: 60 }}
/>
<Button size="sm" variant="outline-success" onClick={() => handleSaveSurcharge(group.id, memberLogin)}></Button>
</div>
) : (
<span
style={{ cursor: canEdit ? 'pointer' : undefined }}
onClick={() => canEdit && setEditSurcharges(prev => ({ ...prev, [key]: { text: member.surchargeText ?? '', amount: member.surchargeAmount != null ? String(member.surchargeAmount / 100) : '' } }))}
title={canEdit ? 'Klikněte pro úpravu příplatku' : undefined}
>
{member.surchargeAmount != null && member.surchargeAmount > 0 ? (
<small>{member.surchargeText ? `${member.surchargeText}: ` : ''}<strong>{member.surchargeAmount / 100} </strong></small>
) : (
<small className="text-muted"></small>
)}
</span>
)}
</td>
<td>
{canEdit && editingNote ? (
<div className="d-flex gap-1">
<Form.Control
type="text"
size="sm"
value={editNotes[key]}
onChange={e => setEditNotes(prev => ({ ...prev, [key]: e.target.value }))}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveNote(group.id, memberLogin); if (e.key === 'Escape') setEditNotes(prev => { const n = { ...prev }; delete n[key]; return n; }); }}
autoFocus
/>
<Button size="sm" variant="outline-success" onClick={() => handleSaveNote(group.id, memberLogin)}></Button>
</div>
) : (
<span
style={{ cursor: canEdit ? 'pointer' : undefined }}
onClick={() => canEdit && setEditNotes(prev => ({ ...prev, [key]: member.note ?? '' }))}
title={canEdit ? 'Klikněte pro úpravu poznámky' : undefined}
>
<small className="text-muted">{member.note || '—'}</small>
</span>
)}
</td>
<td className="text-end">
<small className={memberTotal > 0 ? 'fw-bold' : 'text-muted'}>
{memberTotal > 0 ? `${memberTotal / 100}` : '—'}
</small>
</td>
<td>
<div className="d-flex gap-1 justify-content-end">
{canManageMembers(group) && (isCreator || memberLogin === login) && (memberLogin !== group.creatorLogin) && (
<FontAwesomeIcon
icon={faTrashCan}
className="action-icon"
title={memberLogin === login ? 'Odhlásit se' : 'Odebrat z skupiny'}
onClick={() => refresh(() => removeGroupMember({ body: { id: group.id, login: memberLogin } }))}
/>
)}
</div>
</td>
</tr>
);
})}
</tbody>
{(() => {
const sumBase = memberEntries.reduce((sum, [, m]) => sum + (m.amount ?? 0) + (m.surchargeAmount ?? 0), 0);
const dv = group.discountValue ?? 0;
const totalDiscount = dv > 0
? (group.discountType === 'percent' ? Math.round(sumBase * dv / 100) : dv)
: 0;
const groupTotal = sumBase + totalFees - totalDiscount;
return groupTotal > 0 ? (
<tfoot>
<tr style={{ fontWeight: 700, borderTop: '2px solid var(--luncher-border)' }}>
<td colSpan={4} className="text-end" style={{ fontSize: '0.9em' }}>Celkem za skupinu:</td>
<td className="text-end">{groupTotal / 100} </td>
<td></td>
</tr>
</tfoot>
) : null;
})()}
</Table>
{/* Souhrn poplatků a slevy */}
{(totalFees > 0 || (group.discountValue != null && group.discountValue > 0)) && (
<div className="px-3 py-2 border-top d-flex gap-3 flex-wrap" style={{ fontSize: '0.85em', color: 'var(--luncher-text-muted)' }}>
{group.fees != null && group.fees > 0 && <span>Poplatky: <strong>{group.fees / 100} </strong></span>}
{group.shipping != null && group.shipping > 0 && <span>Doprava: <strong>{group.shipping / 100} </strong></span>}
{group.tip != null && group.tip > 0 && <span>Spropitné: <strong>{group.tip / 100} </strong></span>}
{feeShare > 0 && <span> <strong>{feeShare / 100} </strong>/os.</span>}
{group.discountValue != null && group.discountValue > 0 && (
<span className="text-success">
Sleva: <strong>{group.discountType === 'percent' ? `${group.discountValue}%` : `${group.discountValue / 100}`}</strong>
</span>
)}
</div>
)}
{/* Časy objednání a doručení */}
{isOrdered && (
<div className="px-3 py-2 border-top">
{!isReadOnly && isCreator && editingTimes ? (
<div className="d-flex align-items-center gap-3 flex-wrap">
<div className="d-flex align-items-center gap-1">
<small className="text-muted text-nowrap">Objednáno v:</small>
<Form.Control
type="text"
size="sm"
placeholder="HH:MM"
value={editTimes[group.id]?.orderedAt ?? ''}
onChange={e => setEditTimes(prev => ({ ...prev, [group.id]: { ...prev[group.id], orderedAt: e.target.value } }))}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveTimes(group); }}
style={{ width: 75 }}
autoFocus
/>
</div>
<div className="d-flex align-items-center gap-1">
<small className="text-muted text-nowrap">Doručení v:</small>
<Form.Control
type="text"
size="sm"
placeholder="HH:MM"
value={editTimes[group.id]?.deliveryAt ?? ''}
onChange={e => setEditTimes(prev => ({ ...prev, [group.id]: { ...prev[group.id], deliveryAt: e.target.value } }))}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveTimes(group); }}
style={{ width: 75 }}
/>
</div>
<div className="d-flex align-items-center gap-1">
<small className="text-muted text-nowrap">Bolt odkaz pro sledování:</small>
<Form.Control
type="text"
size="sm"
placeholder={`${BOLT_SHARE_URL_PREFIX}`}
value={editTimes[group.id]?.boltUrl ?? ''}
onChange={e => setEditTimes(prev => ({ ...prev, [group.id]: { ...prev[group.id], boltUrl: e.target.value } }))}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveTimes(group); }}
style={{ width: 260 }}
/>
</div>
<Button size="sm" variant="outline-success" onClick={() => handleSaveTimes(group)}>Uložit</Button>
<Button size="sm" variant="outline-secondary" onClick={() => setEditTimes(prev => { const n = { ...prev }; delete n[group.id]; return n; })}>Zrušit</Button>
</div>
) : (
<div className="d-flex align-items-center gap-3 flex-wrap">
{(() => {
const canEdit = !isReadOnly && isCreator;
// Aktivace editačního režimu stejné chování jako tlačítko s tužkou
const startEdit = () => canEdit && setEditTimes(prev => ({ ...prev, [group.id]: { orderedAt: group.orderedAt ?? '', deliveryAt: group.deliveryAt ?? '', boltUrl: group.boltTrackingToken ? `${BOLT_SHARE_URL_PREFIX}${group.boltTrackingToken}` : '' } }));
const trackingUrl = group.boltTrackingToken ? `${BOLT_SHARE_URL_PREFIX}${group.boltTrackingToken}` : null;
return (
<>
{canEdit && (
<FontAwesomeIcon
icon={faPen}
className="action-icon"
title="Upravit časy a odkaz pro sledování"
onClick={startEdit}
/>
)}
<small
className="text-muted"
style={{ cursor: canEdit ? 'pointer' : undefined }}
onClick={startEdit}
>
Objednáno v: <strong>{group.orderedAt ?? '—'}</strong>
</small>
<small
className="text-muted"
style={{ cursor: canEdit ? 'pointer' : undefined }}
onClick={startEdit}
>
Doručení v: <strong>{group.deliveryAt ?? '—'}</strong>
{group.boltTrackingToken && (
<OverlayTrigger overlay={<Tooltip>Čas doručení se aktualizuje automaticky z Bolt Food</Tooltip>}>
<Badge bg="success" className="ms-1">Bolt</Badge>
</OverlayTrigger>
)}
</small>
<small className="text-muted text-nowrap">
URL pro sledování:{' '}
{trackingUrl ? (
<a
href={trackingUrl}
target="_blank"
rel="noopener noreferrer"
title={trackingUrl}
onClick={e => e.stopPropagation()}
>
{shortenUrl(trackingUrl)}
</a>
) : (
<strong></strong>
)}
</small>
</>
);
})()}
</div>
)}
{group.boltOrderState && (
<div className="mt-2">
<BoltOrderProgress state={group.boltOrderState} courierState={group.boltCourierState} tracking={!!group.boltTrackingToken} />
</div>
)}
</div>
)}
</Card.Body>
</Card>
);
})}
{/* Nevyřízené platby přihlášeného uživatele jen v režimu aktuálního dne */}
{!isReadOnly && (
<PendingPayments
pendingQrs={data.pendingQrs}
login={auth.login}
onDismissed={() => fetchData()}
/>
)}
</div>
</div>
</div>
<Footer />
{/* Potvrzovací dialog pro přechod do stavu Objednáno */}
<Modal show={!!confirmOrderGroup} onHide={() => setConfirmOrderGroup(null)} centered>
<Modal.Header closeButton>
<Modal.Title>Potvrdit objednání</Modal.Title>
</Modal.Header>
<Modal.Body>
Opravdu chcete označit skupinu <strong>{confirmOrderGroup?.name}</strong> jako objednanou?
Tato akce uzavře skupinu a zaznamená čas objednání.
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={() => setConfirmOrderGroup(null)}>Zrušit</Button>
<Button variant="primary" onClick={() => confirmOrderGroup && handleConfirmOrdered(confirmOrderGroup)}>
Objednáno
</Button>
</Modal.Footer>
</Modal>
<StoreAdminModal
isOpen={adminModalOpen}
onClose={() => setAdminModalOpen(false)}
stores={stores}
onStoresChanged={updated => setData(prev => prev ? { ...prev, stores: updated } : prev)}
/>
{payModal && settings?.bankAccount && settings?.holderName && (
<PayForGroupModal
isOpen={!!payModal}
onClose={() => setPayModal(null)}
onSuccess={() => fetchData()}
group={payModal}
groupId={payModal.id}
payerLogin={auth.login}
bankAccount={settings.bankAccount}
bankAccountHolder={settings.holderName}
/>
)}
{feesModal && (
<EditGroupFeesModal
isOpen={!!feesModal}
onClose={() => setFeesModal(null)}
group={feesModal}
onSaved={newData => {
if (newData) {
setData(newData);
socket.emit?.('message', newData as ClientData);
}
setFeesModal(null);
}}
/>
)}
</div>
);
}
+63
View File
@@ -89,4 +89,67 @@
.recharts-cartesian-grid-vertical line {
stroke: var(--luncher-border);
}
.voting-stats-section {
margin-top: 48px;
width: 100%;
max-width: 800px;
h2 {
font-size: 1.5rem;
font-weight: 700;
color: var(--luncher-text);
margin-bottom: 16px;
text-align: center;
}
}
.voting-stats-table {
width: 100%;
background: var(--luncher-bg-card);
border-radius: var(--luncher-radius-lg);
box-shadow: var(--luncher-shadow);
border: 1px solid var(--luncher-border-light);
overflow: hidden;
border-collapse: collapse;
th {
background: var(--luncher-primary);
color: #ffffff;
padding: 12px 20px;
text-align: left;
font-weight: 600;
font-size: 0.9rem;
&:last-child {
text-align: center;
width: 120px;
}
}
td {
padding: 12px 20px;
border-bottom: 1px solid var(--luncher-border-light);
color: var(--luncher-text);
font-size: 0.9rem;
&:last-child {
text-align: center;
font-weight: 600;
color: var(--luncher-primary);
}
}
tbody tr {
transition: var(--luncher-transition);
&:hover {
background: var(--luncher-bg-hover);
}
&:last-child td {
border-bottom: none;
}
}
}
}
+36 -1
View File
@@ -4,7 +4,7 @@ import Header from "../components/Header";
import { useAuth } from "../context/auth";
import Login from "../Login";
import { formatDate, getFirstWorkDayOfWeek, getHumanDate, getLastWorkDayOfWeek } from "../Utils";
import { WeeklyStats, LunchChoice, getStats } from "../../../types";
import { WeeklyStats, LunchChoice, VotingStats, FeatureRequest, getStats, getVotingStats } from "../../../types";
import Loader from "../components/Loader";
import { faChevronLeft, faChevronRight, faGear } from "@fortawesome/free-solid-svg-icons";
import { Legend, Line, LineChart, Tooltip, XAxis, YAxis } from "recharts";
@@ -32,6 +32,7 @@ export default function StatsPage() {
const auth = useAuth();
const [dateRange, setDateRange] = useState<Date[]>();
const [data, setData] = useState<WeeklyStats>();
const [votingStats, setVotingStats] = useState<VotingStats>();
// Prvotní nastavení aktuálního týdne
useEffect(() => {
@@ -48,6 +49,19 @@ export default function StatsPage() {
}
}, [dateRange]);
// Načtení statistik hlasování
useEffect(() => {
getVotingStats().then(response => {
setVotingStats(response.data);
});
}, []);
const sortedVotingStats = useMemo(() => {
if (!votingStats) return [];
return Object.entries(votingStats)
.sort((a, b) => (b[1] as number) - (a[1] as number));
}, [votingStats]);
const renderLine = (location: LunchChoice) => {
const index = Object.values(LunchChoice).indexOf(location);
return <Line key={location} name={getLunchChoiceName(location)} type="monotone" dataKey={data => data.locations[location] ?? 0} stroke={COLORS[index]} strokeWidth={STROKE_WIDTH} />
@@ -128,6 +142,27 @@ export default function StatsPage() {
<Tooltip />
<Legend />
</LineChart>
{sortedVotingStats.length > 0 && (
<div className="voting-stats-section">
<h2>Hlasování o funkcích</h2>
<table className="voting-stats-table">
<thead>
<tr>
<th>Funkce</th>
<th>Počet hlasů</th>
</tr>
</thead>
<tbody>
{sortedVotingStats.map(([feature, count]) => (
<tr key={feature}>
<td>{FeatureRequest[feature as keyof typeof FeatureRequest] ?? feature}</td>
<td>{count as number}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<Footer />
</>
-145
View File
@@ -1,145 +0,0 @@
.suggestions-page {
display: flex;
flex-direction: column;
align-items: center;
padding: 32px 24px;
min-height: calc(100vh - 140px);
background: var(--luncher-bg);
.suggestions-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
width: 100%;
max-width: 900px;
flex-wrap: wrap;
h1 {
font-size: 2rem;
font-weight: 700;
color: var(--luncher-text);
margin: 0;
}
}
.suggestions-info {
width: 100%;
max-width: 900px;
margin: 12px 0 24px;
color: var(--luncher-text-secondary);
font-size: 0.95rem;
}
.suggestions-empty {
color: var(--luncher-text-secondary);
margin-top: 32px;
}
.resolved-section {
width: 100%;
max-width: 900px;
margin-top: 48px;
h2 {
font-size: 1.4rem;
font-weight: 700;
color: var(--luncher-text);
margin-bottom: 8px;
}
}
.suggestions-table.resolved {
th {
background: var(--luncher-text-secondary);
}
td.col-score {
color: var(--luncher-text-secondary);
}
}
.suggestions-table {
width: 100%;
max-width: 900px;
background: var(--luncher-bg-card);
border-radius: var(--luncher-radius-lg);
box-shadow: var(--luncher-shadow);
border: 1px solid var(--luncher-border-light);
overflow: hidden;
border-collapse: collapse;
th {
background: var(--luncher-primary);
color: #ffffff;
padding: 12px 20px;
text-align: left;
font-weight: 600;
font-size: 0.9rem;
}
td {
padding: 12px 20px;
border-bottom: 1px solid var(--luncher-border-light);
color: var(--luncher-text);
font-size: 0.9rem;
vertical-align: middle;
}
.col-score {
text-align: center;
width: 80px;
font-weight: 600;
}
td.col-score {
color: var(--luncher-primary);
}
.col-actions {
text-align: center;
width: 150px;
white-space: nowrap;
}
tbody tr {
cursor: pointer;
transition: var(--luncher-transition);
&:hover {
background: var(--luncher-bg-hover);
}
&:last-child td {
border-bottom: none;
}
}
.vote-btn {
background: transparent;
border: none;
cursor: pointer;
padding: 6px 8px;
border-radius: var(--luncher-radius-sm, 6px);
color: var(--luncher-text-secondary);
transition: var(--luncher-transition);
&:hover {
background: var(--luncher-bg-hover);
color: var(--luncher-text);
}
&.vote-up.active {
color: #2e7d32;
}
&.vote-down.active {
color: #c62828;
}
&.delete-btn:hover {
color: #c62828;
}
}
}
}
-187
View File
@@ -1,187 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { Button, OverlayTrigger, Tooltip } from "react-bootstrap";
import { ToastContainer } from "react-toastify";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faThumbsUp, faThumbsDown, faTrash, faPlus, faGear } from "@fortawesome/free-solid-svg-icons";
import Header from "../components/Header";
import Footer from "../components/Footer";
import Loader from "../components/Loader";
import { useAuth } from "../context/auth";
import Login from "../Login";
import AddSuggestionModal from "../components/modals/AddSuggestionModal";
import SuggestionDetailModal from "../components/modals/SuggestionDetailModal";
import {
Suggestion,
VoteDirection,
listSuggestions,
addSuggestion,
voteSuggestion,
deleteSuggestion,
} from "../../../types";
import "./SuggestionsPage.scss";
export default function SuggestionsPage() {
const auth = useAuth();
const [suggestions, setSuggestions] = useState<Suggestion[]>();
const [addModalOpen, setAddModalOpen] = useState(false);
const [detail, setDetail] = useState<Suggestion>();
const reload = useCallback(async () => {
if (!auth?.login) return;
const response = await listSuggestions();
setSuggestions(response.data ?? []);
}, [auth?.login]);
useEffect(() => {
reload();
}, [reload]);
const handleAdd = async (title: string, description: string) => {
const response = await addSuggestion({ body: { title, description } });
if (response.data) {
setSuggestions(response.data);
}
};
const handleVote = async (id: string, direction: VoteDirection) => {
const response = await voteSuggestion({ body: { id, direction } });
if (response.data) {
setSuggestions(response.data);
}
};
const handleDelete = async (suggestion: Suggestion) => {
if (!window.confirm(`Opravdu chcete smazat návrh „${suggestion.title}“? Smažou se i všechny jeho hlasy.`)) {
return;
}
const response = await deleteSuggestion({ body: { id: suggestion.id } });
if (response.data) {
setSuggestions(response.data);
}
};
// Vykreslí jeden řádek tabulky. Vyřešené návrhy jsou read-only (bez hlasování),
// ale autor je stále může smazat.
const renderRow = (suggestion: Suggestion) => (
<OverlayTrigger
key={suggestion.id}
placement="top"
overlay={<Tooltip id={`tooltip-${suggestion.id}`}>{suggestion.description}</Tooltip>}
>
<tr onClick={() => setDetail(suggestion)}>
<td>{suggestion.author}</td>
<td>{suggestion.title}</td>
<td className="col-score">{suggestion.voteScore}</td>
<td className="col-actions" onClick={e => e.stopPropagation()}>
{!suggestion.resolved && (
<>
<button
type="button"
className={`vote-btn vote-up ${suggestion.myVote === VoteDirection.UP ? "active" : ""}`}
title="Hlasovat pro"
onClick={() => handleVote(suggestion.id, VoteDirection.UP)}
>
<FontAwesomeIcon icon={faThumbsUp} />
</button>
<button
type="button"
className={`vote-btn vote-down ${suggestion.myVote === VoteDirection.DOWN ? "active" : ""}`}
title="Hlasovat proti"
onClick={() => handleVote(suggestion.id, VoteDirection.DOWN)}
>
<FontAwesomeIcon icon={faThumbsDown} />
</button>
</>
)}
{suggestion.isMine && (
<button
type="button"
className="vote-btn delete-btn"
title="Smazat návrh"
onClick={() => handleDelete(suggestion)}
>
<FontAwesomeIcon icon={faTrash} />
</button>
)}
</td>
</tr>
</OverlayTrigger>
);
if (!auth?.login) {
return <Login />;
}
if (!suggestions) {
return <Loader icon={faGear} description={"Načítám návrhy..."} animation={"fa-bounce"} />;
}
const activeSuggestions = suggestions.filter(s => !s.resolved);
const resolvedSuggestions = suggestions.filter(s => s.resolved);
return (
<>
<Header />
<div className="suggestions-page">
<div className="suggestions-header">
<h1>Návrhy na vylepšení</h1>
<Button onClick={() => setAddModalOpen(true)}>
<FontAwesomeIcon icon={faPlus} /> Přidat návrh
</Button>
</div>
<p className="suggestions-info">
Zde můžete navrhovat vylepšení aplikace a hlasovat o návrzích ostatních. U každého návrhu je
zobrazeno jméno navrhovatele. Jména hlasujících jsou dostupná pouze administrátorům.
</p>
{suggestions.length === 0 ? (
<p className="suggestions-empty">Zatím nebyly přidány žádné návrhy. Buďte první!</p>
) : (
<>
{activeSuggestions.length > 0 && (
<table className="suggestions-table">
<thead>
<tr>
<th>Navrhovatel</th>
<th>Název</th>
<th className="col-score">Hlasy</th>
<th className="col-actions">Akce</th>
</tr>
</thead>
<tbody>
{activeSuggestions.map(renderRow)}
</tbody>
</table>
)}
{resolvedSuggestions.length > 0 && (
<div className="resolved-section">
<h2>Vyřešené návrhy</h2>
<p className="suggestions-info">
Tyto návrhy již byly zapracovány. Nelze pro hlasovat, autor je však může odstranit.
</p>
<table className="suggestions-table resolved">
<thead>
<tr>
<th>Navrhovatel</th>
<th>Název</th>
<th className="col-score">Hlasy</th>
<th className="col-actions">Akce</th>
</tr>
</thead>
<tbody>
{resolvedSuggestions.map(renderRow)}
</tbody>
</table>
</div>
)}
</>
)}
</div>
<Footer />
<AddSuggestionModal isOpen={addModalOpen} onClose={() => setAddModalOpen(false)} onSubmit={handleAdd} />
<SuggestionDetailModal suggestion={detail} onClose={() => setDetail(undefined)} />
<ToastContainer />
</>
);
}
-67
View File
@@ -1,67 +0,0 @@
import { OrderGroup, OrderGroupMember } from "../../../types";
/**
* Pomocné funkce pro výpočet částek ve skupinových objednávkách.
*
* Klíčové pravidlo: poplatky (balné + doprava + spropitné) se rozpočítávají
* pouze mezi "aktivní" strávníky — tedy ty, kteří si reálně něco objednali.
* Kdo si nic neobjedná (typicky objednávající, který nakupuje jen pro ostatní),
* neplatí nic a nezapočítává se mu ani poměrná část poplatků.
*/
/** Parametry poplatků a slevy potřebné k výpočtu částky člena. */
export type GroupFeeParams = {
/** Celkové poplatky skupiny v haléřích (balné + doprava + spropitné). */
totalFees: number;
/** Typ slevy ('percent' = procenta, 'fixed' = pevná částka v haléřích). */
discountType?: string;
/** Hodnota slevy — procenta, nebo pevná částka v haléřích dle discountType. */
discountValue?: number;
};
/** Vrátí true, pokud si člen něco objednal (má kladnou částku nebo příplatek). */
export function isActiveMember(member: OrderGroupMember): boolean {
return (member.amount ?? 0) + (member.surchargeAmount ?? 0) > 0;
}
/** Počet aktivních strávníků — jen mezi ně se dělí poplatky. */
export function countActiveMembers(members: OrderGroup["members"]): number {
return Object.values(members).filter(isActiveMember).length;
}
/** Celkové poplatky skupiny (balné + doprava + spropitné) v haléřích. */
export function totalGroupFees(group: OrderGroup): number {
return (group.fees ?? 0) + (group.shipping ?? 0) + (group.tip ?? 0);
}
/** Poměrná část poplatků na jednoho aktivního strávníka v haléřích. */
export function computeFeeShare(totalFees: number, activeCount: number): number {
return activeCount > 0 ? Math.round(totalFees / activeCount) : 0;
}
/**
* Celková částka, kterou má člen zaplatit (v haléřích).
* Neaktivní člen (nic si neobjednal) platí 0 — nepodílí se ani na poplatcích.
*
* @param member člen skupiny
* @param params poplatky a sleva
* @param feeShare poměrná část poplatků na osobu (viz computeFeeShare)
* @param activeCount počet aktivních strávníků (dělitel pevné slevy)
*/
export function computeMemberTotal(
member: OrderGroupMember,
params: GroupFeeParams,
feeShare: number,
activeCount: number,
): number {
if (!isActiveMember(member)) return 0;
const base = member.amount ?? 0;
const surcharge = member.surchargeAmount ?? 0;
const discountValue = params.discountValue ?? 0;
const discount = discountValue > 0
? (params.discountType === 'percent'
? Math.round((base + surcharge) * discountValue / 100)
: Math.round(discountValue / activeCount))
: 0;
return base + surcharge + feeShare - discount;
}
-1
View File
@@ -8,7 +8,6 @@ export default defineConfig({
plugins: [react(), viteTsconfigPaths()],
server: {
open: true,
host: '0.0.0.0',
port: 3000,
proxy: {
'/api': 'http://localhost:3001',
-55
View File
@@ -428,42 +428,6 @@
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz#9e585ab6086bef994c6e8a5b3a0481219ada862b"
integrity sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==
"@floating-ui/core@^1.7.5":
version "1.7.5"
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.7.5.tgz#d4af157a03330af5a60e69da7a4692507ada0622"
integrity sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==
dependencies:
"@floating-ui/utils" "^0.2.11"
"@floating-ui/dom@^1.7.6":
version "1.7.6"
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.7.6.tgz#f915bba5abbb177e1f227cacee1b4d0634b187bf"
integrity sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==
dependencies:
"@floating-ui/core" "^1.7.5"
"@floating-ui/utils" "^0.2.11"
"@floating-ui/react-dom@^2.1.8":
version "2.1.8"
resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.1.8.tgz#5fb5a20d10aafb9505f38c24f38d00c8e1598893"
integrity sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==
dependencies:
"@floating-ui/dom" "^1.7.6"
"@floating-ui/react@^0.27.15":
version "0.27.19"
resolved "https://registry.yarnpkg.com/@floating-ui/react/-/react-0.27.19.tgz#d8d5d895b7cb97dac370bfbf55f3e630878fdf1f"
integrity sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==
dependencies:
"@floating-ui/react-dom" "^2.1.8"
"@floating-ui/utils" "^0.2.11"
tabbable "^6.0.0"
"@floating-ui/utils@^0.2.11":
version "0.2.11"
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.11.tgz#a269e055e40e2f45873bae9d1a2fdccbd314ea3f"
integrity sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==
"@fortawesome/fontawesome-common-types@7.1.0":
version "7.1.0"
resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.1.0.tgz#a4e0b7e40073d5fdef41182da1bc216a05875659"
@@ -1252,11 +1216,6 @@ d3-timer@^3.0.1:
resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0"
integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==
date-fns@^4.1.0:
version "4.4.0"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-4.4.0.tgz#806539edf45c616b2b76b5f78b88c56ed3c7e036"
integrity sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==
debug@^4.1.0, debug@^4.3.1, debug@~4.4.1:
version "4.4.3"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a"
@@ -1667,15 +1626,6 @@ react-bootstrap@^2.10.10:
uncontrollable "^7.2.1"
warning "^4.0.3"
react-datepicker@^9.1.0:
version "9.1.0"
resolved "https://registry.yarnpkg.com/react-datepicker/-/react-datepicker-9.1.0.tgz#638c636780ae98f1930f87e1f76850de5fc37d5c"
integrity sha512-lOp+m5bc+ttgtB5MHEjwiVu4nlp4CvJLS/PG1OiOe5pmg9kV73pEqO8H0Geqvg2E8gjqTaL9eRhSe+ZpeKP3nA==
dependencies:
"@floating-ui/react" "^0.27.15"
clsx "^2.1.1"
date-fns "^4.1.0"
react-dom@^19.2.0:
version "19.2.3"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.3.tgz#f0b61d7e5c4a86773889fcc1853af3ed5f215b17"
@@ -1931,11 +1881,6 @@ supports-color@^7.1.0:
dependencies:
has-flag "^4.0.0"
tabbable@^6.0.0:
version "6.4.0"
resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.4.0.tgz#36eb7a06d80b3924a22095daf45740dea3bf5581"
integrity sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==
tiny-invariant@^1.3.3:
version "1.3.3"
resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz#46680b7a873a0d5d10005995eb90a70d74d60127"
-3
View File
@@ -1,3 +0,0 @@
node_modules/
playwright-report/
test-results/
-16
View File
@@ -1,16 +0,0 @@
{
"name": "@luncher/e2e",
"version": "1.0.0",
"private": true,
"scripts": {
"test": "playwright test",
"test:ui": "playwright test --ui",
"test:debug": "playwright test --debug",
"report": "playwright show-report"
},
"devDependencies": {
"@playwright/test": "^1.50.0",
"@types/node": "^22.0.0",
"typescript": "^5.9.3"
}
}
-64
View File
@@ -1,64 +0,0 @@
import { defineConfig, devices } from '@playwright/test';
import path from 'path';
// Use 127.0.0.1 explicitly — on Node.js 18+/Windows, `localhost` may resolve to ::1
// (IPv6) while the HTTP server only binds to 0.0.0.0 (IPv4), causing the webServer
// readiness poll to time out even though the server is listening.
// Port 3099 avoids conflicts with locally running Docker containers on 3001-3003.
// Override with E2E_PORT env var if needed.
const E2E_PORT = process.env.E2E_PORT ?? '3099';
const BASE_URL = process.env.E2E_BASE_URL ?? `http://127.0.0.1:${E2E_PORT}`;
// Server env vars injected for local runs. In CI these are set at the step level.
const serverEnv: Record<string, string> = {
NODE_ENV: 'test',
MOCK_DATA: 'true',
STORAGE: process.env.STORAGE ?? 'json',
JWT_SECRET: process.env.JWT_SECRET ?? 'e2e-test-secret-min-32-chars-aaaa',
HTTP_REMOTE_USER_ENABLED: 'true',
HTTP_REMOTE_USER_HEADER_NAME: 'remote-user',
HTTP_REMOTE_TRUSTED_IPS: process.env.HTTP_REMOTE_TRUSTED_IPS ?? '127.0.0.1,::1,::ffff:127.0.0.1',
PORT: E2E_PORT,
};
if (process.env.REDIS_HOST) {
serverEnv.REDIS_HOST = process.env.REDIS_HOST;
serverEnv.REDIS_PORT = process.env.REDIS_PORT ?? '6379';
}
export default defineConfig({
testDir: './tests',
timeout: 30_000,
retries: process.env.CI ? 1 : 0,
workers: 1,
reporter: [['list'], ['html', { open: 'never' }]],
use: {
baseURL: BASE_URL,
// Default: every test authenticates as e2e-user via trusted header.
// Tests that need the real login form should override this in their own context.
extraHTTPHeaders: {
'remote-user': 'e2e-user',
},
trace: 'retain-on-failure',
video: 'retain-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
],
// Pre-built server must be started before tests. In CI the step does this
// explicitly. Locally: build types+server+client, cp -r client/dist server/public,
// then `cd e2e && yarn test` OR let webServer below do it if reuseExistingServer=true
// is set and the server is already running.
webServer: {
command: 'node dist/server/src/index.js',
cwd: path.resolve(__dirname, '../server'),
// Poll a dedicated health endpoint — polling '/' can stall in Express 5 when
// server/public/ doesn't exist in the working directory (no finalhandler match).
url: `http://127.0.0.1:${E2E_PORT}/api/health`,
timeout: 15_000,
reuseExistingServer: !process.env.CI,
env: serverEnv,
stdout: 'pipe',
stderr: 'pipe',
},
});
-24
View File
@@ -1,24 +0,0 @@
import { Page, APIRequestContext } from '@playwright/test';
/** Přihlásí uživatele přes POST /api/login a uloží JWT do localStorage. */
export async function loginViaApi(page: Page, login: string): Promise<void> {
const response = await page.request.post('/api/login', {
headers: { 'Content-Type': 'application/json', 'remote-user': login },
data: {},
});
const token = await response.json() as string;
await page.goto('/');
await page.evaluate((t) => localStorage.setItem('token', t), token);
}
/** Vyčistí stav dne pro zadaný dayIndex (0=pondělí4=pátek) přes dev API.
* /api/dev/* vyžaduje JWT nejdřív získáme token přes /api/login.
*/
export async function clearDay(request: APIRequestContext, dayIndex = 4): Promise<void> {
const loginResp = await request.post('/api/login', { data: {} });
const token = await loginResp.json() as string;
await request.post('/api/dev/clear', {
headers: { Authorization: `Bearer ${token}` },
data: { dayIndex },
});
}
-50
View File
@@ -1,50 +0,0 @@
import { test, expect } from '@playwright/test';
// Tento test záměrně NEPOUŽÍVÁ trusted-header testuje reálný login formulář.
test.use({ extraHTTPHeaders: {} });
test('uživatel se přihlásí formulářem a uvidí obsah aplikace', async ({ page }) => {
// Server běží s HTTP_REMOTE_USER_ENABLED=true, takže POST /api/login vždy vyžaduje
// hlavičku remote-user. Zachytíme požadavky z formuláře (mají tělo s polem login)
// a přidáme hlavičku; požadavek auto-loginu (bez těla) projde bez hlavičky a selže,
// čímž formulář zůstane viditelný.
await page.route('**/api/login', async (route) => {
const body = route.request().postData();
let login: string | undefined;
try { login = body ? JSON.parse(body)?.login : undefined; } catch {}
await route.continue({
headers: login
? { ...route.request().headers(), 'remote-user': login }
: route.request().headers(),
});
});
await page.goto('/');
// Formulář musí být viditelný auto-login selhal (nepřišla hlavička)
const loginInput = page.locator('#login-input');
await expect(loginInput).toBeVisible({ timeout: 10_000 });
// Vyplnění loginu a odeslání Enterem
await loginInput.fill('testuser');
await loginInput.press('Enter');
// Po přihlášení musí zmizet login formulář
await expect(loginInput).not.toBeVisible({ timeout: 10_000 });
// JWT musí být uloženo v localStorage jako 3-dílný token
const token = await page.evaluate(() => localStorage.getItem('token'));
expect(token).toBeTruthy();
expect((token as string).split('.')).toHaveLength(3);
});
test('trusted-header přihlášení proběhne automaticky bez formuláře', async ({ page, context }) => {
// Obnoví trusted header (přepíše prázdný extraHTTPHeaders z test.use výše)
await context.setExtraHTTPHeaders({ 'remote-user': 'e2e-auto-user' });
await page.goto('/');
// Login formulář by se neměl nikdy zobrazit, nebo se ihned schová
await page.waitForLoadState('networkidle');
const loginInput = page.locator('#login-input');
await expect(loginInput).not.toBeVisible({ timeout: 5_000 });
});
-68
View File
@@ -1,68 +0,0 @@
import { test, expect } from '@playwright/test';
import { clearDay } from './helpers';
test.beforeEach(async ({ page, request }) => {
// Vyčistíme volby dne, aby testy neovlivnily navzájem
await clearDay(request);
await page.goto('/');
await page.waitForLoadState('networkidle');
// Počkáme, až se zobrazí volba stravování
await expect(page.locator('.choice-section select').first()).toBeVisible({ timeout: 10_000 });
});
test('výběr restaurace zobrazí seznam jídel', async ({ page }) => {
const locationSelect = page.locator('.choice-section select').first();
// Vybereme Sladovnickou mock menu existuje
await locationSelect.selectOption('SLADOVNICKA');
// Po výběru restaurace se zobrazí druhý select s jídly
const foodSelect = page.locator('.choice-section select').nth(1);
await expect(foodSelect).toBeVisible({ timeout: 5_000 });
// Select musí obsahovat alespoň 2 možnosti (empty + ≥1 jídlo)
const options = foodSelect.locator('option');
expect(await options.count()).toBeGreaterThan(1);
});
test('výběr jídla se uloží a přetrvá po reload', async ({ page }) => {
const locationSelect = page.locator('.choice-section select').first();
await locationSelect.selectOption('SLADOVNICKA');
const foodSelect = page.locator('.choice-section select').nth(1);
await expect(foodSelect).toBeVisible({ timeout: 5_000 });
// Vybereme první nenulovou možnost
const options = await foodSelect.locator('option:not([value=""])').all();
if (options.length === 0) {
test.skip(); // Mock data nejsou dostupná pro tuto restauraci
}
const firstValue = await options[0].getAttribute('value');
await foodSelect.selectOption({ value: firstValue! });
// Počkáme, až se volba přenese na server
await page.waitForLoadState('networkidle');
// Po reload musí volba přetrvat v tabulce choices
await page.reload();
await page.waitForLoadState('networkidle');
const choicesTable = page.locator('.choices-table');
await expect(choicesTable).toBeVisible({ timeout: 5_000 });
await expect(choicesTable.locator('text=Sladovnická')).toBeVisible();
});
test('přepnutí na NEOBEDVAM odstraní výběr restaurace', async ({ page }) => {
// Nejprve zvolíme restauraci
const locationSelect = page.locator('.choice-section select').first();
await locationSelect.selectOption('SLADOVNICKA');
await page.waitForLoadState('networkidle');
// Přepneme na "Neobědvám"
await locationSelect.selectOption('NEOBEDVAM');
await page.waitForLoadState('networkidle');
// Tabulka choices musí zobrazovat "Neobědvám"
const choicesTable = page.locator('.choices-table');
await expect(choicesTable).toBeVisible({ timeout: 5_000 });
await expect(choicesTable.locator('text=Neobědvám')).toBeVisible();
});
-83
View File
@@ -1,83 +0,0 @@
import { test, expect } from '@playwright/test';
import { clearDay } from './helpers';
// Pizza day testy musí běžet sekvenčně (sdílejí stav mock dne)
test.describe.serial('pizza day životní cyklus', () => {
test.beforeEach(async ({ request }) => {
// Vyčistíme data mock dne před každým testem
await clearDay(request);
});
test('zobrazí sekci Pizza Day bez aktivního dne', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
// Sekce pizza-section se zobrazí jen pokud má uživatel zvolenou možnost "Pizza day"
await page.locator('select').selectOption({ label: 'Pizza day' });
await page.waitForLoadState('networkidle');
const pizzaSection = page.locator('.pizza-section');
await expect(pizzaSection).toBeVisible({ timeout: 10_000 });
await expect(pizzaSection.locator('text=není aktuálně založen')).toBeVisible();
});
test('vytvoří, uzamkne a dokončí pizza day', async ({ page }) => {
// Tento test má více kroků a server při MOCK_DATA=true záměrně zpožďuje scraping pizz o 3s
test.setTimeout(60_000);
await page.goto('/');
await page.waitForLoadState('networkidle');
// Sekce pizza-section se zobrazí jen pokud má uživatel zvolenou možnost "Pizza day"
await page.locator('select').selectOption({ label: 'Pizza day' });
await page.waitForLoadState('networkidle');
// Přijmeme všechny window.confirm() dialogy v celém testu (vytvoření i doručení pizza dne)
page.on('dialog', dialog => dialog.accept());
// --- CREATED ---
const createBtn = page.locator('.pizza-section button', { hasText: 'Založit Pizza day' });
await expect(createBtn).toBeVisible({ timeout: 10_000 });
// Čekáme na odpověď API před reloadem jinak by reload přerušil probíhající request
// Server s MOCK_DATA=true záměrně zpožďuje stahování pizz o 3s, proto velkorysý timeout
const createResponse = page.waitForResponse(
resp => resp.url().includes('/api/pizzaDay/create'),
{ timeout: 15_000 },
);
await createBtn.click();
await createResponse;
await page.reload();
await page.waitForLoadState('networkidle');
await expect(page.locator('.pizza-section')).toContainText('spravován uživatelem', { timeout: 5_000 });
// Přidáme pizzu přes API (obejde komplex SelectSearch)
const token = await page.evaluate(() => localStorage.getItem('token'));
const addResp = await page.request.post('/api/pizzaDay/add', {
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { pizzaIndex: 0, pizzaSizeIndex: 0 },
});
expect(addResp.ok()).toBeTruthy();
// Reload server aktualizoval data přes WebSocket, ale reload je jistější
await page.reload();
await page.waitForLoadState('networkidle');
// --- LOCK ---
const lockBtn = page.locator('.pizza-section button', { hasText: 'Uzamknout' });
await expect(lockBtn).toBeEnabled({ timeout: 5_000 });
await lockBtn.click();
await page.waitForLoadState('networkidle');
await expect(page.locator('.pizza-section')).toContainText('uzamčeny', { timeout: 5_000 });
// --- ORDERED ---
const orderBtn = page.locator('.pizza-section button', { hasText: 'Objednáno' });
await expect(orderBtn).toBeEnabled({ timeout: 5_000 });
await orderBtn.click();
await page.waitForLoadState('networkidle');
await expect(page.locator('.pizza-section')).toContainText('objednány', { timeout: 5_000 });
// --- DELIVERED ---
const deliverBtn = page.locator('.pizza-section button', { hasText: 'Doručeno' });
await expect(deliverBtn).toBeVisible({ timeout: 5_000 });
await deliverBtn.click();
await page.waitForLoadState('networkidle');
await expect(page.locator('.pizza-section')).toContainText('doručeny', { timeout: 5_000 });
});
});
-79
View File
@@ -1,79 +0,0 @@
import { test, expect } from '@playwright/test';
test.beforeEach(async ({ page, request }) => {
// Naseedujeme 5 uživatelů pro dnešní den GenerateQrModal pracuje se stávajícími choices
await request.post('/api/dev/generate', { data: { dayIndex: 4, count: 5 } });
// Přednastavíme bankovní účet v localStorage (SettingsContext čte z LS při inicializaci)
await page.goto('/');
await page.evaluate(() => {
localStorage.setItem('bank_account_number', '2400000000/2010');
localStorage.setItem('bank_account_holder_name', 'Test User');
});
// Reload tak, aby SettingsContext načetl nové hodnoty z localStorage
await page.reload();
await page.waitForLoadState('networkidle');
});
test('Nastavení ukládají číslo účtu a jméno do localStorage', async ({ page }) => {
// Otevření nastavení
await page.locator('#basic-nav-dropdown').click();
await page.locator('text=Nastavení').click();
// Modal musí být viditelný
await expect(page.locator('.modal-title')).toContainText('Nastavení', { timeout: 5_000 });
// Změníme číslo účtu pressSequentially zajistí spuštění React onChange na každý znak
// Číslo 1000000005 je platné (kontrolní součet mod 11 = 0), jinak by validace zamítla uložení
const accountInput = page.getByPlaceholder('123456-1234567890/1234');
await accountInput.click({ clickCount: 3 });
await accountInput.pressSequentially('1000000005/5500');
// Změníme jméno
const nameInput = page.getByPlaceholder('Jan Novák');
await nameInput.click({ clickCount: 3 });
await nameInput.pressSequentially('Nové Jméno');
// Uložíme a počkáme na zavření modalu
await page.locator('.modal-footer button', { hasText: 'Uložit' }).click();
await expect(page.locator('.modal')).not.toBeVisible({ timeout: 5_000 });
// Ověříme v localStorage
const bankAccount = await page.evaluate(() => localStorage.getItem('bank_account_number'));
const holderName = await page.evaluate(() => localStorage.getItem('bank_account_holder_name'));
expect(bankAccount).toBe('1000000005/5500');
expect(holderName).toBe('Nové Jméno');
});
test('otevře modal Generování QR kódů pokud je nastaven účet', async ({ page }) => {
// Otevření dropdown menu
await page.locator('#basic-nav-dropdown').click();
await page.locator('text=Generování QR kódů').click();
// Modal se otevře
await expect(page.locator('.modal')).toBeVisible({ timeout: 5_000 });
// Modal musí obsahovat seznam uživatelů nebo prázdný stav
await expect(page.locator('.modal-body')).toBeVisible();
});
test('upozorní pokud není nastaven bankovní účet', async ({ page }) => {
// Odebereme nastavení účtu
await page.evaluate(() => {
localStorage.removeItem('bank_account_number');
localStorage.removeItem('bank_account_holder_name');
});
await page.reload();
await page.waitForLoadState('networkidle');
// Dialog místo modalu
page.on('dialog', async dialog => {
expect(dialog.message()).toContain('číslo účtu');
await dialog.accept();
});
await page.locator('#basic-nav-dropdown').click();
await page.locator('text=Generování QR kódů').click();
// Modal se NESMÍ otevřít
await expect(page.locator('.modal')).not.toBeVisible({ timeout: 3_000 });
});
-39
View File
@@ -1,39 +0,0 @@
import { test, expect } from '@playwright/test';
test.beforeEach(async ({ page }) => {
// Trusted-header login runs automatically when Login mounts.
// networkidle zaručí, že fetch('/api/data') byl dokončen.
await page.goto('/');
await page.waitForLoadState('networkidle');
});
test('zobrazí mock datum 10.01.2025', async ({ page }) => {
// MOCK_DATA=true pins today to 2025-01-10
await expect(page.locator('text=10.01')).toBeVisible({ timeout: 10_000 });
});
test('zobrazí čtyři restaurační karty z mock dat', async ({ page }) => {
// Každá restaurace je obalena v .restaurant-card
const cards = page.locator('.restaurant-card');
await expect(cards).toHaveCount(4, { timeout: 10_000 });
});
test('zobrazí alespoň jedno jídlo v menu každé restaurace', async ({ page }) => {
await expect(page.locator('.restaurant-card').first()).toBeVisible({ timeout: 10_000 });
// Každá karta musí mít aspoň jeden řádek v .food-table
const cards = page.locator('.restaurant-card');
const count = await cards.count();
for (let i = 0; i < count; i++) {
const card = cards.nth(i);
const rows = card.locator('.food-table tr');
expect(await rows.count()).toBeGreaterThan(0);
}
});
test('zobrazí volbu stravování před menu', async ({ page }) => {
// Sekce .choice-section obsahuje select pro výběr stravování
const choiceSection = page.locator('.choice-section');
await expect(choiceSection).toBeVisible({ timeout: 10_000 });
await expect(choiceSection.locator('select').first()).toBeVisible();
});
-11
View File
@@ -1,11 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true
},
"include": ["**/*.ts"]
}
-46
View File
@@ -1,46 +0,0 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@playwright/test@^1.50.0":
version "1.59.1"
resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.59.1.tgz#5c4d38eac84a61527af466602ae20277685a02d6"
integrity sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==
dependencies:
playwright "1.59.1"
"@types/node@^22.0.0":
version "22.19.17"
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.19.17.tgz#09c71fb34ba2510f8ac865361b1fcb9552b8a581"
integrity sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==
dependencies:
undici-types "~6.21.0"
fsevents@2.3.2:
version "2.3.2"
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
playwright-core@1.59.1:
version "1.59.1"
resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.59.1.tgz#d8a2b28bcb8f2bd08ef3df93b02ae83c813244b2"
integrity sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==
playwright@1.59.1:
version "1.59.1"
resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.59.1.tgz#f7b0ca61637ae25264cec370df671bbe1f368a4a"
integrity sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==
dependencies:
playwright-core "1.59.1"
optionalDependencies:
fsevents "2.3.2"
typescript@^5.9.3:
version "5.9.3"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f"
integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==
undici-types@~6.21.0:
version "6.21.0"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb"
integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==
-186
View File
@@ -1,186 +0,0 @@
# Kubernetes — Luncher HA
Manifesty pro nasazení Luncheru na Kubernetes s vysokou dostupností (3 repliky, Redis adapter pro Socket.io, WATCH/MULTI atomické zápisy, graceful shutdown).
## Prerekvizity
- kubectl nakonfigurovaný na cílový cluster
- `helm` nainstalovaný
- Redis Stack image přístupný z clusteru (`redis/redis-stack-server:7.2.0-v14`)
- Obraz `luncher:ha-test` načtený do clusteru (viz níže)
## Lokální kind cluster (testik) — setup
### 1. Smazat a znovu vytvořit cluster s port mappings
```powershell
$env:KIND_EXPERIMENTAL_PROVIDER = "nerdctl"
# Přidat nerdctl do PATH (Rancher Desktop)
$env:PATH += ";$env:LOCALAPPDATA\Programs\Rancher Desktop\resources\resources\win32\bin"
kind delete cluster --name testik
kind create cluster --name testik --config k8s/kind/testik.yaml
```
### 2. Sestavit a načíst obraz
```powershell
docker build -t luncher:ha-test .
# Uložit a načíst přes nerdctl (kind + nerdctl provider)
nerdctl save luncher:ha-test -o luncher.tar
kind load image-archive luncher.tar --name testik
Remove-Item luncher.tar
```
### 3. Nainstalovat Traefik (rke2-traefik)
> **Prerekvizita (Rancher Desktop):** Pokud Rancher Desktop běží s `kubernetes.options.traefik=true`,
> host-switch.exe obsadí port 80 dříve než kind. Vypni traefik v k3s:
> ```powershell
> rdctl set --kubernetes.options.traefik=false
> ```
>
> **Prerekvizita — inotify limity:** Čtyř-uzlový kind cluster vyčerpá výchozí
> `fs.inotify.max_user_instances=128`. kube-proxy pak padá s „too many open files".
> Zvyš limit v rancher-desktop WSL2 (přežije restart WSL2, ale ne reboot — přidej do
> `/etc/sysctl.d/99-kind.conf` pro trvalost):
> ```powershell
> wsl -d rancher-desktop -- sysctl -w fs.inotify.max_user_instances=1280
> ```
```powershell
# rke2-traefik je v rke2-charts, ne rancher-charts
helm repo add rke2-charts https://rke2-charts.rancher.io
helm repo update
# Nejdřív CRD chart, pak samotný chart
helm install traefik-crd rke2-charts/rke2-traefik-crd -n kube-system --create-namespace
helm install traefik rke2-charts/rke2-traefik -n kube-system `
--set "tolerations[0].key=node-role.kubernetes.io/control-plane" `
--set "tolerations[0].operator=Exists" `
--set "tolerations[0].effect=NoSchedule"
```
Ověř že Traefik DaemonSet běží na control-plane (má hostPort 80):
```powershell
kubectl get ds -n kube-system traefik-rke2-traefik
kubectl get pods -n kube-system -o wide | Select-String traefik
```
### 4. Nainstalovat Reloader
[stakater/Reloader](https://github.com/stakater/Reloader) sleduje změny Secret a ConfigMap a automaticky spustí rolling restart Deploymentu — odpadá nutnost ručního `kubectl rollout restart` po rotaci `JWT_SECRET` nebo `ADMIN_PASSWORD`.
Manifest je vendorovaný ve verzi v1.4.16 (`k8s/base/reloader.yaml`). Nasadit do `default` namespace:
```powershell
kubectl apply -f k8s/base/reloader.yaml
kubectl rollout status deploy/reloader-reloader
```
Reloader běží cluster-wide díky `ClusterRoleBinding` — nepotřebuje žádnou konfiguraci per-namespace. Deployment Luncheru má anotaci `reloader.stakater.com/auto: "true"`, která říká Reloaderu, ať sleduje všechny Secrety a ConfigMapy odkazované přes `envFrom`.
### 5. Nasadit Luncher
```powershell
# Namespace + Redis
kubectl apply -f k8s/base/namespace.yaml
kubectl apply -f k8s/base/redis-statefulset.yaml
kubectl apply -f k8s/base/redis-service.yaml
# Počkat na Redis
kubectl rollout status statefulset/redis -n luncher
# Server secret (nebo použít šablonu server-secret.yaml)
kubectl create secret generic luncher-secrets -n luncher `
--from-literal=JWT_SECRET=dev-secret-change-me `
--from-literal=ADMIN_PASSWORD=admin
# Server
kubectl apply -f k8s/base/server-configmap.yaml
kubectl apply -f k8s/base/server-deployment.yaml
kubectl apply -f k8s/base/server-service.yaml
kubectl apply -f k8s/base/server-pdb.yaml
kubectl apply -f k8s/base/ingressroute.yaml
# Počkat na server
kubectl rollout status deploy/luncher -n luncher
```
## Testovací scénáře
### Baseline
```powershell
kubectl get pods -n luncher -o wide
# Ověř: 3 pody na 3 různých worker uzlech, status Running
```
### Rolling update bez výpadku
V jednom terminálu posílej provoz:
```powershell
# Nainstaluj hey: go install github.com/rakyll/hey@latest
hey -z 60s -c 20 http://luncher.localhost/api/health
```
Ve druhém terminálu spusť rollout:
```powershell
kubectl rollout restart deploy/luncher -n luncher
```
**Kritérium: 0 non-2xx odpovědí, 0 connection errors.**
### Node drain
```powershell
kubectl cordon testik-worker2
kubectl drain testik-worker2 --ignore-daemonsets --delete-emptydir-data
# PDB zabrání souběžnému drainu druhého nodu
kubectl get pods -n luncher -o wide # pody se přeplánují
kubectl uncordon testik-worker2
```
### Ověření Socket.io cross-pod
1. Otevři dvě záložky prohlížeče na `http://luncher.localhost`
2. Z jednoho podu vyvolej změnu:
```powershell
kubectl exec -it deploy/luncher -n luncher -- curl -s -X POST localhost:3001/api/...
```
3. Ověř, že druhá záložka (pravděpodobně jiný pod) obdrží WebSocket event
### Concurrent write test
1. Otevři stejnou Pizza day objednávku ve dvou záložkách
2. Simuluj souběžné odeslání (otevřít DevTools → síť → odeslat obě požadavky současně)
3. Ověř Redis: `kubectl exec -it redis-0 -n luncher -- redis-cli JSON.GET luncher:<datum>`
— oba zápisy musí být zachovány (WATCH/MULTI retry)
### Auto-rollout při změně Secret / ConfigMap
Reloader automaticky spustí rolling restart, kdykoli se změní `luncher-secrets` nebo `luncher-config`:
```powershell
# Příklad: rotace admin hesla
kubectl -n luncher patch secret luncher-secrets --type=merge `
-p '{"stringData":{"ADMIN_PASSWORD":"nove-heslo"}}'
# Reloader detekuje změnu resourceVersion a patchne pod template
kubectl rollout status deploy/luncher -n luncher
# Ověř anotaci přidanou Reloaderem na pod template
kubectl get deploy luncher -n luncher -o yaml | Select-String "STAKATER"
```
**Kritérium: pody se automaticky vyrolují bez ručního restartu. PDB zajistí, že alespoň jeden pod zůstane dostupný.**
## Pořadí aplikace manifestů
1. `reloader.yaml` (do `default` namespace — musí být před Deployment)
2. `namespace.yaml`
3. `redis-statefulset.yaml` + `redis-service.yaml`
4. `server-configmap.yaml` + `server-secret.yaml`
5. `server-deployment.yaml` + `server-service.yaml` + `server-pdb.yaml`
6. `ingressroute.yaml`
-16
View File
@@ -1,16 +0,0 @@
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: luncher
namespace: luncher
annotations:
kubernetes.io/ingress.class: traefik
spec:
entryPoints:
- web
routes:
- match: Host(`luncher.localhost`)
kind: Rule
services:
- name: luncher
port: 3001
-4
View File
@@ -1,4 +0,0 @@
apiVersion: v1
kind: Namespace
metadata:
name: luncher
-12
View File
@@ -1,12 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: redis
namespace: luncher
spec:
clusterIP: None # headless — StatefulSet pod discovery
selector:
app: redis
ports:
- port: 6379
targetPort: 6379
-50
View File
@@ -1,50 +0,0 @@
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: redis
namespace: luncher
spec:
serviceName: redis
replicas: 1
selector:
matchLabels:
app: redis
template:
metadata:
labels:
app: redis
spec:
containers:
- name: redis
# Redis Stack je nutný — aplikace používá JSON.GET / JSON.SET (modul RedisJSON)
image: redis/redis-stack-server:7.2.0-v14
ports:
- containerPort: 6379
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
volumeMounts:
- name: data
mountPath: /data
readinessProbe:
exec:
command: ["redis-cli", "ping"]
initialDelaySeconds: 5
periodSeconds: 5
livenessProbe:
exec:
command: ["redis-cli", "ping"]
initialDelaySeconds: 10
periodSeconds: 10
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 1Gi
-184
View File
@@ -1,184 +0,0 @@
# stakater/Reloader v1.4.16
# Zdroj: https://raw.githubusercontent.com/stakater/Reloader/v1.4.16/deployments/kubernetes/reloader.yaml
# Aktualizace: stáhnout novou verzi ze stejné URL a nahradit tento soubor.
apiVersion: v1
kind: ServiceAccount
metadata:
name: reloader-reloader
namespace: default
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: reloader-reloader-metadata-role
namespace: default
rules:
- apiGroups:
- ""
resources:
- configmaps
verbs:
- list
- get
- watch
- create
- update
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: reloader-reloader-role
rules:
- apiGroups:
- ""
resources:
- secrets
- configmaps
verbs:
- list
- get
- watch
- apiGroups:
- apps
resources:
- deployments
- daemonsets
- statefulsets
verbs:
- list
- get
- update
- patch
- apiGroups:
- extensions
resources:
- deployments
- daemonsets
verbs:
- list
- get
- update
- patch
- apiGroups:
- batch
resources:
- cronjobs
verbs:
- list
- get
- apiGroups:
- batch
resources:
- jobs
verbs:
- create
- delete
- list
- get
- apiGroups:
- ""
resources:
- events
verbs:
- create
- patch
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: reloader-reloader-metadata-rolebinding
namespace: default
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: reloader-reloader-metadata-role
subjects:
- kind: ServiceAccount
name: reloader-reloader
namespace: default
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: reloader-reloader-role-binding
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: reloader-reloader-role
subjects:
- kind: ServiceAccount
name: reloader-reloader
namespace: default
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: reloader-reloader
namespace: default
spec:
replicas: 1
revisionHistoryLimit: 2
selector:
matchLabels:
app: reloader-reloader
template:
metadata:
labels:
app: reloader-reloader
spec:
containers:
- env:
- name: GOMAXPROCS
valueFrom:
resourceFieldRef:
divisor: "1"
resource: limits.cpu
- name: GOMEMLIMIT
valueFrom:
resourceFieldRef:
divisor: "1"
resource: limits.memory
- name: RELOADER_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: RELOADER_DEPLOYMENT_NAME
value: reloader-reloader
image: ghcr.io/stakater/reloader:v1.4.16
imagePullPolicy: IfNotPresent
livenessProbe:
failureThreshold: 5
httpGet:
path: /live
port: http
initialDelaySeconds: 10
periodSeconds: 10
successThreshold: 1
timeoutSeconds: 5
name: reloader-reloader
ports:
- containerPort: 9090
name: http
readinessProbe:
failureThreshold: 5
httpGet:
path: /metrics
port: http
initialDelaySeconds: 10
periodSeconds: 10
successThreshold: 1
timeoutSeconds: 5
resources:
limits:
cpu: "1"
memory: 512Mi
requests:
cpu: 10m
memory: 512Mi
securityContext: {}
securityContext:
runAsNonRoot: true
runAsUser: 65534
seccompProfile:
type: RuntimeDefault
serviceAccountName: reloader-reloader
-12
View File
@@ -1,12 +0,0 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: luncher-config
namespace: luncher
data:
NODE_ENV: production
STORAGE: redis
REDIS_HOST: redis
REDIS_PORT: "6379"
PORT: "3001"
HOST: "0.0.0.0"
-85
View File
@@ -1,85 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: luncher
namespace: luncher
spec:
replicas: 3
selector:
matchLabels:
app: luncher
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 0 # nelze přidat extra pod — každý worker je obsazen
maxUnavailable: 1 # nejdřív smaž starý pod, pak naplánuj nový
template:
metadata:
labels:
app: luncher
annotations:
reloader.stakater.com/auto: "true"
spec:
terminationGracePeriodSeconds: 30
# Rozmístit každý pod na jiný worker uzel
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: luncher
topologyKey: kubernetes.io/hostname
containers:
- name: luncher
image: luncher:ha-test
imagePullPolicy: IfNotPresent
ports:
- containerPort: 3001
envFrom:
- configMapRef:
name: luncher-config
- secretRef:
name: luncher-secrets
env:
# POD_ID pro leader election scheduleru připomínek
- name: POD_ID
valueFrom:
fieldRef:
fieldPath: metadata.name
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
# Liveness — levná kontrola bez externích závislostí
livenessProbe:
httpGet:
path: /api/health
port: 3001
initialDelaySeconds: 10
periodSeconds: 10
failureThreshold: 3
# Readiness — kontroluje Redis; při shutdown vrací 503
readinessProbe:
httpGet:
path: /api/health/ready
port: 3001
initialDelaySeconds: 10
periodSeconds: 5
failureThreshold: 2
# preStop sleep: dá čas kube-proxy a Traefiku odebrat endpoint
# dřív než kontejner začne odmítat nová spojení
lifecycle:
preStop:
exec:
command: ["sleep", "5"]
-10
View File
@@ -1,10 +0,0 @@
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: luncher-pdb
namespace: luncher
spec:
minAvailable: 2 # ze 3 replik, max 1 voluntary disruption najednou
selector:
matchLabels:
app: luncher
-14
View File
@@ -1,14 +0,0 @@
# Šablona — hodnoty jsou zástupné symboly.
# Pro kind test vytvoř secret příkazem:
# kubectl create secret generic luncher-secrets -n luncher \
# --from-literal=JWT_SECRET=<your-secret> \
# --from-literal=ADMIN_PASSWORD=<your-password>
apiVersion: v1
kind: Secret
metadata:
name: luncher-secrets
namespace: luncher
type: Opaque
stringData:
JWT_SECRET: CHANGE_ME
ADMIN_PASSWORD: CHANGE_ME
-11
View File
@@ -1,11 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: luncher
namespace: luncher
spec:
selector:
app: luncher
ports:
- port: 3001
targetPort: 3001
-16
View File
@@ -1,16 +0,0 @@
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
# Mapuje porty na Windows localhost — luncher.localhost resolves to 127.0.0.1
# Traefik na control-plane podu poslouchá na těchto portech přes hostPort
extraPortMappings:
- containerPort: 80
hostPort: 80
protocol: TCP
- containerPort: 443
hostPort: 443
protocol: TCP
- role: worker
- role: worker
- role: worker
-23
View File
@@ -1,23 +0,0 @@
# Spustí server a klienta v samostatných panelech jednoho okna Windows Terminalu.
# Vyžaduje Windows Terminal (wt.exe) — výchozí součást Windows 11.
$ErrorActionPreference = 'Stop'
$ScriptDir = $PSScriptRoot
Push-Location (Join-Path $ScriptDir 'types')
try { yarn openapi-ts } finally { Pop-Location }
if (-not (Get-Command wt.exe -ErrorAction SilentlyContinue)) {
Write-Error "wt.exe (Windows Terminal) nebyl nalezen. Nainstalujte z Microsoft Store nebo použijte run_dev.sh v WSL."
exit 1
}
$serverDir = Join-Path $ScriptDir 'server'
$clientDir = Join-Path $ScriptDir 'client'
# wt splits on ';' before respecting quoting, so encode the compound server command to avoid it
$serverCmd = '$env:NODE_ENV = ''development''; yarn startReload'
$serverCmdB64 = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($serverCmd))
wt -w 0 new-tab --title 'luncher-server' -d $serverDir pwsh -NoExit -EncodedCommand $serverCmdB64 `; `
split-pane -H --title 'luncher-client' -d $clientDir pwsh -NoExit -Command "yarn start"
-4
View File
@@ -48,7 +48,3 @@
# Heslo pro bypass rate limitu na endpointu /api/food/refresh (pro skripty/admin).
# Bez hesla může refresh volat každý přihlášený uživatel (podléhá rate limitu).
# REFRESH_BYPASS_PASSWORD=
# Admin heslo pro správu seznamu obchodů na stránce /objednani.
# Bez hesla nelze přidávat ani odebírat obchody ze seznamu (POST/DELETE na /api/stores vrátí 403).
# ADMIN_PASSWORD=
-1
View File
@@ -2,7 +2,6 @@
/dist
/resources/easterEggs
/src/gen
/coverage
.env.production
.env.development
.easter-eggs.json
-4
View File
@@ -1,4 +0,0 @@
[
"Zimní atmosféra",
"Skrytí podniku U Motlíků"
]
-3
View File
@@ -1,3 +0,0 @@
[
"Přidání restaurace Zastávka u Michala"
]
-3
View File
@@ -1,3 +0,0 @@
[
"Přidání restaurace Pivovarský šenk Šeříková"
]
-3
View File
@@ -1,3 +0,0 @@
[
"Možnost výběru podniku/jídla kliknutím"
]
-3
View File
@@ -1,3 +0,0 @@
[
"Stránka se statistikami nejoblíbenějších voleb"
]
-3
View File
@@ -1,3 +0,0 @@
[
"Zobrazení počtu osob u každé volby"
]
-3
View File
@@ -1,3 +0,0 @@
[
"Migrace na generované OpenApi"
]
-3
View File
@@ -1,3 +0,0 @@
[
"Odebrání zimní atmosféry"
]
-3
View File
@@ -1,3 +0,0 @@
[
"Možnost ručního přenačtení menu"
]
-3
View File
@@ -1,3 +0,0 @@
[
"Parsování a zobrazení alergenů"
]
-4
View File
@@ -1,4 +0,0 @@
[
"Oddělení přenačtení menu do vlastního dialogu",
"Podzimní atmosféra"
]
-3
View File
@@ -1,3 +0,0 @@
[
"Možnost převzetí poznámky ostatních uživatelů"
]
-3
View File
@@ -1,3 +0,0 @@
[
"Zimní atmosféra"
]
-3
View File
@@ -1,3 +0,0 @@
[
"Možnost označit se jako objednávající u volby \"Budu objednávat\""
]
-3
View File
@@ -1,3 +0,0 @@
[
"Podpora dark mode"
]
-7
View File
@@ -1,7 +0,0 @@
[
"Redesign aplikace pomocí Claude Code",
"Zobrazení uplynulého týdne i o víkendu",
"Podpora Discord, ntfy a Teams notifikací (v Nastavení)",
"Trvalé zobrazení QR kódů do ručního zavření",
"Zobrazení nejvíce požadovaných funkcí (na stránce Statistiky)"
]
-3
View File
@@ -1,3 +0,0 @@
[
"Zobrazení sekce Pizza day pouze při volbě \"Pizza day\""
]
-3
View File
@@ -1,3 +0,0 @@
[
"Možnost generování obecných QR kódů pro platby i mimo Pizza day (v uživatelském menu)"
]
-3
View File
@@ -1,3 +0,0 @@
[
"Podpora push notifikací pro připomenutí výběru oběda (v nastavení)"
]
-3
View File
@@ -1,3 +0,0 @@
[
"Oprava detekce zastaralého menu"
]
-3
View File
@@ -1,3 +0,0 @@
[
"Automatické zobrazení dialogu s dosud nezobrazenými novinkami"
]
-3
View File
@@ -1,3 +0,0 @@
[
"Automatický výběr výchozího času preferovaného odchodu"
]
-3
View File
@@ -1,3 +0,0 @@
[
"Zobrazení nabídky salátů z Pizza Chefie"
]
-3
View File
@@ -1,3 +0,0 @@
[
"Možnost úhrady celého účtu jednou osobou s rozesláním QR kódů ostatním (na Pizza day)"
]
-3
View File
@@ -1,3 +0,0 @@
[
"Skupinové objednávky s QR platbou — stránka /objednani (více skupin, každá z jiného obchodu, stavový automat open/locked/ordered)"
]
-7
View File
@@ -1,7 +0,0 @@
[
"Možnost zobrazení objednávek z historie",
"Podpora neplatících osob u objednávání",
"Zobrazení neuhrazených plateb i na stránce objednávek",
"Oprava duplicitního zobrazení QR kódu u Pizza day",
"Odstranění diakritiky v platebních QR kódech"
]
-3
View File
@@ -1,3 +0,0 @@
[
"Nová stránka pro návrhy na vylepšení (dostupná z uživatelského menu)"
]
-5
View File
@@ -1,5 +0,0 @@
[
"Proklik na nabídku podniku ze stránky objednávek",
"Možnost přidat URL pro sledování stavu doručení pro Bolt Food",
"Automatická aktualizace času doručení na základě sledovací URL pro Bolt Food"
]
-6
View File
@@ -1,6 +0,0 @@
module.exports = {
testEnvironment: 'node',
testMatch: ['<rootDir>/src/**/*.test.ts'],
testPathIgnorePatterns: ['/node_modules/', '/dist/'],
setupFiles: ['<rootDir>/src/tests/setupEnv.ts'],
};
-3
View File
@@ -19,17 +19,14 @@
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.10.0",
"@types/request-promise": "^4.1.48",
"@types/supertest": "^6.0.0",
"@types/web-push": "^3.6.4",
"babel-jest": "^30.2.0",
"jest": "^30.2.0",
"nodemon": "^3.1.10",
"supertest": "^7.0.0",
"ts-node": "^10.9.1",
"typescript": "^5.9.3"
},
"dependencies": {
"@socket.io/redis-adapter": "^8.3.0",
"axios": "^1.13.2",
"cheerio": "^1.1.2",
"cors": "^2.8.5",
+8 -8
View File
@@ -9,13 +9,13 @@ import jwt from 'jsonwebtoken';
*/
export function generateToken(login?: string, trusted?: boolean): string {
if (!process.env.JWT_SECRET) {
throw new Error("Není vyplněna proměnná prostředí JWT_SECRET");
throw Error("Není vyplněna proměnná prostředí JWT_SECRET");
}
if (process.env.JWT_SECRET.length < 32) {
throw new Error("Proměnná prostředí JWT_SECRET musí být minimálně 32 znaků");
throw Error("Proměnná prostředí JWT_SECRET musí být minimálně 32 znaků");
}
if (!login || login.trim().length === 0) {
throw new Error("Nebyl předán login");
throw Error("Nebyl předán login");
}
const payload = { login, trusted: trusted || false, logoutUrl: process.env.LOGOUT_URL };
return jwt.sign(payload, process.env.JWT_SECRET);
@@ -28,7 +28,7 @@ export function generateToken(login?: string, trusted?: boolean): string {
*/
export function verify(token: string): boolean {
if (!process.env.JWT_SECRET) {
throw new Error("Není vyplněna proměnná prostředí JWT_SECRET");
throw Error("Není vyplněna proměnná prostředí JWT_SECRET");
}
try {
jwt.verify(token, process.env.JWT_SECRET);
@@ -45,10 +45,10 @@ export function verify(token: string): boolean {
*/
export function getLogin(token?: string): string {
if (!process.env.JWT_SECRET) {
throw new Error("Není vyplněna proměnná prostředí JWT_SECRET");
throw Error("Není vyplněna proměnná prostředí JWT_SECRET");
}
if (!token) {
throw new Error("Nebyl předán token");
throw Error("Nebyl předán token");
}
const payload: any = jwt.verify(token, process.env.JWT_SECRET);
return payload.login;
@@ -61,10 +61,10 @@ export function getLogin(token?: string): string {
*/
export function getTrusted(token?: string): boolean {
if (!process.env.JWT_SECRET) {
throw new Error("Není vyplněna proměnná prostředí JWT_SECRET");
throw Error("Není vyplněna proměnná prostředí JWT_SECRET");
}
if (!token) {
throw new Error("Nebyl předán token");
throw Error("Nebyl předán token");
}
const payload: any = jwt.verify(token, process.env.JWT_SECRET);
return payload.trusted || false;
-178
View File
@@ -1,178 +0,0 @@
import axios from 'axios';
import crypto from 'crypto';
import getStorage from './storage';
import { createLeaderLease } from './leaderLease';
import { getToday } from './service';
import { formatDate } from './utils';
import { getWebsocket } from './websocket';
import { ClientData, GroupState } from '../../types/gen/types.gen';
const storage = getStorage();
const lease = createLeaderLease('luncher:bolt:leader');
const BOLT_POLLING_URL = 'https://deliveryuser.live.boltsvc.net/deliveryClient/public/getOrderPolling';
const BOLT_TOKEN_REGEX = /^[0-9a-f]{64}$/i;
const TERMINAL_STATE_REGEX = /delivered|finished|cancelled|rejected|failed/i;
const MAX_CONSECUTIVE_FAILURES = 10;
/** Identifikátor zařízení pro Bolt API — generuje se jednou na proces. */
const DEVICE_ID = crypto.randomUUID();
let boltInterval: ReturnType<typeof setInterval> | undefined;
/** Mapa groupId → počet po sobě jdoucích selhání dotazu na Bolt API. */
const consecutiveFailures = new Map<string, number>();
/**
* Vytáhne sledovací token ze sdílecí URL Bolt Food
* (https://food.bolt.eu/sharedActiveOrder/<token>) nebo přijme samotný token.
* Vrátí null, pokud vstup neobsahuje platný token (64 hex znaků).
*/
export function extractBoltToken(input: string): string | null {
const trimmed = input.trim();
if (!trimmed) return null;
if (BOLT_TOKEN_REGEX.test(trimmed)) return trimmed;
let pathname: string;
try {
pathname = new URL(trimmed).pathname;
} catch {
return null;
}
const segments = pathname.split('/').filter(Boolean);
const last = segments[segments.length - 1];
return last && BOLT_TOKEN_REGEX.test(last) ? last : null;
}
/** Spočítá očekávaný čas doručení (teď + sekundy) ve formátu HH:MM. */
export function computeDeliveryHHMM(seconds: number, now: Date = new Date()): string {
const eta = new Date(now.getTime() + seconds * 1000);
return `${String(eta.getHours()).padStart(2, '0')}:${String(eta.getMinutes()).padStart(2, '0')}`;
}
interface BoltOrder {
order_id: number;
order_state: string;
expected_time_to_client_in_seconds?: number;
courier?: { state?: string } | null;
}
/** Dotáže se veřejného Bolt API na stav sdílené objednávky. Vrátí null, pokud objednávka už neexistuje. */
export async function pollBoltOrder(token: string): Promise<BoltOrder | null> {
const res = await axios.post(BOLT_POLLING_URL, { token }, {
params: {
version: 'FW.1.111',
language: 'cs-CZ',
country: 'cz',
device_name: 'web',
device_os_version: 'web',
deviceType: 'web',
session_id: DEVICE_ID,
distinct_id: DEVICE_ID,
deviceId: DEVICE_ID,
},
headers: { 'Content-Type': 'application/json' },
timeout: 10_000,
});
if (res.data?.code !== 0) {
throw new Error(`Bolt API vrátilo kód ${res.data?.code}: ${res.data?.message}`);
}
return res.data?.data?.orders?.[0] ?? null;
}
/**
* Jeden tik scheduleru: pro dnešní objednané skupiny se sledovacím tokenem
* zjistí očekávaný čas doručení z Bolt API a aktualizuje deliveryAt.
* Sledování se automaticky ukončí (token se smaže), když objednávka skončí
* nebo dotazy opakovaně selhávají.
*/
export async function checkBoltTracking(): Promise<void> {
const isLeader = await lease.tryAcquireOrRenew();
if (!isLeader) return;
const key = `${formatDate(getToday())}_extra`;
const data = await storage.getData<ClientData>(key);
const candidates = (data?.groups ?? []).filter(g => g.boltTrackingToken && g.state === GroupState.ORDERED);
// Úklid čítačů selhání pro skupiny, které už nesledujeme
for (const groupId of consecutiveFailures.keys()) {
if (!candidates.some(g => g.id === groupId)) consecutiveFailures.delete(groupId);
}
if (candidates.length === 0) return;
let updated: ClientData | undefined;
for (const group of candidates) {
let deliveryAt: string | undefined;
let orderState: string | undefined;
let courierState: string | undefined;
let clearToken = false;
try {
const order = await pollBoltOrder(group.boltTrackingToken!);
consecutiveFailures.delete(group.id);
if (!order) {
// Objednávka z API zmizela — považujeme ji za doručenou
orderState = 'delivered';
clearToken = true;
} else {
orderState = order.order_state || undefined;
courierState = order.courier?.state || undefined;
if (TERMINAL_STATE_REGEX.test(order.order_state ?? '')) {
clearToken = true;
} else if (typeof order.expected_time_to_client_in_seconds === 'number') {
deliveryAt = computeDeliveryHHMM(order.expected_time_to_client_in_seconds);
}
}
} catch (e) {
const failures = (consecutiveFailures.get(group.id) ?? 0) + 1;
consecutiveFailures.set(group.id, failures);
console.error(`Bolt tracking: chyba dotazu pro skupinu "${group.name}" (${failures}/${MAX_CONSECUTIVE_FAILURES})`, e);
if (failures < MAX_CONSECUTIVE_FAILURES) continue;
consecutiveFailures.delete(group.id);
clearToken = true;
}
const timeChanged = deliveryAt !== undefined && deliveryAt !== group.deliveryAt;
const stateChanged = orderState !== undefined && orderState !== group.boltOrderState;
const courierChanged = courierState !== group.boltCourierState && !clearToken;
if (!clearToken && !timeChanged && !stateChanged && !courierChanged) continue;
updated = await storage.updateData<ClientData>(key, current => {
const d = current ?? data!;
const g = d.groups?.find(x => x.id === group.id);
if (g?.boltTrackingToken) {
if (timeChanged) g.deliveryAt = deliveryAt;
if (stateChanged) g.boltOrderState = orderState;
if (courierChanged) g.boltCourierState = courierState;
if (clearToken) g.boltTrackingToken = undefined;
}
return d;
});
if (clearToken) {
console.log(`Bolt tracking: sledování skupiny "${group.name}" ukončeno`);
}
}
if (updated) {
getWebsocket()?.emit('message', updated);
}
}
/** Spustí scheduler pro sledování Bolt objednávek (každou minutu). */
export function startBoltTrackingScheduler(): void {
boltInterval = setInterval(checkBoltTracking, 60_000);
console.log('Bolt tracking: scheduler spuštěn');
}
/** Stopne scheduler sledování. Volá se při graceful shutdown. */
export function stopBoltTrackingScheduler(): void {
if (boltInterval) {
clearInterval(boltInterval);
boltInterval = undefined;
}
}
/** Uvolní leader lease při graceful shutdown. */
export async function releaseBoltTrackingLease(): Promise<void> {
await lease.release();
}

Some files were not shown because too many files have changed in this diff Show More