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
185 changed files with 1685 additions and 9508 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}"
+1 -7
View File
@@ -1,9 +1,3 @@
node_modules node_modules
types/gen types/gen
**.DS_Store **.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) types/ → Shared OpenAPI-generated TypeScript types (source of truth: types/api.yml)
server/ → Express 5 backend (Node.js 22, ts-node) server/ → Express 5 backend (Node.js 22, ts-node)
client/ → React 19 frontend (Vite 7, React Bootstrap) 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**. Each directory has its own `package.json` and `tsconfig.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`.
## Development Commands ## 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 types && yarn install && yarn openapi-ts # Generate API types first
cd ../server && yarn install cd ../server && yarn install
cd ../client && yarn install cd ../client && yarn install
cd ../e2e && yarn install
``` ```
### Running dev environment ### Running dev environment
@@ -48,62 +44,38 @@ cd client && yarn build # tsc --noEmit + vite build → client/dist
### Tests ### Tests
```bash ```bash
# Server unit tests (Jest) cd server && yarn test # Jest (tests in server/src/tests/)
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
``` ```
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 ### 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 ## Architecture
### API Types (types/) ### API Types (types/)
- OpenAPI 3.0 spec in `types/api.yml` — all API endpoints and DTOs defined here - 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) - `yarn openapi-ts` generates `types/gen/` (client.gen.ts, sdk.gen.ts, types.gen.ts)
- Both server and client import from these generated types - Both server and client import from these generated types
- **When changing API contracts: update api.yml first, then regenerate** - **When changing API contracts: update api.yml first, then regenerate**
### Server (server/src/) ### Server (server/src/)
- **Entry:** `index.ts` — Express app + Socket.io setup - **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`) - **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 - **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 - **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 - **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) - **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) - **Config:** `.env.development` / `.env.production` (see `.env.template` for all options)
### Client (client/src/) ### 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) - **Pages:** `pages/` (StatsPage)
- **Components:** `components/` (Header, Footer, Loader, PizzaOrderList, PizzaOrderRow, and modals in `components/modals/`) - **Components:** `components/` (Header, Footer, Loader, modals/, PizzaOrderList, PizzaOrderRow)
- **Context providers:** `context/``auth.tsx`, `settings.tsx`, `socket.js`, `eggs.tsx` (note: `socket.js` is the only non-TSX context file) - **Context providers:** `context/`AuthContext, SettingsContext, SocketContext, EasterEggContext
- **Hooks:** `hooks/` (`usePushReminder.ts`)
- **Utils:** `utils/` (`parsePrice.ts`)
- **Styling:** Bootstrap 5 + React Bootstrap + custom SCSS files (co-located with components) - **Styling:** Bootstrap 5 + React Bootstrap + custom SCSS files (co-located with components)
- **API calls:** use OpenAPI-generated SDK from `types/gen/` - **API calls:** use OpenAPI-generated SDK from `types/gen/`
- **Routing:** React Router DOM v7 - **Routing:** React Router DOM v7
@@ -117,7 +89,7 @@ Prettier is installed in `client/` (devDependency only, no script or config) —
## Environment ## Environment
- **Server env files:** `server/.env.development`, `server/.env.production` (see `server/.env.template`) - **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. - **Docker:** multi-stage Dockerfile, `compose.yml` for app + Redis. Timezone: Europe/Prague.
## Conventions ## 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 - Czech naming for domain variables and UI strings; English for infrastructure code
- TypeScript strict mode in both client and server - TypeScript strict mode in both client and server
- Server module resolution: Node16; Client: ESNext/bundler - 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" ARG NODE_VERSION="node:22-alpine"
# ─── Builder ────────────────────────────────────────────────────────────────── # Builder
FROM ${NODE_VERSION} AS builder FROM ${NODE_VERSION} AS builder
WORKDIR /build WORKDIR /build
@@ -62,9 +62,8 @@ RUN yarn build
WORKDIR /build/client WORKDIR /build/client
RUN yarn build RUN yarn build
# ─── Runner base ────────────────────────────────────────────────────────────── # Runner
# Společný základ pro oba runner targety nastaví prostředí a metadata běhu. FROM ${NODE_VERSION}
FROM ${NODE_VERSION} AS runner-base
RUN apk add --no-cache tzdata RUN apk add --no-cache tzdata
ENV TZ=Europe/Prague \ ENV TZ=Europe/Prague \
@@ -73,17 +72,6 @@ ENV TZ=Europe/Prague \
WORKDIR /app 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 # Vykopírování sestaveného serveru
COPY --from=builder /build/server/node_modules ./server/node_modules COPY --from=builder /build/server/node_modules ./server/node_modules
COPY --from=builder /build/server/dist ./ 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 # Zkopírování produkčních .env serveru
COPY /server/.env.production ./server COPY /server/.env.production ./server
# Zkopírování changelogů (seznamu novinek)
COPY /server/changelogs ./server/changelogs
# Zkopírování konfigurace easter eggů # 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) ──────────────────────────────────────────────────────── # Export /data/db.json do složky /data
# Použití: docker build --target runner-prebuilt . VOLUME ["/data"]
# 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
# Vykopírování sestaveného serveru EXPOSE 3000
COPY ./server/node_modules ./server/node_modules
COPY ./server/dist ./
# Vykopírování sestaveného klienta CMD [ "node", "./server/src/index.js" ]
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
+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 # 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 - [ ] 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ěď) - [ ] 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í) - [ ] 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", "bootstrap": "^5.3.8",
"react": "^19.2.0", "react": "^19.2.0",
"react-bootstrap": "^2.10.10", "react-bootstrap": "^2.10.10",
"react-datepicker": "^9.1.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-jwt": "^1.3.0", "react-jwt": "^1.3.0",
"react-modal": "^3.16.3", "react-modal": "^3.16.3",
+10 -9
View File
@@ -7,7 +7,6 @@ self.addEventListener('push', (event) => {
body: data.body, body: data.body,
icon: '/favicon.ico', icon: '/favicon.ico',
tag: 'lunch-reminder', tag: 'lunch-reminder',
data: { login: data.login, token: data.token },
actions: [ actions: [
{ action: 'neobedvam', title: 'Mám vlastní/neobědvám' }, { action: 'neobedvam', title: 'Mám vlastní/neobědvám' },
], ],
@@ -19,26 +18,28 @@ self.addEventListener('notificationclick', (event) => {
event.notification.close(); event.notification.close();
if (event.action === 'neobedvam') { if (event.action === 'neobedvam') {
const { login, token } = event.notification.data ?? {}; event.waitUntil(
if (login && token) { self.registration.pushManager.getSubscription().then((subscription) => {
event.waitUntil( if (!subscription) return;
fetch('/api/notifications/push/quickChoice', { return fetch('/api/notifications/push/quickChoice', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ login, token }), body: JSON.stringify({ endpoint: subscription.endpoint }),
}) });
); })
} );
return; return;
} }
event.waitUntil( event.waitUntil(
self.clients.matchAll({ type: 'window' }).then((clientList) => { self.clients.matchAll({ type: 'window' }).then((clientList) => {
// Pokud je již otevřené okno, zaostříme na něj
for (const client of clientList) { for (const client of clientList) {
if (client.url.includes(self.location.origin) && 'focus' in client) { if (client.url.includes(self.location.origin) && 'focus' in client) {
return client.focus(); return client.focus();
} }
} }
// Jinak otevřeme nové
return self.clients.openWindow('/'); return self.clients.openWindow('/');
}) })
); );
-100
View File
@@ -226,7 +226,6 @@ body {
&:hover svg { &:hover svg {
transform: rotate(15deg); 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 // FOOD TABLES - CARD STYLE
// ============================================ // ============================================
+85 -140
View File
@@ -1,6 +1,6 @@
import React, { useContext, useEffect, useMemo, useRef, useState, useCallback } from 'react'; import React, { useContext, useEffect, useMemo, useRef, useState, useCallback } from 'react';
import 'bootstrap/dist/css/bootstrap.min.css'; 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 { useAuth } from './context/auth';
import Login from './Login'; import Login from './Login';
import { Alert, Button, Col, Form, Row, Table } from 'react-bootstrap'; 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 { faCircleCheck, faNoteSticky, faTrashCan, faComment } from '@fortawesome/free-regular-svg-icons';
import { useSettings } from './context/settings'; import { useSettings } from './context/settings';
import Footer from './components/Footer'; import Footer from './components/Footer';
import { faArrowUpRightFromSquare, faBasketShopping, faChainBroken, faChevronLeft, faChevronRight, faGear, faMoneyBillTransfer, faSatelliteDish, faSearch, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'; import { faBasketShopping, faChainBroken, faChevronLeft, faChevronRight, faGear, faMoneyBillTransfer, faSatelliteDish, faSearch, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons';
import { useNavigate } from 'react-router-dom';
import Loader from './components/Loader'; import Loader from './components/Loader';
import { getHumanDateTime, isInTheFuture } from './Utils'; import { getHumanDateTime, isInTheFuture, formatDateString } from './Utils';
import NoteModal from './components/modals/NoteModal'; import NoteModal from './components/modals/NoteModal';
import PayForAllModal from './components/modals/PayForAllModal'; import PayForAllModal from './components/modals/PayForAllModal';
import PendingPayments from './components/PendingPayments';
import { useEasterEgg } from './context/eggs'; 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 { getLunchChoiceName } from './enums';
// import FallingLeaves, { LEAF_PRESETS, LEAF_COLOR_THEMES } from './FallingLeaves'; // import FallingLeaves, { LEAF_PRESETS, LEAF_COLOR_THEMES } from './FallingLeaves';
// import './FallingLeaves.scss'; // import './FallingLeaves.scss';
@@ -61,7 +59,6 @@ const EASTER_EGG_DEFAULT_DURATION = 0.75;
function App() { function App() {
const auth = useAuth(); const auth = useAuth();
const settings = useSettings(); const settings = useSettings();
const navigate = useNavigate();
const [easterEgg, _] = useEasterEgg(auth); const [easterEgg, _] = useEasterEgg(auth);
const [isConnected, setIsConnected] = useState<boolean>(false); const [isConnected, setIsConnected] = useState<boolean>(false);
const [data, setData] = useState<ClientData>(); const [data, setData] = useState<ClientData>();
@@ -129,46 +126,19 @@ function App() {
}); });
socket.on(EVENT_MESSAGE, (newData: ClientData) => { socket.on(EVENT_MESSAGE, (newData: ClientData) => {
// console.log("Přijata nová data ze socketu", newData); // 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ý // Aktualizujeme pouze, pokud jsme dostali data pro den, který máme aktuálně zobrazený
if (dayIndexRef.current == null || newData.dayIndex === dayIndexRef.current) { if (dayIndexRef.current == null || newData.dayIndex === dayIndexRef.current) {
setData(newData); setData(newData);
} }
}); });
socket.on(EVENT_PENDING_QR, (pendingQr: PendingQr) => {
setData(prev => prev ? { ...prev, pendingQrs: [...(prev.pendingQrs ?? []), pendingQr] } : prev);
});
return () => { return () => {
socket.off(EVENT_CONNECT); socket.off(EVENT_CONNECT);
socket.off(EVENT_DISCONNECT); socket.off(EVENT_DISCONNECT);
socket.off(EVENT_MESSAGE); socket.off(EVENT_MESSAGE);
socket.off(EVENT_PENDING_QR);
} }
}, [socket]); }, [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(() => { useEffect(() => {
if (!auth?.login || !data?.choices) { if (!auth?.login || !data?.choices) {
return return
@@ -319,7 +289,6 @@ function App() {
try { try {
await addChoice({ body: { locationKey: location, foodIndex, dayIndex } }); await addChoice({ body: { locationKey: location, foodIndex, dayIndex } });
await tryAutoSelectDepartureTime();
} catch (error: any) { } catch (error: any) {
alert(`Chyba při změně volby: ${error.message || error}`); alert(`Chyba při změně volby: ${error.message || error}`);
} }
@@ -346,10 +315,6 @@ function App() {
foodChoiceRef.current.value = ""; foodChoiceRef.current.value = "";
} }
choiceRef.current?.blur(); 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) { } catch (error: any) {
alert(`Chyba při změně volby: ${error.message || error}`); alert(`Chyba při změně volby: ${error.message || error}`);
// Reset výběru zpět // Reset výběru zpět
@@ -374,7 +339,6 @@ function App() {
const locationKey = choiceRef.current.value as LunchChoice; const locationKey = choiceRef.current.value as LunchChoice;
if (auth?.login) { if (auth?.login) {
await addChoice({ body: { locationKey, foodIndex: Number(event.target.value), dayIndex } }); await addChoice({ body: { locationKey, foodIndex: Number(event.target.value), dayIndex } });
await tryAutoSelectDepartureTime();
} }
} }
} }
@@ -423,80 +387,32 @@ 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(() => { const pizzaSuggestions = useMemo(() => {
if (!data?.pizzaList && !data?.salatList) { if (!data?.pizzaList) {
return []; return [];
} }
const suggestions: SelectSearchOption[] = []; const suggestions: SelectSearchOption[] = [];
data.pizzaList?.forEach((pizza, index) => { data.pizzaList.forEach((pizza, index) => {
const group: SelectSearchOption = { name: pizza.name, type: "group", items: [] } const group: SelectSearchOption = { name: pizza.name, type: "group", items: [] }
pizza.sizes.forEach((size, sizeIndex) => { pizza.sizes.forEach((size, sizeIndex) => {
const name = `${size.size} (${size.price / 100} Kč)`; const name = `${size.size} (${size.price} Kč)`;
const value = `pizza|${index}|${sizeIndex}`; const value = `${index}|${sizeIndex}`;
group.items?.push({ name, value }); group.items?.push({ name, value });
}) })
suggestions.push(group); 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; return suggestions;
}, [data?.pizzaList, data?.salatList]); }, [data?.pizzaList]);
const handlePizzaChange = async (value: SelectedOptionValue | SelectedOptionValue[]) => { const handlePizzaChange = async (value: SelectedOptionValue | SelectedOptionValue[]) => {
if (auth?.login) { if (auth?.login && data?.pizzaList) {
if (typeof value !== 'string') { if (typeof value !== 'string') {
throw new TypeError('Nepodporovaný typ hodnoty: ' + typeof value); throw new TypeError('Nepodporovaný typ hodnoty: ' + typeof value);
} }
const s = value.split('|'); const s = value.split('|');
if (s[0] === 'salat') { const pizzaIndex = Number.parseInt(s[0]);
const salatIndex = Number.parseInt(s[1]); const pizzaSizeIndex = Number.parseInt(s[1]);
await addPizza({ body: { salatIndex } }); await addPizza({ body: { pizzaIndex, pizzaSizeIndex } });
} else {
const pizzaIndex = Number.parseInt(s[1]);
const pizzaSizeIndex = Number.parseInt(s[2]);
await addPizza({ body: { pizzaIndex, pizzaSizeIndex } });
}
} }
} }
@@ -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) => { const handleDayChange = async (dayIndex: number) => {
setDayIndex(dayIndex); setDayIndex(dayIndex);
dayIndexRef.current = dayIndex; dayIndexRef.current = dayIndex;
@@ -678,7 +584,7 @@ function App() {
</Form.Select> </Form.Select>
<small>Je možné vybrat jen jednu možnost. Výběr jiné odstraní předchozí.</small> <small>Je možné vybrat jen jednu možnost. Výběr jiné odstraní předchozí.</small>
{foodChoiceList && !closed && <> {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}> <Form.Select ref={foodChoiceRef} onChange={doAddFoodChoice}>
<option value="">Vyber jídlo...</option> <option value="">Vyber jídlo...</option>
{foodChoiceList.map((food, index) => <option key={food.name} value={index}>{food.name}</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}> <Form.Select ref={departureChoiceRef} onChange={handleChangeDepartureTime}>
<option value="">Vyber čas...</option> <option value="">Vyber čas...</option>
{Object.values(DepartureTime) {Object.values(DepartureTime)
.filter(time => dayIndex !== data.todayDayIndex || isInTheFuture(time)) .filter(time => isInTheFuture(time))
.map(time => <option key={time} value={time}>{time}</option>)} .map(time => <option key={time} value={time}>{time}</option>)}
</Form.Select> </Form.Select>
</>} </>}
@@ -714,15 +620,15 @@ function App() {
{locationPickCount >= 2 && auth.login && loginObject[auth.login] !== undefined {locationPickCount >= 2 && auth.login && loginObject[auth.login] !== undefined
&& locationKey !== LunchChoice.PIZZA && locationKey !== LunchChoice.NEOBEDVAM && locationKey !== LunchChoice.ROZHODUJI && locationKey !== LunchChoice.PIZZA && locationKey !== LunchChoice.NEOBEDVAM && locationKey !== LunchChoice.ROZHODUJI
&& settings?.bankAccount && settings?.holderName && ( && settings?.bankAccount && settings?.holderName && (
<span title='Zaplatit za všechny a vygenerovat QR kódy ostatním' className="ms-2"> <span title='Zaplatit za všechny a vygenerovat QR kódy ostatním' className="ms-2">
<FontAwesomeIcon <FontAwesomeIcon
icon={faMoneyBillTransfer} icon={faMoneyBillTransfer}
onClick={() => setPayForAllLocationKey(locationKey)} onClick={() => setPayForAllLocationKey(locationKey)}
className='action-icon' className='action-icon'
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
/> />
</span> </span>
)} )}
</td> </td>
<td className='p-0'> <td className='p-0'>
<Table className="nested-table"> <Table className="nested-table">
@@ -750,9 +656,6 @@ function App() {
markAsBuyer(); markAsBuyer();
}} icon={faBasketShopping} className={isBuyer ? 'buyer-icon' : 'action-icon'} style={{ cursor: 'pointer' }} /> }} icon={faBasketShopping} className={isBuyer ? 'buyer-icon' : 'action-icon'} style={{ cursor: 'pointer' }} />
</span>} </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í'> {login !== auth.login && locationKey === LunchChoice.OBJEDNAVAM && isBuyer && <span title='Objednávající'>
<FontAwesomeIcon onClick={() => { <FontAwesomeIcon onClick={() => {
copyNote(userPayload.note!); copyNote(userPayload.note!);
@@ -819,7 +722,10 @@ function App() {
</span> </span>
: :
<div> <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> <Button variant="outline-primary" onClick={doJdemeObed}>Jdeme na oběd!</Button>
</div> </div>
} }
@@ -838,8 +744,12 @@ function App() {
{ {
data.pizzaDay.creator === auth.login && data.pizzaDay.creator === auth.login &&
<div className="mb-4"> <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 variant="danger" title="Smaže kompletně pizza day, včetně dosud zadaných objednávek." onClick={async () => {
<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> 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> </div>
} }
</> </>
@@ -850,8 +760,12 @@ function App() {
<p>Objednávky jsou uzamčeny uživatelem <strong>{data.pizzaDay.creator}</strong></p> <p>Objednávky jsou uzamčeny uživatelem <strong>{data.pizzaDay.creator}</strong></p>
{data.pizzaDay.creator === auth.login && {data.pizzaDay.creator === auth.login &&
<div className="mb-4"> <div className="mb-4">
<Button variant="secondary" title="Umožní znovu editovat objednávky." onClick={handleUnlockPizzaDay}>Odemknout</Button> <Button variant="secondary" title="Umožní znovu editovat objednávky." onClick={async () => {
<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> 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> </div>
} }
</> </>
@@ -862,8 +776,12 @@ function App() {
<p>Pizzy byly objednány uživatelem <strong>{data.pizzaDay.creator}</strong></p> <p>Pizzy byly objednány uživatelem <strong>{data.pizzaDay.creator}</strong></p>
{data.pizzaDay.creator === auth.login && {data.pizzaDay.creator === auth.login &&
<div className="mb-4"> <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 variant="secondary" title="Vrátí stav do předchozího kroku (před objednáním)." onClick={async () => {
<Button title="Nastaví stav na 'Doručeno' - koncový stav." onClick={handleFinishDelivery}>Doručeno</Button> 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> </div>
} }
</> </>
@@ -880,7 +798,7 @@ function App() {
<SelectSearch <SelectSearch
search={true} search={true}
options={pizzaSuggestions} options={pizzaSuggestions}
placeholder='Vyhledat pizzu nebo salát...' placeholder='Vyhledat pizzu...'
onChange={handlePizzaChange} onChange={handlePizzaChange}
onBlur={_ => { }} onBlur={_ => { }}
onFocus={_ => { }} onFocus={_ => { }}
@@ -902,21 +820,48 @@ function App() {
</div> </div>
} }
<PizzaOrderList state={data.pizzaDay.state!} orders={data.pizzaDay.orders!} onDelete={handlePizzaDelete} creator={data.pizzaDay.creator!} /> <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>
} }
</div> </div>
<PendingPayments {data.pendingQrs && data.pendingQrs.length > 0 &&
pendingQrs={data.pendingQrs} <div className='pizza-section fade-in mt-4'>
login={auth.login} <h3>Nevyřízené platby</h3>
onDismissed={async () => { <p>Máte neuhrazené platby.</p>
const response = await getData({ query: { dayIndex } }); {data.pendingQrs.map(qr => (
if (response.data) { <div key={qr.id} className='qr-code mb-3'>
setData(response.data); <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> </div>
{/* <FallingLeaves {/* <FallingLeaves
-17
View File
@@ -5,31 +5,14 @@ import { SnowOverlay } from 'react-snow-overlay';
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
import { SocketContext, socket } from "./context/socket"; import { SocketContext, socket } from "./context/socket";
import StatsPage from "./pages/StatsPage"; import StatsPage from "./pages/StatsPage";
import OrderGroupsPage from "./pages/OrderGroupsPage";
import SuggestionsPage from "./pages/SuggestionsPage";
import App from "./App"; import App from "./App";
export const STATS_URL = '/stats'; export const STATS_URL = '/stats';
export const OBJEDNANI_URL = '/objednani';
export const NAVRHY_URL = '/navrhy';
export default function AppRoutes() { export default function AppRoutes() {
return ( return (
<Routes> <Routes>
<Route path={STATS_URL} element={<StatsPage />} /> <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={ <Route path="/" element={
<ProvideSettings> <ProvideSettings>
<SocketContext.Provider value={socket}> <SocketContext.Provider value={socket}>
-13
View File
@@ -109,17 +109,4 @@ export function getHumanDate(date: Date) {
export function formatDateString(dateString: string): string { export function formatDateString(dateString: string): string {
const [year, month, day] = dateString.split('-'); const [year, month, day] = dateString.split('-');
return `${day}.${month}.${year}`; 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;
} }
+64 -53
View File
@@ -3,20 +3,24 @@ import { Navbar, Nav, NavDropdown, Modal, Button } from "react-bootstrap";
import { useAuth } from "../context/auth"; import { useAuth } from "../context/auth";
import SettingsModal from "./modals/SettingsModal"; import SettingsModal from "./modals/SettingsModal";
import { useSettings, ThemePreference } from "../context/settings"; import { useSettings, ThemePreference } from "../context/settings";
import HuePicker from "./HuePicker"; import FeaturesVotingModal from "./modals/FeaturesVotingModal";
import PizzaCalculatorModal from "./modals/PizzaCalculatorModal"; import PizzaCalculatorModal from "./modals/PizzaCalculatorModal";
import RefreshMenuModal from "./modals/RefreshMenuModal"; import RefreshMenuModal from "./modals/RefreshMenuModal";
import GenerateQrModal from "./modals/GenerateQrModal"; import GenerateQrModal from "./modals/GenerateQrModal";
import GenerateMockDataModal from "./modals/GenerateMockDataModal"; import GenerateMockDataModal from "./modals/GenerateMockDataModal";
import ClearMockDataModal from "./modals/ClearMockDataModal"; import ClearMockDataModal from "./modals/ClearMockDataModal";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { STATS_URL, OBJEDNANI_URL, NAVRHY_URL } from "../AppRoutes"; import { STATS_URL } from "../AppRoutes";
import { LunchChoices, getChangelogs } from "../../../types"; import { FeatureRequest, getVotes, updateVote, LunchChoices } from "../../../types";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons"; 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'; const IS_DEV = process.env.NODE_ENV === 'development';
@@ -30,33 +34,51 @@ export default function Header({ choices, dayIndex }: Props) {
const settings = useSettings(); const settings = useSettings();
const navigate = useNavigate(); const navigate = useNavigate();
const [settingsModalOpen, setSettingsModalOpen] = useState<boolean>(false); const [settingsModalOpen, setSettingsModalOpen] = useState<boolean>(false);
const [votingModalOpen, setVotingModalOpen] = useState<boolean>(false);
const [pizzaModalOpen, setPizzaModalOpen] = useState<boolean>(false); const [pizzaModalOpen, setPizzaModalOpen] = useState<boolean>(false);
const [refreshMenuModalOpen, setRefreshMenuModalOpen] = useState<boolean>(false); const [refreshMenuModalOpen, setRefreshMenuModalOpen] = useState<boolean>(false);
const [changelogModalOpen, setChangelogModalOpen] = useState<boolean>(false); const [changelogModalOpen, setChangelogModalOpen] = useState<boolean>(false);
const [changelogEntries, setChangelogEntries] = useState<Record<string, string[]>>({});
const [qrModalOpen, setQrModalOpen] = useState<boolean>(false); const [qrModalOpen, setQrModalOpen] = useState<boolean>(false);
const [generateMockModalOpen, setGenerateMockModalOpen] = useState<boolean>(false); const [generateMockModalOpen, setGenerateMockModalOpen] = useState<boolean>(false);
const [clearMockModalOpen, setClearMockModalOpen] = 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(() => { useEffect(() => {
if (!auth?.login) return; const updateEffectiveTheme = () => {
const lastSeen = localStorage.getItem(LAST_SEEN_CHANGELOG_KEY) ?? undefined; if (settings?.themePreference === 'system') {
getChangelogs({ query: lastSeen ? { since: lastSeen } : {} }).then(response => { const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const entries = response.data; setEffectiveTheme(isDark ? 'dark' : 'light');
if (!entries || Object.keys(entries).length === 0) return; } else {
setChangelogEntries(entries); setEffectiveTheme(settings?.themePreference || 'light');
setChangelogModalOpen(true); }
const newestDate = Object.keys(entries).sort((a, b) => b.localeCompare(a))[0]; };
localStorage.setItem(LAST_SEEN_CHANGELOG_KEY, newestDate);
}); 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]); }, [auth?.login]);
const closeSettingsModal = () => { const closeSettingsModal = () => {
setSettingsModalOpen(false); setSettingsModalOpen(false);
} }
const closeVotingModal = () => {
setVotingModalOpen(false);
}
const closePizzaModal = () => { const closePizzaModal = () => {
setPizzaModalOpen(false); setPizzaModalOpen(false);
} }
@@ -78,7 +100,8 @@ export default function Header({ choices, dayIndex }: Props) {
} }
const toggleTheme = () => { 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); settings?.setThemePreference(newTheme);
} }
@@ -143,6 +166,17 @@ export default function Header({ choices, dayIndex }: Props) {
closeSettingsModal(); 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"> return <Navbar variant='dark' expand="lg">
<Navbar.Brand href="/">Luncher</Navbar.Brand> <Navbar.Brand href="/">Luncher</Navbar.Brand>
<Navbar.Toggle aria-controls="basic-navbar-nav" /> <Navbar.Toggle aria-controls="basic-navbar-nav" />
@@ -151,35 +185,19 @@ export default function Header({ choices, dayIndex }: Props) {
<button <button
className="theme-toggle" className="theme-toggle"
onClick={toggleTheme} onClick={toggleTheme}
title={effectiveDark ? 'Přepnout na světlý režim' : 'Přepnout na tmavý režim'} title={effectiveTheme === 'dark' ? 'Přepnout na světlý režim' : 'Přepnout na tmavý režim'}
aria-label="Přepnout světlý/tmavý režim" aria-label="Přepnout barevný motiv"
> >
<FontAwesomeIcon icon={effectiveDark ? faSun : faMoon} /> <FontAwesomeIcon icon={effectiveTheme === 'dark' ? faSun : faMoon} />
</button> </button>
<HuePicker
accentHue={settings?.accentHue ?? 142}
isDark={effectiveDark}
onChange={hue => settings?.setAccentHue(hue)}
/>
<NavDropdown align="end" title={auth?.login} id="basic-nav-dropdown"> <NavDropdown align="end" title={auth?.login} id="basic-nav-dropdown">
<NavDropdown.Item onClick={() => setSettingsModalOpen(true)}>Nastavení</NavDropdown.Item> <NavDropdown.Item onClick={() => setSettingsModalOpen(true)}>Nastavení</NavDropdown.Item>
<NavDropdown.Item onClick={() => setRefreshMenuModalOpen(true)}>Přenačtení menu</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={() => setPizzaModalOpen(true)}>Pizza kalkulačka</NavDropdown.Item>
<NavDropdown.Item onClick={handleQrMenuClick}>Generování QR kódů</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(STATS_URL)}>Statistiky</NavDropdown.Item>
<NavDropdown.Item onClick={() => navigate(OBJEDNANI_URL)}>Objednání</NavDropdown.Item> <NavDropdown.Item onClick={() => setChangelogModalOpen(true)}>Novinky</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>
{IS_DEV && ( {IS_DEV && (
<> <>
<NavDropdown.Divider /> <NavDropdown.Divider />
@@ -194,6 +212,7 @@ export default function Header({ choices, dayIndex }: Props) {
</Navbar.Collapse> </Navbar.Collapse>
<SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} /> <SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} />
<RefreshMenuModal isOpen={refreshMenuModalOpen} onClose={closeRefreshMenuModal} /> <RefreshMenuModal isOpen={refreshMenuModalOpen} onClose={closeRefreshMenuModal} />
<FeaturesVotingModal isOpen={votingModalOpen} onClose={closeVotingModal} onChange={saveFeatureVote} initialValues={featureVotes} />
<PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} /> <PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} />
{choices && settings?.bankAccount && settings?.holderName && ( {choices && settings?.bankAccount && settings?.holderName && (
<GenerateQrModal <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.Header closeButton>
<Modal.Title><h2>Novinky</h2></Modal.Title> <Modal.Title><h2>Novinky</h2></Modal.Title>
</Modal.Header> </Modal.Header>
<Modal.Body> <Modal.Body>
{Object.keys(changelogEntries).sort((a, b) => b.localeCompare(a)).map(date => ( <ul>
<div key={date}> {CHANGELOG.map((item, index) => (
<strong>{formatDateString(date)}</strong> <li key={index}>{item}</li>
<ul> ))}
{changelogEntries[date].map((item, index) => ( </ul>
<li key={index}>{item}</li>
))}
</ul>
</div>
))}
{Object.keys(changelogEntries).length === 0 && (
<p>Žádné novinky.</p>
)}
</Modal.Body> </Modal.Body>
<Modal.Footer> <Modal.Footer>
<Button variant="secondary" onClick={() => setChangelogModalOpen(false)}> <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>
);
}
-58
View File
@@ -1,58 +0,0 @@
import { useState } from 'react';
import { Button } 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.
export default function PendingPayments({ pendingQrs, login, onDismissed }: Readonly<Props>) {
const [dismissQrId, setDismissQrId] = useState<string | null>(null);
if (!pendingQrs || pendingQrs.length === 0) return null;
return (
<>
<div className='pizza-section fade-in mt-4'>
<h3>Nevyřízené platby</h3>
<p>Máte neuhrazené platby.</p>
{pendingQrs.map(qr => (
<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>
))}
</div>
<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)' borderTop: '2px solid var(--luncher-border)'
}}> }}>
<td colSpan={4} style={{ padding: '16px 20px', border: 'none' }}>Celkem</td> <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> </tr>
</tbody> </tbody>
</Table> </Table>
+4 -4
View File
@@ -26,7 +26,7 @@ export default function PizzaOrderRow({ creator, order, state, onDelete, onFeeMo
<td>{order.customer}</td> <td>{order.customer}</td>
<td>{order.pizzaList!.map<React.ReactNode>(pizzaOrder => <td>{order.pizzaList!.map<React.ReactNode>(pizzaOrder =>
<span key={pizzaOrder.name}> <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 && {auth?.login === order.customer && state === PizzaDayState.CREATED &&
<span title='Odstranit'> <span title='Odstranit'>
<FontAwesomeIcon onClick={() => { <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])} .reduce((prev, curr, index) => [prev, <br key={`br-${index}`} />, curr])}
</td> </td>
<td style={{ maxWidth: "200px" }}>{order.note ?? '-'}</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> <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> </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>
}
+44 -41
View File
@@ -33,7 +33,9 @@ function parseAmount(s: string): number | null {
if (!s || s.trim().length === 0) return null; if (!s || s.trim().length === 0) return null;
const n = parseFloat(s); const n = parseFloat(s);
if (isNaN(n) || n < 0) return null; 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>) { 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; let baseAmountParseFailed = false;
if (menu) { if (menu) {
for (const idx of selectedFoods) { for (const idx of selectedFoods) {
const priceKc = parsePriceCzk(menu.food?.[idx]?.price); const price = parsePriceCzk(menu.food?.[idx]?.price);
if (priceKc === null) { if (price === null) {
baseAmountParseFailed = true; baseAmountParseFailed = true;
} else { } 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; if (includedDiners.length === 0) return 0;
const tip = parseAmount(tipTotal); const tip = parseAmount(tipTotal);
if (tip === null || tip === 0) return 0; if (tip === null || tip === 0) return 0;
const totalPeople = includedDiners.length + 1; return Math.round((tip / includedDiners.length) * 100) / 100;
return Math.round(tip / totalPeople);
})();
const payerTipShare = (() => {
const tip = parseAmount(tipTotal);
if (!tip) return 0;
return tip - tipPerPerson * includedDiners.length;
})(); })();
const getTotal = (d: DinerEntry): number => { const getTotal = (d: DinerEntry): number => {
const surcharge = parseAmount(d.surchargeAmount) ?? 0; const surcharge = parseAmount(d.surchargeAmount) ?? 0;
const tip = d.login === payerLogin ? payerTipShare : tipPerPerson; const tip = d.included && d.login !== payerLogin ? tipPerPerson : 0;
return d.baseAmount + surcharge + tip; return Math.round((d.baseAmount + surcharge + tip) * 100) / 100;
}; };
const handleInclude = useCallback((login: string, checked: boolean) => { 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á`); setError(`Celková částka pro ${d.login} musí být kladná`);
return; 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 foods = d.selectedFoods.map(i => menu?.food?.[i]?.name).filter(Boolean).join(', ');
const purposeBase = `Oběd ${locationName}${foods ? `${foods}` : ''}`; const purposeBase = `Oběd ${locationName}${foods ? `${foods}` : ''}`;
recipients.push({ recipients.push({
@@ -166,7 +167,7 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
</Alert> </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 && ( {!hasMenu && (
<Alert variant="info"> <Alert variant="info">
@@ -193,7 +194,7 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
<th>Strávník</th> <th>Strávník</th>
<th>Jídla</th> <th>Jídla</th>
<th style={{ width: 220 }}>Příplatek</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> <th style={{ width: 90 }}>Celkem</th>
</tr> </tr>
</thead> </thead>
@@ -219,38 +220,40 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
<td> <td>
<small> <small>
{foodNames || <span className="text-muted">—</span>} {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>} {d.baseAmountParseFailed && <span className="text-warning"> ⚠</span>}
</small> </small>
</td> </td>
<td> <td>
<div className="d-flex gap-1"> {!isPayer && (
<Form.Control <div className="d-flex gap-1">
type="text" <Form.Control
placeholder="popis" type="text"
value={d.surchargeText} placeholder="popis"
onChange={e => handleSurchargeText(d.login, e.target.value)} value={d.surchargeText}
disabled={!isPayer && !d.included} onChange={e => handleSurchargeText(d.login, e.target.value)}
size="sm" disabled={!d.included}
onKeyDown={e => e.stopPropagation()} size="sm"
/> onKeyDown={e => e.stopPropagation()}
<Form.Control />
type="text" <Form.Control
placeholder="" type="text"
value={d.surchargeAmount} placeholder=""
onChange={e => handleSurchargeAmount(d.login, e.target.value)} value={d.surchargeAmount}
disabled={!isPayer && !d.included} onChange={e => handleSurchargeAmount(d.login, e.target.value)}
size="sm" disabled={!d.included}
style={{ width: 70 }} size="sm"
onKeyDown={e => e.stopPropagation()} style={{ width: 70 }}
/> onKeyDown={e => e.stopPropagation()}
</div> />
</div>
)}
</td> </td>
<td className="text-end"> <td className="text-end">
{(() => { const s = isPayer ? payerTipShare : tipPerPerson; return s > 0 ? `${s / 100} Kč` : '—'; })()} {!isPayer && d.included ? `${tipPerPerson} Kč` : '—'}
</td> </td>
<td className="text-end fw-bold"> <td className="text-end fw-bold">
{`${total / 100} Kč`} {!isPayer ? `${total} Kč` : '—'}
</td> </td>
</tr> </tr>
); );
@@ -259,7 +262,7 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
</Table> </Table>
<div className="d-flex align-items-center gap-2 mt-2"> <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 <Form.Control
type="text" type="text"
placeholder="0" placeholder="0"
@@ -271,7 +274,7 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
/> />
<small className="text-muted"> <small className="text-muted">
{includedDiners.length > 0 && tipPerPerson > 0 {includedDiners.length > 0 && tipPerPerson > 0
? `(${tipPerPerson / 100} Kč / osoba)` ? `(${tipPerPerson} Kč / osoba)`
: ''} : ''}
</small> </small>
</div> </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 priceRef = useRef<HTMLInputElement>(null);
const doSubmit = () => { 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>) => { const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') { 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,119 +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 { addStore, deleteStore } from "../../../../types";
type Props = {
isOpen: boolean;
onClose: () => void;
stores: string[];
onStoresChanged: (stores: string[]) => void;
};
export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChanged }: Readonly<Props>) {
const [newName, setNewName] = 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(), heslo } });
if (res.error) {
setError((res.error as any).error || 'Nastala chyba');
} else if (res.data) {
onStoresChanged(res.data as string[]);
setNewName('');
}
} 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 string[]);
}
} 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>
<div className="d-flex gap-2 mb-3">
<Form.Control
type="text"
placeholder="Název obchodu"
value={newName}
onChange={e => setNewName(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} className="d-flex justify-content-between align-items-center">
{s}
<FontAwesomeIcon
icon={faTrashCan}
className="action-icon"
title="Odebrat"
onClick={() => handleRemove(s)}
style={{ cursor: 'pointer' }}
/>
</ListGroup.Item>
))}
</ListGroup>
)}
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={onClose}>Zavřít</Button>
</Modal.Footer>
</Modal>
);
}
@@ -1,29 +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>
</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 BANK_ACCOUNT_HOLDER_KEY = 'bank_account_holder_name';
const HIDE_SOUPS_KEY = 'hide_soups'; const HIDE_SOUPS_KEY = 'hide_soups';
const THEME_KEY = 'theme_preference'; const THEME_KEY = 'theme_preference';
const ACCENT_HUE_KEY = 'accent_hue';
const LEGACY_COLOR_THEME_KEY = 'color_theme';
export type ThemePreference = 'system' | 'light' | 'dark'; export type ThemePreference = 'system' | 'light' | 'dark';
@@ -14,13 +12,10 @@ export type SettingsContextProps = {
holderName?: string, holderName?: string,
hideSoups?: boolean, hideSoups?: boolean,
themePreference: ThemePreference, themePreference: ThemePreference,
accentHue: number,
effectiveDark: boolean,
setBankAccountNumber: (accountNumber?: string) => void, setBankAccountNumber: (accountNumber?: string) => void,
setBankAccountHolderName: (holderName?: string) => void, setBankAccountHolderName: (holderName?: string) => void,
setHideSoupsOption: (hideSoups?: boolean) => void, setHideSoupsOption: (hideSoups?: boolean) => void,
setThemePreference: (theme: ThemePreference) => void, setThemePreference: (theme: ThemePreference) => void,
setAccentHue: (hue: number) => void,
} }
type ContextProps = { type ContextProps = {
@@ -50,74 +45,11 @@ function getInitialTheme(): ThemePreference {
return 'system'; 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 { function useProvideSettings(): SettingsContextProps {
const [bankAccount, setBankAccount] = useState<string | undefined>(); const [bankAccount, setBankAccount] = useState<string | undefined>();
const [holderName, setHolderName] = useState<string | undefined>(); const [holderName, setHolderName] = useState<string | undefined>();
const [hideSoups, setHideSoups] = useState<boolean | undefined>(); const [hideSoups, setHideSoups] = useState<boolean | undefined>();
const [themePreference, setTheme] = useState<ThemePreference>(getInitialTheme); 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(() => { useEffect(() => {
const accountNumber = localStorage.getItem(BANK_ACCOUNT_NUMBER_KEY); const accountNumber = localStorage.getItem(BANK_ACCOUNT_NUMBER_KEY);
@@ -163,27 +95,24 @@ function useProvideSettings(): SettingsContextProps {
}, [themePreference]); }, [themePreference]);
useEffect(() => { useEffect(() => {
const applyTheme = (dark: boolean) => { const applyTheme = (theme: 'light' | 'dark') => {
document.documentElement.setAttribute('data-bs-theme', dark ? 'dark' : 'light'); document.documentElement.setAttribute('data-bs-theme', theme);
setEffectiveDark(dark);
}; };
if (themePreference === 'system') { if (themePreference === 'system') {
const mq = window.matchMedia('(prefers-color-scheme: dark)'); const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
applyTheme(mq.matches); applyTheme(mediaQuery.matches ? 'dark' : 'light');
const handler = (e: MediaQueryListEvent) => applyTheme(e.matches);
mq.addEventListener('change', handler); const handler = (e: MediaQueryListEvent) => {
return () => mq.removeEventListener('change', handler); applyTheme(e.matches ? 'dark' : 'light');
};
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
} else { } else {
applyTheme(themePreference === 'dark'); applyTheme(themePreference);
} }
}, [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) { function setBankAccountNumber(bankAccount?: string) {
setBankAccount(bankAccount); setBankAccount(bankAccount);
} }
@@ -200,21 +129,14 @@ function useProvideSettings(): SettingsContextProps {
setTheme(theme); setTheme(theme);
} }
function setAccentHue(hue: number) {
setHue(hue);
}
return { return {
bankAccount, bankAccount,
holderName, holderName,
hideSoups, hideSoups,
themePreference, themePreference,
accentHue,
effectiveDark,
setBankAccountNumber, setBankAccountNumber,
setBankAccountHolderName, setBankAccountHolderName,
setHideSoupsOption, setHideSoupsOption,
setThemePreference, setThemePreference,
setAccentHue,
} }
} }
+1 -15
View File
@@ -8,27 +8,13 @@ if (process.env.NODE_ENV === 'development') {
socketPath = undefined; socketPath = undefined;
} else { } else {
socketUrl = `${globalThis.location.host}`; 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 socket = socketio.connect(socketUrl, { path: socketPath, transports: ["websocket"] });
export const SocketContext = React.createContext(); 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! // Konstanty websocket eventů, musí odpovídat těm na serveru!
export const EVENT_CONNECT = 'connect'; export const EVENT_CONNECT = 'connect';
export const EVENT_DISCONNECT = 'disconnect'; export const EVENT_DISCONNECT = 'disconnect';
export const EVENT_MESSAGE = 'message'; export const EVENT_MESSAGE = 'message';
export const EVENT_PENDING_QR = 'pendingQr';
-761
View File
@@ -1,761 +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, 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, 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';
const SLOT = MealSlot.EXTRA;
const TIME_REGEX = /^([01]\d|2[0-3]):[0-5]\d$/;
// Č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 }>>({});
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]);
useEffect(() => {
// Po znovupřipojení socketu načteme aktuálně zobrazený den (mohli jsme přijít o živé aktualizace)
const onReconnect = () => fetchData(selectedDateRef.current);
socket.io.on('reconnect', onReconnect);
return () => { socket.io.off('reconnect', onReconnect); };
}, [socket]);
// 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 } = 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;
}
const ok = await refresh(() => updateGroupTimes({ body: { id: group.id, orderedAt, deliveryAt } }));
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} value={s}>{s}</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;
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">
<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>
<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"
style={{ cursor: !isReadOnly && isCreator ? 'pointer' : undefined }}
onClick={() => !isReadOnly && isCreator && setEditTimes(prev => ({ ...prev, [group.id]: { orderedAt: group.orderedAt ?? '', deliveryAt: group.deliveryAt ?? '' } }))}
title={!isReadOnly && isCreator ? 'Klikněte pro úpravu časů' : undefined}
>
<small className="text-muted">
Objednáno v: <strong>{group.orderedAt ?? '—'}</strong>
</small>
<small className="text-muted">
Doručení v: <strong>{group.deliveryAt ?? '—'}</strong>
</small>
</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 { .recharts-cartesian-grid-vertical line {
stroke: var(--luncher-border); 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 { useAuth } from "../context/auth";
import Login from "../Login"; import Login from "../Login";
import { formatDate, getFirstWorkDayOfWeek, getHumanDate, getLastWorkDayOfWeek } from "../Utils"; 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 Loader from "../components/Loader";
import { faChevronLeft, faChevronRight, faGear } from "@fortawesome/free-solid-svg-icons"; import { faChevronLeft, faChevronRight, faGear } from "@fortawesome/free-solid-svg-icons";
import { Legend, Line, LineChart, Tooltip, XAxis, YAxis } from "recharts"; import { Legend, Line, LineChart, Tooltip, XAxis, YAxis } from "recharts";
@@ -32,6 +32,7 @@ export default function StatsPage() {
const auth = useAuth(); const auth = useAuth();
const [dateRange, setDateRange] = useState<Date[]>(); const [dateRange, setDateRange] = useState<Date[]>();
const [data, setData] = useState<WeeklyStats>(); const [data, setData] = useState<WeeklyStats>();
const [votingStats, setVotingStats] = useState<VotingStats>();
// Prvotní nastavení aktuálního týdne // Prvotní nastavení aktuálního týdne
useEffect(() => { useEffect(() => {
@@ -48,6 +49,19 @@ export default function StatsPage() {
} }
}, [dateRange]); }, [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 renderLine = (location: LunchChoice) => {
const index = Object.values(LunchChoice).indexOf(location); 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} /> 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 /> <Tooltip />
<Legend /> <Legend />
</LineChart> </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> </div>
<Footer /> <Footer />
</> </>
-122
View File
@@ -1,122 +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;
}
.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;
}
}
}
}
-150
View File
@@ -1,150 +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);
}
};
if (!auth?.login) {
return <Login />;
}
if (!suggestions) {
return <Loader icon={faGear} description={"Načítám návrhy..."} animation={"fa-bounce"} />;
}
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>
) : (
<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>
{suggestions.map(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()}>
<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>
))}
</tbody>
</table>
)}
</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 č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()], plugins: [react(), viteTsconfigPaths()],
server: { server: {
open: true, open: true,
host: '0.0.0.0',
port: 3000, port: 3000,
proxy: { proxy: {
'/api': 'http://localhost:3001', '/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" resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz#9e585ab6086bef994c6e8a5b3a0481219ada862b"
integrity sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ== 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": "@fortawesome/fontawesome-common-types@7.1.0":
version "7.1.0" version "7.1.0"
resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.1.0.tgz#a4e0b7e40073d5fdef41182da1bc216a05875659" 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" resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0"
integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA== 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: debug@^4.1.0, debug@^4.3.1, debug@~4.4.1:
version "4.4.3" version "4.4.3"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" 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" uncontrollable "^7.2.1"
warning "^4.0.3" 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: react-dom@^19.2.0:
version "19.2.3" version "19.2.3"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.3.tgz#f0b61d7e5c4a86773889fcc1853af3ed5f215b17" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.3.tgz#f0b61d7e5c4a86773889fcc1853af3ed5f215b17"
@@ -1931,11 +1881,6 @@ supports-color@^7.1.0:
dependencies: dependencies:
has-flag "^4.0.0" 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: tiny-invariant@^1.3.3:
version "1.3.3" version "1.3.3"
resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz#46680b7a873a0d5d10005995eb90a70d74d60127" 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"
+1 -5
View File
@@ -47,8 +47,4 @@
# Heslo pro bypass rate limitu na endpointu /api/food/refresh (pro skripty/admin). # 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). # Bez hesla může refresh volat každý přihlášený uživatel (podléhá rate limitu).
# REFRESH_BYPASS_PASSWORD= # 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 /dist
/resources/easterEggs /resources/easterEggs
/src/gen /src/gen
/coverage
.env.production .env.production
.env.development .env.development
.easter-eggs.json .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)"
]
-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/jsonwebtoken": "^9.0.10",
"@types/node": "^24.10.0", "@types/node": "^24.10.0",
"@types/request-promise": "^4.1.48", "@types/request-promise": "^4.1.48",
"@types/supertest": "^6.0.0",
"@types/web-push": "^3.6.4", "@types/web-push": "^3.6.4",
"babel-jest": "^30.2.0", "babel-jest": "^30.2.0",
"jest": "^30.2.0", "jest": "^30.2.0",
"nodemon": "^3.1.10", "nodemon": "^3.1.10",
"supertest": "^7.0.0",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"typescript": "^5.9.3" "typescript": "^5.9.3"
}, },
"dependencies": { "dependencies": {
"@socket.io/redis-adapter": "^8.3.0",
"axios": "^1.13.2", "axios": "^1.13.2",
"cheerio": "^1.1.2", "cheerio": "^1.1.2",
"cors": "^2.8.5", "cors": "^2.8.5",
+8 -8
View File
@@ -9,13 +9,13 @@ import jwt from 'jsonwebtoken';
*/ */
export function generateToken(login?: string, trusted?: boolean): string { export function generateToken(login?: string, trusted?: boolean): string {
if (!process.env.JWT_SECRET) { 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) { 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) { 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 }; const payload = { login, trusted: trusted || false, logoutUrl: process.env.LOGOUT_URL };
return jwt.sign(payload, process.env.JWT_SECRET); 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 { export function verify(token: string): boolean {
if (!process.env.JWT_SECRET) { 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 { try {
jwt.verify(token, process.env.JWT_SECRET); jwt.verify(token, process.env.JWT_SECRET);
@@ -45,10 +45,10 @@ export function verify(token: string): boolean {
*/ */
export function getLogin(token?: string): string { export function getLogin(token?: string): string {
if (!process.env.JWT_SECRET) { 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) { 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); const payload: any = jwt.verify(token, process.env.JWT_SECRET);
return payload.login; return payload.login;
@@ -61,10 +61,10 @@ export function getLogin(token?: string): string {
*/ */
export function getTrusted(token?: string): boolean { export function getTrusted(token?: string): boolean {
if (!process.env.JWT_SECRET) { 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) { 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); const payload: any = jwt.verify(token, process.env.JWT_SECRET);
return payload.trusted || false; return payload.trusted || false;
+9 -48
View File
@@ -1,7 +1,6 @@
import axios from 'axios'; import axios from 'axios';
import { load } from 'cheerio'; import { load } from 'cheerio';
import { getPizzaListMock, getSalatListMock } from './mock'; import { getPizzaListMock } from './mock';
import { Salat } from '../../types/gen/types.gen';
// TODO přesunout do types // TODO přesunout do types
type PizzaSize = { type PizzaSize = {
@@ -21,27 +20,23 @@ type Pizza = {
// TODO mělo by být konfigurovatelné proměnnou z prostředí s tímhle jako default // TODO mělo by být konfigurovatelné proměnnou z prostředí s tímhle jako default
const baseUrl = 'https://www.pizzachefie.cz'; const baseUrl = 'https://www.pizzachefie.cz';
const pizzyUrl = `${baseUrl}/pizzy.html`; const pizzyUrl = `${baseUrl}/pizzy.html?pobocka=plzen`;
const salayUrl = `${baseUrl}/salaty.html`;
const buildPizzaUrl = (pizzaUrl: string) => { const buildPizzaUrl = (pizzaUrl: string) => {
return `${baseUrl}/${pizzaUrl}`; return `${baseUrl}/${pizzaUrl}`;
} }
// Ceny krabic dle velikosti v haléřích // Ceny krabic dle velikosti
const boxPrices: { [key: string]: number } = { const boxPrices: { [key: string]: number } = {
"30cm": 1300, "30cm": 13,
"35cm": 1500, "35cm": 15,
"40cm": 1800, "40cm": 18,
"50cm": 2500 "50cm": 25
} }
// Cena obalu pro salát v haléřích
const SALAT_BOX_PRICE = 1300;
/** /**
* Stáhne a scrapne aktuální pizzy ze stránek Pizza Chefie. * Stáhne a scrapne aktuální pizzy ze stránek Pizza Chefie.
* *
* @param mock zda vrátit pouze mock data * @param mock zda vrátit pouze mock data
*/ */
export async function downloadPizzy(mock: boolean): Promise<Pizza[]> { export async function downloadPizzy(mock: boolean): Promise<Pizza[]> {
@@ -79,7 +74,7 @@ export async function downloadPizzy(mock: boolean): Promise<Pizza[]> {
a.each((i, elm) => { a.each((i, elm) => {
const varId = Number.parseInt(elm.attribs.href.split('?varianta=')[1].trim()); const varId = Number.parseInt(elm.attribs.href.split('?varianta=')[1].trim());
const size = $($(elm).contents().get(0)).text().trim(); const size = $($(elm).contents().get(0)).text().trim();
const price = Number.parseInt($($(elm).contents().get(1)).text().trim().split(" Kč")[0]) * 100; const price = Number.parseInt($($(elm).contents().get(1)).text().trim().split(" Kč")[0]);
sizes.push({ varId, size, pizzaPrice: price, boxPrice: boxPrices[size], price: price + boxPrices[size] }); sizes.push({ varId, size, pizzaPrice: price, boxPrice: boxPrices[size], price: price + boxPrices[size] });
}) })
result.push({ result.push({
@@ -89,38 +84,4 @@ export async function downloadPizzy(mock: boolean): Promise<Pizza[]> {
}); });
} }
return result; return result;
}
/**
* Stáhne a scrapne aktuální saláty ze stránek Pizza Chefie.
* Příplatek za obal je pro každý salát pevně 13 .
*
* @param mock zda vrátit pouze mock data
*/
export async function downloadSalaty(mock: boolean): Promise<Salat[]> {
if (mock) {
return new Promise((resolve) => setTimeout(() => resolve(getSalatListMock()), 1000));
}
const html = await axios.get(salayUrl).then(res => res.data);
const $ = load(html);
const links = $('.vypisproduktu > div > h4 > a');
const urls = [];
for (const element of links) {
if (element.name === 'a' && element.attribs?.href) {
urls.push(buildPizzaUrl(element.attribs.href));
}
}
const result: Salat[] = [];
for (const url of urls) {
const salatHtml = await axios.get(url).then(res => res.data);
const name = $('.produkt > h2', salatHtml).first().text().trim();
const ingredients: string[] = [];
$('.prisady > li', salatHtml).each((i, elm) => {
ingredients.push($(elm).text());
});
const priceText = $('.cena > span', salatHtml).first().text().trim();
const price = Number.parseInt(priceText.split(' Kč')[0]) * 100;
result.push({ name, ingredients, price: price + SALAT_BOX_PRICE });
}
return result;
} }
-201
View File
@@ -1,201 +0,0 @@
import crypto from "crypto";
import getStorage from "./storage";
import { getClientData, getToday, initIfNeeded } from "./service";
import { getStores } from "./stores";
import { removePendingQrsByGroupId } from "./pizza";
import { ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember } from "../../types/gen/types.gen";
import { formatDate } from "./utils";
const storage = getStorage();
async function getExtraData(date?: Date): Promise<ClientData> {
await initIfNeeded(date, MealSlot.EXTRA);
const data = await getClientData(date, MealSlot.EXTRA);
data.stores = await getStores();
return data;
}
function getExtraKey(date?: Date): string {
return `${formatDate(date ?? getToday())}_extra`;
}
async function saveExtraData(data: ClientData, date?: Date): Promise<ClientData> {
await storage.setData(getExtraKey(date), data);
return data;
}
function findGroup(data: ClientData, id: string): OrderGroup | undefined {
return data.groups?.find(g => g.id === id);
}
/**
* Vrátí seznam ISO dat (YYYY-MM-DD), pro která existuje alespoň jedna objednávková skupina.
* Slouží ke zvýraznění dnů v date pickeru na stránce objednávání.
*/
export async function getOrderDates(): Promise<string[]> {
const EXTRA_SUFFIX = '_extra';
const keys = await storage.listKeys(EXTRA_SUFFIX);
const dates: string[] = [];
for (const key of keys) {
if (!key.endsWith(EXTRA_SUFFIX)) continue;
const data = await storage.getData<ClientData>(key);
if (data?.groups && data.groups.length > 0) {
dates.push(key.slice(0, -EXTRA_SUFFIX.length));
}
}
return dates.sort();
}
export async function createGroup(creatorLogin: string, name: string, date?: Date): Promise<ClientData> {
const stores = await getStores();
if (!stores.some(s => s.toLowerCase() === name.trim().toLowerCase())) {
throw new Error('Obchod není v seznamu povolených obchodů');
}
const data = await getExtraData(date);
const canonical = stores.find(s => s.toLowerCase() === name.trim().toLowerCase())!;
const group: OrderGroup = {
id: crypto.randomUUID(),
name: canonical,
creatorLogin,
state: GroupState.OPEN,
members: { [creatorLogin]: {} },
};
data.groups = [...(data.groups ?? []), group];
return saveExtraData(data, date);
}
export async function deleteGroup(login: string, groupId: string, date?: Date): Promise<ClientData> {
const data = await getExtraData(date);
const group = findGroup(data, groupId);
if (!group) throw new Error('Skupina nebyla nalezena');
if (group.creatorLogin !== login) throw new Error('Skupinu může smazat pouze zakladatel');
data.groups = (data.groups ?? []).filter(g => g.id !== groupId);
return saveExtraData(data, date);
}
export async function addGroupMember(login: string, groupId: string, targetLogin: string, date?: Date): Promise<ClientData> {
const data = await getExtraData(date);
const group = findGroup(data, groupId);
if (!group) throw new Error('Skupina nebyla nalezena');
if (group.state === GroupState.ORDERED) throw new Error('Skupinu ve stavu "objednáno" nelze upravovat');
if (login !== group.creatorLogin && login !== targetLogin) {
throw new Error('Přidat jiného uživatele může pouze zakladatel');
}
if (group.state === GroupState.LOCKED && login !== group.creatorLogin) {
throw new Error('Skupinu ve stavu "uzamčeno" může upravovat pouze zakladatel');
}
if (group.members[targetLogin]) throw new Error('Uživatel je již ve skupině');
group.members[targetLogin] = {};
return saveExtraData(data, date);
}
export async function removeGroupMember(login: string, groupId: string, targetLogin: string, date?: Date): Promise<ClientData> {
const data = await getExtraData(date);
const group = findGroup(data, groupId);
if (!group) throw new Error('Skupina nebyla nalezena');
if (group.state === GroupState.ORDERED) throw new Error('Skupinu ve stavu "objednáno" nelze upravovat');
if (login !== group.creatorLogin && login !== targetLogin) {
throw new Error('Odebrat jiného uživatele může pouze zakladatel');
}
if (group.state === GroupState.LOCKED && login !== group.creatorLogin) {
throw new Error('Skupinu ve stavu "uzamčeno" může upravovat pouze zakladatel');
}
if (targetLogin === group.creatorLogin) throw new Error('Zakladatel skupiny nemůže být odebrán');
if (!group.members[targetLogin]) throw new Error('Uživatel není ve skupině');
delete group.members[targetLogin];
return saveExtraData(data, date);
}
export async function updateGroupMember(login: string, groupId: string, targetLogin: string, patch: Partial<OrderGroupMember>, date?: Date): Promise<ClientData> {
const data = await getExtraData(date);
const group = findGroup(data, groupId);
if (!group) throw new Error('Skupina nebyla nalezena');
if (group.state === GroupState.ORDERED) throw new Error('Skupinu ve stavu "objednáno" nelze upravovat');
const isSelf = login === targetLogin;
const isCreator = login === group.creatorLogin;
if (!isSelf && !isCreator) throw new Error('Upravit jiného uživatele může pouze zakladatel');
if (!isCreator && group.state === GroupState.LOCKED) {
throw new Error('Skupinu ve stavu "uzamčeno" může upravovat pouze zakladatel');
}
if (!group.members[targetLogin]) throw new Error('Uživatel není ve skupině');
group.members[targetLogin] = { ...group.members[targetLogin], ...patch };
return saveExtraData(data, date);
}
const VALID_TRANSITIONS: Record<GroupState, GroupState[]> = {
[GroupState.OPEN]: [GroupState.LOCKED],
[GroupState.LOCKED]: [GroupState.OPEN, GroupState.ORDERED],
[GroupState.ORDERED]: [GroupState.LOCKED],
};
function getCurrentHHMM(): string {
const now = new Date();
return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
}
export async function setGroupState(login: string, groupId: string, newState: GroupState, date?: Date): Promise<ClientData> {
const data = await getExtraData(date);
const group = findGroup(data, groupId);
if (!group) throw new Error('Skupina nebyla nalezena');
if (group.creatorLogin !== login) throw new Error('Stav může měnit pouze zakladatel');
if (!VALID_TRANSITIONS[group.state].includes(newState)) {
throw new Error(`Nelze přejít ze stavu "${group.state}" do stavu "${newState}"`);
}
if (newState === GroupState.ORDERED) {
group.orderedAt = getCurrentHHMM();
}
if (group.state === GroupState.ORDERED && newState === GroupState.LOCKED) {
const memberLogins = Object.keys(group.members);
await removePendingQrsByGroupId(memberLogins, groupId);
group.orderedAt = undefined;
group.deliveryAt = undefined;
group.qrGenerated = undefined;
for (const ml of memberLogins) {
group.members[ml] = { ...group.members[ml], paid: undefined };
}
}
group.state = newState;
return saveExtraData(data, date);
}
export async function markGroupQrGenerated(login: string, groupId: string, date?: Date): Promise<void> {
const data = await getExtraData(date);
const group = findGroup(data, groupId);
if (!group) throw new Error('Skupina nebyla nalezena');
if (group.creatorLogin !== login) throw new Error('QR kódy může generovat pouze zakladatel');
if (group.state !== GroupState.ORDERED) throw new Error('QR kódy lze generovat pouze ve stavu "objednáno"');
group.qrGenerated = true;
await saveExtraData(data, date);
}
export async function markGroupMemberPaid(login: string, groupId: string, date?: Date): Promise<ClientData | null> {
const data = await getExtraData(date);
const group = findGroup(data, groupId);
if (!group || !group.members[login]) return null;
group.members[login] = { ...group.members[login], paid: true };
return saveExtraData(data, date);
}
export async function updateGroupFees(login: string, groupId: string, fees?: number, shipping?: number, tip?: number, discountType?: string, discountValue?: number, date?: Date): Promise<ClientData> {
const data = await getExtraData(date);
const group = findGroup(data, groupId);
if (!group) throw new Error('Skupina nebyla nalezena');
if (group.creatorLogin !== login) throw new Error('Poplatky může měnit pouze zakladatel');
if (group.state === GroupState.ORDERED) throw new Error('Skupinu ve stavu "objednáno" nelze upravovat');
if (fees !== undefined) group.fees = fees > 0 ? fees : undefined;
if (shipping !== undefined) group.shipping = shipping > 0 ? shipping : undefined;
if (tip !== undefined) group.tip = tip > 0 ? tip : undefined;
if (discountType !== undefined) group.discountType = (discountType as any) || undefined;
if (discountValue !== undefined) group.discountValue = discountValue > 0 ? discountValue : undefined;
return saveExtraData(data, date);
}
export async function updateGroupTimes(login: string, groupId: string, orderedAt?: string, deliveryAt?: string, date?: Date): Promise<ClientData> {
const data = await getExtraData(date);
const group = findGroup(data, groupId);
if (!group) throw new Error('Skupina nebyla nalezena');
if (group.creatorLogin !== login) throw new Error('Časy může měnit pouze zakladatel');
if (orderedAt !== undefined) group.orderedAt = orderedAt || undefined;
if (deliveryAt !== undefined) group.deliveryAt = deliveryAt || undefined;
return saveExtraData(data, date);
}
+45 -134
View File
@@ -1,52 +1,44 @@
import express from "express"; import express from "express";
import bodyParser from "body-parser"; import bodyParser from "body-parser";
import cors from 'cors'; import cors from 'cors';
import { getData, addChoice, getDateForWeekIndex, getToday } from "./service"; import { getData, getDateForWeekIndex, getToday } from "./service";
import { MealSlot } from "../../types/gen/types.gen";
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import path from 'path'; import path from 'path';
import { getQr } from "./qr"; import { getQr } from "./qr";
import { generateToken, getLogin, verify } from "./auth"; import { generateToken, getLogin, verify } from "./auth";
import { getIsWeekend, InsufficientPermissions, PizzaDayConflictError, parseToken } from "./utils"; import { getIsWeekend, InsufficientPermissions, PizzaDayConflictError, parseToken } from "./utils";
import { getPendingQrs } from "./pizza"; import { getPendingQrs } from "./pizza";
import { initWebsocket, initRedisAdapter, shutdownWebsocketClients, getWebsocket } from "./websocket"; import { initWebsocket } from "./websocket";
import { startReminderScheduler, stopReminderScheduler, releaseReminderLease, verifyQuickChoiceToken } from "./pushReminder"; import { startReminderScheduler } from "./pushReminder";
import { storageReady } from "./storage";
import getStorage from "./storage";
import { shutdownRedisStorage } from "./storage/redis";
import pizzaDayRoutes from "./routes/pizzaDayRoutes"; import pizzaDayRoutes from "./routes/pizzaDayRoutes";
import foodRoutes, { refreshMetoda } from "./routes/foodRoutes"; import foodRoutes, { refreshMetoda } from "./routes/foodRoutes";
import suggestionRoutes from "./routes/suggestionRoutes"; import votingRoutes from "./routes/votingRoutes";
import easterEggRoutes from "./routes/easterEggRoutes"; import easterEggRoutes from "./routes/easterEggRoutes";
import statsRoutes from "./routes/statsRoutes"; import statsRoutes from "./routes/statsRoutes";
import notificationRoutes from "./routes/notificationRoutes"; import notificationRoutes from "./routes/notificationRoutes";
import qrRoutes from "./routes/qrRoutes"; import qrRoutes from "./routes/qrRoutes";
import devRoutes from "./routes/devRoutes"; import devRoutes from "./routes/devRoutes";
import changelogRoutes from "./routes/changelogRoutes";
import groupRoutes from "./routes/groupRoutes";
import storeRoutes from "./routes/storeRoutes";
const ENVIRONMENT = process.env.NODE_ENV ?? 'production'; const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) }); dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
// Validace nastavení JWT tokenu - nemá bez něj smysl vůbec povolit server spustit
if (!process.env.JWT_SECRET) { 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");
} }
const app = express(); const app = express();
const server = require("http").createServer(app); const server = require("http").createServer(app);
// Tune keep-alive timeouts to outlive Traefik's 60s idle timeout.
// headersTimeout must be strictly greater than keepAliveTimeout.
server.keepAliveTimeout = 65_000;
server.headersTimeout = 66_000;
server.requestTimeout = 30_000;
initWebsocket(server); initWebsocket(server);
// Body-parser middleware for parsing JSON
app.use(bodyParser.json()); app.use(bodyParser.json());
app.use(cors({ origin: '*' }));
app.use(cors({
origin: '*'
}));
// Zapínatelný login přes hlavičky - pokud je zapnutý nepovolí "basicauth"
const HTTP_REMOTE_USER_ENABLED = process.env.HTTP_REMOTE_USER_ENABLED === 'true' || false; const HTTP_REMOTE_USER_ENABLED = process.env.HTTP_REMOTE_USER_ENABLED === 'true' || false;
const HTTP_REMOTE_USER_HEADER_NAME = process.env.HTTP_REMOTE_USER_HEADER_NAME ?? 'remote-user'; const HTTP_REMOTE_USER_HEADER_NAME = process.env.HTTP_REMOTE_USER_HEADER_NAME ?? 'remote-user';
if (HTTP_REMOTE_USER_ENABLED) { if (HTTP_REMOTE_USER_ENABLED) {
@@ -54,68 +46,14 @@ if (HTTP_REMOTE_USER_ENABLED) {
throw new Error('Je zapnutý login z hlaviček, ale není nastaven rozsah adres ze kterých hlavička může přijít.'); throw new Error('Je zapnutý login z hlaviček, ale není nastaven rozsah adres ze kterých hlavička může přijít.');
} }
const HTTP_REMOTE_TRUSTED_IPS = process.env.HTTP_REMOTE_TRUSTED_IPS.split(',').map(ip => ip.trim()); const HTTP_REMOTE_TRUSTED_IPS = process.env.HTTP_REMOTE_TRUSTED_IPS.split(',').map(ip => ip.trim());
//TODO: nevim jak udelat console.log pouze pro "debug"
//console.log("Budu věřit hlavičkám z: " + HTTP_REMOTE_TRUSTED_IPS);
app.set('trust proxy', HTTP_REMOTE_TRUSTED_IPS); app.set('trust proxy', HTTP_REMOTE_TRUSTED_IPS);
console.log('Zapnutý login přes hlavičky z proxy.'); console.log('Zapnutý login přes hlavičky z proxy.');
} }
// ─── Shutdown state ──────────────────────────────────────────────────────────
let shuttingDown = false; // ----------- Metody nevyžadující token --------------
async function shutdown(signal: string) {
if (shuttingDown) return;
shuttingDown = true;
console.log(`${signal} received — initiating graceful shutdown`);
// Hard-exit failsafe: fires before terminationGracePeriodSeconds (30s)
setTimeout(() => {
console.error('Graceful shutdown timed out, forcing exit');
process.exit(1);
}, 25_000).unref();
// Disconnect WebSocket clients so they reconnect to another pod
const io = getWebsocket();
io?.disconnectSockets(true);
// Stop accepting new HTTP connections and drain in-flight requests
(server as any).closeIdleConnections?.();
await new Promise<void>(resolve => server.close(() => resolve()));
// Stop reminder scheduler and release leader lease
stopReminderScheduler();
await releaseReminderLease();
// Shut down Redis pub/sub clients (Socket.io adapter)
await shutdownWebsocketClients();
// Shut down main Redis storage client
if (process.env.STORAGE?.toLowerCase() === 'redis') {
await shutdownRedisStorage();
}
console.log('Graceful shutdown complete');
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
// ─── Routes — no auth required ───────────────────────────────────────────────
/** Liveness probe — cheap, no external deps. */
app.get("/api/health", (_req, res) => {
res.status(200).json({ ok: true });
});
/** Readiness probe — verifies Redis connectivity and rejects traffic during shutdown. */
app.get("/api/health/ready", async (_req, res) => {
if (shuttingDown) {
return res.status(503).json({ ok: false, reason: 'shutting down' });
}
const healthy = await getStorage().healthCheck?.() ?? true;
if (!healthy) return res.status(503).json({ ok: false, reason: 'storage unavailable' });
res.status(200).json({ ok: true });
});
app.get("/api/whoami", (req, res) => { app.get("/api/whoami", (req, res) => {
if (!HTTP_REMOTE_USER_ENABLED) { if (!HTTP_REMOTE_USER_ENABLED) {
@@ -129,17 +67,21 @@ app.get("/api/whoami", (req, res) => {
}) })
app.post("/api/login", (req, res) => { app.post("/api/login", (req, res) => {
if (HTTP_REMOTE_USER_ENABLED) { if (HTTP_REMOTE_USER_ENABLED) { // je rovno app.enabled('trust proxy')
// Autentizace pomocí trusted headers
const remoteUser = req.header(HTTP_REMOTE_USER_HEADER_NAME); const remoteUser = req.header(HTTP_REMOTE_USER_HEADER_NAME);
//const remoteName = req.header('remote-name');
if (remoteUser && remoteUser.length > 0) { if (remoteUser && remoteUser.length > 0) {
res.status(200).json(generateToken(Buffer.from(remoteUser, 'latin1').toString(), true)); res.status(200).json(generateToken(Buffer.from(remoteUser, 'latin1').toString(), true));
} else { } else {
throw new Error("Je zapnuto přihlášení přes hlavičky, ale nepřišla hlavička nebo ??"); throw Error("Je zapnuto přihlášení přes hlavičky, ale nepřišla hlavička nebo ??");
} }
} else { } else {
// Klasická autentizace loginem
if (!req.body?.login || req.body.login.trim().length === 0) { if (!req.body?.login || req.body.login.trim().length === 0) {
throw new Error("Nebyl předán login"); throw Error("Nebyl předán login");
} }
// TODO zavést podmínky pro délku loginu (min i max)
res.status(200).json(generateToken(req.body.login, false)); res.status(200).json(generateToken(req.body.login, false));
} }
}); });
@@ -160,29 +102,15 @@ app.get("/api/qr", async (req, res) => {
res.end(img); res.end(img);
}); });
// ─── Semi-public routes ─────────────────────────────────────────────────────── // ----------------------------------------------------
// Přeskočení auth pro refresh dat xd
app.use("/api/food/refresh", refreshMetoda); app.use("/api/food/refresh", refreshMetoda);
app.post("/api/notifications/push/quickChoice", async (req, res, next) => { /** Middleware ověřující JWT token */
try {
const { login, token } = req.body ?? {};
if (!login || typeof login !== 'string' || !token || typeof token !== 'string') {
return res.status(400).json({ error: 'Chybí login nebo token' });
}
if (!verifyQuickChoiceToken(login, token)) {
return res.status(403).json({ error: 'Neplatný token' });
}
const updatedData = await addChoice(login, false, 'NEOBEDVAM', undefined, undefined);
getWebsocket().emit("message", updatedData);
res.status(200).json({});
} catch (e: any) { next(e); }
});
// ─── Auth middleware ──────────────────────────────────────────────────────────
app.use("/api/", (req, res, next) => { app.use("/api/", (req, res, next) => {
if (HTTP_REMOTE_USER_ENABLED) { if (HTTP_REMOTE_USER_ENABLED) {
// Autentizace pomocí trusted headers
const remoteUser = req.header(HTTP_REMOTE_USER_HEADER_NAME); const remoteUser = req.header(HTTP_REMOTE_USER_HEADER_NAME);
if (process.env.ENABLE_HEADERS_LOGGING === 'yes') { if (process.env.ENABLE_HEADERS_LOGGING === 'yes') {
delete req.headers["cookie"] delete req.headers["cookie"]
@@ -205,31 +133,20 @@ app.use("/api/", (req, res, next) => {
next(); next();
}); });
// ─── Authenticated routes ───────────────────────────────────────────────────── /** Vrátí data pro aktuální den. */
app.get("/api/data", async (req, res) => { app.get("/api/data", async (req, res) => {
let date = undefined; let date = undefined;
if (req.query.date != null && typeof req.query.date === 'string') { if (req.query.dayIndex != null && typeof req.query.dayIndex === 'string') {
// Konkrétní datum (YYYY-MM-DD) umožňuje načtení historie i mimo aktuální týden
const parsed = new Date(`${req.query.date}T00:00:00`);
if (isNaN(parsed.getTime())) {
return res.status(400).json({ error: 'Neplatné datum' });
}
// Budoucnost ořízneme na dnešek do budoucna historii nedává smysl zobrazovat
date = parsed.getTime() > getToday().getTime() ? getToday() : parsed;
} else if (req.query.dayIndex != null && typeof req.query.dayIndex === 'string') {
const index = parseInt(req.query.dayIndex); const index = parseInt(req.query.dayIndex);
if (!isNaN(index)) { if (!isNaN(index)) {
date = getDateForWeekIndex(parseInt(req.query.dayIndex)); date = getDateForWeekIndex(parseInt(req.query.dayIndex));
} }
} else if (getIsWeekend(getToday())) { } else if (getIsWeekend(getToday())) {
// Na víkendu zobrazíme pátek místo hlášky "Užívejte víkend"
date = getDateForWeekIndex(4); date = getDateForWeekIndex(4);
} }
const slotParam = typeof req.query.slot === 'string' ? req.query.slot as MealSlot : undefined; const data = await getData(date);
if (slotParam && slotParam !== MealSlot.OBED && slotParam !== MealSlot.EXTRA) { // Připojíme nevyřízené QR kódy pro přihlášeného uživatele
return res.status(400).json({ error: 'Neplatný slot' });
}
const data = await getData(date, slotParam);
try { try {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
const pendingQrs = await getPendingQrs(login); const pendingQrs = await getPendingQrs(login);
@@ -242,24 +159,20 @@ app.get("/api/data", async (req, res) => {
res.status(200).json(data); res.status(200).json(data);
}); });
// Ostatní routes
app.use("/api/pizzaDay", pizzaDayRoutes); app.use("/api/pizzaDay", pizzaDayRoutes);
app.use("/api/food", foodRoutes); app.use("/api/food", foodRoutes);
app.use("/api/suggestions", suggestionRoutes); app.use("/api/voting", votingRoutes);
app.use("/api/easterEggs", easterEggRoutes); app.use("/api/easterEggs", easterEggRoutes);
app.use("/api/stats", statsRoutes); app.use("/api/stats", statsRoutes);
app.use("/api/notifications", notificationRoutes); app.use("/api/notifications", notificationRoutes);
app.use("/api/qr", qrRoutes); app.use("/api/qr", qrRoutes);
app.use("/api/dev", devRoutes); app.use("/api/dev", devRoutes);
app.use("/api/changelogs", changelogRoutes);
app.use("/api/groups", groupRoutes);
app.use("/api/stores", storeRoutes);
app.use(express.static(path.join(process.cwd(), 'public'))); app.use('/stats', express.static('public'));
app.get('*splat', (_req, res) => { app.use(express.static('public'));
res.sendFile(path.join(process.cwd(), 'public', 'index.html'));
});
// Error handling middleware // Middleware pro zpracování chyb
app.use((err: any, req: any, res: any, next: any) => { app.use((err: any, req: any, res: any, next: any) => {
if (err instanceof InsufficientPermissions) { if (err instanceof InsufficientPermissions) {
res.status(403).send({ error: err.message }) res.status(403).send({ error: err.message })
@@ -271,18 +184,16 @@ app.use((err: any, req: any, res: any, next: any) => {
next(); next();
}); });
// ─── Bootstrap ────────────────────────────────────────────────────────────────
const PORT = process.env.PORT ?? 3001; const PORT = process.env.PORT ?? 3001;
const HOST = process.env.HOST ?? '0.0.0.0'; const HOST = process.env.HOST ?? '0.0.0.0';
storageReady.then(async () => { server.listen(PORT, () => {
// Init Redis adapter after storage is connected (only in Redis mode) console.log(`Server listening on ${HOST}, port ${PORT}`);
if (process.env.STORAGE?.toLowerCase() === 'redis') { startReminderScheduler();
await initRedisAdapter();
}
server.listen(PORT, () => {
console.log(`Server listening on ${HOST}, port ${PORT}`);
startReminderScheduler();
});
}); });
// Umožníme vypnutí serveru přes SIGINT, jinak Docker čeká než ho sestřelí
process.on('SIGINT', function () {
console.log("\nSIGINT (Ctrl-C), vypínám server");
process.exit(0);
});
+236 -255
View File
@@ -661,30 +661,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 1, varId: 1,
size: "30cm", size: "30cm",
pizzaPrice: 13800, pizzaPrice: 138,
boxPrice: 1300, boxPrice: 13,
price: 15100 price: 151
}, },
{ {
varId: 2, varId: 2,
size: "35cm", size: "35cm",
pizzaPrice: 16600, pizzaPrice: 166,
boxPrice: 1500, boxPrice: 15,
price: 18100 price: 181
}, },
{ {
varId: 3, varId: 3,
size: "40cm", size: "40cm",
pizzaPrice: 22300, pizzaPrice: 223,
boxPrice: 1800, boxPrice: 18,
price: 24100 price: 241
}, },
{ {
varId: 4, varId: 4,
size: "50cm", size: "50cm",
pizzaPrice: 30600, pizzaPrice: 306,
boxPrice: 2500, boxPrice: 25,
price: 33100 price: 331
} }
] ]
}, },
@@ -700,30 +700,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 6, varId: 6,
size: "30cm", size: "30cm",
pizzaPrice: 14200, pizzaPrice: 142,
boxPrice: 1300, boxPrice: 13,
price: 15500 price: 155
}, },
{ {
varId: 7, varId: 7,
size: "35cm", size: "35cm",
pizzaPrice: 17200, pizzaPrice: 172,
boxPrice: 1500, boxPrice: 15,
price: 18700 price: 187
}, },
{ {
varId: 8, varId: 8,
size: "40cm", size: "40cm",
pizzaPrice: 23300, pizzaPrice: 233,
boxPrice: 1800, boxPrice: 18,
price: 25100 price: 251
}, },
{ {
varId: 9, varId: 9,
size: "50cm", size: "50cm",
pizzaPrice: 31600, pizzaPrice: 316,
boxPrice: 2500, boxPrice: 25,
price: 34100 price: 341
} }
] ]
}, },
@@ -741,30 +741,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 10, varId: 10,
size: "30cm", size: "30cm",
pizzaPrice: 14200, pizzaPrice: 142,
boxPrice: 1300, boxPrice: 13,
price: 15500 price: 155
}, },
{ {
varId: 11, varId: 11,
size: "35cm", size: "35cm",
pizzaPrice: 17200, pizzaPrice: 172,
boxPrice: 1500, boxPrice: 15,
price: 18700 price: 187
}, },
{ {
varId: 12, varId: 12,
size: "40cm", size: "40cm",
pizzaPrice: 23300, pizzaPrice: 233,
boxPrice: 1800, boxPrice: 18,
price: 25100 price: 251
}, },
{ {
varId: 13, varId: 13,
size: "50cm", size: "50cm",
pizzaPrice: 31600, pizzaPrice: 316,
boxPrice: 2500, boxPrice: 25,
price: 34100 price: 341
} }
] ]
}, },
@@ -780,30 +780,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 14, varId: 14,
size: "30cm", size: "30cm",
pizzaPrice: 14200, pizzaPrice: 142,
boxPrice: 1300, boxPrice: 13,
price: 15500 price: 155
}, },
{ {
varId: 15, varId: 15,
size: "35cm", size: "35cm",
pizzaPrice: 17200, pizzaPrice: 172,
boxPrice: 1500, boxPrice: 15,
price: 18700 price: 187
}, },
{ {
varId: 16, varId: 16,
size: "40cm", size: "40cm",
pizzaPrice: 23300, pizzaPrice: 233,
boxPrice: 1800, boxPrice: 18,
price: 25100 price: 251
}, },
{ {
varId: 17, varId: 17,
size: "50cm", size: "50cm",
pizzaPrice: 29400, pizzaPrice: 294,
boxPrice: 2500, boxPrice: 25,
price: 31900 price: 319
} }
] ]
}, },
@@ -821,30 +821,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 22, varId: 22,
size: "30cm", size: "30cm",
pizzaPrice: 16200, pizzaPrice: 162,
boxPrice: 1300, boxPrice: 13,
price: 17500 price: 175
}, },
{ {
varId: 23, varId: 23,
size: "35cm", size: "35cm",
pizzaPrice: 18600, pizzaPrice: 186,
boxPrice: 1500, boxPrice: 15,
price: 20100 price: 201
}, },
{ {
varId: 24, varId: 24,
size: "40cm", size: "40cm",
pizzaPrice: 26300, pizzaPrice: 263,
boxPrice: 1800, boxPrice: 18,
price: 28100 price: 281
}, },
{ {
varId: 25, varId: 25,
size: "50cm", size: "50cm",
pizzaPrice: 34600, pizzaPrice: 346,
boxPrice: 2500, boxPrice: 25,
price: 37100 price: 371
} }
] ]
}, },
@@ -861,30 +861,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 26, varId: 26,
size: "30cm", size: "30cm",
pizzaPrice: 16200, pizzaPrice: 162,
boxPrice: 1300, boxPrice: 13,
price: 17500 price: 175
}, },
{ {
varId: 27, varId: 27,
size: "35cm", size: "35cm",
pizzaPrice: 18600, pizzaPrice: 186,
boxPrice: 1500, boxPrice: 15,
price: 20100 price: 201
}, },
{ {
varId: 28, varId: 28,
size: "40cm", size: "40cm",
pizzaPrice: 26300, pizzaPrice: 263,
boxPrice: 1800, boxPrice: 18,
price: 28100 price: 281
}, },
{ {
varId: 29, varId: 29,
size: "50cm", size: "50cm",
pizzaPrice: 34600, pizzaPrice: 346,
boxPrice: 2500, boxPrice: 25,
price: 37100 price: 371
} }
] ]
}, },
@@ -902,30 +902,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 30, varId: 30,
size: "30cm", size: "30cm",
pizzaPrice: 16200, pizzaPrice: 162,
boxPrice: 1300, boxPrice: 13,
price: 17500 price: 175
}, },
{ {
varId: 31, varId: 31,
size: "35cm", size: "35cm",
pizzaPrice: 18600, pizzaPrice: 186,
boxPrice: 1500, boxPrice: 15,
price: 20100 price: 201
}, },
{ {
varId: 32, varId: 32,
size: "40cm", size: "40cm",
pizzaPrice: 26300, pizzaPrice: 263,
boxPrice: 1800, boxPrice: 18,
price: 28100 price: 281
}, },
{ {
varId: 33, varId: 33,
size: "50cm", size: "50cm",
pizzaPrice: 34600, pizzaPrice: 346,
boxPrice: 2500, boxPrice: 25,
price: 37100 price: 371
} }
] ]
}, },
@@ -946,30 +946,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 34, varId: 34,
size: "30cm", size: "30cm",
pizzaPrice: 16200, pizzaPrice: 162,
boxPrice: 1300, boxPrice: 13,
price: 17500 price: 175
}, },
{ {
varId: 35, varId: 35,
size: "35cm", size: "35cm",
pizzaPrice: 18600, pizzaPrice: 186,
boxPrice: 1500, boxPrice: 15,
price: 20100 price: 201
}, },
{ {
varId: 36, varId: 36,
size: "40cm", size: "40cm",
pizzaPrice: 26300, pizzaPrice: 263,
boxPrice: 1800, boxPrice: 18,
price: 28100 price: 281
}, },
{ {
varId: 37, varId: 37,
size: "50cm", size: "50cm",
pizzaPrice: 34600, pizzaPrice: 346,
boxPrice: 2500, boxPrice: 25,
price: 37100 price: 371
} }
] ]
}, },
@@ -987,30 +987,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 38, varId: 38,
size: "30cm", size: "30cm",
pizzaPrice: 16200, pizzaPrice: 162,
boxPrice: 1300, boxPrice: 13,
price: 17500 price: 175
}, },
{ {
varId: 39, varId: 39,
size: "35cm", size: "35cm",
pizzaPrice: 18600, pizzaPrice: 186,
boxPrice: 1500, boxPrice: 15,
price: 20100 price: 201
}, },
{ {
varId: 40, varId: 40,
size: "40cm", size: "40cm",
pizzaPrice: 26300, pizzaPrice: 263,
boxPrice: 1800, boxPrice: 18,
price: 28100 price: 281
}, },
{ {
varId: 41, varId: 41,
size: "50cm", size: "50cm",
pizzaPrice: 34600, pizzaPrice: 346,
boxPrice: 2500, boxPrice: 25,
price: 37100 price: 371
} }
] ]
}, },
@@ -1028,30 +1028,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 42, varId: 42,
size: "30cm", size: "30cm",
pizzaPrice: 17200, pizzaPrice: 172,
boxPrice: 1300, boxPrice: 13,
price: 18500 price: 185
}, },
{ {
varId: 43, varId: 43,
size: "35cm", size: "35cm",
pizzaPrice: 21200, pizzaPrice: 212,
boxPrice: 1500, boxPrice: 15,
price: 22700 price: 227
}, },
{ {
varId: 44, varId: 44,
size: "40cm", size: "40cm",
pizzaPrice: 29300, pizzaPrice: 293,
boxPrice: 1800, boxPrice: 18,
price: 31100 price: 311
}, },
{ {
varId: 45, varId: 45,
size: "50cm", size: "50cm",
pizzaPrice: 37600, pizzaPrice: 376,
boxPrice: 2500, boxPrice: 25,
price: 40100 price: 401
} }
] ]
}, },
@@ -1069,30 +1069,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 46, varId: 46,
size: "30cm", size: "30cm",
pizzaPrice: 18200, pizzaPrice: 182,
boxPrice: 1300, boxPrice: 13,
price: 19500 price: 195
}, },
{ {
varId: 47, varId: 47,
size: "35cm", size: "35cm",
pizzaPrice: 22200, pizzaPrice: 222,
boxPrice: 1500, boxPrice: 15,
price: 23700 price: 237
}, },
{ {
varId: 48, varId: 48,
size: "40cm", size: "40cm",
pizzaPrice: 30300, pizzaPrice: 303,
boxPrice: 1800, boxPrice: 18,
price: 32100 price: 321
}, },
{ {
varId: 49, varId: 49,
size: "50cm", size: "50cm",
pizzaPrice: 38600, pizzaPrice: 386,
boxPrice: 2500, boxPrice: 25,
price: 41100 price: 411
} }
] ]
}, },
@@ -1114,30 +1114,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 50, varId: 50,
size: "30cm", size: "30cm",
pizzaPrice: 18200, pizzaPrice: 182,
boxPrice: 1300, boxPrice: 13,
price: 19500 price: 195
}, },
{ {
varId: 51, varId: 51,
size: "35cm", size: "35cm",
pizzaPrice: 22200, pizzaPrice: 222,
boxPrice: 1500, boxPrice: 15,
price: 23700 price: 237
}, },
{ {
varId: 52, varId: 52,
size: "40cm", size: "40cm",
pizzaPrice: 30300, pizzaPrice: 303,
boxPrice: 1800, boxPrice: 18,
price: 32100 price: 321
}, },
{ {
varId: 53, varId: 53,
size: "50cm", size: "50cm",
pizzaPrice: 39600, pizzaPrice: 396,
boxPrice: 2500, boxPrice: 25,
price: 42100 price: 421
} }
] ]
}, },
@@ -1156,30 +1156,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 54, varId: 54,
size: "30cm", size: "30cm",
pizzaPrice: 18200, pizzaPrice: 182,
boxPrice: 1300, boxPrice: 13,
price: 19500 price: 195
}, },
{ {
varId: 55, varId: 55,
size: "35cm", size: "35cm",
pizzaPrice: 22200, pizzaPrice: 222,
boxPrice: 1500, boxPrice: 15,
price: 23700 price: 237
}, },
{ {
varId: 56, varId: 56,
size: "40cm", size: "40cm",
pizzaPrice: 30300, pizzaPrice: 303,
boxPrice: 1800, boxPrice: 18,
price: 32100 price: 321
}, },
{ {
varId: 57, varId: 57,
size: "50cm", size: "50cm",
pizzaPrice: 39600, pizzaPrice: 396,
boxPrice: 2500, boxPrice: 25,
price: 42100 price: 421
} }
] ]
}, },
@@ -1199,30 +1199,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 58, varId: 58,
size: "30cm", size: "30cm",
pizzaPrice: 18200, pizzaPrice: 182,
boxPrice: 1300, boxPrice: 13,
price: 19500 price: 195
}, },
{ {
varId: 59, varId: 59,
size: "35cm", size: "35cm",
pizzaPrice: 22200, pizzaPrice: 222,
boxPrice: 1500, boxPrice: 15,
price: 23700 price: 237
}, },
{ {
varId: 60, varId: 60,
size: "40cm", size: "40cm",
pizzaPrice: 30300, pizzaPrice: 303,
boxPrice: 1800, boxPrice: 18,
price: 32100 price: 321
}, },
{ {
varId: 61, varId: 61,
size: "50cm", size: "50cm",
pizzaPrice: 39600, pizzaPrice: 396,
boxPrice: 2500, boxPrice: 25,
price: 42100 price: 421
} }
] ]
}, },
@@ -1241,30 +1241,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 62, varId: 62,
size: "30cm", size: "30cm",
pizzaPrice: 18800, pizzaPrice: 188,
boxPrice: 1300, boxPrice: 13,
price: 20100 price: 201
}, },
{ {
varId: 63, varId: 63,
size: "35cm", size: "35cm",
pizzaPrice: 22600, pizzaPrice: 226,
boxPrice: 1500, boxPrice: 15,
price: 24100 price: 241
}, },
{ {
varId: 64, varId: 64,
size: "40cm", size: "40cm",
pizzaPrice: 31300, pizzaPrice: 313,
boxPrice: 1800, boxPrice: 18,
price: 33100 price: 331
}, },
{ {
varId: 65, varId: 65,
size: "50cm", size: "50cm",
pizzaPrice: 42600, pizzaPrice: 426,
boxPrice: 2500, boxPrice: 25,
price: 45100 price: 451
} }
] ]
}, },
@@ -1283,30 +1283,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 66, varId: 66,
size: "30cm", size: "30cm",
pizzaPrice: 18800, pizzaPrice: 188,
boxPrice: 1300, boxPrice: 13,
price: 20100 price: 201
}, },
{ {
varId: 67, varId: 67,
size: "35cm", size: "35cm",
pizzaPrice: 22600, pizzaPrice: 226,
boxPrice: 1500, boxPrice: 15,
price: 24100 price: 241
}, },
{ {
varId: 68, varId: 68,
size: "40cm", size: "40cm",
pizzaPrice: 31300, pizzaPrice: 313,
boxPrice: 1800, boxPrice: 18,
price: 33100 price: 331
}, },
{ {
varId: 69, varId: 69,
size: "50cm", size: "50cm",
pizzaPrice: 42600, pizzaPrice: 426,
boxPrice: 2500, boxPrice: 25,
price: 45100 price: 451
} }
] ]
}, },
@@ -1327,30 +1327,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 309, varId: 309,
size: "30cm", size: "30cm",
pizzaPrice: 18200, pizzaPrice: 182,
boxPrice: 1300, boxPrice: 13,
price: 19500 price: 195
}, },
{ {
varId: 310, varId: 310,
size: "35cm", size: "35cm",
pizzaPrice: 22200, pizzaPrice: 222,
boxPrice: 1500, boxPrice: 15,
price: 23700 price: 237
}, },
{ {
varId: 311, varId: 311,
size: "40cm", size: "40cm",
pizzaPrice: 30300, pizzaPrice: 303,
boxPrice: 1800, boxPrice: 18,
price: 32100 price: 321
}, },
{ {
varId: 312, varId: 312,
size: "50cm", size: "50cm",
pizzaPrice: 39600, pizzaPrice: 396,
boxPrice: 2500, boxPrice: 25,
price: 42100 price: 421
} }
] ]
}, },
@@ -1369,30 +1369,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 394, varId: 394,
size: "30cm", size: "30cm",
pizzaPrice: 18800, pizzaPrice: 188,
boxPrice: 1300, boxPrice: 13,
price: 20100 price: 201
}, },
{ {
varId: 395, varId: 395,
size: "35cm", size: "35cm",
pizzaPrice: 22600, pizzaPrice: 226,
boxPrice: 1500, boxPrice: 15,
price: 24100 price: 241
}, },
{ {
varId: 396, varId: 396,
size: "40cm", size: "40cm",
pizzaPrice: 31300, pizzaPrice: 313,
boxPrice: 1800, boxPrice: 18,
price: 33100 price: 331
}, },
{ {
varId: 397, varId: 397,
size: "50cm", size: "50cm",
pizzaPrice: 42600, pizzaPrice: 426,
boxPrice: 2500, boxPrice: 25,
price: 45100 price: 451
} }
] ]
} }
@@ -1429,46 +1429,27 @@ export const getPizzaListMock = () => {
return MOCK_PIZZA_LIST; return MOCK_PIZZA_LIST;
} }
// Mockovací data pro saláty
const MOCK_SALAT_LIST = [
{
name: "Greek",
ingredients: ["Salát", "Černé olivy", "Paprika mix", "Červená cibule", "Rajčata", "Okurka salátová", "Jogurtový dresing"],
price: (174 + 13) * 100,
},
{
name: "Caesar",
ingredients: ["Salát", "Rajčata", "Kuřecí maso", "Krutony", "Parmazán", "Caesar dresing", "Olivový olej"],
price: (184 + 13) * 100,
},
{
name: "Šopský salát",
ingredients: ["Salátová okurka", "Rajčata", "Paprika mix", "Červená cibule", "Balkánský sýr"],
price: (164 + 13) * 100,
},
{
name: "Těstovinový salát",
ingredients: ["Penne", "Okurka", "Rajčata", "Paprika mix", "Kuřecí maso", "Jogurtový dresing"],
price: (184 + 13) * 100,
},
]
export const getSalatListMock = () => {
return MOCK_SALAT_LIST;
}
export const getStatsMock = (): WeeklyStats => { export const getStatsMock = (): WeeklyStats => {
const mkDay = (date: string, di: number) => ({
date,
locations: Object.keys(LunchChoice).reduce((prev, cur, ci) => (
{ ...prev, [cur]: (di * 7 + ci * 3) % 10 }
), {} as Record<string, number>),
});
return [ return [
mkDay('24.02.', 0), {
mkDay('25.02.', 1), date: '24.02.',
mkDay('26.02.', 2), locations: { ...Object.keys(LunchChoice).reduce((prev, cur) => ({ ...prev, [cur]: Math.floor(Math.random() * 10) }), {}) }
mkDay('27.02.', 3), },
mkDay('28.02.', 4), {
date: '25.02.',
locations: { ...Object.keys(LunchChoice).reduce((prev, cur) => ({ ...prev, [cur]: Math.floor(Math.random() * 10) }), {}) }
},
{
date: '26.02.',
locations: { ...Object.keys(LunchChoice).reduce((prev, cur) => ({ ...prev, [cur]: Math.floor(Math.random() * 10) }), {}) }
},
{
date: '27.02.',
locations: { ...Object.keys(LunchChoice).reduce((prev, cur) => ({ ...prev, [cur]: Math.floor(Math.random() * 10) }), {}) }
},
{
date: '28.02.',
locations: { ...Object.keys(LunchChoice).reduce((prev, cur) => ({ ...prev, [cur]: Math.floor(Math.random() * 10) }), {}) }
}
]; ];
} }

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