1 Commits

Author SHA1 Message Date
cfcbd7a68b Otypování dnů v týdnu 2025-01-20 23:16:22 +01:00
149 changed files with 4096 additions and 11459 deletions

24
.gitignore vendored
View File

@@ -1,3 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
node_modules
types/gen
**.DS_Store
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

View File

@@ -1,64 +0,0 @@
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}}"

111
CLAUDE.md
View File

@@ -1,111 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Luncher is a lunch management app for teams — daily restaurant menus, food ordering, pizza day events, and payment QR codes. Czech-language UI. Full-stack TypeScript monorepo.
## Monorepo Structure
```
types/ → Shared OpenAPI-generated TypeScript types (source of truth: types/api.yml)
server/ → Express 5 backend (Node.js 22, ts-node)
client/ → React 19 frontend (Vite 7, React Bootstrap)
```
Each directory has its own `package.json` and `tsconfig.json`. Package manager: **Yarn Classic**.
## Development Commands
### Initial setup
```bash
cd types && yarn install && yarn openapi-ts # Generate API types first
cd ../server && yarn install
cd ../client && yarn install
```
### Running dev environment
```bash
# All-in-one (tmux):
./run_dev.sh
# Or manually in separate terminals:
cd server && NODE_ENV=development yarn startReload # Port 3001, nodemon watch
cd client && yarn start # Port 3000, proxies /api → 3001
```
### Building
```bash
cd types && yarn openapi-ts # Regenerate types from api.yml
cd server && yarn build # tsc → server/dist
cd client && yarn build # tsc --noEmit + vite build → client/dist
```
### Tests
```bash
cd server && yarn test # Jest (tests in server/src/tests/)
```
### Formatting
```bash
# Prettier available in client (no config file — uses defaults)
```
## Architecture
### API Types (types/)
- OpenAPI 3.0 spec in `types/api.yml` — all API endpoints and DTOs defined here
- `yarn openapi-ts` generates `types/gen/` (client.gen.ts, sdk.gen.ts, types.gen.ts)
- Both server and client import from these generated types
- **When changing API contracts: update api.yml first, then regenerate**
### Server (server/src/)
- **Entry:** `index.ts` — Express app + Socket.io setup
- **Routes:** `routes/` — 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`)
- **Auth:** `auth.ts` — JWT + optional trusted-header authentication
- **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
- **WebSocket:** `websocket.ts` — Socket.io for real-time client updates
- **Mock mode:** `MOCK_DATA=true` env var returns fake menus (useful for weekend/holiday dev)
- **Config:** `.env.development` / `.env.production` (see `.env.template` for all options)
### Client (client/src/)
- **Entry:** `index.tsx``App.tsx``AppRoutes.tsx`
- **Pages:** `pages/` (StatsPage)
- **Components:** `components/` (Header, Footer, Loader, modals/, PizzaOrderList, PizzaOrderRow)
- **Context providers:** `context/` — AuthContext, SettingsContext, SocketContext, EasterEggContext
- **Styling:** Bootstrap 5 + React Bootstrap + custom SCSS files (co-located with components)
- **API calls:** use OpenAPI-generated SDK from `types/gen/`
- **Routing:** React Router DOM v7
### Data Flow
1. Client calls API via generated SDK → Express routes
2. Server scrapes restaurant websites or returns cached data
3. Storage: Redis (production) or JSON file (development)
4. Socket.io broadcasts changes to all connected clients
## Environment
- **Server env files:** `server/.env.development`, `server/.env.production` (see `server/.env.template`)
- Key vars: `JWT_SECRET`, `STORAGE` (json/redis), `MOCK_DATA`, `REDIS_HOST`, `REDIS_PORT`
- **Docker:** multi-stage Dockerfile, `compose.yml` for app + Redis. Timezone: Europe/Prague.
## Conventions
- Czech naming for domain variables and UI strings; English for infrastructure code
- TypeScript strict mode in both client and server
- Server module resolution: Node16; Client: ESNext/bundler
## 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.

View File

@@ -1,18 +1,8 @@
ARG NODE_VERSION="node:22-alpine"
# Builder
FROM ${NODE_VERSION} AS builder
FROM node:18-alpine3.18 AS builder
WORKDIR /build
# Zkopírování závislostí - OpenAPI generátor
COPY types/package.json ./types/
COPY types/yarn.lock ./types/
COPY types/api.yml ./types/
COPY types/schemas ./types/schemas/
COPY types/paths ./types/paths/
COPY types/openapi-ts.config.ts ./types/
# Zkopírování závislostí - server
COPY server/package.json ./server/
COPY server/yarn.lock ./server/
@@ -21,10 +11,6 @@ COPY server/yarn.lock ./server/
COPY client/package.json ./client/
COPY client/yarn.lock ./client/
# Instalace závislostí - OpenAPI generátor
WORKDIR /build/types
RUN yarn install --frozen-lockfile
# Instalace závislostí - server
WORKDIR /build/server
RUN yarn install --frozen-lockfile
@@ -48,11 +34,7 @@ COPY client/src ./client/src
COPY client/public ./client/public
# Zkopírování společných typů
COPY types/index.ts ./types/
# Vygenerování společných typů z OpenAPI
WORKDIR /build/types
RUN yarn openapi-ts
COPY types ./types/
# Sestavení serveru
WORKDIR /build/server
@@ -63,33 +45,26 @@ WORKDIR /build/client
RUN yarn build
# Runner
FROM ${NODE_VERSION}
RUN apk add --no-cache tzdata
ENV TZ=Europe/Prague \
LC_ALL=cs_CZ.UTF-8 \
NODE_ENV=production
FROM node:18-alpine3.18
ENV LANG cs_CZ.UTF-8
ENV NODE_ENV production
WORKDIR /app
# Vykopírování sestaveného serveru
COPY --from=builder /build/server/node_modules ./server/node_modules
COPY --from=builder /build/server/dist ./
COPY server/resources ./server/resources
# Vykopírování sestaveného klienta
COPY --from=builder /build/client/dist ./public
# Zkopírování produkčních .env serveru
COPY /server/.env.production ./server
COPY /server/.env.production ./server/src
# Zkopírování changelogů (seznamu novinek)
COPY /server/changelogs ./server/changelogs
# Zkopírování konfigurace easter eggů a changelogů
RUN if [ -f ./server/.easter-eggs.json ]; then cp ./server/.easter-eggs.json ./server/; fi
# Export /data/db.json do složky /data
VOLUME ["/data"]
# Zkopírování konfigurace easter eggů
# TODO tohle spadne když nebude existovat!
COPY /server/.easter-eggs.json ./server/
EXPOSE 3000

View File

@@ -1,30 +0,0 @@
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í changelogů (seznamu novinek)
COPY ./server/changelogs ./server/changelogs
# Zkopírování konfigurace easter eggů a changelogů
RUN if [ -f ./server/.easter-eggs.json ]; then cp ./server/.easter-eggs.json ./server/; fi \
&& if [ -d ./server/changelogs ]; then cp -r ./server/changelogs ./server/changelogs; fi
EXPOSE 3000
CMD [ "node", "./server/src/index.js" ]

View File

@@ -1,9 +1,7 @@
# Luncher
Aplikace pro profesionální management obědů.
Aplikace sestává ze tří modulů.
- types
- OpenAPI definice společných typů, generované přes [openapi-ts](https://github.com/hey-api/openapi-ts)
Aplikace sestává ze dvou modulů + společných TypeScript definic (adresář `types`).
- server
- backend psaný v [node.js](https://nodejs.dev)
- client
@@ -12,27 +10,19 @@ Aplikace sestává ze tří modulů.
## Spuštění pro vývoj
### Závislosti
#### Klient/server
- [Node.js 22.x (>= 22.11)](https://nodejs.dev)
- [Node.js 18.x](https://nodejs.dev)
- [Yarn 1.22.x (Classic)](https://classic.yarnpkg.com)
### Spuštění na *nix platformách
- Nainstalovat závislosti viz předchozí bod
- Zkopírovat `server/.env.template` do `server/.env.development` a upravit dle potřeby
- Vygenerovat společné TypeScript typy
- `cd types && yarn install && yarn openapi-ts`
- Server
- `cd server && yarn install && export NODE_ENV=development && yarn startReload`
- Klient
- `cd client && yarn install && yarn start`
- Spustit `./run_dev.sh`. Na jiných platformách se lze inspirovat jeho obsahem, postup by měl být víceméně stejný.
## Sestavení a spuštění produkční verze v Docker
### Závislosti
- [Docker](https://www.docker.com)
- [Docker Compose](https://docs.docker.com/compose)
### Spuštění
- `docker compose up --build -d`
### Spuštení s traefik
- `docker compose -f compose-traefik.yml up --build -d`

1
client/.gitignore vendored
View File

@@ -1,2 +1,3 @@
build
dist
src/types

View File

@@ -10,26 +10,6 @@
<link rel="apple-touch-icon" href="/logo192.png" />
<link rel="manifest" href="/manifest.json" />
<title>Luncher</title>
<script>
(function() {
try {
var saved = localStorage.getItem('theme_preference');
var theme;
if (saved === 'dark') {
theme = 'dark';
} else if (saved === 'light') {
theme = 'light';
} else {
// 'system' nebo neuloženo - použij systémové nastavení
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
document.documentElement.setAttribute('data-bs-theme', theme);
} catch (e) {
// Fallback pokud localStorage není dostupný
document.documentElement.setAttribute('data-bs-theme', 'light');
}
})();
</script>
</head>
<body>

View File

@@ -6,37 +6,34 @@
"type": "module",
"homepage": ".",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-regular-svg-icons": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
"@fortawesome/react-fontawesome": "^3.1.0",
"@types/jest": "^30.0.0",
"@types/node": "^24.10.0",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.0",
"bootstrap": "^5.3.8",
"react": "^19.2.0",
"react-bootstrap": "^2.10.10",
"react-dom": "^19.2.0",
"react-jwt": "^1.3.0",
"react-modal": "^3.16.3",
"react-router": "^7.9.5",
"react-router-dom": "^7.9.5",
"@fortawesome/fontawesome-svg-core": "^6.4.0",
"@fortawesome/free-regular-svg-icons": "^6.4.0",
"@fortawesome/free-solid-svg-icons": "^6.4.0",
"@fortawesome/react-fontawesome": "^0.2.0",
"@types/jest": "^29.5.12",
"@types/node": "^20.11.20",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"bootstrap": "^5.2.3",
"react": "^19.0.0",
"react-bootstrap": "^2.7.2",
"react-dom": "^19.0.0",
"react-jwt": "^1.2.0",
"react-modal": "^3.16.1",
"react-select-search": "^4.1.6",
"react-snow-overlay": "^1.0.14",
"react-snowfall": "^2.3.0",
"react-toastify": "^11.0.5",
"recharts": "^3.4.1",
"sass": "^1.93.3",
"react-snowfall": "^2.2.0",
"react-toastify": "^10.0.4",
"sass": "^1.80.6",
"socket.io-client": "^4.6.1",
"typescript": "^5.9.3",
"vite": "^7.2.2",
"typescript": "^5.3.3",
"vite": "^6.0.3",
"vite-tsconfig-paths": "^5.1.4"
},
"scripts": {
"start": "yarn vite",
"build": "tsc --noEmit && yarn vite build"
"copy-types": "cp -r ../types ./src",
"start": "yarn copy-types && vite",
"build": "yarn copy-types && vite build"
},
"eslintConfig": {
"extends": [
@@ -57,6 +54,6 @@
},
"devDependencies": {
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"prettier": "^3.6.2"
"prettier": "^3.2.5"
}
}

View File

@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="100px" height="100px" viewBox="1 0 100 100" enable-background="new 1 0 100 100" xml:space="preserve">
<path fill="#8B4513" d="M31.5,77.2c0,0,2.6-1.9,3.4-2.8c0.8-0.9,2-2.5,2-2.5s-4.7-0.7-7.9-1.6c-3.2-0.9-9.7-3.7-9.7-3.7
s1.3-0.4,2.9-1.4c1.6-1.1,2-1.7,2-1.7s-4.7-1.3-7.5-2.4c-2.8-1-7.3-2.9-7.3-2.9s1.4-0.2,2.8-1.3c1.4-1.1,1.5-2.2,1.5-2.2
S9.4,53.3,7,51.9c-2.4-1.5-5.3-3.7-5.3-3.7s5.2-4,8.7-5.5c3.5-1.5,9.1-2.6,9.1-2.6s-0.9-1.5-2.1-2.6c-1.1-1.1-4.2-2.9-4.2-2.9
s5.6-1.9,8.3-2.5c2.7-0.6,8.2,0,8.2,0s-0.1-1.1-1.3-2.2c-1.2-1.1-2.7-2-2.7-2S33,25.4,37,24.4c4-1,10.8-0.6,10.8-0.6
S46.1,22.1,45,21c-1.1-1.1-2.7-2.3-2.7-2.3s5.4-0.4,9.1-0.4c3.7,0.1,9.5,1.1,9.5,1.1s-0.7-1-1.4-2.3c-0.8-1.3-1.5-2.6-1.5-2.6
s7.1,1.7,9.5,2.8c2.4,1,7.6,3.9,7.6,3.9s-0.1-1.8-1-3.5c-0.9-1.7-1.8-3-1.8-3s27.7,17.2,28.1,33.5c0.1,1.8-0.1,3.7-0.3,5.5
c-1.5-0.7-3.1-1.3-4.7-1.8c-2.3-6.3-4.3-9.9-6.4-12.6c-2.4-2.9-6.9-8-9.6-9.1c2.3,1.9,6.8,6.2,9.7,11.8c2.3,4.6,3,5.6,3.8,9.2
c-3.6-1.1-7.3-1.9-11-2.5c-0.7-0.1-1.4-0.2-2.1-0.3c-2-4.3-4.4-8.8-7.6-11.5c-3.7-3.2-8.3-6.7-11.1-7.5c2.6,1.5,8.7,6.8,11.8,10.9
c2.2,3,2.9,5.1,4,7.7c-5-0.6-9.9-0.9-14.9-1.1c-2-3.7-4.5-7.5-7.6-9.6c-3.4-2.4-7.6-5-10-5.5c2.3,1.1,7.9,5.1,10.8,8.3
c2.3,2.6,3,4.4,4.2,6.9c-11.1,0.4-22,1.7-32.8,3.6c9.4-0.8,18.9-1,28.3-0.6c-1.1,1.7-1.8,3-3.8,4.7c-2.6,2.2-7.5,4.5-9.5,4.9
c2,0.1,5.6-1.3,8.6-2.6c2.7-1.3,5.1-4.1,7.1-6.9c1.8,0.1,3.5,0.2,5.3,0.3c2.9,0.1,5.8,0.3,8.7,0.5c-1.4,3-2,5-4.6,8.1
c-3.1,3.6-9.1,8-11.6,9.2c2.7-0.4,7.2-3.3,10.8-5.9c3.3-2.4,6-6.8,8.1-11c2.7,0.3,5.4,0.6,8.1,1.1c2,0.3,3.9,0.7,5.9,1.2
c-1.9,4.1-2,4.6-5,8c-3.4,3.9-9.1,8.9-11.6,10.4c2.9-0.7,8.5-4.7,11.1-6.7c2.4-1.8,5.8-5.5,8.2-11.1c3,0.8,5.9,1.9,8.7,3.2
c-3.1,12.9-11.9,24.1-11.9,24.1s0.2-1.7-0.5-3.9c-0.8-2.2-1-2-1-2s-1.1,2.9-5,6.8c-3.9,3.9-8.9,5-8.9,5s0.7-1.1,0.8-2.4
c0.1-1.3-0.3-2-0.3-2s-2.6,1.9-5.3,3c-2.7,1.2-8.6,2.5-8.6,2.5s1.6-1.9,1.9-3c0.3-1-0.3-1.9-0.3-1.9s-3.9,1.5-7.5,1.5
c-3.6,0-7.7-1.5-7.7-1.5s1-0.5,1.7-1.8c0.8-1.3,0.2-1.9,0.2-1.9s-4.4,0.5-7.5,0.1C36.6,79.3,31.5,77.2,31.5,77.2z"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="100px" height="100px" viewBox="1 0 100 100" enable-background="new 1 0 100 100" xml:space="preserve">
<path fill="#228B22" d="M31.5,77.2c0,0,2.6-1.9,3.4-2.8c0.8-0.9,2-2.5,2-2.5s-4.7-0.7-7.9-1.6c-3.2-0.9-9.7-3.7-9.7-3.7
s1.3-0.4,2.9-1.4c1.6-1.1,2-1.7,2-1.7s-4.7-1.3-7.5-2.4c-2.8-1-7.3-2.9-7.3-2.9s1.4-0.2,2.8-1.3c1.4-1.1,1.5-2.2,1.5-2.2
S9.4,53.3,7,51.9c-2.4-1.5-5.3-3.7-5.3-3.7s5.2-4,8.7-5.5c3.5-1.5,9.1-2.6,9.1-2.6s-0.9-1.5-2.1-2.6c-1.1-1.1-4.2-2.9-4.2-2.9
s5.6-1.9,8.3-2.5c2.7-0.6,8.2,0,8.2,0s-0.1-1.1-1.3-2.2c-1.2-1.1-2.7-2-2.7-2S33,25.4,37,24.4c4-1,10.8-0.6,10.8-0.6
S46.1,22.1,45,21c-1.1-1.1-2.7-2.3-2.7-2.3s5.4-0.4,9.1-0.4c3.7,0.1,9.5,1.1,9.5,1.1s-0.7-1-1.4-2.3c-0.8-1.3-1.5-2.6-1.5-2.6
s7.1,1.7,9.5,2.8c2.4,1,7.6,3.9,7.6,3.9s-0.1-1.8-1-3.5c-0.9-1.7-1.8-3-1.8-3s27.7,17.2,28.1,33.5c0.1,1.8-0.1,3.7-0.3,5.5
c-1.5-0.7-3.1-1.3-4.7-1.8c-2.3-6.3-4.3-9.9-6.4-12.6c-2.4-2.9-6.9-8-9.6-9.1c2.3,1.9,6.8,6.2,9.7,11.8c2.3,4.6,3,5.6,3.8,9.2
c-3.6-1.1-7.3-1.9-11-2.5c-0.7-0.1-1.4-0.2-2.1-0.3c-2-4.3-4.4-8.8-7.6-11.5c-3.7-3.2-8.3-6.7-11.1-7.5c2.6,1.5,8.7,6.8,11.8,10.9
c2.2,3,2.9,5.1,4,7.7c-5-0.6-9.9-0.9-14.9-1.1c-2-3.7-4.5-7.5-7.6-9.6c-3.4-2.4-7.6-5-10-5.5c2.3,1.1,7.9,5.1,10.8,8.3
c2.3,2.6,3,4.4,4.2,6.9c-11.1,0.4-22,1.7-32.8,3.6c9.4-0.8,18.9-1,28.3-0.6c-1.1,1.7-1.8,3-3.8,4.7c-2.6,2.2-7.5,4.5-9.5,4.9
c2,0.1,5.6-1.3,8.6-2.6c2.7-1.3,5.1-4.1,7.1-6.9c1.8,0.1,3.5,0.2,5.3,0.3c2.9,0.1,5.8,0.3,8.7,0.5c-1.4,3-2,5-4.6,8.1
c-3.1,3.6-9.1,8-11.6,9.2c2.7-0.4,7.2-3.3,10.8-5.9c3.3-2.4,6-6.8,8.1-11c2.7,0.3,5.4,0.6,8.1,1.1c2,0.3,3.9,0.7,5.9,1.2
c-1.9,4.1-2,4.6-5,8c-3.4,3.9-9.1,8.9-11.6,10.4c2.9-0.7,8.5-4.7,11.1-6.7c2.4-1.8,5.8-5.5,8.2-11.1c3,0.8,5.9,1.9,8.7,3.2
c-3.1,12.9-11.9,24.1-11.9,24.1s0.2-1.7-0.5-3.9c-0.8-2.2-1-2-1-2s-1.1,2.9-5,6.8c-3.9,3.9-8.9,5-8.9,5s0.7-1.1,0.8-2.4
c0.1-1.3-0.3-2-0.3-2s-2.6,1.9-5.3,3c-2.7,1.2-8.6,2.5-8.6,2.5s1.6-1.9,1.9-3c0.3-1-0.3-1.9-0.3-1.9s-3.9,1.5-7.5,1.5
c-3.6,0-7.7-1.5-7.7-1.5s1-0.5,1.7-1.8c0.8-1.3,0.2-1.9,0.2-1.9s-4.4,0.5-7.5,0.1C36.6,79.3,31.5,77.2,31.5,77.2z"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="100px" height="100px" viewBox="1 0 100 100" enable-background="new 1 0 100 100" xml:space="preserve">
<path fill="#D2691E" d="M31.5,77.2c0,0,2.6-1.9,3.4-2.8c0.8-0.9,2-2.5,2-2.5s-4.7-0.7-7.9-1.6c-3.2-0.9-9.7-3.7-9.7-3.7
s1.3-0.4,2.9-1.4c1.6-1.1,2-1.7,2-1.7s-4.7-1.3-7.5-2.4c-2.8-1-7.3-2.9-7.3-2.9s1.4-0.2,2.8-1.3c1.4-1.1,1.5-2.2,1.5-2.2
S9.4,53.3,7,51.9c-2.4-1.5-5.3-3.7-5.3-3.7s5.2-4,8.7-5.5c3.5-1.5,9.1-2.6,9.1-2.6s-0.9-1.5-2.1-2.6c-1.1-1.1-4.2-2.9-4.2-2.9
s5.6-1.9,8.3-2.5c2.7-0.6,8.2,0,8.2,0s-0.1-1.1-1.3-2.2c-1.2-1.1-2.7-2-2.7-2S33,25.4,37,24.4c4-1,10.8-0.6,10.8-0.6
S46.1,22.1,45,21c-1.1-1.1-2.7-2.3-2.7-2.3s5.4-0.4,9.1-0.4c3.7,0.1,9.5,1.1,9.5,1.1s-0.7-1-1.4-2.3c-0.8-1.3-1.5-2.6-1.5-2.6
s7.1,1.7,9.5,2.8c2.4,1,7.6,3.9,7.6,3.9s-0.1-1.8-1-3.5c-0.9-1.7-1.8-3-1.8-3s27.7,17.2,28.1,33.5c0.1,1.8-0.1,3.7-0.3,5.5
c-1.5-0.7-3.1-1.3-4.7-1.8c-2.3-6.3-4.3-9.9-6.4-12.6c-2.4-2.9-6.9-8-9.6-9.1c2.3,1.9,6.8,6.2,9.7,11.8c2.3,4.6,3,5.6,3.8,9.2
c-3.6-1.1-7.3-1.9-11-2.5c-0.7-0.1-1.4-0.2-2.1-0.3c-2-4.3-4.4-8.8-7.6-11.5c-3.7-3.2-8.3-6.7-11.1-7.5c2.6,1.5,8.7,6.8,11.8,10.9
c2.2,3,2.9,5.1,4,7.7c-5-0.6-9.9-0.9-14.9-1.1c-2-3.7-4.5-7.5-7.6-9.6c-3.4-2.4-7.6-5-10-5.5c2.3,1.1,7.9,5.1,10.8,8.3
c2.3,2.6,3,4.4,4.2,6.9c-11.1,0.4-22,1.7-32.8,3.6c9.4-0.8,18.9-1,28.3-0.6c-1.1,1.7-1.8,3-3.8,4.7c-2.6,2.2-7.5,4.5-9.5,4.9
c2,0.1,5.6-1.3,8.6-2.6c2.7-1.3,5.1-4.1,7.1-6.9c1.8,0.1,3.5,0.2,5.3,0.3c2.9,0.1,5.8,0.3,8.7,0.5c-1.4,3-2,5-4.6,8.1
c-3.1,3.6-9.1,8-11.6,9.2c2.7-0.4,7.2-3.3,10.8-5.9c3.3-2.4,6-6.8,8.1-11c2.7,0.3,5.4,0.6,8.1,1.1c2,0.3,3.9,0.7,5.9,1.2
c-1.9,4.1-2,4.6-5,8c-3.4,3.9-9.1,8.9-11.6,10.4c2.9-0.7,8.5-4.7,11.1-6.7c2.4-1.8,5.8-5.5,8.2-11.1c3,0.8,5.9,1.9,8.7,3.2
c-3.1,12.9-11.9,24.1-11.9,24.1s0.2-1.7-0.5-3.9c-0.8-2.2-1-2-1-2s-1.1,2.9-5,6.8c-3.9,3.9-8.9,5-8.9,5s0.7-1.1,0.8-2.4
c0.1-1.3-0.3-2-0.3-2s-2.6,1.9-5.3,3c-2.7,1.2-8.6,2.5-8.6,2.5s1.6-1.9,1.9-3c0.3-1-0.3-1.9-0.3-1.9s-3.9,1.5-7.5,1.5
c-3.6,0-7.7-1.5-7.7-1.5s1-0.5,1.7-1.8c0.8-1.3,0.2-1.9,0.2-1.9s-4.4,0.5-7.5,0.1C36.6,79.3,31.5,77.2,31.5,77.2z"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="100px" height="100px" viewBox="1 0 100 100" enable-background="new 1 0 100 100" xml:space="preserve">
<path fill="#B22222" d="M31.5,77.2c0,0,2.6-1.9,3.4-2.8c0.8-0.9,2-2.5,2-2.5s-4.7-0.7-7.9-1.6c-3.2-0.9-9.7-3.7-9.7-3.7
s1.3-0.4,2.9-1.4c1.6-1.1,2-1.7,2-1.7s-4.7-1.3-7.5-2.4c-2.8-1-7.3-2.9-7.3-2.9s1.4-0.2,2.8-1.3c1.4-1.1,1.5-2.2,1.5-2.2
S9.4,53.3,7,51.9c-2.4-1.5-5.3-3.7-5.3-3.7s5.2-4,8.7-5.5c3.5-1.5,9.1-2.6,9.1-2.6s-0.9-1.5-2.1-2.6c-1.1-1.1-4.2-2.9-4.2-2.9
s5.6-1.9,8.3-2.5c2.7-0.6,8.2,0,8.2,0s-0.1-1.1-1.3-2.2c-1.2-1.1-2.7-2-2.7-2S33,25.4,37,24.4c4-1,10.8-0.6,10.8-0.6
S46.1,22.1,45,21c-1.1-1.1-2.7-2.3-2.7-2.3s5.4-0.4,9.1-0.4c3.7,0.1,9.5,1.1,9.5,1.1s-0.7-1-1.4-2.3c-0.8-1.3-1.5-2.6-1.5-2.6
s7.1,1.7,9.5,2.8c2.4,1,7.6,3.9,7.6,3.9s-0.1-1.8-1-3.5c-0.9-1.7-1.8-3-1.8-3s27.7,17.2,28.1,33.5c0.1,1.8-0.1,3.7-0.3,5.5
c-1.5-0.7-3.1-1.3-4.7-1.8c-2.3-6.3-4.3-9.9-6.4-12.6c-2.4-2.9-6.9-8-9.6-9.1c2.3,1.9,6.8,6.2,9.7,11.8c2.3,4.6,3,5.6,3.8,9.2
c-3.6-1.1-7.3-1.9-11-2.5c-0.7-0.1-1.4-0.2-2.1-0.3c-2-4.3-4.4-8.8-7.6-11.5c-3.7-3.2-8.3-6.7-11.1-7.5c2.6,1.5,8.7,6.8,11.8,10.9
c2.2,3,2.9,5.1,4,7.7c-5-0.6-9.9-0.9-14.9-1.1c-2-3.7-4.5-7.5-7.6-9.6c-3.4-2.4-7.6-5-10-5.5c2.3,1.1,7.9,5.1,10.8,8.3
c2.3,2.6,3,4.4,4.2,6.9c-11.1,0.4-22,1.7-32.8,3.6c9.4-0.8,18.9-1,28.3-0.6c-1.1,1.7-1.8,3-3.8,4.7c-2.6,2.2-7.5,4.5-9.5,4.9
c2,0.1,5.6-1.3,8.6-2.6c2.7-1.3,5.1-4.1,7.1-6.9c1.8,0.1,3.5,0.2,5.3,0.3c2.9,0.1,5.8,0.3,8.7,0.5c-1.4,3-2,5-4.6,8.1
c-3.1,3.6-9.1,8-11.6,9.2c2.7-0.4,7.2-3.3,10.8-5.9c3.3-2.4,6-6.8,8.1-11c2.7,0.3,5.4,0.6,8.1,1.1c2,0.3,3.9,0.7,5.9,1.2
c-1.9,4.1-2,4.6-5,8c-3.4,3.9-9.1,8.9-11.6,10.4c2.9-0.7,8.5-4.7,11.1-6.7c2.4-1.8,5.8-5.5,8.2-11.1c3,0.8,5.9,1.9,8.7,3.2
c-3.1,12.9-11.9,24.1-11.9,24.1s0.2-1.7-0.5-3.9c-0.8-2.2-1-2-1-2s-1.1,2.9-5,6.8c-3.9,3.9-8.9,5-8.9,5s0.7-1.1,0.8-2.4
c0.1-1.3-0.3-2-0.3-2s-2.6,1.9-5.3,3c-2.7,1.2-8.6,2.5-8.6,2.5s1.6-1.9,1.9-3c0.3-1-0.3-1.9-0.3-1.9s-3.9,1.5-7.5,1.5
c-3.6,0-7.7-1.5-7.7-1.5s1-0.5,1.7-1.8c0.8-1.3,0.2-1.9,0.2-1.9s-4.4,0.5-7.5,0.1C36.6,79.3,31.5,77.2,31.5,77.2z"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="100px" height="100px" viewBox="1 0 100 100" enable-background="new 1 0 100 100" xml:space="preserve">
<path fill="#FFD700" d="M31.5,77.2c0,0,2.6-1.9,3.4-2.8c0.8-0.9,2-2.5,2-2.5s-4.7-0.7-7.9-1.6c-3.2-0.9-9.7-3.7-9.7-3.7
s1.3-0.4,2.9-1.4c1.6-1.1,2-1.7,2-1.7s-4.7-1.3-7.5-2.4c-2.8-1-7.3-2.9-7.3-2.9s1.4-0.2,2.8-1.3c1.4-1.1,1.5-2.2,1.5-2.2
S9.4,53.3,7,51.9c-2.4-1.5-5.3-3.7-5.3-3.7s5.2-4,8.7-5.5c3.5-1.5,9.1-2.6,9.1-2.6s-0.9-1.5-2.1-2.6c-1.1-1.1-4.2-2.9-4.2-2.9
s5.6-1.9,8.3-2.5c2.7-0.6,8.2,0,8.2,0s-0.1-1.1-1.3-2.2c-1.2-1.1-2.7-2-2.7-2S33,25.4,37,24.4c4-1,10.8-0.6,10.8-0.6
S46.1,22.1,45,21c-1.1-1.1-2.7-2.3-2.7-2.3s5.4-0.4,9.1-0.4c3.7,0.1,9.5,1.1,9.5,1.1s-0.7-1-1.4-2.3c-0.8-1.3-1.5-2.6-1.5-2.6
s7.1,1.7,9.5,2.8c2.4,1,7.6,3.9,7.6,3.9s-0.1-1.8-1-3.5c-0.9-1.7-1.8-3-1.8-3s27.7,17.2,28.1,33.5c0.1,1.8-0.1,3.7-0.3,5.5
c-1.5-0.7-3.1-1.3-4.7-1.8c-2.3-6.3-4.3-9.9-6.4-12.6c-2.4-2.9-6.9-8-9.6-9.1c2.3,1.9,6.8,6.2,9.7,11.8c2.3,4.6,3,5.6,3.8,9.2
c-3.6-1.1-7.3-1.9-11-2.5c-0.7-0.1-1.4-0.2-2.1-0.3c-2-4.3-4.4-8.8-7.6-11.5c-3.7-3.2-8.3-6.7-11.1-7.5c2.6,1.5,8.7,6.8,11.8,10.9
c2.2,3,2.9,5.1,4,7.7c-5-0.6-9.9-0.9-14.9-1.1c-2-3.7-4.5-7.5-7.6-9.6c-3.4-2.4-7.6-5-10-5.5c2.3,1.1,7.9,5.1,10.8,8.3
c2.3,2.6,3,4.4,4.2,6.9c-11.1,0.4-22,1.7-32.8,3.6c9.4-0.8,18.9-1,28.3-0.6c-1.1,1.7-1.8,3-3.8,4.7c-2.6,2.2-7.5,4.5-9.5,4.9
c2,0.1,5.6-1.3,8.6-2.6c2.7-1.3,5.1-4.1,7.1-6.9c1.8,0.1,3.5,0.2,5.3,0.3c2.9,0.1,5.8,0.3,8.7,0.5c-1.4,3-2,5-4.6,8.1
c-3.1,3.6-9.1,8-11.6,9.2c2.7-0.4,7.2-3.3,10.8-5.9c3.3-2.4,6-6.8,8.1-11c2.7,0.3,5.4,0.6,8.1,1.1c2,0.3,3.9,0.7,5.9,1.2
c-1.9,4.1-2,4.6-5,8c-3.4,3.9-9.1,8.9-11.6,10.4c2.9-0.7,8.5-4.7,11.1-6.7c2.4-1.8,5.8-5.5,8.2-11.1c3,0.8,5.9,1.9,8.7,3.2
c-3.1,12.9-11.9,24.1-11.9,24.1s0.2-1.7-0.5-3.9c-0.8-2.2-1-2-1-2s-1.1,2.9-5,6.8c-3.9,3.9-8.9,5-8.9,5s0.7-1.1,0.8-2.4
c0.1-1.3-0.3-2-0.3-2s-2.6,1.9-5.3,3c-2.7,1.2-8.6,2.5-8.6,2.5s1.6-1.9,1.9-3c0.3-1-0.3-1.9-0.3-1.9s-3.9,1.5-7.5,1.5
c-3.6,0-7.7-1.5-7.7-1.5s1-0.5,1.7-1.8c0.8-1.3,0.2-1.9,0.2-1.9s-4.4,0.5-7.5,0.1C36.6,79.3,31.5,77.2,31.5,77.2z"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="100px" height="100px" viewBox="1 0 100 100" enable-background="new 1 0 100 100" xml:space="preserve">
<path fill="#471C37" d="M31.5,77.2c0,0,2.6-1.9,3.4-2.8c0.8-0.9,2-2.5,2-2.5s-4.7-0.7-7.9-1.6c-3.2-0.9-9.7-3.7-9.7-3.7
s1.3-0.4,2.9-1.4c1.6-1.1,2-1.7,2-1.7s-4.7-1.3-7.5-2.4c-2.8-1-7.3-2.9-7.3-2.9s1.4-0.2,2.8-1.3c1.4-1.1,1.5-2.2,1.5-2.2
S9.4,53.3,7,51.9c-2.4-1.5-5.3-3.7-5.3-3.7s5.2-4,8.7-5.5c3.5-1.5,9.1-2.6,9.1-2.6s-0.9-1.5-2.1-2.6c-1.1-1.1-4.2-2.9-4.2-2.9
s5.6-1.9,8.3-2.5c2.7-0.6,8.2,0,8.2,0s-0.1-1.1-1.3-2.2c-1.2-1.1-2.7-2-2.7-2S33,25.4,37,24.4c4-1,10.8-0.6,10.8-0.6
S46.1,22.1,45,21c-1.1-1.1-2.7-2.3-2.7-2.3s5.4-0.4,9.1-0.4c3.7,0.1,9.5,1.1,9.5,1.1s-0.7-1-1.4-2.3c-0.8-1.3-1.5-2.6-1.5-2.6
s7.1,1.7,9.5,2.8c2.4,1,7.6,3.9,7.6,3.9s-0.1-1.8-1-3.5c-0.9-1.7-1.8-3-1.8-3s27.7,17.2,28.1,33.5c0.1,1.8-0.1,3.7-0.3,5.5
c-1.5-0.7-3.1-1.3-4.7-1.8c-2.3-6.3-4.3-9.9-6.4-12.6c-2.4-2.9-6.9-8-9.6-9.1c2.3,1.9,6.8,6.2,9.7,11.8c2.3,4.6,3,5.6,3.8,9.2
c-3.6-1.1-7.3-1.9-11-2.5c-0.7-0.1-1.4-0.2-2.1-0.3c-2-4.3-4.4-8.8-7.6-11.5c-3.7-3.2-8.3-6.7-11.1-7.5c2.6,1.5,8.7,6.8,11.8,10.9
c2.2,3,2.9,5.1,4,7.7c-5-0.6-9.9-0.9-14.9-1.1c-2-3.7-4.5-7.5-7.6-9.6c-3.4-2.4-7.6-5-10-5.5c2.3,1.1,7.9,5.1,10.8,8.3
c2.3,2.6,3,4.4,4.2,6.9c-11.1,0.4-22,1.7-32.8,3.6c9.4-0.8,18.9-1,28.3-0.6c-1.1,1.7-1.8,3-3.8,4.7c-2.6,2.2-7.5,4.5-9.5,4.9
c2,0.1,5.6-1.3,8.6-2.6c2.7-1.3,5.1-4.1,7.1-6.9c1.8,0.1,3.5,0.2,5.3,0.3c2.9,0.1,5.8,0.3,8.7,0.5c-1.4,3-2,5-4.6,8.1
c-3.1,3.6-9.1,8-11.6,9.2c2.7-0.4,7.2-3.3,10.8-5.9c3.3-2.4,6-6.8,8.1-11c2.7,0.3,5.4,0.6,8.1,1.1c2,0.3,3.9,0.7,5.9,1.2
c-1.9,4.1-2,4.6-5,8c-3.4,3.9-9.1,8.9-11.6,10.4c2.9-0.7,8.5-4.7,11.1-6.7c2.4-1.8,5.8-5.5,8.2-11.1c3,0.8,5.9,1.9,8.7,3.2
c-3.1,12.9-11.9,24.1-11.9,24.1s0.2-1.7-0.5-3.9c-0.8-2.2-1-2-1-2s-1.1,2.9-5,6.8c-3.9,3.9-8.9,5-8.9,5s0.7-1.1,0.8-2.4
c0.1-1.3-0.3-2-0.3-2s-2.6,1.9-5.3,3c-2.7,1.2-8.6,2.5-8.6,2.5s1.6-1.9,1.9-3c0.3-1-0.3-1.9-0.3-1.9s-3.9,1.5-7.5,1.5
c-3.6,0-7.7-1.5-7.7-1.5s1-0.5,1.7-1.8c0.8-1.3,0.2-1.9,0.2-1.9s-4.4,0.5-7.5,0.1C36.6,79.3,31.5,77.2,31.5,77.2z"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -1,46 +0,0 @@
// Service Worker pro Web Push notifikace (připomínka výběru oběda)
self.addEventListener('push', (event) => {
const data = event.data?.json() ?? { title: 'Luncher', body: 'Ještě nemáte zvolený oběd!' };
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: '/favicon.ico',
tag: 'lunch-reminder',
actions: [
{ action: 'neobedvam', title: 'Mám vlastní/neobědvám' },
],
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'neobedvam') {
event.waitUntil(
self.registration.pushManager.getSubscription().then((subscription) => {
if (!subscription) return;
return fetch('/api/notifications/push/quickChoice', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint: subscription.endpoint }),
});
})
);
return;
}
event.waitUntil(
self.clients.matchAll({ type: 'window' }).then((clientList) => {
// Pokud je již otevřené okno, zaostříme na něj
for (const client of clientList) {
if (client.url.includes(self.location.origin) && 'focus' in client) {
return client.focus();
}
}
// Jinak otevřeme nové
return self.clients.openWindow('/');
})
);
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,27 +1,29 @@
import React, { useContext, useEffect, useMemo, useRef, useState, useCallback } from 'react';
import 'bootstrap/dist/css/bootstrap.min.css';
import { EVENT_DISCONNECT, EVENT_MESSAGE, SocketContext } from './context/socket';
import { addPizza, createPizzaDay, deletePizzaDay, finishDelivery, finishOrder, lockPizzaDay, removePizza, unlockPizzaDay, updatePizzaDayNote } from './api/PizzaDayApi';
import { useAuth } from './context/auth';
import Login from './Login';
import { Alert, Button, Col, Form, Row, Table } from 'react-bootstrap';
import Header from './components/Header';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import PizzaOrderList from './components/PizzaOrderList';
import SelectSearch, { SelectedOptionValue, SelectSearchOption } from 'react-select-search';
import SelectSearch, { SelectedOptionValue } from 'react-select-search';
import 'react-select-search/style.css';
import './App.scss';
import { faCircleCheck, faNoteSticky, faTrashCan, faComment } from '@fortawesome/free-regular-svg-icons';
import { SelectSearchOption } from 'react-select-search';
import { faCircleCheck, faNoteSticky, faTrashCan } from '@fortawesome/free-regular-svg-icons';
import { useSettings } from './context/settings';
import { ClientData, Restaurants, Food, Order, Locations, PizzaOrder, PizzaDayState, FoodChoices, DayMenu, DepartureTime, LocationKey } from './types';
import Footer from './components/Footer';
import { faBasketShopping, faChainBroken, faChevronLeft, faChevronRight, faGear, faSatelliteDish, faSearch, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons';
import { faChainBroken, faChevronLeft, faChevronRight, faGear, faSatelliteDish, faSearch } from '@fortawesome/free-solid-svg-icons';
import Loader from './components/Loader';
import { getHumanDateTime, isInTheFuture, formatDateString } from './Utils';
import { getData, errorHandler, getQrUrl } from './api/Api';
import { addChoice, removeChoices, removeChoice, changeDepartureTime, jdemeObed, updateNote } from './api/FoodApi';
import { getHumanDateTime, isInTheFuture } from './Utils';
import NoteModal from './components/modals/NoteModal';
import { useEasterEgg } from './context/eggs';
import { ClientData, Food, PizzaOrder, DepartureTime, PizzaDayState, Restaurant, RestaurantDayMenu, RestaurantDayMenuMap, LunchChoice, UserLunchChoice, PizzaVariant, getData, getEasterEggImage, addPizza, removePizza, updatePizzaDayNote, createPizzaDay, deletePizzaDay, lockPizzaDay, unlockPizzaDay, finishOrder, finishDelivery, addChoice, jdemeObed, removeChoices, removeChoice, updateNote, changeDepartureTime, setBuyer, dismissQr } from '../../types';
import { getLunchChoiceName } from './enums';
// import FallingLeaves, { LEAF_PRESETS, LEAF_COLOR_THEMES } from './FallingLeaves';
// import './FallingLeaves.scss';
import { getImage } from './api/EasterEggApi';
const EVENT_CONNECT = "connect"
@@ -32,37 +34,17 @@ const EASTER_EGG_STYLE = {
animationTimingFunction: "ease"
}
// Mapování čísel alergenů na jejich názvy
const ALLERGENS: { [key: number]: string } = {
1: "Obiloviny obsahující lepek",
2: "Korýši a výrobky z nich",
3: "Vejce a výrobky z nich",
4: "Ryby a výrobky z nich",
5: "Arašidy a výrobky z nich",
6: "Sója a výrobky z nich",
7: "Mléko a výrobky z nich (včetně laktózy)",
8: "Skořápkové plody",
9: "Celer a výrobky z něj",
10: "Hořčice a výrobky z ní",
11: "Sezamová semena a výrobky z nich",
12: "Oxid siřičitý a siřičitany",
13: "Vlčí bob (Lupina) a výrobky z něj",
14: "Měkkýši a výrobky z nich"
}
const LINK_ALLERGENS = 'https://www.strava.cz/Strava/Napoveda/cz/Prilohy/alergeny.pdf';
// Výchozí doba trvání animace v sekundách, pokud není přetíženo v konfiguračním JSONu
const EASTER_EGG_DEFAULT_DURATION = 0.75;
function App() {
const auth = useAuth();
const settings = useSettings();
const [easterEgg, _] = useEasterEgg(auth);
const [easterEgg, easterEggLoading] = useEasterEgg(auth);
const [isConnected, setIsConnected] = useState<boolean>(false);
const [data, setData] = useState<ClientData>();
const [food, setFood] = useState<RestaurantDayMenuMap>();
const [myOrder, setMyOrder] = useState<PizzaOrder>();
const [food, setFood] = useState<{ [key in Restaurants]?: DayMenu }>();
const [myOrder, setMyOrder] = useState<Order>();
const [foodChoiceList, setFoodChoiceList] = useState<Food[]>();
const [closed, setClosed] = useState<boolean>(false);
const socket = useContext(SocketContext);
@@ -82,17 +64,14 @@ function App() {
// Načtení dat po přihlášení
useEffect(() => {
if (!auth?.login) {
if (!auth || !auth.login) {
return
}
getData().then(response => {
const data = response.data
if (data) {
getData().then((data: ClientData) => {
setData(data);
setDayIndex(data.dayIndex);
dayIndexRef.current = data.dayIndex;
setDayIndex(data.weekIndex);
dayIndexRef.current = data.weekIndex;
setFood(data.menus);
}
}).catch(e => {
setFailure(true);
})
@@ -100,15 +79,12 @@ function App() {
// Přenačtení pro zvolený den
useEffect(() => {
if (!auth?.login) {
if (!auth || !auth.login) {
return
}
getData({ query: { dayIndex: dayIndex } }).then(response => {
const data = response.data;
getData(dayIndex).then((data: ClientData) => {
setData(data);
if (data) {
setFood(data.menus);
}
}).catch(e => {
setFailure(true);
})
@@ -117,15 +93,17 @@ function App() {
// Registrace socket eventů
useEffect(() => {
socket.on(EVENT_CONNECT, () => {
// console.log("Connected!");
setIsConnected(true);
});
socket.on(EVENT_DISCONNECT, () => {
// console.log("Disconnected!");
setIsConnected(false);
});
socket.on(EVENT_MESSAGE, (newData: ClientData) => {
// console.log("Přijata nová data ze socketu", newData);
// 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.weekIndex === dayIndexRef.current) {
setData(newData);
}
});
@@ -138,33 +116,19 @@ function App() {
}, [socket]);
useEffect(() => {
if (!auth?.login || !data?.choices) {
if (!auth || !auth.login) {
return
}
// Pre-fill form refs from existing choices
let foundKey: LunchChoice | undefined;
let foundChoice: UserLunchChoice | undefined;
for (const key of Object.keys(data.choices)) {
const locationKey = key as LunchChoice;
const locationChoices = data.choices[locationKey];
if (locationChoices && auth.login in locationChoices) {
foundKey = locationKey;
foundChoice = locationChoices[auth.login];
break;
}
}
if (foundKey && choiceRef.current) {
choiceRef.current.value = foundKey;
const restaurantKey = Object.keys(Restaurant).indexOf(foundKey);
if (restaurantKey > -1 && food) {
const restaurant = Object.keys(Restaurant)[restaurantKey] as Restaurant;
setFoodChoiceList(food[restaurant]?.food);
setClosed(food[restaurant]?.closed ?? false);
}
}
if (foundChoice?.departureTime && departureChoiceRef.current) {
departureChoiceRef.current.value = foundChoice.departureTime;
}
// TODO tohle občas náhodně nezafunguje, nutno přepsat, viz https://medium.com/@teh_builder/ref-objects-inside-useeffect-hooks-eb7c15198780
// TODO nutno opravit
// if (data?.choices && choiceRef.current) {
// for (let entry of Object.entries(data.choices)) {
// if (entry[1].includes(auth.login)) {
// const value = entry[0] as any as number; // TODO tohle je absurdní
// choiceRef.current.value = Object.values(Locations)[value];
// }
// }
// }
}, [auth, auth?.login, data?.choices])
// Reference na mojí objednávku
@@ -175,17 +139,12 @@ function App() {
}
}, [auth?.login, data?.pizzaDay?.orders])
// Kontrola, zda má uživatel vybranou volbu PIZZA
const userHasPizzaChoice = useMemo(() => {
return auth?.login ? data?.choices?.PIZZA?.[auth.login] != null : false;
}, [data?.choices?.PIZZA, auth?.login]);
useEffect(() => {
if (choiceRef?.current?.value && choiceRef.current.value !== "") {
const locationKey = choiceRef.current.value as LunchChoice;
const restaurantKey = Object.keys(Restaurant).indexOf(locationKey);
const locationKey = choiceRef.current.value as LocationKey;
const restaurantKey = Object.keys(Restaurants).indexOf(locationKey);
if (restaurantKey > -1 && food) {
const restaurant = Object.keys(Restaurant)[restaurantKey] as Restaurant;
const restaurant = Object.values(Restaurants)[restaurantKey];
setFoodChoiceList(food[restaurant]?.food);
setClosed(food[restaurant]?.closed ?? false);
} else {
@@ -217,9 +176,9 @@ function App() {
// Stažení a nastavení easter egg obrázku
useEffect(() => {
if (auth?.login && easterEgg?.url && !eggImage) {
getEasterEggImage({ path: { url: easterEgg.url } }).then(response => {
if (response.data) {
setEggImage(response.data);
getImage(easterEgg.url).then(data => {
if (data) {
setEggImage(data);
// Smazání obrázku z DOMu po animaci
setTimeout(() => {
if (eggRef?.current) {
@@ -231,103 +190,13 @@ function App() {
}
}, [auth?.login, easterEgg?.duration, easterEgg?.url, eggImage]);
// Pomocná funkce pro kontrolu a potvrzení změny volby při existujícím Pizza day
const checkPizzaDayBeforeChange = async (newLocationKey: LunchChoice): Promise<boolean> => {
if (!auth?.login || !data) return false;
// Kontrola, zda uživatel má vybranou PIZZA a mění na něco jiného
const hasPizzaChoice = data.choices?.PIZZA?.[auth.login] != null;
const isCreator = data.pizzaDay?.creator === auth.login;
const isPizzaDayCreated = data.pizzaDay?.state === PizzaDayState.CREATED;
// Pokud není vybraná PIZZA nebo přepínáme na PIZZA, není potřeba kontrolovat
if (!hasPizzaChoice || newLocationKey === LunchChoice.PIZZA) {
return true;
}
// Pokud uživatel není zakladatel Pizza day, není potřeba dialogu
if (!isCreator || !data?.pizzaDay) {
return true;
}
// Uživatel je zakladatel Pizza day a mění volbu z PIZZA
if (!isPizzaDayCreated) {
// Pizza day není ve stavu CREATED, nelze změnit volbu
alert(`Nelze změnit volbu. Pizza day je ve stavu "${data.pizzaDay.state}" a musí být nejprve dokončen.`);
return false;
}
// Pizza day je CREATED, zobrazit potvrzovací dialog
const confirmed = window.confirm(
'Jsi zakladatel aktivního Pizza day. Změna volby smaže celý Pizza day včetně všech objednávek. Pokračovat?'
);
if (!confirmed) {
return false;
}
// Uživatel potvrdil, smazat Pizza day
try {
await deletePizzaDay();
return true;
} catch (error: any) {
alert(`Chyba při mazání Pizza day: ${error.message || error}`);
return false;
}
};
const doAddClickFoodChoice = async (location: LunchChoice, foodIndex?: number) => {
if (document.getSelection()?.type !== 'Range') { // pouze pokud se nejedná o výběr textu
if (canChangeChoice && auth?.login) {
// Kontrola Pizza day před změnou volby
const canProceed = await checkPizzaDayBeforeChange(location);
if (!canProceed) {
return;
}
try {
await addChoice({ body: { locationKey: location, foodIndex, dayIndex } });
await tryAutoSelectDepartureTime();
} catch (error: any) {
alert(`Chyba při změně volby: ${error.message || error}`);
}
}
}
}
const doAddChoice = async (event: React.ChangeEvent<HTMLSelectElement>) => {
const locationKey = event.target.value as LunchChoice;
if (canChangeChoice && auth?.login) {
// Kontrola Pizza day před změnou volby
const canProceed = await checkPizzaDayBeforeChange(locationKey);
if (!canProceed) {
// Uživatel zrušil akci nebo došlo k chybě, reset výběru zpět na PIZZA
if (choiceRef.current) {
choiceRef.current.value = LunchChoice.PIZZA;
}
return;
}
try {
await addChoice({ body: { locationKey, dayIndex } });
const locationKey = event.target.value as LocationKey;
if (auth?.login) {
await errorHandler(() => addChoice(locationKey, undefined, dayIndex));
if (foodChoiceRef.current?.value) {
foodChoiceRef.current.value = "";
}
choiceRef.current?.blur();
// Automatický výběr času odchodu pouze pro restaurace s menu
if (Object.keys(Restaurant).includes(locationKey)) {
await tryAutoSelectDepartureTime();
}
} catch (error: any) {
alert(`Chyba při změně volby: ${error.message || error}`);
// Reset výběru zpět
const hasPizzaChoice = data?.choices?.PIZZA?.[auth.login] != null;
if (choiceRef.current && hasPizzaChoice) {
choiceRef.current.value = LunchChoice.PIZZA;
} else if (choiceRef.current) {
choiceRef.current.value = "";
}
}
}
}
@@ -339,17 +208,16 @@ function App() {
const doAddFoodChoice = async (event: React.ChangeEvent<HTMLSelectElement>) => {
if (event.target.value && foodChoiceList?.length && choiceRef.current?.value) {
const locationKey = choiceRef.current.value as LunchChoice;
const locationKey = choiceRef.current.value as LocationKey;
if (auth?.login) {
await addChoice({ body: { locationKey, foodIndex: Number(event.target.value), dayIndex } });
await tryAutoSelectDepartureTime();
await errorHandler(() => addChoice(locationKey, Number(event.target.value), dayIndex));
}
}
}
const doRemoveChoices = async (locationKey: LunchChoice) => {
const doRemoveChoices = async (locationKey: LocationKey) => {
if (auth?.login) {
await removeChoices({ body: { locationKey, dayIndex } });
await errorHandler(() => removeChoices(locationKey, dayIndex));
// Vyresetujeme výběr, aby bylo jasné pro který případně vybíráme jídlo
if (choiceRef?.current?.value) {
choiceRef.current.value = "";
@@ -360,9 +228,9 @@ function App() {
}
}
const doRemoveFoodChoice = async (locationKey: LunchChoice, foodIndex: number) => {
const doRemoveFoodChoice = async (locationKey: LocationKey, foodIndex: number) => {
if (auth?.login) {
await removeChoice({ body: { locationKey, foodIndex, dayIndex } });
await errorHandler(() => removeChoice(locationKey, foodIndex, dayIndex));
if (choiceRef?.current?.value) {
choiceRef.current.value = "";
}
@@ -374,59 +242,11 @@ function App() {
const saveNote = async (note?: string) => {
if (auth?.login) {
await updateNote({ body: { note, dayIndex } });
await errorHandler(() => updateNote(note, dayIndex));
setNoteModalOpen(false);
}
}
const copyNote = async (note: string) => {
if (auth?.login && note) {
await updateNote({ body: { note, dayIndex } });
}
}
const markAsBuyer = async () => {
if (auth?.login) {
await setBuyer();
}
}
const handleCreatePizzaDay = async () => {
if (!window.confirm('Opravdu chcete založit Pizza day?')) return;
setLoadingPizzaDay(true);
await createPizzaDay().then(() => setLoadingPizzaDay(false));
}
const handleDeletePizzaDay = async () => {
if (!window.confirm('Opravdu chcete smazat Pizza day? Budou smazány i všechny dosud zadané objednávky.')) return;
await deletePizzaDay();
}
const handleLockPizzaDay = async () => {
if (!window.confirm('Opravdu chcete uzamknout objednávky? Po uzamčení nebude možné přidávat ani odebírat objednávky.')) return;
await lockPizzaDay();
}
const handleUnlockPizzaDay = async () => {
if (!window.confirm('Opravdu chcete odemknout objednávky? Uživatelé budou moci opět upravovat své objednávky.')) return;
await unlockPizzaDay();
}
const handleFinishOrder = async () => {
if (!window.confirm('Opravdu chcete označit objednávky jako objednané? Objednávky zůstanou zamčeny.')) return;
await finishOrder();
}
const handleReturnToLocked = async () => {
if (!window.confirm('Opravdu chcete vrátit stav zpět do "uzamčeno" (před objednáním)?')) return;
await lockPizzaDay();
}
const handleFinishDelivery = async () => {
if (!window.confirm(`Opravdu chcete označit pizzy jako doručené?${settings?.bankAccount && settings?.holderName ? ' Uživatelům bude vygenerován QR kód pro platbu.' : ''}`)) return;
await finishDelivery({ body: { bankAccount: settings?.bankAccount, bankAccountHolder: settings?.holderName } });
}
const pizzaSuggestions = useMemo(() => {
if (!data?.pizzaList) {
return [];
@@ -446,18 +266,18 @@ function App() {
const handlePizzaChange = async (value: SelectedOptionValue | SelectedOptionValue[]) => {
if (auth?.login && data?.pizzaList) {
if (typeof value !== 'string') {
throw new TypeError('Nepodporovaný typ hodnoty: ' + typeof value);
if (!(typeof value === 'string')) {
throw Error('Nepodporovaný typ hodnoty');
}
const s = value.split('|');
const pizzaIndex = Number.parseInt(s[0]);
const pizzaSizeIndex = Number.parseInt(s[1]);
await addPizza({ body: { pizzaIndex, pizzaSizeIndex } });
await addPizza(pizzaIndex, pizzaSizeIndex);
}
}
const handlePizzaDelete = async (pizzaOrder: PizzaVariant) => {
await removePizza({ body: { pizzaOrder } });
const handlePizzaDelete = async (pizzaOrder: PizzaOrder) => {
await removePizza(pizzaOrder);
}
const handlePizzaPoznamkaChange = async () => {
@@ -465,22 +285,36 @@ function App() {
alert("Poznámka může mít maximálně 70 znaků");
return;
}
updatePizzaDayNote({ body: { note: pizzaPoznamkaRef.current?.value } });
updatePizzaDayNote(pizzaPoznamkaRef.current?.value);
}
// const addToCart = async () => {
// TODO aktuálně nefunkční - nedokážeme poslat PHPSESSIONID cookie
// if (data?.pizzaDay?.orders) {
// for (const order of data?.pizzaDay?.orders) {
// for (const pizzaOrder of order.pizzaList) {
// const url = 'https://www.pizzachefie.cz/pridat.html';
// const payload = new URLSearchParams();
// payload.append('varId', pizzaOrder.varId.toString());
// await fetch(url, {
// method: "POST",
// mode: "no-cors",
// cache: "no-cache",
// credentials: "same-origin",
// headers: {
// 'Content-Type': 'application/x-www-form-urlencoded',
// },
// body: payload,
// })
// }
// }
// // TODO otevřít košík v nové záložce
// }
// }
const handleChangeDepartureTime = async (event: React.ChangeEvent<HTMLSelectElement>) => {
if (foodChoiceList?.length && choiceRef.current?.value) {
await changeDepartureTime({ body: { time: event.target.value as DepartureTime, dayIndex } });
}
}
// 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 } });
await changeDepartureTime(event.target.value, dayIndex);
}
}
@@ -498,67 +332,33 @@ function App() {
}
}
const renderFoodTable = (location: Restaurant, menu: RestaurantDayMenu) => {
const renderFoodTable = (name: string, menu: DayMenu) => {
let content;
if (menu?.closed) {
content = <div className="restaurant-closed">Zavřeno</div>
} else if (menu?.food?.length && menu.food.length > 0) {
const hideSoups = settings?.hideSoups;
content = <Table className="food-table">
<tbody style={{ cursor: canChangeChoice ? 'pointer' : 'default' }}>
{menu.food.map((f: Food, index: number) =>
(!hideSoups || !f.isSoup) &&
<tr key={f.name} onClick={() => doAddClickFoodChoice(location, index)}>
<td>
<div className="food-name">
{f.name}
{f.allergens && f.allergens.length > 0 && (
<span className="food-allergens">
{' '}({f.allergens.map((a, idx) => (
<span key={a}>
<span className="allergen-link" title={ALLERGENS[a]} onClick={e => {
e.stopPropagation();
window.open(LINK_ALLERGENS, '_blank');
}}>{a}</span>
{idx < f.allergens!.length - 1 && ', '}
</span>
))})
</span>
)}
</div>
<div className="food-meta">
{f.amount && f.amount !== '-' && <span className="food-amount">{f.amount}</span>}
<span className="food-price">{f.price}</span>
</div>
</td>
content = <h3>Zavřeno</h3>
} else if (menu?.food?.length > 0) {
content = <Table striped bordered hover>
<tbody>
{menu.food.filter(f => (settings?.hideSoups ? !f.isSoup : true)).map((f: any, index: number) =>
<tr key={index}>
<td>{f.amount}</td>
<td>{f.name}</td>
<td>{f.price}</td>
</tr>
)}
</tbody>
</Table>
} else {
content = <div className="restaurant-error">Chyba načtení dat</div>
content = <h3>Chyba načtení dat</h3>
}
return <Col md={6} lg={3} className='mt-3'>
<div className="restaurant-card">
<div className="restaurant-header" style={{ cursor: canChangeChoice ? 'pointer' : 'default' }} onClick={() => doAddClickFoodChoice(location)}>
<div className="restaurant-header-content">
<h3>
{getLunchChoiceName(location)}
</h3>
{menu?.lastUpdate && <small>Aktualizace: {getHumanDateTime(new Date(menu.lastUpdate))}</small>}
</div>
{menu?.warnings && menu.warnings.length > 0 && (
<span className="restaurant-warning" title={menu.warnings.join('\n')}>
<FontAwesomeIcon icon={faTriangleExclamation} />
</span>
)}
</div>
return <Col md={12} lg={4} className='mt-3'>
<h3>{name}</h3>
{menu?.lastUpdate && <small>Poslední aktualizace: {getHumanDateTime(new Date(menu.lastUpdate))}</small>}
{content}
</div>
</Col>
}
if (!auth?.login) {
if (!auth || !auth.login) {
return <Login />;
}
@@ -587,153 +387,124 @@ function App() {
}
const noOrders = data?.pizzaDay?.orders?.length === 0;
const canChangeChoice = dayIndex == null || data.todayDayIndex == null || dayIndex >= data.todayDayIndex;
const canChangeChoice = dayIndex == null || data.todayWeekIndex == null || dayIndex >= data.todayWeekIndex;
const { path, url, startOffset, endOffset, duration, ...style } = easterEgg || {};
return (
<div className="app-container">
{easterEgg && eggImage && <img ref={eggRef} alt='' src={URL.createObjectURL(eggImage)} style={{ position: 'absolute', ...EASTER_EGG_STYLE, ...style, animationDuration: `${duration ?? EASTER_EGG_DEFAULT_DURATION}s` }} />}
<Header choices={data?.choices} dayIndex={dayIndex} />
<div className='wrapper'>
{data.todayDayIndex != null && data.todayDayIndex > 4 &&
<Alert variant="info" className="mb-3">
Zobrazujete uplynulý týden
</Alert>
}
<>
{easterEgg && eggImage && <img ref={eggRef} alt='' src={URL.createObjectURL(eggImage)} style={{ position: 'absolute', ...EASTER_EGG_STYLE, ...style, animationDuration: `${duration ?? EASTER_EGG_DEFAULT_DURATION}s` }} />}
<Header />
<div className='wrapper'>
{data.isWeekend ? <h4>Užívejte víkend :)</h4> : <>
<Alert variant={'primary'}>
<img src='hat.png' style={{ position: "absolute", width: "70px", rotate: "-45deg", left: -40, top: -58 }} />
<img src='snowman.png' style={{ position: "absolute", height: "110px", right: 10, top: 5 }} />
Poslední změny:
<ul>
<li>Zimní atmosféra</li>
<li>Odstranění podniku U Motlíků</li>
</ul>
</Alert>
{dayIndex != null &&
<div className='day-navigator'>
<span title='Předchozí den'>
<FontAwesomeIcon icon={faChevronLeft} style={{ cursor: "pointer", visibility: dayIndex > 0 ? "initial" : "hidden" }} onClick={() => handleDayChange(dayIndex - 1)} />
</span>
<h1 className={`title ${dayIndex !== data.todayDayIndex ? 'text-muted' : ''}`}>{data.date}</h1>
<span title="Následující den">
<FontAwesomeIcon icon={faChevronRight} style={{ cursor: "pointer", visibility: dayIndex < 4 ? "initial" : "hidden" }} onClick={() => handleDayChange(dayIndex + 1)} />
</span>
<FontAwesomeIcon title="Předchozí den" icon={faChevronLeft} style={{ cursor: "pointer", visibility: dayIndex > 0 ? "initial" : "hidden" }} onClick={() => handleDayChange(dayIndex - 1)} />
<h1 className='title' style={{ color: dayIndex === data.todayWeekIndex ? 'black' : 'gray' }}>{data.date}</h1>
<FontAwesomeIcon title="Následující den" icon={faChevronRight} style={{ cursor: "pointer", visibility: dayIndex < 4 ? "initial" : "hidden" }} onClick={() => handleDayChange(dayIndex + 1)} />
</div>
}
<Row className='food-tables'>
{Object.keys(Restaurant).map(key => {
const locationKey = key as Restaurant;
return food[locationKey] && renderFoodTable(locationKey, food[locationKey]);
})}
{food[Restaurants.SLADOVNICKA] && renderFoodTable('Sladovnická', food[Restaurants.SLADOVNICKA])}
{/* {food[Restaurants.UMOTLIKU] && renderFoodTable('U Motlíků', food[Restaurants.UMOTLIKU])} */}
{food[Restaurants.TECHTOWER] && renderFoodTable('TechTower', food[Restaurants.TECHTOWER])}
{food[Restaurants.ZASTAVKAUMICHALA] && renderFoodTable('Zastávka u Michala', food[Restaurants.ZASTAVKAUMICHALA])}
</Row>
<div className='content-wrapper'>
<div className='content'>
{canChangeChoice && <div className="choice-section fade-in">
<p>{`Jak to ${dayIndex == null || dayIndex === data.todayDayIndex ? 'dnes' : 'tento den'} vidíš s obědem?`}</p>
{canChangeChoice && <>
<p>{`Jak to ${dayIndex == null || dayIndex === data.todayWeekIndex ? 'dnes' : 'tento den'} vidíš s obědem?`}</p>
<Form.Select ref={choiceRef} onChange={doAddChoice}>
<option value="">Vyber možnost...</option>
{Object.entries(LunchChoice)
<option></option>
{Object.entries(Locations)
.filter(entry => {
const locationKey = entry[0] as Restaurant;
return !food[locationKey]?.closed;
const locationKey = entry[0] as LocationKey;
const restaurantKey = Object.keys(Restaurants).indexOf(locationKey);
const v = Object.values(Restaurants)[restaurantKey];
return v == null || !food[v]?.closed;
})
.map(entry => <option key={entry[0]} value={entry[0]}>{getLunchChoiceName(entry[1])}</option>)}
.map(entry => <option key={entry[0]} value={entry[0]}>{entry[1]}</option>)}
</Form.Select>
<small>Je možné vybrat jen jednu možnost. Výběr jiné odstraní předchozí.</small>
{foodChoiceList && !closed && <>
<p className="mt-3">Na co dobrého?</p>
<p style={{ marginTop: "10px" }}>Na co dobrého? <small>(nepovinné)</small></p>
<Form.Select ref={foodChoiceRef} onChange={doAddFoodChoice}>
<option value="">Vyber jídlo...</option>
{foodChoiceList.map((food, index) => <option key={food.name} value={index}>{food.name}</option>)}
<option></option>
{foodChoiceList.map((food, index) => <option key={index} value={index}>{food.name}</option>)}
</Form.Select>
</>}
{foodChoiceList && !closed && <>
<p className="mt-3">V kolik hodin preferuješ odchod?</p>
<p style={{ marginTop: "10px" }}>V kolik hodin preferuješ odchod?</p>
<Form.Select ref={departureChoiceRef} onChange={handleChangeDepartureTime}>
<option value="">Vyber čas...</option>
<option></option>
{Object.values(DepartureTime)
.filter(time => dayIndex !== data.todayDayIndex || isInTheFuture(time))
.filter(time => isInTheFuture(time))
.map(time => <option key={time} value={time}>{time}</option>)}
</Form.Select>
</>}
</div>}
</>}
{Object.keys(data.choices).length > 0 ?
<Table className='choices-table mt-4 fade-in'>
<Table bordered className='mt-5'>
<tbody>
{Object.keys(data.choices).map(key => {
const locationKey = key as LunchChoice;
const locationName = getLunchChoiceName(locationKey);
const locationKey = key as LocationKey;
const locationName = Locations[locationKey];
const loginObject = data.choices[locationKey];
if (!loginObject) {
return null;
return;
}
const locationLoginList = Object.entries(loginObject);
const locationPickCount = locationLoginList.length
return (
<tr key={key}>
<td>
{locationName}
{(locationPickCount ?? 0) > 1 && <span className="ms-1">({locationPickCount})</span>}
</td>
<td>{locationName}</td>
<td className='p-0'>
<Table className="nested-table">
<Table>
<tbody>
{locationLoginList.map((entry: [string, UserLunchChoice]) => {
{locationLoginList.map((entry: [string, FoodChoices], index) => {
const login = entry[0];
const userPayload = entry[1];
const userChoices = userPayload?.selectedFoods;
const userChoices = userPayload?.options;
const trusted = userPayload?.trusted || false;
const isBuyer = userPayload?.isBuyer || false;
return <tr key={entry[0]}>
return <tr key={index}>
<td>
<div className="user-row">
<div className="user-info">
{trusted && <span className='trusted-icon' title='Uživatel ověřený doménovým přihlášením'>
<FontAwesomeIcon icon={faCircleCheck} style={{ cursor: "help" }} />
{trusted && <span className='trusted-icon'>
<FontAwesomeIcon title='Uživatel ověřený doménovým přihlášením' icon={faCircleCheck} style={{ cursor: "help" }} />
</span>}
<strong>{login}</strong>
{userPayload.departureTime && <small className="ms-2" style={{ color: 'var(--luncher-text-muted)' }}>({userPayload.departureTime})</small>}
{userPayload.note && <span className="ms-2" style={{ fontSize: 'small', color: 'var(--luncher-text-secondary)' }}>({userPayload.note})</span>}
</div>
<div className="user-actions">
{login === auth.login && canChangeChoice && locationKey === LunchChoice.OBJEDNAVAM && <span title='Označit/odznačit se jako objednávající'>
<FontAwesomeIcon onClick={() => {
markAsBuyer();
}} icon={faBasketShopping} className={isBuyer ? 'buyer-icon' : 'action-icon'} style={{ cursor: 'pointer' }} />
</span>}
{login !== auth.login && locationKey === LunchChoice.OBJEDNAVAM && isBuyer && <span title='Objednávající'>
<FontAwesomeIcon onClick={() => {
copyNote(userPayload.note!);
}} icon={faBasketShopping} className='buyer-icon' />
</span>}
{login !== auth.login && canChangeChoice && userPayload?.note?.length && <span title='Převzít poznámku'>
<FontAwesomeIcon onClick={() => {
copyNote(userPayload.note!);
}} className='action-icon' icon={faComment} />
</span>}
{login === auth.login && canChangeChoice && <span title='Upravit poznámku'>
<FontAwesomeIcon onClick={() => {
{login}
{userPayload.departureTime && <small> ({userPayload.departureTime})</small>}
{userPayload.note && <small style={{ overflowWrap: 'anywhere' }}> ({userPayload.note})</small>}
{login === auth.login && canChangeChoice && <FontAwesomeIcon onClick={() => {
setNoteModalOpen(true);
}} className='action-icon' icon={faNoteSticky} />
</span>}
{login === auth.login && canChangeChoice && <span title={`Odstranit volbu ${locationName}, včetně případných zvolených jídel`}>
<FontAwesomeIcon onClick={() => {
doRemoveChoices(key as LunchChoice);
}} className='action-icon' icon={faTrashCan} />
</span>}
</div>
</div>
{userChoices && userChoices.length > 0 && food && (
<div className="food-choices">
{userChoices.map(foodIndex => {
const restaurantKey = key as Restaurant;
const foodName = food[restaurantKey]?.food?.[foodIndex].name;
return <div key={foodIndex} className="food-choice-item">
<span className="food-choice-name">{foodName}</span>
{login === auth.login && canChangeChoice &&
<span title={`Odstranit ${foodName}`}>
<FontAwesomeIcon onClick={() => {
doRemoveFoodChoice(restaurantKey, foodIndex);
}} className='action-icon' icon={faTrashCan} />
</span>}
</div>
})}
</div>
)}
}} title='Upravit poznámku' className='action-icon' icon={faNoteSticky} />}
{login === auth.login && canChangeChoice && <FontAwesomeIcon onClick={() => {
doRemoveChoices(key as LocationKey);
}} title={`Odstranit volbu ${locationName}, včetně případných zvolených jídel`} className='action-icon' icon={faTrashCan} />}
</td>
{userChoices?.length && food ? <td>
<ul>
{userChoices?.map(foodIndex => {
// TODO narovnat, tohle je zbytečně složité
const restaurantKey = Object.keys(Restaurants).indexOf(key);
const restaurant = Object.values(Restaurants)[restaurantKey];
const foodName = food[restaurant]?.food[foodIndex].name;
return <li key={foodIndex}>
{foodName}
{login === auth.login && canChangeChoice && <FontAwesomeIcon onClick={() => {
doRemoveFoodChoice(key as LocationKey, foodIndex);
}} title={`Odstranit ${foodName}`} className='action-icon' icon={faTrashCan} />}
</li>
})}
</ul>
</td> : null}
</tr>
}
)}
@@ -745,79 +516,97 @@ function App() {
)}
</tbody>
</Table>
: <div className='no-votes mt-4'>Zatím nikdo nehlasoval...</div>
: <div className='mt-5'><i>Zatím nikdo nehlasoval...</i></div>
}
</div>
{dayIndex === data.todayDayIndex && userHasPizzaChoice &&
<div className='pizza-section fade-in'>
{dayIndex === data.todayWeekIndex &&
<div className='mt-5'>
{!data.pizzaDay &&
<>
<h3>Pizza Day</h3>
<div style={{ textAlign: 'center' }}>
<p>Pro dnešní den není aktuálně založen Pizza day.</p>
{loadingPizzaDay ?
<span style={{ color: 'var(--luncher-primary)' }}>
<FontAwesomeIcon icon={faGear} className='fa-spin me-2' /> Zjišťujeme dostupné pizzy
<span>
<FontAwesomeIcon icon={faGear} className='fa-spin' /> Zjišťujeme dostupné pizzy
</span>
:
<div>
<Button onClick={handleCreatePizzaDay}>Založit Pizza day</Button>
<Button variant="outline-primary" onClick={doJdemeObed}>Jdeme na oběd!</Button>
</div>
}
<>
<Button onClick={async () => {
setLoadingPizzaDay(true);
await createPizzaDay().then(() => setLoadingPizzaDay(false));
}}>Založit Pizza day</Button>
<Button onClick={doJdemeObed} style={{ marginLeft: "14px" }}>Jdeme na oběd !</Button>
</>
}
</div>
}
{data.pizzaDay &&
<>
<h3>Pizza Day</h3>
<div>
<div style={{ textAlign: 'center' }}>
<h3>Pizza day</h3>
{
data.pizzaDay.state === PizzaDayState.CREATED &&
<>
<div>
<p>
Pizza Day je založen a spravován uživatelem <strong>{data.pizzaDay.creator}</strong>.<br />
Pizza Day je založen a spravován uživatelem {data.pizzaDay.creator}.<br />
Můžete upravovat své objednávky.
</p>
{
data.pizzaDay.creator === auth.login &&
<div className="mb-4">
<Button variant="danger" title="Smaže kompletně pizza day, včetně dosud zadaných objednávek." onClick={handleDeletePizzaDay}>Smazat Pizza day</Button>
<Button title={noOrders ? "Nelze uzamknout - neexistuje žádná objednávka" : "Zamezí přidávat/odebírat objednávky. Použij před samotným objednáním, aby již nemohlo docházet ke změnám."} disabled={noOrders} onClick={handleLockPizzaDay}>Uzamknout</Button>
</div>
}
<>
<Button className='danger mb-3' title="Smaže kompletně pizza day, včetně dosud zadaných objednávek." onClick={async () => {
await deletePizzaDay();
}}>Smazat Pizza day</Button>
<Button className='mb-3' style={{ marginLeft: '20px' }} 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>
}
{
data.pizzaDay.state === PizzaDayState.LOCKED &&
<>
<p>Objednávky jsou uzamčeny uživatelem <strong>{data.pizzaDay.creator}</strong></p>
<div>
<p>Objednávky jsou uzamčeny uživatelem {data.pizzaDay.creator}</p>
{data.pizzaDay.creator === auth.login &&
<div className="mb-4">
<Button variant="secondary" title="Umožní znovu editovat objednávky." onClick={handleUnlockPizzaDay}>Odemknout</Button>
<Button title={noOrders ? "Nelze objednat - neexistuje žádná objednávka" : "Použij po objednání. Objednávky zůstanou zamčeny."} disabled={noOrders} onClick={handleFinishOrder}>Objednáno</Button>
</div>
}
<>
<Button className='danger mb-3' title="Umožní znovu editovat objednávky." onClick={async () => {
await unlockPizzaDay();
}}>Odemknout</Button>
{/* <Button className='danger mb-3' style={{ marginLeft: '20px' }} onClick={async () => {
await addToCart();
}}>Přidat vše do košíku</Button> */}
<Button className='danger mb-3' style={{ marginLeft: '20px' }} 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>
}
{
data.pizzaDay.state === PizzaDayState.ORDERED &&
<>
<p>Pizzy byly objednány uživatelem <strong>{data.pizzaDay.creator}</strong></p>
<div>
<p>Pizzy byly objednány uživatelem {data.pizzaDay.creator}</p>
{data.pizzaDay.creator === auth.login &&
<div className="mb-4">
<Button variant="secondary" title="Vrátí stav do předchozího kroku (před objednáním)." onClick={handleReturnToLocked}>Vrátit do "uzamčeno"</Button>
<Button title="Nastaví stav na 'Doručeno' - koncový stav." onClick={handleFinishDelivery}>Doručeno</Button>
<div>
<Button className='danger mb-3' title="Vrátí stav do předchozího kroku (před objednáním)." onClick={async () => {
await lockPizzaDay();
}}>Vrátit do "uzamčeno"</Button>
<Button className='danger mb-3' style={{ marginLeft: '20px' }} title="Nastaví stav na 'Doručeno' - koncový stav." onClick={async () => {
await finishDelivery(settings?.bankAccount, settings?.holderName);
}}>Doručeno</Button>
</div>
}
</>
</div>
}
{
data.pizzaDay.state === PizzaDayState.DELIVERED &&
<p>
Pizzy byly doručeny.
{myOrder?.hasQr ? ` Objednávku můžete uživateli ${data.pizzaDay.creator} uhradit pomocí QR kódu níže.` : ''}
</p>
<div>
<p>{`Pizzy byly doručeny.${myOrder?.hasQr ? ` Objednávku můžete uživateli ${data.pizzaDay.creator} uhradit pomocí QR kódu níže.` : ''}`}</p>
</div>
}
</div>
{data.pizzaDay.state === PizzaDayState.CREATED &&
<div className="pizza-order-form">
<div style={{ textAlign: 'center' }}>
<SelectSearch
search={true}
options={pizzaSuggestions}
@@ -826,71 +615,38 @@ function App() {
onBlur={_ => { }}
onFocus={_ => { }}
/>
<div className="d-flex align-items-center gap-2">
<label style={{ color: 'var(--luncher-text-secondary)' }}>Poznámka:</label>
<input ref={pizzaPoznamkaRef} type="text" placeholder="Např. bez cibule" onKeyDown={event => {
Poznámka: <input ref={pizzaPoznamkaRef} className='mt-3' type="text" onKeyDown={event => {
if (event.key === 'Enter') {
handlePizzaPoznamkaChange();
}
event.stopPropagation();
}} />
<Button
style={{ marginLeft: '20px' }}
disabled={!myOrder?.pizzaList?.length}
onClick={handlePizzaPoznamkaChange}>
Uložit
</Button>
</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 &&
<div className='qr-code'>
<h3>QR platba</h3>
<img src={`/api/qr?login=${auth.login}`} alt='QR kód' />
<img src={getQrUrl(auth.login)} alt='QR kód' />
</div>
}
</>
</div>
}
</div>
}
</div>
{data.pendingQrs && data.pendingQrs.length > 0 &&
<div className='pizza-section fade-in mt-4'>
<h3>Nevyřízené platby</h3>
<p>Máte neuhrazené platby z předchozích dní.</p>
{data.pendingQrs.map(qr => (
<div key={qr.date} className='qr-code mb-3'>
<p>
<strong>{formatDateString(qr.date)}</strong> {qr.creator} ({qr.totalPrice} )
{qr.purpose && <><br /><span className="text-muted">{qr.purpose}</span></>}
</p>
<img src={`/api/qr?login=${auth.login}`} alt='QR kód' />
<div className='mt-2'>
<Button variant="success" onClick={async () => {
await dismissQr({ body: { date: qr.date } });
// Přenačteme data pro aktualizaci
const response = await getData({ query: { dayIndex } });
if (response.data) {
setData(response.data);
}
}}>
Zaplatil jsem
</Button>
</>}
</div>
</div>
))}
</div>
}
</>
</div>
{/* <FallingLeaves
numLeaves={LEAF_PRESETS.NORMAL}
leafVariants={LEAF_COLOR_THEMES.AUTUMN}
/> */}
<Footer />
<NoteModal isOpen={noteModalOpen} onClose={() => setNoteModalOpen(false)} onSave={saveNote} />
</div>
</>
);
}

View File

@@ -1,35 +0,0 @@
import { Routes, Route } from "react-router-dom";
import { ProvideSettings } from "./context/settings";
// import Snowfall from "react-snowfall";
import { SnowOverlay } from 'react-snow-overlay';
import { ToastContainer } from "react-toastify";
import { SocketContext, socket } from "./context/socket";
import StatsPage from "./pages/StatsPage";
import App from "./App";
export const STATS_URL = '/stats';
export default function AppRoutes() {
return (
<Routes>
<Route path={STATS_URL} element={<StatsPage />} />
<Route path="/" element={
<ProvideSettings>
<SocketContext.Provider value={socket}>
<>
{/* <Snowfall style={{
zIndex: 2,
position: 'fixed',
width: '100vw',
height: '100vh'
}} /> */}
<SnowOverlay color={'rgba(240, 240, 240, 0.9)'} disabledOnSingleCpuDevices={true} />
<App />
</>
<ToastContainer />
</SocketContext.Provider>
</ProvideSettings>
} />
</Routes>
);
}

View File

@@ -1,31 +0,0 @@
.falling-leaves {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
pointer-events: none;
z-index: 0;
overflow: hidden;
}
.leaf-scene {
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 100%;
transform-style: preserve-3d;
div {
position: absolute;
top: 0;
left: 0;
width: 20px;
height: 20px;
background-repeat: no-repeat;
background-size: 100%;
transform-style: preserve-3d;
backface-visibility: visible;
}
}

View File

@@ -1,317 +0,0 @@
import React, { useEffect, useRef, useCallback } from 'react';
// Různé barevné varianty listů
const LEAF_VARIANTS = [
'leaf.svg', // Původní tmavě hnědá
'leaf-orange.svg', // Oranžová
'leaf-yellow.svg', // Žlutá
'leaf-red.svg', // Červená
'leaf-brown.svg', // Světle hnědá
'leaf-green.svg', // Zelená
] as const;
interface LeafData {
el: HTMLDivElement;
x: number;
y: number;
z: number;
rotation: {
axis: 'X' | 'Y' | 'Z';
value: number;
speed: number;
x: number;
};
xSpeedVariation: number;
ySpeed: number;
path: {
type: number;
start: number;
};
image: number;
}
interface WindOptions {
magnitude: number;
maxSpeed: number;
duration: number;
start: number;
speed: (t: number, y: number) => number;
}
interface LeafSceneOptions {
numLeaves: number;
wind: WindOptions;
}
interface FallingLeavesProps {
/** Počet padających listů (výchozí: 20) */
numLeaves?: number;
/** CSS třída pro kontejner (výchozí: 'falling-leaves') */
className?: string;
/** Barevné varianty listů k použití (výchozí: všechny) */
leafVariants?: readonly string[];
}
class LeafScene {
private viewport: HTMLElement;
private world: HTMLDivElement;
private leaves: LeafData[] = [];
private options: LeafSceneOptions;
private width: number;
private height: number;
private timer: number = 0;
private animationId: number | null = null;
private leafVariants: readonly string[];
constructor(el: HTMLElement, numLeaves: number = 20, leafVariants: readonly string[] = LEAF_VARIANTS) {
this.viewport = el;
this.world = document.createElement('div');
this.leafVariants = leafVariants;
this.options = {
numLeaves,
wind: {
magnitude: 1.2,
maxSpeed: 12,
duration: 300,
start: 0,
speed: () => 0
},
};
this.width = this.viewport.offsetWidth;
this.height = this.viewport.offsetHeight;
}
private resetLeaf = (leaf: LeafData): LeafData => {
// place leaf towards the top left
leaf.x = this.width * 2 - Math.random() * this.width * 1.75;
leaf.y = -10;
leaf.z = Math.random() * 200;
if (leaf.x > this.width) {
leaf.x = this.width + 10;
leaf.y = Math.random() * this.height / 2;
}
// at the start, the leaf can be anywhere
if (this.timer === 0) {
leaf.y = Math.random() * this.height;
}
// Choose axis of rotation.
// If axis is not X, chose a random static x-rotation for greater variability
leaf.rotation.speed = Math.random() * 10;
const randomAxis = Math.random();
if (randomAxis > 0.5) {
leaf.rotation.axis = 'X';
} else if (randomAxis > 0.25) {
leaf.rotation.axis = 'Y';
leaf.rotation.x = Math.random() * 180 + 90;
} else {
leaf.rotation.axis = 'Z';
leaf.rotation.x = Math.random() * 360 - 180;
// looks weird if the rotation is too fast around this axis
leaf.rotation.speed = Math.random() * 3;
}
// random speed
leaf.xSpeedVariation = Math.random() * 0.8 - 0.4;
leaf.ySpeed = Math.random() + 1.5;
// randomly select leaf color variant
const randomVariantIndex = Math.floor(Math.random() * this.leafVariants.length);
leaf.image = randomVariantIndex;
// apply the background image to the leaf element
const leafVariant = this.leafVariants[randomVariantIndex];
leaf.el.style.backgroundImage = `url(${leafVariant})`;
return leaf;
};
private updateLeaf = (leaf: LeafData): void => {
const leafWindSpeed = this.options.wind.speed(this.timer - this.options.wind.start, leaf.y);
const xSpeed = leafWindSpeed + leaf.xSpeedVariation;
leaf.x -= xSpeed;
leaf.y += leaf.ySpeed;
leaf.rotation.value += leaf.rotation.speed;
const transform = `translateX(${leaf.x}px) translateY(${leaf.y}px) translateZ(${leaf.z}px) rotate${leaf.rotation.axis}(${leaf.rotation.value}deg)${leaf.rotation.axis !== 'X' ? ` rotateX(${leaf.rotation.x}deg)` : ''
}`;
leaf.el.style.transform = transform;
// reset if out of view
if (leaf.x < -10 || leaf.y > this.height + 10) {
this.resetLeaf(leaf);
}
};
private updateWind = (): void => {
// wind follows a sine curve: asin(b*time + c) + a
// where a = wind magnitude as a function of leaf position, b = wind.duration, c = offset
// wind duration should be related to wind magnitude, e.g. higher windspeed means longer gust duration
if (this.timer === 0 || this.timer > (this.options.wind.start + this.options.wind.duration)) {
this.options.wind.magnitude = Math.random() * this.options.wind.maxSpeed;
this.options.wind.duration = this.options.wind.magnitude * 50 + (Math.random() * 20 - 10);
this.options.wind.start = this.timer;
const screenHeight = this.height;
this.options.wind.speed = function (t: number, y: number) {
// should go from full wind speed at the top, to 1/2 speed at the bottom, using leaf Y
const a = this.magnitude / 2 * (screenHeight - 2 * y / 3) / screenHeight;
return a * Math.sin(2 * Math.PI / this.duration * t + (3 * Math.PI / 2)) + a;
};
}
};
public init = (): void => {
// Clear existing leaves
this.leaves = [];
this.world.innerHTML = '';
for (let i = 0; i < this.options.numLeaves; i++) {
const leaf: LeafData = {
el: document.createElement('div'),
x: 0,
y: 0,
z: 0,
rotation: {
axis: 'X',
value: 0,
speed: 0,
x: 0
},
xSpeedVariation: 0,
ySpeed: 0,
path: {
type: 1,
start: 0,
},
image: 1
};
this.resetLeaf(leaf);
this.leaves.push(leaf);
this.world.appendChild(leaf.el);
}
this.world.className = 'leaf-scene';
this.viewport.appendChild(this.world);
// set perspective
this.world.style.perspective = "400px";
// reset window height/width on resize
const handleResize = (): void => {
this.width = this.viewport.offsetWidth;
this.height = this.viewport.offsetHeight;
};
window.addEventListener('resize', handleResize);
};
public render = (): void => {
this.updateWind();
for (let i = 0; i < this.leaves.length; i++) {
this.updateLeaf(this.leaves[i]);
}
this.timer++;
this.animationId = requestAnimationFrame(this.render);
};
public destroy = (): void => {
if (this.animationId) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
if (this.world && this.world.parentNode) {
this.world.parentNode.removeChild(this.world);
}
window.removeEventListener('resize', () => {
this.width = this.viewport.offsetWidth;
this.height = this.viewport.offsetHeight;
});
};
}
/**
* Komponenta pro zobrazení padajících listů na pozadí stránky
*
* @param numLeaves - Počet padajících listů (výchozí: 20)
* @param className - CSS třída pro kontejner (výchozí: 'falling-leaves')
* @param leafVariants - Barevné varianty listů k použití (výchozí: všechny)
*
* @example
* // Základní použití s výchozím počtem listů
* <FallingLeaves />
*
* @example
* // Použití s vlastním počtem listů
* <FallingLeaves numLeaves={50} />
*
* @example
* // Použití s vlastní CSS třídou a pouze podzimními barvami
* <FallingLeaves
* numLeaves={15}
* className="autumn-leaves"
* leafVariants={['leaf-orange.svg', 'leaf-red.svg', 'leaf-yellow.svg']}
* />
*/
const FallingLeaves: React.FC<FallingLeavesProps> = ({
numLeaves = 20,
className = 'falling-leaves',
leafVariants = LEAF_VARIANTS
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const leafSceneRef = useRef<LeafScene | null>(null);
const initializeLeafScene = useCallback(() => {
if (containerRef.current) {
leafSceneRef.current = new LeafScene(containerRef.current, numLeaves, leafVariants);
leafSceneRef.current.init();
leafSceneRef.current.render();
}
}, [numLeaves, leafVariants]);
useEffect(() => {
initializeLeafScene();
return () => {
if (leafSceneRef.current) {
leafSceneRef.current.destroy();
leafSceneRef.current = null;
}
};
}, [initializeLeafScene]);
return <div ref={containerRef} className={className} />;
};
// Přednastavení pro různé účely
export const LEAF_PRESETS = {
LIGHT: 10, // Lehký podzimní efekt
NORMAL: 20, // Standardní množství
HEAVY: 40, // Silný podzimní vítr
BLIZZARD: 80 // Hustý pád listí
} as const;
// Přednastavené barevné kombinace
export const LEAF_COLOR_THEMES = {
ALL: LEAF_VARIANTS, // Všechny barvy
AUTUMN: ['leaf-orange.svg', 'leaf-red.svg', 'leaf-yellow.svg', 'leaf-brown.svg'] as const, // Podzimní barvy
WARM: ['leaf-orange.svg', 'leaf-red.svg', 'leaf-brown.svg'] as const, // Teplé barvy
CLASSIC: ['leaf.svg', 'leaf-brown.svg'] as const, // Klasické hnědé odstíny
BRIGHT: ['leaf-yellow.svg', 'leaf-orange.svg'] as const, // Světlé barvy
} as const;
export default FallingLeaves;

View File

@@ -1,89 +1,13 @@
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--luncher-bg);
padding: 24px;
}
.login-card {
background: var(--luncher-bg-card);
border-radius: var(--luncher-radius-xl);
box-shadow: var(--luncher-shadow-lg);
padding: 48px;
max-width: 420px;
width: 100%;
text-align: center;
border: 1px solid var(--luncher-border-light);
}
.login-logo {
font-size: 2.5rem;
font-weight: 700;
color: var(--luncher-text);
margin-bottom: 8px;
letter-spacing: -0.5px;
}
.login-subtitle {
color: var(--luncher-text-secondary);
font-size: 1rem;
margin-bottom: 40px;
line-height: 1.5;
}
.login-form {
.login {
height: 100%;
display: flex;
flex-direction: column;
gap: 20px;
text-align: center;
justify-content: center;
}
.login-form label {
display: block;
text-align: left;
font-weight: 500;
color: var(--luncher-text);
margin-bottom: 8px;
}
.login-form .hint {
font-size: 0.85rem;
color: var(--luncher-text-muted);
margin-top: 8px;
text-align: left;
line-height: 1.5;
}
.login-form input[type="text"] {
width: 100%;
padding: 14px 18px;
font-size: 1rem;
border: 2px solid var(--luncher-border);
border-radius: var(--luncher-radius-sm);
background: var(--luncher-bg);
color: var(--luncher-text);
transition: var(--luncher-transition);
}
.login-form input[type="text"]:hover {
border-color: var(--luncher-text-muted);
}
.login-form input[type="text"]:focus {
border-color: var(--luncher-primary);
box-shadow: 0 0 0 3px var(--luncher-primary-light);
outline: none;
}
.login-form input[type="text"]::placeholder {
color: var(--luncher-text-muted);
}
.login-form .btn {
width: 100%;
padding: 14px 24px;
font-size: 1rem;
font-weight: 600;
margin-top: 8px;
.login-inner {
display: flex;
flex-direction: column;
align-items: center;
}

View File

@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useRef } from 'react';
import { Button } from 'react-bootstrap';
import { useAuth } from './context/auth';
import { login } from '../../types';
import { login } from './api/Api';
import './Login.css';
/**
@@ -14,10 +14,9 @@ export default function Login() {
useEffect(() => {
if (auth && !auth.login) {
// Vyzkoušíme přihlášení "naprázdno", pokud projde, přihlásili nás trusted headers
login().then(response => {
const token = response.data;
login().then(token => {
if (token) {
auth?.setToken(token as unknown as string); // TODO vyřešit, API definice je špatně, je to skutečně string
auth?.setToken(token);
}
}).catch(error => {
// nezajímá nás
@@ -26,45 +25,32 @@ export default function Login() {
}, [auth]);
const doLogin = useCallback(async () => {
const length = loginRef?.current?.value.length && loginRef.current.value.replaceAll(/\s/g, '').length
const length = loginRef?.current?.value && loginRef?.current?.value.length && loginRef.current.value.replace(/\s/g, '').length
if (length) {
const response = await login({ body: { login: loginRef.current?.value } });
if (response.data) {
auth?.setToken(response.data as unknown as string); // TODO vyřešit
// TODO odchytávat cokoliv mimo 200
const token = await login(loginRef.current.value);
if (token) {
auth?.setToken(token);
}
}
}, [auth]);
if (!auth?.login) {
return (
<div className='login-page'>
<div className='login-card'>
<h1 className='login-logo'>Luncher</h1>
<p className='login-subtitle'>Aplikace pro profesionální management obědů</p>
<div className='login-form'>
<div>
<label htmlFor="login-input">Zobrazované jméno</label>
<input
id="login-input"
ref={loginRef}
type='text'
placeholder="Např. Jan Novák"
onKeyDown={event => {
if (!auth || !auth.login) {
return <div className='login'>
<h1>Luncher</h1>
<h4 style={{ marginBottom: "50px" }}>Aplikace pro profesionální management obědů</h4>
<div className='login-inner'>
<p style={{ fontSize: "12px", marginTop: "10px" }}>
Zobrazované jméno by mělo být vaše jméno nebo přezdívka, pod kterou vás kolegové dokáží snadno identifikovat. Jméno je možné kdykoli změnit.
</p>
Zobrazované jméno: <input style={{ marginTop: "10px" }} ref={loginRef} type='text' onKeyDown={event => {
if (event.key === 'Enter') {
doLogin()
}
}}
/>
<p className='hint'>
Zadejte jméno nebo přezdívku, pod kterou vás kolegové snadno identifikují.
Jméno je možné kdykoli změnit.
</p>
</div>
<Button onClick={doLogin}>Pokračovat</Button>
}} />
<Button onClick={doLogin} style={{ marginTop: "20px" }}>Uložit</Button>
</div>
</div>
</div>
);
}
return <div>Neplatný stav</div>
}

View File

@@ -16,8 +16,8 @@ export const storeToken = (token: string) => {
*
* @returns token nebo null
*/
export const getToken = (): string | undefined => {
return localStorage.getItem(TOKEN_KEY) ?? undefined;
export const getToken = (): string | null => {
return localStorage.getItem(TOKEN_KEY);
}
/**
@@ -53,60 +53,7 @@ export function isInTheFuture(time: DepartureTime) {
const now = new Date();
const currentHours = now.getHours();
const currentMinutes = now.getMinutes();
const currentDate = now.toDateString();
const [hours, minutes] = time.split(':').map(Number);
if (currentDate === now.toDateString()) {
return hours > currentHours || (hours === currentHours && minutes > currentMinutes);
}
return true;
}
/**
* Vrátí index dne v týdnu, kde pondělí=0, neděle=6
*
* @param date datum
* @returns index dne v týdnu
*/
export const getDayOfWeekIndex = (date: Date) => {
// https://stackoverflow.com/a/4467559
return (((date.getDay() - 1) % 7) + 7) % 7;
}
/** Vrátí první pracovní den v týdnu předaného data. */
export function getFirstWorkDayOfWeek(date: Date) {
const firstDay = new Date(date);
firstDay.setDate(date.getDate() - getDayOfWeekIndex(date));
return firstDay;
}
/** Vrátí poslední pracovní den v týdnu předaného data. */
export function getLastWorkDayOfWeek(date: Date) {
const lastDay = new Date(date);
lastDay.setDate(date.getDate() + (4 - getDayOfWeekIndex(date)));
return lastDay;
}
/** Vrátí datum v ISO formátu. */
export function formatDate(date: Date, format?: string) {
let day = String(date.getDate()).padStart(2, '0');
let month = String(date.getMonth() + 1).padStart(2, "0");
let year = String(date.getFullYear());
const f = format ?? 'YYYY-MM-DD';
return f.replace('DD', day).replace('MM', month).replace('YYYY', year);
}
/** Vrátí human-readable reprezentaci předaného data pro zobrazení. */
export function getHumanDate(date: Date) {
let currentDay = String(date.getDate()).padStart(2, '0');
let currentMonth = String(date.getMonth() + 1).padStart(2, "0");
let currentYear = date.getFullYear();
return `${currentDay}.${currentMonth}.${currentYear}`;
}
/** Převede datum ve formátu YYYY-MM-DD na DD.MM.YYYY */
export function formatDateString(dateString: string): string {
const [year, month, day] = dateString.split('-');
return `${day}.${month}.${year}`;
}

83
client/src/api/Api.ts Normal file
View File

@@ -0,0 +1,83 @@
import { toast } from "react-toastify";
import { getToken } from "../Utils";
/**
* Wrapper pro volání API, u kterých chceme automaticky zobrazit toaster s chybou ze serveru.
*
* @param apiFunction volaná API funkce
*/
export function errorHandler<T>(apiFunction: () => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
apiFunction().then((result) => {
resolve(result);
}).catch(e => {
toast.error(e.message, { theme: "colored" });
});
});
}
async function request<TResponse>(
url: string,
config: RequestInit = {}
): Promise<TResponse> {
config.headers = config?.headers ? new Headers(config.headers) : new Headers();
config.headers.set("Authorization", `Bearer ${getToken()}`);
try {
const response = await fetch(url, config);
if (!response.ok) {
// TODO tohle je blbě, jelikož automaticky očekáváme, že v případě chyby přijde vždy JSON, což není pravda
const json = await response.json();
// Vyhodíme samotnou hlášku z odpovědi, odchytí si jí errorHandler
throw new Error(json.error);
}
const contentType = response.headers.get("content-type");
if (contentType && contentType.indexOf("application/json") !== -1) {
return response.json() as TResponse;
} else {
return response.text() as TResponse;
}
} catch (e) {
return Promise.reject(e);
}
}
async function blobRequest(
url: string,
config: RequestInit = {}
): Promise<Blob> {
config.headers = config?.headers ? new Headers(config.headers) : new Headers();
config.headers.set("Authorization", `Bearer ${getToken()}`);
try {
const response = await fetch(url, config);
if (!response.ok) {
const json = await response.json();
// Vyhodíme samotnou hlášku z odpovědi, odchytí si jí errorHandler
throw new Error(json.error);
}
return response.blob()
} catch (e) {
return Promise.reject(e);
}
}
export const api = {
get: <TResponse>(url: string) => request<TResponse>(url),
blobGet: (url: string) => blobRequest(url),
post: <TBody, TResponse>(url: string, body?: TBody) => request<TResponse>(url, { method: 'POST', body: JSON.stringify(body), headers: { 'Content-Type': 'application/json' } }),
}
export const getQrUrl = (login: string) => {
return `/api/qr?login=${login}`;
}
export const getData = async (dayIndex?: number) => {
let url = '/api/data';
if (dayIndex != null) {
url += '?dayIndex=' + dayIndex;
}
return await api.get<any>(url);
}
export const login = async (login?: string) => {
return await api.post<any, any>('/api/login', { login });
}

View File

@@ -0,0 +1,12 @@
import { EasterEgg } from "../types";
import { api } from "./Api";
const EASTER_EGGS_API_PREFIX = '/api/easterEggs';
export const getEasterEgg = async (): Promise<EasterEgg | undefined> => {
return await api.get<EasterEgg>(`${EASTER_EGGS_API_PREFIX}`);
}
export const getImage = async (url: string) => {
return await api.blobGet(`${EASTER_EGGS_API_PREFIX}/${url}`);
}

28
client/src/api/FoodApi.ts Normal file
View File

@@ -0,0 +1,28 @@
import { AddChoiceRequest, ChangeDepartureTimeRequest, LocationKey, RemoveChoiceRequest, RemoveChoicesRequest, UpdateNoteRequest } from "../types";
import { api } from "./Api";
const FOOD_API_PREFIX = '/api/food';
export const addChoice = async (locationKey: LocationKey, foodIndex?: number, dayIndex?: number) => {
return await api.post<AddChoiceRequest, void>(`${FOOD_API_PREFIX}/addChoice`, { locationKey, foodIndex, dayIndex });
}
export const removeChoices = async (locationKey: LocationKey, dayIndex?: number) => {
return await api.post<RemoveChoicesRequest, void>(`${FOOD_API_PREFIX}/removeChoices`, { locationKey, dayIndex });
}
export const removeChoice = async (locationKey: LocationKey, foodIndex: number, dayIndex?: number) => {
return await api.post<RemoveChoiceRequest, void>(`${FOOD_API_PREFIX}/removeChoice`, { locationKey, foodIndex, dayIndex });
}
export const updateNote = async (note?: string, dayIndex?: number) => {
return await api.post<UpdateNoteRequest, void>(`${FOOD_API_PREFIX}/updateNote`, { note, dayIndex });
}
export const changeDepartureTime = async (time: string, dayIndex?: number) => {
return await api.post<ChangeDepartureTimeRequest, void>(`${FOOD_API_PREFIX}/changeDepartureTime`, { time, dayIndex });
}
export const jdemeObed = async () => {
return await api.post<undefined, void>(`${FOOD_API_PREFIX}/jdemeObed`);
}

View File

@@ -0,0 +1,44 @@
import { AddPizzaRequest, FinishDeliveryRequest, PizzaOrder, RemovePizzaRequest, UpdatePizzaDayNoteRequest, UpdatePizzaFeeRequest } from "../types";
import { api } from "./Api";
const PIZZADAY_API_PREFIX = '/api/pizzaDay';
export const createPizzaDay = async () => {
return await api.post<undefined, void>(`${PIZZADAY_API_PREFIX}/create`);
}
export const deletePizzaDay = async () => {
return await api.post<undefined, void>(`${PIZZADAY_API_PREFIX}/delete`);
}
export const lockPizzaDay = async () => {
return await api.post<undefined, void>(`${PIZZADAY_API_PREFIX}/lock`);
}
export const unlockPizzaDay = async () => {
return await api.post<undefined, void>(`${PIZZADAY_API_PREFIX}/unlock`);
}
export const finishOrder = async () => {
return await api.post<undefined, void>(`${PIZZADAY_API_PREFIX}/finishOrder`);
}
export const finishDelivery = async (bankAccount?: string, bankAccountHolder?: string) => {
return await api.post<FinishDeliveryRequest, void>(`${PIZZADAY_API_PREFIX}/finishDelivery`, { bankAccount, bankAccountHolder });
}
export const addPizza = async (pizzaIndex: number, pizzaSizeIndex: number) => {
return await api.post<AddPizzaRequest, void>(`${PIZZADAY_API_PREFIX}/add`, { pizzaIndex, pizzaSizeIndex });
}
export const removePizza = async (pizzaOrder: PizzaOrder) => {
return await api.post<RemovePizzaRequest, void>(`${PIZZADAY_API_PREFIX}/remove`, { pizzaOrder });
}
export const updatePizzaDayNote = async (note?: string) => {
return await api.post<UpdatePizzaDayNoteRequest, void>(`${PIZZADAY_API_PREFIX}/updatePizzaDayNote`, { note });
}
export const updatePizzaFee = async (login: string, text?: string, price?: number) => {
return await api.post<UpdatePizzaFeeRequest, void>(`${PIZZADAY_API_PREFIX}/updatePizzaFee`, { login, text, price });
}

View File

@@ -0,0 +1,12 @@
import { FeatureRequest, UpdateFeatureVoteRequest } from "../types";
import { api } from "./Api";
const VOTING_API_PREFIX = '/api/voting';
export const getFeatureVotes = async () => {
return await api.get<FeatureRequest[]>(`${VOTING_API_PREFIX}/getVotes`);
}
export const updateFeatureVote = async (option: FeatureRequest, active: boolean) => {
return await api.post<UpdateFeatureVoteRequest, void>(`${VOTING_API_PREFIX}/updateVote`, { option, active });
}

View File

@@ -1,12 +1,7 @@
import { Navbar } from "react-bootstrap";
export default function Footer() {
return (
<footer className="footer">
<span>
Zdroj. kódy dostupné na{' '}
<a href="https://gitea.melancholik.eu/mates/Luncher" target="_blank" rel="noopener noreferrer">
Gitea
</a>
</span>
</footer>
);
return <Navbar className="text-light" variant='dark' expand="lg" style={{ display: "flex", justifyContent: "center" }}>
<span>🄯 Žádná práva nevyhrazena. TODO a zdrojové kódy dostupné <a href="https://gitea.melancholik.eu/mates/Luncher">zde</a>.</span>
</Navbar >
}

View File

@@ -1,86 +1,31 @@
import { useEffect, useState } from "react";
import { Navbar, Nav, NavDropdown, Modal, Button } from "react-bootstrap";
import { Navbar, Nav, NavDropdown } from "react-bootstrap";
import { useAuth } from "../context/auth";
import SettingsModal from "./modals/SettingsModal";
import { useSettings, ThemePreference } from "../context/settings";
import { useSettings } from "../context/settings";
import FeaturesVotingModal from "./modals/FeaturesVotingModal";
import { FeatureRequest } from "../types";
import { errorHandler } from "../api/Api";
import { getFeatureVotes, updateFeatureVote } from "../api/VotingApi";
import PizzaCalculatorModal from "./modals/PizzaCalculatorModal";
import RefreshMenuModal from "./modals/RefreshMenuModal";
import GenerateQrModal from "./modals/GenerateQrModal";
import GenerateMockDataModal from "./modals/GenerateMockDataModal";
import ClearMockDataModal from "./modals/ClearMockDataModal";
import { useNavigate } from "react-router";
import { STATS_URL } from "../AppRoutes";
import { FeatureRequest, getVotes, updateVote, LunchChoices, getChangelogs } from "../../../types";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons";
import { formatDateString } from "../Utils";
const LAST_SEEN_CHANGELOG_KEY = "lastChangelogDate";
const IS_DEV = process.env.NODE_ENV === 'development';
type Props = {
choices?: LunchChoices;
dayIndex?: number;
};
export default function Header({ choices, dayIndex }: Props) {
export default function Header() {
const auth = useAuth();
const settings = useSettings();
const navigate = useNavigate();
const [settingsModalOpen, setSettingsModalOpen] = useState<boolean>(false);
const [votingModalOpen, setVotingModalOpen] = useState<boolean>(false);
const [pizzaModalOpen, setPizzaModalOpen] = useState<boolean>(false);
const [refreshMenuModalOpen, setRefreshMenuModalOpen] = useState<boolean>(false);
const [changelogModalOpen, setChangelogModalOpen] = useState<boolean>(false);
const [changelogEntries, setChangelogEntries] = useState<Record<string, string[]>>({});
const [qrModalOpen, setQrModalOpen] = useState<boolean>(false);
const [generateMockModalOpen, setGenerateMockModalOpen] = useState<boolean>(false);
const [clearMockModalOpen, setClearMockModalOpen] = useState<boolean>(false);
const [featureVotes, setFeatureVotes] = useState<FeatureRequest[] | undefined>([]);
// Zjistíme aktuální efektivní téma (pro zobrazení správné ikony)
const [effectiveTheme, setEffectiveTheme] = useState<'light' | 'dark'>('light');
useEffect(() => {
const updateEffectiveTheme = () => {
if (settings?.themePreference === 'system') {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
setEffectiveTheme(isDark ? 'dark' : 'light');
} else {
setEffectiveTheme(settings?.themePreference || 'light');
}
};
updateEffectiveTheme();
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', updateEffectiveTheme);
return () => mediaQuery.removeEventListener('change', updateEffectiveTheme);
}, [settings?.themePreference]);
const [featureVotes, setFeatureVotes] = useState<FeatureRequest[]>([]);
useEffect(() => {
if (auth?.login) {
getVotes().then(response => {
setFeatureVotes(response.data);
getFeatureVotes().then(votes => {
setFeatureVotes(votes);
})
}
}, [auth?.login]);
useEffect(() => {
if (!auth?.login) return;
const lastSeen = localStorage.getItem(LAST_SEEN_CHANGELOG_KEY) ?? undefined;
getChangelogs({ query: lastSeen ? { since: lastSeen } : {} }).then(response => {
const entries = response.data;
if (!entries || Object.keys(entries).length === 0) return;
setChangelogEntries(entries);
setChangelogModalOpen(true);
const newestDate = Object.keys(entries).sort((a, b) => b.localeCompare(a))[0];
localStorage.setItem(LAST_SEEN_CHANGELOG_KEY, newestDate);
});
}, [auth?.login]);
const closeSettingsModal = () => {
setSettingsModalOpen(false);
}
@@ -93,28 +38,6 @@ export default function Header({ choices, dayIndex }: Props) {
setPizzaModalOpen(false);
}
const closeRefreshMenuModal = () => {
setRefreshMenuModalOpen(false);
}
const closeQrModal = () => {
setQrModalOpen(false);
}
const handleQrMenuClick = () => {
if (!settings?.bankAccount || !settings?.holderName) {
alert('Pro generování QR kódů je nutné mít v nastavení vyplněné číslo účtu a jméno držitele účtu.');
return;
}
setQrModalOpen(true);
}
const toggleTheme = () => {
// Přepínáme mezi light a dark (ignorujeme system pro jednoduchost)
const newTheme: ThemePreference = effectiveTheme === 'dark' ? 'light' : 'dark';
settings?.setThemePreference(newTheme);
}
const isValidInteger = (str: string) => {
str = str.trim();
if (!str) {
@@ -125,19 +48,19 @@ export default function Header({ choices, dayIndex }: Props) {
return n !== Infinity && String(n) === str && n >= 0;
}
const saveSettings = (bankAccountNumber?: string, bankAccountHolderName?: string, hideSoupsOption?: boolean, themePreference?: ThemePreference) => {
const saveSettings = (bankAccountNumber?: string, bankAccountHolderName?: string, hideSoupsOption?: boolean) => {
if (bankAccountNumber) {
try {
// Validace kódu banky
if (!bankAccountNumber.includes('/')) {
throw new Error("Číslo účtu neobsahuje lomítko/kód banky")
if (bankAccountNumber.indexOf('/') < 0) {
throw Error("Číslo účtu neobsahuje lomítko/kód banky")
}
const split = bankAccountNumber.split("/");
if (split[1].length !== 4) {
throw new Error("Kód banky musí být 4 číslice")
throw Error("Kód banky musí být 4 číslice")
}
if (!isValidInteger(split[1])) {
throw new Error("Kód banky není číslo")
throw Error("Kód banky není číslo")
}
// Validace čísla a předčíslí
@@ -147,20 +70,20 @@ export default function Header({ choices, dayIndex }: Props) {
cislo = cislo.replace('-', '');
}
if (!isValidInteger(cislo)) {
throw new Error("Předčíslí nebo číslo účtu neobsahuje pouze číslice")
throw Error("Předčíslí nebo číslo účtu neobsahuje pouze číslice")
}
if (cislo.length < 16) {
cislo = cislo.padStart(16, '0');
}
let sum = 0;
for (let i = 0; i < cislo.length; i++) {
for (var i = 0; i < cislo.length; i++) {
const char = cislo.charAt(i);
const order = (cislo.length - 1) - i;
const weight = (2 ** order) % 11;
sum += Number.parseInt(char) * weight
}
if (sum % 11 !== 0) {
throw new Error("Číslo účtu je neplatné")
throw Error("Číslo účtu je neplatné")
}
} catch (e: any) {
alert(e.message)
@@ -170,15 +93,12 @@ export default function Header({ choices, dayIndex }: Props) {
settings?.setBankAccountNumber(bankAccountNumber);
settings?.setBankAccountHolderName(bankAccountHolderName);
settings?.setHideSoupsOption(hideSoupsOption);
if (themePreference) {
settings?.setThemePreference(themePreference);
}
closeSettingsModal();
}
const saveFeatureVote = async (option: FeatureRequest, active: boolean) => {
await updateVote({ body: { option, active } });
const votes = [...featureVotes || []];
await errorHandler(() => updateFeatureVote(option, active));
const votes = [...featureVotes];
if (active) {
votes.push(option);
} else {
@@ -188,99 +108,21 @@ export default function Header({ choices, dayIndex }: Props) {
}
return <Navbar variant='dark' expand="lg">
<Navbar.Brand href="/">Luncher</Navbar.Brand>
<Navbar.Brand>Luncher</Navbar.Brand>
<Navbar.Toggle aria-controls="basic-navbar-nav" />
<Navbar.Collapse id="basic-navbar-nav">
<Nav className="nav">
<button
className="theme-toggle"
onClick={toggleTheme}
title={effectiveTheme === 'dark' ? 'Přepnout na světlý režim' : 'Přepnout na tmavý režim'}
aria-label="Přepnout barevný motiv"
>
<FontAwesomeIcon icon={effectiveTheme === 'dark' ? faSun : faMoon} />
</button>
<NavDropdown align="end" title={auth?.login} id="basic-nav-dropdown">
<NavDropdown.Item onClick={() => setSettingsModalOpen(true)}>Nastavení</NavDropdown.Item>
<NavDropdown.Item onClick={() => setRefreshMenuModalOpen(true)}>Přenačtení menu</NavDropdown.Item>
<NavDropdown.Item onClick={() => setVotingModalOpen(true)}>Hlasovat o nových funkcích</NavDropdown.Item>
<NavDropdown.Item onClick={() => setPizzaModalOpen(true)}>Pizza kalkulačka</NavDropdown.Item>
<NavDropdown.Item onClick={handleQrMenuClick}>Generování QR kódů</NavDropdown.Item>
<NavDropdown.Item onClick={() => navigate(STATS_URL)}>Statistiky</NavDropdown.Item>
<NavDropdown.Item onClick={() => {
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 && (
<>
<NavDropdown.Divider />
<NavDropdown.Item onClick={() => setGenerateMockModalOpen(true)}>🔧 Generovat mock data</NavDropdown.Item>
<NavDropdown.Item onClick={() => setClearMockModalOpen(true)}>🔧 Smazat data dne</NavDropdown.Item>
</>
)}
<NavDropdown.Divider />
<NavDropdown.Item onClick={auth?.logout}>Odhlásit se</NavDropdown.Item>
</NavDropdown>
</Nav>
</Navbar.Collapse>
<SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} />
<RefreshMenuModal isOpen={refreshMenuModalOpen} onClose={closeRefreshMenuModal} />
<FeaturesVotingModal isOpen={votingModalOpen} onClose={closeVotingModal} onChange={saveFeatureVote} initialValues={featureVotes} />
<PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} />
{choices && settings?.bankAccount && settings?.holderName && (
<GenerateQrModal
isOpen={qrModalOpen}
onClose={closeQrModal}
choices={choices}
bankAccount={settings.bankAccount}
bankAccountHolder={settings.holderName}
/>
)}
{IS_DEV && (
<>
<GenerateMockDataModal
isOpen={generateMockModalOpen}
onClose={() => setGenerateMockModalOpen(false)}
currentDayIndex={dayIndex}
/>
<ClearMockDataModal
isOpen={clearMockModalOpen}
onClose={() => setClearMockModalOpen(false)}
currentDayIndex={dayIndex}
/>
</>
)}
<Modal show={changelogModalOpen} onHide={() => setChangelogModalOpen(false)} size="lg">
<Modal.Header closeButton>
<Modal.Title><h2>Novinky</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
{Object.keys(changelogEntries).sort((a, b) => b.localeCompare(a)).map(date => (
<div key={date}>
<strong>{formatDateString(date)}</strong>
<ul>
{changelogEntries[date].map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</div>
))}
{Object.keys(changelogEntries).length === 0 && (
<p>Žádné novinky.</p>
)}
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={() => setChangelogModalOpen(false)}>
Zavřít
</Button>
</Modal.Footer>
</Modal>
</Navbar>
}

View File

@@ -2,20 +2,18 @@ import { IconDefinition } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
type Props = {
title?: string,
title?: String,
icon: IconDefinition,
description: string,
animation?: string,
description: String,
animation?: String,
}
function Loader(props: Readonly<Props>) {
return (
<div className='loader'>
<FontAwesomeIcon icon={props.icon} className={`loader-icon ${props.animation ?? ''}`} />
<h2 className='loader-title'>{props.title ?? 'Prosím čekejte...'}</h2>
<p className='loader-description'>{props.description}</p>
function Loader(props: Props) {
return <div className='loader'>
<h1>{props.title || 'Prosím čekejte...'}</h1>
<FontAwesomeIcon icon={props.icon} className={`loader-icon mb-3 ` + (props.animation ?? '')} />
<p>{props.description}</p>
</div>
);
}
export default Loader;

View File

@@ -1,57 +1,46 @@
import { Table } from "react-bootstrap";
import { Order, PizzaDayState, PizzaOrder } from "../types";
import PizzaOrderRow from "./PizzaOrderRow";
import { PizzaDayState, PizzaOrder, PizzaVariant, updatePizzaFee } from "../../../types";
import { updatePizzaFee } from "../api/PizzaDayApi";
type Props = {
state: PizzaDayState,
orders: PizzaOrder[],
onDelete: (pizzaOrder: PizzaVariant) => void,
orders: Order[],
onDelete: (pizzaOrder: PizzaOrder) => void,
creator: string,
}
export default function PizzaOrderList({ state, orders, onDelete, creator }: Readonly<Props>) {
export default function PizzaOrderList({ state, orders, onDelete, creator }: Props) {
const saveFees = async (customer: string, text?: string, price?: number) => {
await updatePizzaFee({ body: { login: customer, text, price } });
await updatePizzaFee(customer, text, price);
}
if (!orders?.length) {
return <p className="mt-4" style={{ color: 'var(--luncher-text-muted)', fontStyle: 'italic' }}>Zatím žádné objednávky...</p>
return <p className="mt-3"><i>Zatím žádné objednávky...</i></p>
}
const total = orders.reduce((total, order) => total + order.totalPrice, 0);
return (
<div className="mt-4" style={{
background: 'var(--luncher-bg-card)',
borderRadius: 'var(--luncher-radius-lg)',
overflow: 'hidden',
border: '1px solid var(--luncher-border-light)',
boxShadow: 'var(--luncher-shadow)'
}}>
<Table className="mb-0" style={{ color: 'var(--luncher-text)' }}>
<thead style={{ background: 'var(--luncher-primary-light)' }}>
return <>
<Table className="mt-3" striped bordered hover>
<thead>
<tr>
<th style={{ padding: '16px 20px', color: 'var(--luncher-primary)', fontWeight: 600, border: 'none' }}>Jméno</th>
<th style={{ padding: '16px 20px', color: 'var(--luncher-primary)', fontWeight: 600, border: 'none' }}>Objednávka</th>
<th style={{ padding: '16px 20px', color: 'var(--luncher-primary)', fontWeight: 600, border: 'none' }}>Poznámka</th>
<th style={{ padding: '16px 20px', color: 'var(--luncher-primary)', fontWeight: 600, border: 'none' }}>Příplatek</th>
<th style={{ padding: '16px 20px', color: 'var(--luncher-primary)', fontWeight: 600, border: 'none', textAlign: 'right' }}>Cena</th>
<th>Jméno</th>
<th>Objednávka</th>
<th>Poznámka</th>
<th>Příplatek</th>
<th>Cena</th>
</tr>
</thead>
<tbody>
{orders.map(order => <tr key={order.customer} style={{ borderColor: 'var(--luncher-border-light)' }}>
{orders.map(order => <tr key={order.customer}>
<PizzaOrderRow creator={creator} state={state} order={order} onDelete={onDelete} onFeeModalSave={saveFees} />
</tr>)}
<tr style={{
fontWeight: 700,
background: 'var(--luncher-bg-hover)',
borderTop: '2px solid var(--luncher-border)'
}}>
<td colSpan={4} style={{ padding: '16px 20px', border: 'none' }}>Celkem</td>
<td style={{ padding: '16px 20px', border: 'none', textAlign: 'right', color: 'var(--luncher-primary)' }}>{`${total}`}</td>
<tr style={{ fontWeight: 'bold' }}>
<td colSpan={4}>Celkem</td>
<td>{`${total}`}</td>
</tr>
</tbody>
</Table>
</div>
);
</>
}

View File

@@ -2,46 +2,44 @@ import React, { useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faMoneyBill1, faTrashCan } from "@fortawesome/free-regular-svg-icons";
import { useAuth } from "../context/auth";
import { Order, PizzaDayState, PizzaOrder } from "../types";
import PizzaAdditionalFeeModal from "./modals/PizzaAdditionalFeeModal";
import { PizzaDayState, PizzaOrder, PizzaVariant } from "../../../types";
type Props = {
creator: string,
order: PizzaOrder,
order: Order,
state: PizzaDayState,
onDelete: (order: PizzaVariant) => void,
onDelete: (order: PizzaOrder) => void,
onFeeModalSave: (customer: string, name?: string, price?: number) => void,
}
export default function PizzaOrderRow({ creator, order, state, onDelete, onFeeModalSave }: Readonly<Props>) {
export default function PizzaOrderRow({ creator, order, state, onDelete, onFeeModalSave }: Props) {
const auth = useAuth();
const [isFeeModalOpen, setIsFeeModalOpen] = useState<boolean>(false);
const [isFeeModalOpen, setFeeModalOpen] = useState<boolean>(false);
const saveFees = (customer: string, text?: string, price?: number) => {
onFeeModalSave(customer, text, price);
setIsFeeModalOpen(false);
setFeeModalOpen(false);
}
return <>
<td>{order.customer}</td>
<td>{order.pizzaList!.map<React.ReactNode>(pizzaOrder =>
<span key={pizzaOrder.name}>
<td>{order.pizzaList.map<React.ReactNode>((pizzaOrder, index) =>
<span key={index}>
{`${pizzaOrder.name}, ${pizzaOrder.size} (${pizzaOrder.price} Kč)`}
{auth?.login === order.customer && state === PizzaDayState.CREATED &&
<span title='Odstranit'>
<FontAwesomeIcon onClick={() => {
onDelete(pizzaOrder);
}} className='action-icon' icon={faTrashCan} />
</span>
}} title='Odstranit' className='action-icon' icon={faTrashCan} />
}
</span>)
.reduce((prev, curr, index) => [prev, <br key={`br-${index}`} />, curr])}
</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}${order.fee.text ? ` (${order.fee.text})` : ''}` : '-'}</td>
<td>
{order.totalPrice} {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 && <FontAwesomeIcon onClick={() => { setFeeModalOpen(true) }} title='Nastavit příplatek' className='action-icon' icon={faMoneyBill1} />}
</td>
<PizzaAdditionalFeeModal customerName={order.customer} isOpen={isFeeModalOpen} onClose={() => setIsFeeModalOpen(false)} onSave={saveFees} initialValues={{ text: order.fee?.text, price: order.fee?.price?.toString() }} />
<PizzaAdditionalFeeModal customerName={order.customer} isOpen={isFeeModalOpen} onClose={() => setFeeModalOpen(false)} onSave={saveFees} initialValues={{ text: order.fee?.text, price: order.fee?.price?.toString() }} />
</>
}

View File

@@ -1,104 +0,0 @@
import { useState } from "react";
import { Modal, Button, Alert } from "react-bootstrap";
import { clearMockData, DayIndex } from "../../../../types";
type Props = {
isOpen: boolean;
onClose: () => void;
currentDayIndex?: number;
};
const DAY_NAMES = ['Pondělí', 'Úterý', 'Středa', 'Čtvrtek', 'Pátek'];
/** Modální dialog pro smazání mock dat (pouze DEV). */
export default function ClearMockDataModal({ isOpen, onClose, currentDayIndex }: Readonly<Props>) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const handleClear = async () => {
setError(null);
setLoading(true);
try {
const body: any = {};
if (currentDayIndex !== undefined) {
body.dayIndex = currentDayIndex as DayIndex;
}
const response = await clearMockData({ body });
if (response.error) {
setError((response.error as any).error || 'Nastala chyba při mazání dat');
} else {
setSuccess(true);
setTimeout(() => {
onClose();
setSuccess(false);
}, 1500);
}
} catch (e: any) {
setError(e.message || 'Nastala chyba při mazání dat');
} finally {
setLoading(false);
}
};
const handleClose = () => {
setError(null);
setSuccess(false);
onClose();
};
const dayName = currentDayIndex !== undefined ? DAY_NAMES[currentDayIndex] : 'aktuální den';
return (
<Modal show={isOpen} onHide={handleClose}>
<Modal.Header closeButton>
<Modal.Title><h2>Smazat data</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
{success ? (
<Alert variant="success">
Data byla úspěšně smazána!
</Alert>
) : (
<>
<Alert variant="warning">
<strong>DEV režim</strong> - Tato funkce je dostupná pouze ve vývojovém prostředí.
</Alert>
{error && (
<Alert variant="danger" onClose={() => setError(null)} dismissible>
{error}
</Alert>
)}
<p>
Opravdu chcete smazat všechny volby stravování pro <strong>{dayName}</strong>?
</p>
<p className="text-muted">
Tato akce je nevratná.
</p>
</>
)}
</Modal.Body>
<Modal.Footer>
{!success && (
<>
<Button variant="secondary" onClick={handleClose} disabled={loading}>
Ne, zrušit
</Button>
<Button variant="danger" onClick={handleClear} disabled={loading}>
{loading ? 'Mažu...' : 'Ano, smazat'}
</Button>
</>
)}
{success && (
<Button variant="secondary" onClick={handleClose}>
Zavřít
</Button>
)}
</Modal.Footer>
</Modal>
);
}

View File

@@ -1,5 +1,5 @@
import { Modal, Button, Form } from "react-bootstrap"
import { FeatureRequest } from "../../../../types";
import { FeatureRequest } from "../../types";
type Props = {
isOpen: boolean,
@@ -9,7 +9,7 @@ type Props = {
}
/** Modální dialog pro hlasování o nových funkcích. */
export default function FeaturesVotingModal({ isOpen, onClose, onChange, initialValues }: Readonly<Props>) {
export default function FeaturesVotingModal({ isOpen, onClose, onChange, initialValues }: Props) {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.currentTarget.value as FeatureRequest, e.currentTarget.checked);
@@ -31,7 +31,7 @@ export default function FeaturesVotingModal({ isOpen, onClose, onChange, initial
label={FeatureRequest[key]}
onChange={handleChange}
value={key}
defaultChecked={initialValues?.includes(key as FeatureRequest)}
defaultChecked={initialValues && initialValues.includes(key as FeatureRequest)}
/>
})}
<p className="mt-3" style={{ fontSize: '12px' }}>Něco jiného? Dejte vědět.</p>

View File

@@ -1,140 +0,0 @@
import { useState } from "react";
import { Modal, Button, Form, Alert } from "react-bootstrap";
import { generateMockData, DayIndex } from "../../../../types";
type Props = {
isOpen: boolean;
onClose: () => void;
currentDayIndex?: number;
};
const DAY_NAMES = ['Pondělí', 'Úterý', 'Středa', 'Čtvrtek', 'Pátek'];
/** Modální dialog pro generování mock dat (pouze DEV). */
export default function GenerateMockDataModal({ isOpen, onClose, currentDayIndex }: Readonly<Props>) {
const [dayIndex, setDayIndex] = useState<number | undefined>(currentDayIndex);
const [count, setCount] = useState<string>('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const handleGenerate = async () => {
setError(null);
setLoading(true);
try {
const body: any = {};
if (dayIndex !== undefined) {
body.dayIndex = dayIndex as DayIndex;
}
if (count && count.trim() !== '') {
const countNum = parseInt(count, 10);
if (isNaN(countNum) || countNum < 1 || countNum > 100) {
setError('Počet musí být číslo mezi 1 a 100');
setLoading(false);
return;
}
body.count = countNum;
}
const response = await generateMockData({ body });
if (response.error) {
setError((response.error as any).error || 'Nastala chyba při generování dat');
} else {
setSuccess(true);
setTimeout(() => {
onClose();
setSuccess(false);
setCount('');
}, 1500);
}
} catch (e: any) {
setError(e.message || 'Nastala chyba při generování dat');
} finally {
setLoading(false);
}
};
const handleClose = () => {
setError(null);
setSuccess(false);
setCount('');
onClose();
};
return (
<Modal show={isOpen} onHide={handleClose}>
<Modal.Header closeButton>
<Modal.Title><h2>Generovat mock data</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
{success ? (
<Alert variant="success">
Mock data byla úspěšně vygenerována!
</Alert>
) : (
<>
<Alert variant="warning">
<strong>DEV režim</strong> - Tato funkce je dostupná pouze ve vývojovém prostředí.
</Alert>
{error && (
<Alert variant="danger" onClose={() => setError(null)} dismissible>
{error}
</Alert>
)}
<Form.Group className="mb-3">
<Form.Label>Den</Form.Label>
<Form.Select
value={dayIndex ?? ''}
onChange={e => setDayIndex(e.target.value === '' ? undefined : parseInt(e.target.value, 10))}
>
<option value="">Aktuální den</option>
{DAY_NAMES.map((name, index) => (
<option key={index} value={index}>{name}</option>
))}
</Form.Select>
<Form.Text className="text-muted">
Pokud není vybráno, použije se aktuální den.
</Form.Text>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Počet záznamů</Form.Label>
<Form.Control
type="number"
placeholder="Náhodný (5-20)"
value={count}
onChange={e => setCount(e.target.value)}
min={1}
max={100}
onKeyDown={e => e.stopPropagation()}
/>
<Form.Text className="text-muted">
Pokud není zadáno, vybere se náhodný počet 5-20.
</Form.Text>
</Form.Group>
</>
)}
</Modal.Body>
<Modal.Footer>
{!success && (
<>
<Button variant="secondary" onClick={handleClose} disabled={loading}>
Storno
</Button>
<Button variant="primary" onClick={handleGenerate} disabled={loading}>
{loading ? 'Generuji...' : 'Generovat'}
</Button>
</>
)}
{success && (
<Button variant="secondary" onClick={handleClose}>
Zavřít
</Button>
)}
</Modal.Footer>
</Modal>
);
}

View File

@@ -1,255 +0,0 @@
import { useState, useEffect, useCallback } from "react";
import { Modal, Button, Form, Table, Alert } from "react-bootstrap";
import { generateQr, LunchChoices, QrRecipient } from "../../../../types";
type UserEntry = {
login: string;
selected: boolean;
purpose: string;
amount: string;
};
type Props = {
isOpen: boolean;
onClose: () => void;
choices: LunchChoices;
bankAccount: string;
bankAccountHolder: string;
};
/** Modální dialog pro generování QR kódů pro platbu. */
export default function GenerateQrModal({ isOpen, onClose, choices, bankAccount, bankAccountHolder }: Readonly<Props>) {
const [users, setUsers] = useState<UserEntry[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
// Při otevření modálu načteme seznam uživatelů z choices
useEffect(() => {
if (isOpen && choices) {
const userLogins = new Set<string>();
// Projdeme všechny lokace a získáme unikátní loginy
Object.values(choices).forEach(locationChoices => {
if (locationChoices) {
Object.keys(locationChoices).forEach(login => {
userLogins.add(login);
});
}
});
// Vytvoříme seznam uživatelů
const userList: UserEntry[] = Array.from(userLogins)
.sort((a, b) => a.localeCompare(b, 'cs'))
.map(login => ({
login,
selected: false,
purpose: '',
amount: '',
}));
setUsers(userList);
setError(null);
setSuccess(false);
}
}, [isOpen, choices]);
const handleCheckboxChange = useCallback((login: string, checked: boolean) => {
setUsers(prev => prev.map(u =>
u.login === login ? { ...u, selected: checked } : u
));
}, []);
const handlePurposeChange = useCallback((login: string, value: string) => {
setUsers(prev => prev.map(u =>
u.login === login ? { ...u, purpose: value } : u
));
}, []);
const handleAmountChange = useCallback((login: string, value: string) => {
// Povolíme pouze čísla, tečku a čárku
const sanitized = value.replace(/[^0-9.,]/g, '').replace(',', '.');
setUsers(prev => prev.map(u =>
u.login === login ? { ...u, amount: sanitized } : u
));
}, []);
const validateAmount = (amountStr: string): number | null => {
if (!amountStr || amountStr.trim().length === 0) {
return null;
}
const amount = parseFloat(amountStr);
if (isNaN(amount) || amount <= 0) {
return null;
}
// Max 2 desetinná místa
const parts = amountStr.split('.');
if (parts.length === 2 && parts[1].length > 2) {
return null;
}
return Math.round(amount * 100) / 100; // Zaokrouhlíme na 2 desetinná místa
};
const handleGenerate = async () => {
setError(null);
const selectedUsers = users.filter(u => u.selected);
if (selectedUsers.length === 0) {
setError("Nebyl vybrán žádný uživatel");
return;
}
// Validace
const recipients: QrRecipient[] = [];
for (const user of selectedUsers) {
if (!user.purpose || user.purpose.trim().length === 0) {
setError(`Uživatel ${user.login} nemá vyplněný účel platby`);
return;
}
const amount = validateAmount(user.amount);
if (amount === null) {
setError(`Uživatel ${user.login} má neplatnou částku (musí být kladné číslo s max. 2 desetinnými místy)`);
return;
}
recipients.push({
login: user.login,
purpose: user.purpose.trim(),
amount,
});
}
setLoading(true);
try {
const response = await generateQr({
body: {
recipients,
bankAccount,
bankAccountHolder,
}
});
if (response.error) {
setError((response.error as any).error || 'Nastala chyba při generování QR kódů');
} else {
setSuccess(true);
// Po 2 sekundách zavřeme modal
setTimeout(() => {
onClose();
}, 2000);
}
} catch (e: any) {
setError(e.message || 'Nastala chyba při generování QR kódů');
} finally {
setLoading(false);
}
};
const handleClose = () => {
setError(null);
setSuccess(false);
onClose();
};
const selectedCount = users.filter(u => u.selected).length;
return (
<Modal show={isOpen} onHide={handleClose} size="lg">
<Modal.Header closeButton>
<Modal.Title><h2>Generování QR kódů</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>
Vyberte uživatele, kterým chcete vygenerovat QR kód pro platbu.
QR kódy se uživatelům zobrazí v sekci "Nevyřízené platby".
</p>
{error && (
<Alert variant="danger" onClose={() => setError(null)} dismissible>
{error}
</Alert>
)}
{users.length === 0 ? (
<Alert variant="info">
V tento den nemá žádný uživatel zvolenou možnost stravování.
</Alert>
) : (
<Table striped bordered hover responsive>
<thead>
<tr>
<th style={{ width: '50px' }}></th>
<th>Uživatel</th>
<th>Účel platby</th>
<th style={{ width: '120px' }}>Částka ()</th>
</tr>
</thead>
<tbody>
{users.map(user => (
<tr key={user.login} className={user.selected ? '' : 'text-muted'}>
<td className="text-center">
<Form.Check
type="checkbox"
checked={user.selected}
onChange={e => handleCheckboxChange(user.login, e.target.checked)}
/>
</td>
<td>{user.login}</td>
<td>
<Form.Control
type="text"
placeholder="např. Pizza prosciutto"
value={user.purpose}
onChange={e => handlePurposeChange(user.login, e.target.value)}
disabled={!user.selected}
size="sm"
onKeyDown={e => e.stopPropagation()}
/>
</td>
<td>
<Form.Control
type="text"
placeholder="0.00"
value={user.amount}
onChange={e => handleAmountChange(user.login, e.target.value)}
disabled={!user.selected}
size="sm"
onKeyDown={e => e.stopPropagation()}
/>
</td>
</tr>
))}
</tbody>
</Table>
)}
</>
)}
</Modal.Body>
<Modal.Footer>
{!success && (
<>
<span className="me-auto text-muted">
Vybráno: {selectedCount} / {users.length}
</span>
<Button variant="secondary" onClick={handleClose} disabled={loading}>
Storno
</Button>
<Button
variant="primary"
onClick={handleGenerate}
disabled={loading || selectedCount === 0}
>
{loading ? 'Generuji...' : 'Generovat'}
</Button>
</>
)}
{success && (
<Button variant="secondary" onClick={handleClose}>
Zavřít
</Button>
)}
</Modal.Footer>
</Modal>
);
}

View File

@@ -8,7 +8,7 @@ type Props = {
}
/** Modální dialog pro úpravu obecné poznámky. */
export default function NoteModal({ isOpen, onClose, onSave }: Readonly<Props>) {
export default function NoteModal({ isOpen, onClose, onSave }: Props) {
const note = useRef<HTMLInputElement>(null);
const save = () => {

View File

@@ -10,17 +10,17 @@ type Props = {
}
/** Modální dialog pro nastavení příplatků za pizzu. */
export default function PizzaAdditionalFeeModal({ customerName, isOpen, onClose, onSave, initialValues }: Readonly<Props>) {
export default function PizzaAdditionalFeeModal({ customerName, isOpen, onClose, onSave, initialValues }: Props) {
const textRef = useRef<HTMLInputElement>(null);
const priceRef = useRef<HTMLInputElement>(null);
const doSubmit = () => {
onSave(customerName, textRef.current?.value, Number.parseInt(priceRef.current?.value ?? "0"));
onSave(customerName, textRef.current?.value, parseInt(priceRef.current?.value || "0"));
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
onSave(customerName, textRef.current?.value, Number.parseInt(priceRef.current?.value ?? "0"));
onSave(customerName, textRef.current?.value, parseInt(priceRef.current?.value || "0"));
}
}

View File

@@ -23,7 +23,7 @@ type Result = {
}
/** Modální dialog pro výpočet výhodnosti pizzy. */
export default function PizzaCalculatorModal({ isOpen, onClose }: Readonly<Props>) {
export default function PizzaCalculatorModal({ isOpen, onClose }: Props) {
const diameter1Ref = useRef<HTMLInputElement>(null);
const price1Ref = useRef<HTMLInputElement>(null);
const diameter2Ref = useRef<HTMLInputElement>(null);
@@ -36,13 +36,15 @@ export default function PizzaCalculatorModal({ isOpen, onClose }: Readonly<Props
// 1. pizza
if (diameter1Ref.current?.value) {
const diameter1 = Number.parseInt(diameter1Ref.current?.value);
r.pizza1 ??= {};
const diameter1 = parseInt(diameter1Ref.current?.value);
if (!r.pizza1) {
r.pizza1 = {};
}
if (diameter1 && diameter1 > 0) {
r.pizza1.diameter = diameter1;
r.pizza1.area = Math.PI * Math.pow(diameter1 / 2, 2);
if (price1Ref.current?.value) {
const price1 = Number.parseInt(price1Ref.current?.value);
const price1 = parseInt(price1Ref.current?.value);
if (price1) {
r.pizza1.pricePerM = price1 / r.pizza1.area;
} else {
@@ -56,13 +58,15 @@ export default function PizzaCalculatorModal({ isOpen, onClose }: Readonly<Props
// 2. pizza
if (diameter2Ref.current?.value) {
const diameter2 = Number.parseInt(diameter2Ref.current?.value);
r.pizza2 ??= {};
const diameter2 = parseInt(diameter2Ref.current?.value);
if (!r.pizza2) {
r.pizza2 = {};
}
if (diameter2 && diameter2 > 0) {
r.pizza2.diameter = diameter2;
r.pizza2.area = Math.PI * Math.pow(diameter2 / 2, 2);
if (price2Ref.current?.value) {
const price2 = Number.parseInt(price2Ref.current?.value);
const price2 = parseInt(price2Ref.current?.value);
if (price2) {
r.pizza2.pricePerM = price2 / r.pizza2.area;
} else {
@@ -77,8 +81,8 @@ export default function PizzaCalculatorModal({ isOpen, onClose }: Readonly<Props
// Srovnání
if (r.pizza1?.pricePerM && r.pizza2?.pricePerM && r.pizza1.diameter && r.pizza2.diameter) {
r.choice = r.pizza1.pricePerM < r.pizza2.pricePerM ? 1 : 2;
const bigger = Math.max(r.pizza1.pricePerM, r.pizza2.pricePerM);
const smaller = Math.min(r.pizza1.pricePerM, r.pizza2.pricePerM);
const bigger = r.pizza1.pricePerM > r.pizza2.pricePerM ? r.pizza1.pricePerM : r.pizza2.pricePerM;
const smaller = r.pizza1.pricePerM < r.pizza2.pricePerM ? r.pizza1.pricePerM : r.pizza2.pricePerM;
r.ratio = (bigger / smaller) - 1;
r.diameterDiff = Math.abs(r.pizza1.diameter - r.pizza2.diameter);
} else {

View File

@@ -1,97 +0,0 @@
import { useRef, useState } from "react";
import { Modal, Button, Alert, Form } from "react-bootstrap";
type Props = {
isOpen: boolean;
onClose: () => void;
};
/** Modální dialog pro přenačtení menu z restaurací. */
export default function RefreshMenuModal({ isOpen, onClose }: Readonly<Props>) {
const refreshPassRef = useRef<HTMLInputElement>(null);
const refreshTypeRef = useRef<HTMLSelectElement>(null);
const [refreshLoading, setRefreshLoading] = useState(false);
const [refreshMessage, setRefreshMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
const handleRefresh = async () => {
const password = refreshPassRef.current?.value;
const type = refreshTypeRef.current?.value;
if (!password || !type) {
setRefreshMessage({ type: 'error', text: 'Zadejte heslo a typ refresh.' });
return;
}
setRefreshLoading(true);
setRefreshMessage(null);
try {
const res = await fetch(`/api/food/refresh?type=${type}&heslo=${encodeURIComponent(password)}`);
const data = await res.json();
if (res.ok) {
setRefreshMessage({ type: 'success', text: 'Uspesny fetch' });
if (refreshPassRef.current) {
refreshPassRef.current.value = '';
}
} else {
setRefreshMessage({ type: 'error', text: data.error || 'Chyba při obnovování jídelníčku.' });
}
} catch (error) {
console.error('Error refreshing menu:', error);
setRefreshMessage({ type: 'error', text: 'Chyba při obnovování jídelníčku.' });
} finally {
setRefreshLoading(false);
}
};
const handleClose = () => {
setRefreshMessage(null);
onClose();
};
return (
<Modal show={isOpen} onHide={handleClose}>
<Modal.Header closeButton>
<Modal.Title><h2>Přenačtení menu</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
<p>Ruční refresh dat z restaurací.</p>
{refreshMessage && (
<Alert variant={refreshMessage.type === 'success' ? 'success' : 'danger'}>
{refreshMessage.text}
</Alert>
)}
<Form.Group className="mb-3">
<Form.Label>Heslo</Form.Label>
<Form.Control
ref={refreshPassRef}
type="password"
placeholder="Zadejte heslo"
onKeyDown={e => e.stopPropagation()}
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Typ refreshe</Form.Label>
<Form.Select ref={refreshTypeRef} defaultValue="week">
<option value="week">Týden</option>
<option value="day">Den</option>
</Form.Select>
</Form.Group>
<Button
onClick={handleRefresh}
disabled={refreshLoading}
>
{refreshLoading ? 'Načítám...' : 'Obnovit menu'}
</Button>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={handleClose}>
Zavřít
</Button>
</Modal.Footer>
</Modal>
);
}

View File

@@ -1,238 +1,42 @@
import { useEffect, useRef, useState } from "react";
import { Modal, Button, Form } from "react-bootstrap"
import { useSettings, ThemePreference } from "../../context/settings";
import { NotificationSettings, UdalostEnum, getNotificationSettings, updateNotificationSettings } from "../../../../types";
import { useAuth } from "../../context/auth";
import { subscribeToPush, unsubscribeFromPush } from "../../hooks/usePushReminder";
import { useRef } from "react";
import { Modal, Button } from "react-bootstrap"
import { useSettings } from "../../context/settings";
type Props = {
isOpen: boolean,
onClose: () => void,
onSave: (bankAccountNumber?: string, bankAccountHolderName?: string, hideSoupsOption?: boolean, themePreference?: ThemePreference) => void,
onSave: (bankAccountNumber?: string, bankAccountHolderName?: string, hideSoupsOption?: boolean) => void,
}
/** Modální dialog pro uživatelská nastavení. */
export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Props>) {
const auth = useAuth();
export default function SettingsModal({ isOpen, onClose, onSave }: Props) {
const settings = useSettings();
const bankAccountRef = useRef<HTMLInputElement>(null);
const nameRef = useRef<HTMLInputElement>(null);
const hideSoupsRef = useRef<HTMLInputElement>(null);
const themeRef = useRef<HTMLSelectElement>(null);
const reminderTimeRef = useRef<HTMLInputElement>(null);
const ntfyTopicRef = useRef<HTMLInputElement>(null);
const discordWebhookRef = useRef<HTMLInputElement>(null);
const teamsWebhookRef = useRef<HTMLInputElement>(null);
const [notifSettings, setNotifSettings] = useState<NotificationSettings>({});
const [enabledEvents, setEnabledEvents] = useState<UdalostEnum[]>([]);
useEffect(() => {
if (isOpen && auth?.login) {
getNotificationSettings().then(response => {
if (response.data) {
setNotifSettings(response.data);
setEnabledEvents(response.data.enabledEvents ?? []);
}
}).catch(() => {});
}
}, [isOpen, auth?.login]);
const toggleEvent = (event: UdalostEnum) => {
setEnabledEvents(prev =>
prev.includes(event) ? prev.filter(e => e !== event) : [...prev, event]
);
};
const handleSave = async () => {
const newReminderTime = reminderTimeRef.current?.value || undefined;
const oldReminderTime = notifSettings.reminderTime;
// Uložení notifikačních nastavení na server
await updateNotificationSettings({
body: {
ntfyTopic: ntfyTopicRef.current?.value || undefined,
discordWebhookUrl: discordWebhookRef.current?.value || undefined,
teamsWebhookUrl: teamsWebhookRef.current?.value || undefined,
enabledEvents,
reminderTime: newReminderTime,
}
}).catch(() => {});
// Správa push subscription pro připomínky
if (newReminderTime && newReminderTime !== oldReminderTime) {
subscribeToPush(newReminderTime);
} else if (!newReminderTime && oldReminderTime) {
unsubscribeFromPush();
}
// Uložení ostatních nastavení (localStorage)
onSave(
bankAccountRef.current?.value,
nameRef.current?.value,
hideSoupsRef.current?.checked,
themeRef.current?.value as ThemePreference,
);
};
return (
<Modal show={isOpen} onHide={onClose} size="lg">
return <Modal show={isOpen} onHide={onClose} size="lg">
<Modal.Header closeButton>
<Modal.Title><h2>Nastavení</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
<h4>Vzhled</h4>
<Form.Group className="mb-3">
<Form.Label>Barevný motiv</Form.Label>
<Form.Select ref={themeRef} defaultValue={settings?.themePreference}>
<option value="system">Podle systému</option>
<option value="light">Světlý</option>
<option value="dark">Tmavý</option>
</Form.Select>
</Form.Group>
<hr />
<h4>Obecné</h4>
<Form.Group className="mb-3">
<Form.Check
id="hideSoupsCheckbox"
ref={hideSoupsRef}
type="checkbox"
label="Skrýt polévky"
defaultChecked={settings?.hideSoups}
title="V nabídkách nebudou zobrazovány polévky. Tato funkce je experimentální."
/>
<Form.Text className="text-muted">
Experimentální funkce - zejména u TechTower bývá problém polévky spolehlivě rozeznat.
</Form.Text>
</Form.Group>
<span title="V nabídkách nebudou zobrazovány polévky. Tato funkce je experimentální, a zejména u TechTower bývá často problém polévky spolehlivě rozeznat. V případě využití této funkce průběžně nahlašujte stále se zobrazující polévky." style={{ "cursor": "help" }}>
<input ref={hideSoupsRef} type="checkbox" defaultChecked={settings?.hideSoups} /> Skrýt polévky
</span>
<hr />
<h4>Notifikace</h4>
<p>
Nastavením notifikací budete dostávat upozornění o událostech (např. "Jdeme na oběd") přímo do vámi zvoleného komunikačního kanálu.
</p>
<Form.Group className="mb-3">
<Form.Label>Připomínka výběru oběda</Form.Label>
<Form.Control
ref={reminderTimeRef}
type="time"
defaultValue={notifSettings.reminderTime ?? ''}
key={notifSettings.reminderTime ?? 'reminder-empty'}
/>
<Form.Text className="text-muted">
V zadaný čas vám přijde push notifikace, pokud nemáte zvolenou možnost stravování. Nechte prázdné pro vypnutí.
</Form.Text>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>ntfy téma (topic)</Form.Label>
<Form.Control
ref={ntfyTopicRef}
type="text"
placeholder="moje-tema"
defaultValue={notifSettings.ntfyTopic}
key={notifSettings.ntfyTopic ?? 'ntfy-empty'}
onKeyDown={e => e.stopPropagation()}
/>
<Form.Text className="text-muted">
Téma pro ntfy push notifikace. Nechte prázdné pro vypnutí.
</Form.Text>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Discord webhook URL</Form.Label>
<Form.Control
ref={discordWebhookRef}
type="text"
placeholder="https://discord.com/api/webhooks/..."
defaultValue={notifSettings.discordWebhookUrl}
key={notifSettings.discordWebhookUrl ?? 'discord-empty'}
onKeyDown={e => e.stopPropagation()}
/>
<Form.Text className="text-muted">
URL webhooku Discord kanálu. Nechte prázdné pro vypnutí.
</Form.Text>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>MS Teams webhook URL</Form.Label>
<Form.Control
ref={teamsWebhookRef}
type="text"
placeholder="https://outlook.office.com/webhook/..."
defaultValue={notifSettings.teamsWebhookUrl}
key={notifSettings.teamsWebhookUrl ?? 'teams-empty'}
onKeyDown={e => e.stopPropagation()}
/>
<Form.Text className="text-muted">
URL webhooku MS Teams kanálu. Nechte prázdné pro vypnutí.
</Form.Text>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Události k odběru</Form.Label>
{Object.values(UdalostEnum).map(event => (
<Form.Check
key={event}
id={`notif-event-${event}`}
type="checkbox"
label={event}
checked={enabledEvents.includes(event)}
onChange={() => toggleEvent(event)}
/>
))}
<Form.Text className="text-muted">
Zvolte události, o kterých chcete být notifikováni. Notifikace jsou odesílány pouze uživatelům se stejnou zvolenou lokalitou.
</Form.Text>
</Form.Group>
<hr />
<h4>Bankovní účet</h4>
<p>
Nastavením čísla účtu umožníte automatické generování QR kódů pro úhradu za vámi provedené objednávky v rámci Pizza day.
</p>
<Form.Group className="mb-3">
<Form.Label>Číslo účtu</Form.Label>
<Form.Control
ref={bankAccountRef}
type="text"
placeholder="123456-1234567890/1234"
defaultValue={settings?.bankAccount}
onKeyDown={e => e.stopPropagation()}
/>
<Form.Text className="text-muted">
Pokud vaše číslo účtu neobsahuje předčíslí, je možné ho zcela vynechat.
</Form.Text>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Název příjemce</Form.Label>
<Form.Control
ref={nameRef}
type="text"
placeholder="Jan Novák"
defaultValue={settings?.holderName}
onKeyDown={e => e.stopPropagation()}
/>
<Form.Text className="text-muted">
Jméno majitele účtu pro QR platbu.
</Form.Text>
</Form.Group>
<p>Nastavením čísla účtu umožníte automatické generování QR kódů pro úhradu za vámi provedené objednávky v rámci Pizza day.<br />Pokud vaše číslo účtu neobsahuje předčíslí, je možné ho zcela vynechat.<br /><br />Číslo účtu není ukládáno na serveru, posílá se na něj pouze za účelem vygenerování QR kódů.</p>
Číslo účtu: <input className="mb-3" ref={bankAccountRef} type="text" placeholder="123456-1234567890/1234" defaultValue={settings?.bankAccount} onKeyDown={e => e.stopPropagation()} /> <br />
Název příjemce (jméno majitele účtu): <input ref={nameRef} type="text" placeholder="Jan Novák" defaultValue={settings?.holderName} onKeyDown={e => e.stopPropagation()} />
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={onClose}>
Storno
</Button>
<Button onClick={handleSave}>
<Button variant="primary" onClick={() => onSave(bankAccountRef.current?.value, nameRef.current?.value, hideSoupsRef.current?.checked)}>
Uložit
</Button>
</Modal.Footer>
</Modal>
);
}

View File

@@ -1,4 +1,5 @@
import React, { ReactNode, useContext, useEffect, useState } from "react"
import React, { ReactNode, useContext, useState } from "react"
import { useEffect } from "react"
import { useJwt } from "react-jwt";
import { deleteToken, getToken, storeToken } from "../Utils";
@@ -15,7 +16,7 @@ type ContextProps = {
const authContext = React.createContext<AuthContextProps | null>(null);
export function ProvideAuth(props: Readonly<ContextProps>) {
export function ProvideAuth(props: ContextProps) {
const auth = useProvideAuth();
return <authContext.Provider value={auth}>{props.children}</authContext.Provider>
}
@@ -27,8 +28,8 @@ export const useAuth = () => {
function useProvideAuth(): AuthContextProps {
const [loginName, setLoginName] = useState<string | undefined>();
const [trusted, setTrusted] = useState<boolean | undefined>();
const [token, setToken] = useState<string | undefined>(getToken());
const { decodedToken } = useJwt(token ?? '');
const [token, setToken] = useState<string | null>(getToken());
const { decodedToken } = useJwt(token || '');
useEffect(() => {
if (token && token.length > 0) {
@@ -51,11 +52,11 @@ function useProvideAuth(): AuthContextProps {
function logout() {
const trusted = (decodedToken as any).trusted;
const logoutUrl = (decodedToken as any).logoutUrl;
setToken(undefined);
setToken(null);
setLoginName(undefined);
setTrusted(undefined);
if (trusted && logoutUrl?.length) {
globalThis.location.replace(logoutUrl);
window.location.replace(logoutUrl);
}
}

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { getEasterEgg } from "../api/EasterEggApi";
import { AuthContextProps } from "./auth";
import { EasterEgg, getEasterEgg } from "../../../types";
import { EasterEgg } from "../types";
export const useEasterEgg = (auth?: AuthContextProps | null): [EasterEgg | undefined, boolean] => {
const [result, setResult] = useState<EasterEgg | undefined>();
@@ -10,7 +11,7 @@ export const useEasterEgg = (auth?: AuthContextProps | null): [EasterEgg | undef
async function fetchEasterEgg() {
if (auth?.login) {
setLoading(true);
const egg = (await getEasterEgg())?.data;
const egg = await getEasterEgg();
setResult(egg);
setLoading(false);
}

View File

@@ -1,21 +1,17 @@
import React, { ReactNode, useContext, useEffect, useState } from "react"
import React, { ReactNode, useContext, useState } from "react"
import { useEffect } from "react"
const BANK_ACCOUNT_NUMBER_KEY = 'bank_account_number';
const BANK_ACCOUNT_HOLDER_KEY = 'bank_account_holder_name';
const HIDE_SOUPS_KEY = 'hide_soups';
const THEME_KEY = 'theme_preference';
export type ThemePreference = 'system' | 'light' | 'dark';
export type SettingsContextProps = {
bankAccount?: string,
holderName?: string,
hideSoups?: boolean,
themePreference: ThemePreference,
setBankAccountNumber: (accountNumber?: string) => void,
setBankAccountHolderName: (holderName?: string) => void,
setHideSoupsOption: (hideSoups?: boolean) => void,
setThemePreference: (theme: ThemePreference) => void,
}
type ContextProps = {
@@ -24,7 +20,7 @@ type ContextProps = {
const settingsContext = React.createContext<SettingsContextProps | null>(null);
export function ProvideSettings(props: Readonly<ContextProps>) {
export function ProvideSettings(props: ContextProps) {
const settings = useProvideSettings();
return <settingsContext.Provider value={settings}>{props.children}</settingsContext.Provider>
}
@@ -33,23 +29,10 @@ export const useSettings = () => {
return useContext(settingsContext);
}
function getInitialTheme(): ThemePreference {
try {
const saved = localStorage.getItem(THEME_KEY) as ThemePreference | null;
if (saved && ['system', 'light', 'dark'].includes(saved)) {
return saved;
}
} catch (e) {
// localStorage nedostupný
}
return 'system';
}
function useProvideSettings(): SettingsContextProps {
const [bankAccount, setBankAccount] = useState<string | undefined>();
const [holderName, setHolderName] = useState<string | undefined>();
const [hideSoups, setHideSoups] = useState<boolean | undefined>();
const [themePreference, setTheme] = useState<ThemePreference>(getInitialTheme);
useEffect(() => {
const accountNumber = localStorage.getItem(BANK_ACCOUNT_NUMBER_KEY);
@@ -62,7 +45,7 @@ function useProvideSettings(): SettingsContextProps {
}
const hideSoups = localStorage.getItem(HIDE_SOUPS_KEY);
if (hideSoups !== null) {
setHideSoups(hideSoups === 'true');
setHideSoups(hideSoups === 'true' ? true : false);
}
}, [])
@@ -90,29 +73,6 @@ function useProvideSettings(): SettingsContextProps {
}
}, [hideSoups]);
useEffect(() => {
localStorage.setItem(THEME_KEY, themePreference);
}, [themePreference]);
useEffect(() => {
const applyTheme = (theme: 'light' | 'dark') => {
document.documentElement.setAttribute('data-bs-theme', theme);
};
if (themePreference === 'system') {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
applyTheme(mediaQuery.matches ? 'dark' : 'light');
const handler = (e: MediaQueryListEvent) => {
applyTheme(e.matches ? 'dark' : 'light');
};
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
} else {
applyTheme(themePreference);
}
}, [themePreference]);
function setBankAccountNumber(bankAccount?: string) {
setBankAccount(bankAccount);
}
@@ -125,18 +85,12 @@ function useProvideSettings(): SettingsContextProps {
setHideSoups(hideSoups);
}
function setThemePreference(theme: ThemePreference) {
setTheme(theme);
}
return {
bankAccount,
holderName,
hideSoups,
themePreference,
setBankAccountNumber,
setBankAccountHolderName,
setHideSoupsOption,
setThemePreference,
}
}

View File

@@ -7,8 +7,8 @@ if (process.env.NODE_ENV === 'development') {
socketUrl = `http://localhost:3001`;
socketPath = undefined;
} else {
socketUrl = `${globalThis.location.host}`;
socketPath = `${globalThis.location.pathname}socket.io`;
socketUrl = `${window.location.host}`;
socketPath = `${window.location.pathname}socket.io`;
}
export const socket = socketio.connect(socketUrl, { path: socketPath, transports: ["websocket"] });
@@ -18,3 +18,8 @@ export const SocketContext = React.createContext();
export const EVENT_CONNECT = 'connect';
export const EVENT_DISCONNECT = 'disconnect';
export const EVENT_MESSAGE = 'message';
// export const EVENT_CONFIG = 'config';
// export const EVENT_TOASTER = 'toaster';
// export const EVENT_VOTING = 'voting';
// export const EVENT_VOTE_CONFIG = 'voteSettings';
// export const EVENT_ADMIN = 'admin';

View File

@@ -1,41 +0,0 @@
import { LunchChoice, Restaurant } from "../../types";
export function getRestaurantName(restaurant: Restaurant) {
switch (restaurant) {
case Restaurant.SLADOVNICKA:
return "Sladovnická";
case Restaurant.TECHTOWER:
return "TechTower";
case Restaurant.ZASTAVKAUMICHALA:
return "Zastávka u Michala";
case Restaurant.SENKSERIKOVA:
return "Šenk Šeříková";
default:
return restaurant;
}
}
export function getLunchChoiceName(location: LunchChoice) {
switch (location) {
case Restaurant.SLADOVNICKA:
return "Sladovnická";
case Restaurant.TECHTOWER:
return "TechTower";
case Restaurant.ZASTAVKAUMICHALA:
return "Zastávka u Michala";
case Restaurant.SENKSERIKOVA:
return "Šenk Šeříková";
case LunchChoice.SPSE:
return "SPŠE";
case LunchChoice.PIZZA:
return "Pizza day";
case LunchChoice.OBJEDNAVAM:
return "Budu objednávat";
case LunchChoice.NEOBEDVAM:
return "Mám vlastní/neobědvám";
case LunchChoice.ROZHODUJI:
return "Rozhoduji se";
default:
return location;
}
}

View File

@@ -1,108 +0,0 @@
import { getToken } from '../Utils';
/** Převede base64url VAPID klíč na Uint8Array pro PushManager. */
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
/** Helper pro autorizované API volání na push endpointy. */
async function pushApiFetch(path: string, options: RequestInit = {}): Promise<Response> {
const token = getToken();
return fetch(`/api/notifications/push${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
});
}
/**
* Zaregistruje service worker, přihlásí se k push notifikacím
* a odešle subscription na server.
*/
export async function subscribeToPush(reminderTime: string): Promise<boolean> {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
console.warn('Push notifikace nejsou v tomto prohlížeči podporovány');
return false;
}
try {
// Registrace service workeru
const registration = await navigator.serviceWorker.register('/sw.js');
await navigator.serviceWorker.ready;
// Vyžádání oprávnění
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
console.warn('Push notifikace: oprávnění zamítnuto');
return false;
}
// Získání VAPID veřejného klíče ze serveru
const vapidResponse = await pushApiFetch('/vapidKey');
if (!vapidResponse.ok) {
console.error('Push notifikace: nepodařilo se získat VAPID klíč');
return false;
}
const { key: vapidPublicKey } = await vapidResponse.json();
// Přihlášení k push
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey) as BufferSource,
});
// Odeslání subscription na server
const response = await pushApiFetch('/subscribe', {
method: 'POST',
body: JSON.stringify({
subscription: subscription.toJSON(),
reminderTime,
}),
});
if (!response.ok) {
console.error('Push notifikace: nepodařilo se odeslat subscription na server');
return false;
}
console.log('Push notifikace: úspěšně přihlášeno k připomínkám v', reminderTime);
return true;
} catch (error) {
console.error('Push notifikace: chyba při registraci', error);
return false;
}
}
/**
* Odhlásí se z push notifikací a informuje server.
*/
export async function unsubscribeFromPush(): Promise<void> {
if (!('serviceWorker' in navigator)) {
return;
}
try {
const registration = await navigator.serviceWorker.getRegistration('/sw.js');
if (registration) {
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
await subscription.unsubscribe();
}
}
await pushApiFetch('/unsubscribe', { method: 'POST' });
console.log('Push notifikace: úspěšně odhlášeno z připomínek');
} catch (error) {
console.error('Push notifikace: chyba při odhlášení', error);
}
}

View File

@@ -7,32 +7,14 @@ body,
body {
margin: 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
line-height: 1.5;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
/* Smooth scrolling */
html {
scroll-behavior: smooth;
}
/* Better focus styles */
:focus-visible {
outline: 2px solid var(--luncher-primary);
outline-offset: 2px;
}
/* Selection color */
::selection {
background: var(--luncher-primary-light);
color: var(--luncher-primary);
}

View File

@@ -1,38 +1,33 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { SocketContext, socket } from './context/socket';
import { ProvideAuth } from './context/auth';
import { ToastContainer } from 'react-toastify';
import { ProvideSettings } from './context/settings';
import 'react-toastify/dist/ReactToastify.css';
import './index.css';
import AppRoutes from './AppRoutes';
import { BrowserRouter } from 'react-router';
import { client } from '../../types/gen/client.gen';
import { getToken } from './Utils';
import { toast } from 'react-toastify';
client.setConfig({
auth: () => getToken(),
baseUrl: '/api', // openapi-ts si to z nějakého důvodu neumí převzít z api.yml
});
// Interceptor na vyhození toasteru při chybě
client.interceptors.response.use(async response => {
// TODO opravit - login je zatím výjimka, voláme ho "naprázdno" abychom zjistili, zda nás nepřihlásily trusted headers
if (!response.ok && !response.url.includes("/login")) {
const json = await response.json();
toast.error(json.error, { theme: "colored" });
}
return response;
});
import Snowfall from 'react-snowfall';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<BrowserRouter>
<ProvideAuth>
<AppRoutes />
<ProvideSettings>
<SocketContext.Provider value={socket}>
<>
<Snowfall style={{
zIndex: 2,
position: 'fixed',
width: '100vw',
height: '100vh'}} />
<App />
</>
<ToastContainer />
</SocketContext.Provider>
</ProvideSettings>
</ProvideAuth>
</BrowserRouter>
</React.StrictMode>
);

View File

@@ -1,155 +0,0 @@
.stats-page {
display: flex;
flex-direction: column;
align-items: center;
padding: 32px 24px;
min-height: calc(100vh - 140px);
background: var(--luncher-bg);
h1 {
font-size: 2rem;
font-weight: 700;
color: var(--luncher-text);
margin-bottom: 24px;
}
.week-navigator {
display: flex;
align-items: center;
gap: 24px;
margin-bottom: 32px;
svg {
font-size: 1.5rem;
color: var(--luncher-text-secondary);
cursor: pointer;
padding: 12px;
border-radius: 50%;
background: var(--luncher-bg-card);
box-shadow: var(--luncher-shadow-sm);
transition: var(--luncher-transition);
&:hover {
color: var(--luncher-primary);
background: var(--luncher-primary-light);
transform: scale(1.05);
}
}
.date-range {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--luncher-text);
min-width: 280px;
text-align: center;
}
}
// Chart container
.recharts-wrapper {
background: var(--luncher-bg-card);
border-radius: var(--luncher-radius-lg);
box-shadow: var(--luncher-shadow);
padding: 24px;
border: 1px solid var(--luncher-border-light);
}
// Chart text styling
.recharts-cartesian-axis-tick-value {
fill: var(--luncher-text-secondary);
font-size: 0.85rem;
}
.recharts-legend-item-text {
color: var(--luncher-text) !important;
font-weight: 500;
}
.recharts-tooltip-wrapper {
.recharts-default-tooltip {
background: var(--luncher-bg-card) !important;
border: 1px solid var(--luncher-border) !important;
border-radius: var(--luncher-radius-sm) !important;
box-shadow: var(--luncher-shadow-lg) !important;
.recharts-tooltip-label {
color: var(--luncher-text) !important;
font-weight: 600;
margin-bottom: 8px;
}
.recharts-tooltip-item {
color: var(--luncher-text-secondary) !important;
}
}
}
.recharts-cartesian-grid-horizontal line,
.recharts-cartesian-grid-vertical line {
stroke: var(--luncher-border);
}
.voting-stats-section {
margin-top: 48px;
width: 100%;
max-width: 800px;
h2 {
font-size: 1.5rem;
font-weight: 700;
color: var(--luncher-text);
margin-bottom: 16px;
text-align: center;
}
}
.voting-stats-table {
width: 100%;
background: var(--luncher-bg-card);
border-radius: var(--luncher-radius-lg);
box-shadow: var(--luncher-shadow);
border: 1px solid var(--luncher-border-light);
overflow: hidden;
border-collapse: collapse;
th {
background: var(--luncher-primary);
color: #ffffff;
padding: 12px 20px;
text-align: left;
font-weight: 600;
font-size: 0.9rem;
&:last-child {
text-align: center;
width: 120px;
}
}
td {
padding: 12px 20px;
border-bottom: 1px solid var(--luncher-border-light);
color: var(--luncher-text);
font-size: 0.9rem;
&:last-child {
text-align: center;
font-weight: 600;
color: var(--luncher-primary);
}
}
tbody tr {
transition: var(--luncher-transition);
&:hover {
background: var(--luncher-bg-hover);
}
&:last-child td {
border-bottom: none;
}
}
}
}

View File

@@ -1,170 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import Footer from "../components/Footer";
import Header from "../components/Header";
import { useAuth } from "../context/auth";
import Login from "../Login";
import { formatDate, getFirstWorkDayOfWeek, getHumanDate, getLastWorkDayOfWeek } from "../Utils";
import { WeeklyStats, LunchChoice, VotingStats, FeatureRequest, getStats, getVotingStats } from "../../../types";
import Loader from "../components/Loader";
import { faChevronLeft, faChevronRight, faGear } from "@fortawesome/free-solid-svg-icons";
import { Legend, Line, LineChart, Tooltip, XAxis, YAxis } from "recharts";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { getLunchChoiceName } from "../enums";
import './StatsPage.scss';
const CHART_WIDTH = 1400;
const CHART_HEIGHT = 700;
const STROKE_WIDTH = 2.5;
const COLORS = [
'#ff1493',
'#1e90ff',
'#c5a700',
'#006400',
'#b300ff',
'#ff4500',
'#bc8f8f',
'#00ff00',
'#7c7c7c',
]
export default function StatsPage() {
const auth = useAuth();
const [dateRange, setDateRange] = useState<Date[]>();
const [data, setData] = useState<WeeklyStats>();
const [votingStats, setVotingStats] = useState<VotingStats>();
// Prvotní nastavení aktuálního týdne
useEffect(() => {
const today = new Date();
setDateRange([getFirstWorkDayOfWeek(today), getLastWorkDayOfWeek(today)]);
}, []);
// Přenačtení pro zvolený týden
useEffect(() => {
if (dateRange) {
getStats({ query: { startDate: formatDate(dateRange[0]), endDate: formatDate(dateRange[1]) } }).then(response => {
setData(response.data);
});
}
}, [dateRange]);
// Načtení statistik hlasování
useEffect(() => {
getVotingStats().then(response => {
setVotingStats(response.data);
});
}, []);
const sortedVotingStats = useMemo(() => {
if (!votingStats) return [];
return Object.entries(votingStats)
.sort((a, b) => (b[1] as number) - (a[1] as number));
}, [votingStats]);
const renderLine = (location: LunchChoice) => {
const index = Object.values(LunchChoice).indexOf(location);
return <Line key={location} name={getLunchChoiceName(location)} type="monotone" dataKey={data => data.locations[location] ?? 0} stroke={COLORS[index]} strokeWidth={STROKE_WIDTH} />
}
const handlePreviousWeek = () => {
if (dateRange) {
const previousStartDate = new Date(dateRange[0]);
previousStartDate.setDate(previousStartDate.getDate() - 7);
const previousEndDate = new Date(previousStartDate);
previousEndDate.setDate(previousEndDate.getDate() + 4);
setDateRange([previousStartDate, previousEndDate]);
}
}
const handleNextWeek = () => {
if (dateRange) {
const nextStartDate = new Date(dateRange[0]);
nextStartDate.setDate(nextStartDate.getDate() + 7);
const nextEndDate = new Date(nextStartDate);
nextEndDate.setDate(nextEndDate.getDate() + 4);
setDateRange([nextStartDate, nextEndDate]);
}
}
const isCurrentOrFutureWeek = useMemo(() => {
if (!dateRange) return true;
const currentWeekEnd = getLastWorkDayOfWeek(new Date());
currentWeekEnd.setHours(23, 59, 59, 999);
return dateRange[1] >= currentWeekEnd;
}, [dateRange]);
const handleKeyDown = useCallback((e: any) => {
if (e.keyCode === 37) {
handlePreviousWeek();
} else if (e.keyCode === 39 && !isCurrentOrFutureWeek) {
handleNextWeek()
}
}, [dateRange, isCurrentOrFutureWeek]);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
}
}, [handleKeyDown]);
if (!auth?.login) {
return <Login />;
}
if (!dateRange) {
return <Loader
icon={faGear}
description={'Načítám data...'}
animation={'fa-bounce'}
/>
}
return (
<>
<Header />
<div className="stats-page">
<h1>Statistiky</h1>
<div className="week-navigator">
<span title="Předchozí týden">
<FontAwesomeIcon icon={faChevronLeft} style={{ cursor: "pointer" }} onClick={handlePreviousWeek} />
</span>
<h2 className="date-range">{getHumanDate(dateRange[0])} - {getHumanDate(dateRange[1])}</h2>
<span title="Následující týden">
<FontAwesomeIcon icon={faChevronRight} style={{ cursor: "pointer", visibility: isCurrentOrFutureWeek ? "hidden" : "visible" }} onClick={handleNextWeek} />
</span>
</div>
<LineChart width={CHART_WIDTH} height={CHART_HEIGHT} data={data}>
{Object.values(LunchChoice).map(location => renderLine(location))}
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Legend />
</LineChart>
{sortedVotingStats.length > 0 && (
<div className="voting-stats-section">
<h2>Hlasování o funkcích</h2>
<table className="voting-stats-table">
<thead>
<tr>
<th>Funkce</th>
<th>Počet hlasů</th>
</tr>
</thead>
<tbody>
{sortedVotingStats.map(([feature, count]) => (
<tr key={feature}>
<td>{FeatureRequest[feature as keyof typeof FeatureRequest] ?? feature}</td>
<td>{count as number}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<Footer />
</>
);
}

View File

@@ -1,5 +1,6 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": [
"dom",
"dom.iterable",
@@ -15,12 +16,10 @@
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"moduleResolution": "bundler",
"module": "ESNext",
"target": "ESNext",
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"allowImportingTsExtensions": true,
"noEmit": true,
"jsx": "react-jsx"
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,5 @@
#!/bin/bash
# Spustí server a klienta v samostatných panelech uvnitř stejného tmux okna.
# Pokud už daná tmux session existuje, pouze se k ní připojí.
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
SESSION="luncher"
if ! tmux has-session -t $SESSION 2>/dev/null; then
cd types && yarn openapi-ts && cd ..
tmux new-session -d -s $SESSION
tmux send-keys -t $SESSION:0 "cd $SCRIPT_DIR" Enter
tmux split-window -v
tmux send-keys -t $SESSION:0.0 "cd server && export NODE_ENV=development && yarn startReload" Enter
tmux send-keys -t $SESSION:0.1 "cd client && yarn start" Enter
fi
tmux attach-session -t $SESSION
export NODE_ENV=development
yarn install
cd server && yarn start &
cd client && yarn start &
wait

View File

@@ -38,13 +38,3 @@
# Název důvěryhodné hlavičky obsahující login uživatele. Výchozí hodnota je 'remote-user'.
# HTTP_REMOTE_USER_HEADER_NAME=remote-user
# VAPID klíče pro Web Push notifikace (připomínka výběru oběda).
# Vygenerovat pomocí: npx web-push generate-vapid-keys
# VAPID_PUBLIC_KEY=
# VAPID_PRIVATE_KEY=
# VAPID_SUBJECT=mailto:admin@example.com
# Heslo pro bypass rate limitu na endpointu /api/food/refresh (pro skripty/admin).
# Bez hesla může refresh volat každý přihlášený uživatel (podléhá rate limitu).
# REFRESH_BYPASS_PASSWORD=

6
server/.gitignore vendored
View File

@@ -1,7 +1,7 @@
/data
/node_modules
/dist
/resources/easterEggs
/src/gen
data.json
.env.production
.env.development
.easter-eggs.json
/resources/easterEggs

View File

@@ -1,4 +0,0 @@
[
"Zimní atmosféra",
"Skrytí podniku U Motlíků"
]

View File

@@ -1,3 +0,0 @@
[
"Přidání restaurace Zastávka u Michala"
]

View File

@@ -1,3 +0,0 @@
[
"Přidání restaurace Pivovarský šenk Šeříková"
]

View File

@@ -1,3 +0,0 @@
[
"Možnost výběru podniku/jídla kliknutím"
]

View File

@@ -1,3 +0,0 @@
[
"Stránka se statistikami nejoblíbenějších voleb"
]

View File

@@ -1,3 +0,0 @@
[
"Zobrazení počtu osob u každé volby"
]

View File

@@ -1,3 +0,0 @@
[
"Migrace na generované OpenApi"
]

View File

@@ -1,3 +0,0 @@
[
"Odebrání zimní atmosféry"
]

View File

@@ -1,3 +0,0 @@
[
"Možnost ručního přenačtení menu"
]

View File

@@ -1,3 +0,0 @@
[
"Parsování a zobrazení alergenů"
]

View File

@@ -1,4 +0,0 @@
[
"Oddělení přenačtení menu do vlastního dialogu",
"Podzimní atmosféra"
]

View File

@@ -1,3 +0,0 @@
[
"Možnost převzetí poznámky ostatních uživatelů"
]

View File

@@ -1,3 +0,0 @@
[
"Zimní atmosféra"
]

View File

@@ -1,3 +0,0 @@
[
"Možnost označit se jako objednávající u volby \"Budu objednávat\""
]

View File

@@ -1,3 +0,0 @@
[
"Podpora dark mode"
]

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)"
]

View File

@@ -1,3 +0,0 @@
[
"Zobrazení sekce Pizza day pouze při volbě \"Pizza day\""
]

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)"
]

View File

@@ -1,3 +0,0 @@
[
"Podpora push notifikací pro připomenutí výběru oběda (v nastavení)"
]

View File

@@ -1,3 +0,0 @@
[
"Oprava detekce zastaralého menu"
]

View File

@@ -1,3 +0,0 @@
[
"Automatické zobrazení dialogu s dosud nezobrazenými novinkami"
]

View File

@@ -1,3 +0,0 @@
[
"Automatický výběr výchozího času preferovaného odchodu"
]

View File

@@ -11,31 +11,29 @@
"test": "jest"
},
"devDependencies": {
"@babel/core": "^7.28.5",
"@babel/preset-env": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@types/express": "^5.0.5",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.10.0",
"@babel/core": "^7.23.0",
"@babel/preset-env": "^7.22.20",
"@babel/preset-typescript": "^7.23.0",
"@types/express": "^4.17.17",
"@types/jest": "^29.5.14",
"@types/jsonwebtoken": "^9.0.6",
"@types/node": "^20.11.20",
"@types/request-promise": "^4.1.48",
"@types/web-push": "^3.6.4",
"babel-jest": "^30.2.0",
"jest": "^30.2.0",
"nodemon": "^3.1.10",
"babel-jest": "^29.7.0",
"jest": "^29.7.0",
"nodemon": "^3.1.0",
"ts-node": "^10.9.1",
"typescript": "^5.9.3"
"typescript": "^5.0.2"
},
"dependencies": {
"axios": "^1.13.2",
"cheerio": "^1.1.2",
"axios": "^1.4.0",
"cheerio": "^1.0.0-rc.12",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"dotenv": "^16.4.5",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.0",
"redis": "^5.9.0",
"redis": "^4.6.7",
"simple-json-db": "^2.0.0",
"socket.io": "^4.6.1",
"web-push": "^3.6.7"
"socket.io": "^4.6.1"
}
}

View File

@@ -49,16 +49,16 @@ export async function downloadPizzy(mock: boolean): Promise<Pizza[]> {
const $ = load(html);
const links = $('.vypisproduktu > div > h4 > a');
const urls = [];
for (const element of links) {
if (element.name === 'a' && element.attribs?.href) {
const pizzaUrl = element.attribs?.href;
for (let i = 0; i < links.length; i++) {
if (links[i].name === 'a' && links[i].attribs?.href) {
const pizzaUrl = links[i].attribs?.href;
urls.push(buildPizzaUrl(pizzaUrl));
}
}
// Scrapneme jednotlivé pizzy
const result: Pizza[] = [];
for (const element of urls) {
const pizzaUrl = element;
for (let i = 0; i < urls.length; i++) {
const pizzaUrl = urls[i];
const pizzaHtml = await axios.get(pizzaUrl).then(res => res.data);
// Název
const name = $('.produkt > h2', pizzaHtml).first().text()

View File

@@ -1,27 +1,20 @@
import express from "express";
import bodyParser from "body-parser";
import cors from 'cors';
import { getData, getDateForWeekIndex, getToday } from "./service";
import { getData, getDateForWeekIndex } from "./service";
import dotenv from 'dotenv';
import path from 'path';
import { getQr } from "./qr";
import { generateToken, getLogin, verify } from "./auth";
import { getIsWeekend, InsufficientPermissions, PizzaDayConflictError, parseToken } from "./utils";
import { getPendingQrs } from "./pizza";
import { generateToken, verify } from "./auth";
import { InsufficientPermissions } from "./utils";
import { initWebsocket } from "./websocket";
import { startReminderScheduler } from "./pushReminder";
import pizzaDayRoutes from "./routes/pizzaDayRoutes";
import foodRoutes, { refreshMetoda } from "./routes/foodRoutes";
import foodRoutes from "./routes/foodRoutes";
import votingRoutes from "./routes/votingRoutes";
import easterEggRoutes from "./routes/easterEggRoutes";
import statsRoutes from "./routes/statsRoutes";
import notificationRoutes from "./routes/notificationRoutes";
import qrRoutes from "./routes/qrRoutes";
import devRoutes from "./routes/devRoutes";
import changelogRoutes from "./routes/changelogRoutes";
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
const ENVIRONMENT = process.env.NODE_ENV || 'production';
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) {
@@ -41,7 +34,7 @@ app.use(cors({
// 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_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 (!process.env.HTTP_REMOTE_TRUSTED_IPS) {
throw new Error('Je zapnutý login z hlaviček, ale není nastaven rozsah adres ze kterých hlavička může přijít.');
@@ -60,10 +53,6 @@ app.get("/api/whoami", (req, res) => {
if (!HTTP_REMOTE_USER_ENABLED) {
res.status(403).json({ error: 'Není zapnuté přihlášení z hlaviček' });
}
if (process.env.ENABLE_HEADERS_LOGGING === 'yes') {
delete req.headers["cookie"]
console.log(req.headers)
}
res.send(req.header(HTTP_REMOTE_USER_HEADER_NAME));
})
@@ -71,11 +60,11 @@ app.post("/api/login", (req, res) => {
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 remoteName = req.header('remote-name');
if (remoteUser && remoteUser.length > 0) {
res.status(200).json(generateToken(Buffer.from(remoteUser, 'latin1').toString(), true));
const remoteName = req.header('remote-name');
if (remoteUser && remoteUser.length > 0 && remoteName && remoteName.length > 0) {
res.status(200).json(generateToken(Buffer.from(remoteName, 'latin1').toString(), true));
} else {
throw Error("Je zapnuto přihlášení přes hlavičky, ale nepřišla hlavička nebo ??");
throw Error("Tohle nema nastat nekdo neco dela spatne.");
}
} else {
// Klasická autentizace loginem
@@ -89,6 +78,7 @@ app.post("/api/login", (req, res) => {
// TODO dočasné řešení - QR se zobrazuje přes <img>, nemáme sem jak dostat token
app.get("/api/qr", (req, res) => {
// const login = getLogin(parseToken(req));
if (!req.query?.login) {
throw Error("Nebyl předán login");
}
@@ -102,23 +92,15 @@ app.get("/api/qr", (req, res) => {
// ----------------------------------------------------
// Přeskočení auth pro refresh dat xd
app.use("/api/food/refresh", refreshMetoda);
/** Middleware ověřující JWT token */
app.use("/api/", (req, res, next) => {
if (HTTP_REMOTE_USER_ENABLED) {
// Autentizace pomocí trusted headers
const remoteUser = req.header(HTTP_REMOTE_USER_HEADER_NAME);
if (process.env.ENABLE_HEADERS_LOGGING === 'yes') {
delete req.headers["cookie"]
console.log(req.headers)
}
if (remoteUser && remoteUser.length > 0) {
const remoteName = Buffer.from(remoteUser, 'latin1').toString();
if (ENVIRONMENT !== "production") {
console.log("Tvuj username: %s.", remoteName);
}
const userHeader = req.header(HTTP_REMOTE_USER_HEADER_NAME);
const nameHeader = req.header('remote-name');
const emailHeader = req.header('remote-email');
if (userHeader !== undefined && nameHeader !== undefined) {
const remoteName = Buffer.from(nameHeader, 'latin1').toString();
console.log("Tvuj username, name a email: %s, %s, %s.", userHeader, remoteName, emailHeader);
}
}
if (!req.headers.authorization) {
@@ -139,22 +121,8 @@ app.get("/api/data", async (req, res) => {
if (!isNaN(index)) {
date = getDateForWeekIndex(parseInt(req.query.dayIndex));
}
} else if (getIsWeekend(getToday())) {
// Na víkendu zobrazíme pátek místo hlášky "Užívejte víkend"
date = getDateForWeekIndex(4);
}
const data = await getData(date);
// Připojíme nevyřízené QR kódy pro přihlášeného uživatele
try {
const login = getLogin(parseToken(req));
const pendingQrs = await getPendingQrs(login);
if (pendingQrs.length > 0) {
data.pendingQrs = pendingQrs;
}
} catch {
// Token nemusí být validní, ignorujeme
}
res.status(200).json(data);
res.status(200).json(await getData(date));
});
// Ostatní routes
@@ -162,33 +130,23 @@ app.use("/api/pizzaDay", pizzaDayRoutes);
app.use("/api/food", foodRoutes);
app.use("/api/voting", votingRoutes);
app.use("/api/easterEggs", easterEggRoutes);
app.use("/api/stats", statsRoutes);
app.use("/api/notifications", notificationRoutes);
app.use("/api/qr", qrRoutes);
app.use("/api/dev", devRoutes);
app.use("/api/changelogs", changelogRoutes);
app.use('/stats', express.static('public'));
app.use(express.static('public'));
app.use(express.static('public'))
// Middleware pro zpracování chyb
app.use((err: any, req: any, res: any, next: any) => {
if (err instanceof InsufficientPermissions) {
res.status(403).send({ error: err.message })
} else if (err instanceof PizzaDayConflictError) {
res.status(409).send({ error: err.message })
} else {
res.status(500).send({ error: err.message })
}
next();
});
const PORT = process.env.PORT ?? 3001;
const HOST = process.env.HOST ?? '0.0.0.0';
const PORT = process.env.PORT || 3001;
const HOST = process.env.HOST || '0.0.0.0';
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í

View File

@@ -1,36 +1,82 @@
import { WeeklyStats, LunchChoice } from "../../types/gen/types.gen";
// Mockovací data pro podporované podniky, na jeden týden
const MOCK_DATA = {
'sladovnicka': [
[
{
amount: "0,25l",
name: "Česnečka s uzeným masem a krutony",
name: "Kulajda",
price: "35\xA0Kč",
isSoup: true,
allergens: [1, 3, 7, 9]
},
{
amount: "250g",
name: "Přírodní roštěná s jasmínovou rýží",
name: "Kuřecí křidélka s vařeným bramborem",
price: "135\xA0Kč",
isSoup: false,
allergens: [1, 9, 10]
},
{
amount: "150g",
name: "Noky s kuřecím masem a sýrovou omáčkou",
name: "Hovězí hamburger s BBQ omáčkou a hranolky",
price: "145\xA0Kč",
isSoup: false,
allergens: [1, 3, 7]
},
{
amount: "150g",
name: "Kuřecí stehno pečené na Moravance s feferony, bramborový knedlík",
name: "Frankfurtská hovězí pečeně s jasmínovou rýží",
price: "135\xA0Kč",
isSoup: false,
}
],
[
{
amount: "0,25l",
name: "Hovězí vývar s kapáním",
price: "35\xA0Kč",
isSoup: true,
},
{
amount: "200g",
name: "Smažený karbanátek s bramborovou kaší",
price: "135\xA0Kč",
isSoup: false,
},
{
amount: "150g",
name: "Vepřová plec na smetaně s kynutým knedlíkem",
price: "135\xA0Kč",
isSoup: false,
},
{
amount: "150g",
name: "Trhané kachní maso se zeleninovým kuskusem",
price: "135\xA0Kč",
isSoup: false,
}
],
[
{
amount: "0,25l",
name: "Zelná polévka s klobásou",
price: "35\xA0Kč",
isSoup: true,
},
{
amount: "150g",
name: "Hovězí na česneku s bramborovým knedlíkem",
price: "135\xA0Kč",
isSoup: false,
},
{
amount: "250g",
name: "Přírodní holandský řízek s bramborovou kaší, rajčatový salát",
price: "135\xA0Kč",
isSoup: false,
},
{
amount: "350g",
name: "Bagel s vinnou klobásou, cibulový konfit, kysané zelí, slanina a hořčicová mayo, hranolky, curry omáčka",
price: "135\xA0Kč",
isSoup: false,
allergens: [1, 3, 7, 9]
}
],
[
@@ -39,118 +85,50 @@ const MOCK_DATA = {
name: "Kuřecí vývar s nudlemi",
price: "35\xA0Kč",
isSoup: true,
allergens: [1, 3, 7, 9]
},
{
amount: "200g",
name: "Hovězí maso v rajské omáčce s kynutým knedlíkem",
price: "135\xA0Kč",
isSoup: false,
allergens: [1, 3, 7]
},
{
amount: "150g",
name: "Krůtí roláda se sušenými rajčaty , mozzarellou a bramborovou kaší",
price: "135\xA0Kč",
isSoup: false,
allergens: [1, 7]
},
{
amount: "150g",
name: "Telecí játra na grilu, restované brambory, tatarská omáčka , polníčkový salát",
price: "135\xA0Kč",
isSoup: false,
allergens: [3, 7]
}
],
[
{
amount: "0,25l",
name: "Zeleninová polévka",
price: "35\xA0Kč",
isSoup: true,
allergens: [3, 9]
},
{
amount: "150g",
name: "Smažené rybí filé s vařeným bramborem, tatarka",
price: "135\xA0Kč",
isSoup: false,
allergens: [3, 7]
},
{
amount: "250g",
name: "Vepřové výpečky se špenátem, bramborový knedlík 1,3,7",
price: "135\xA0Kč",
isSoup: false,
allergens: [1, 3, 7]
},
{
amount: "350g",
name: "Kuřecí řízek \" Ondráš \" , kysané zelí",
price: "135\xA0Kč",
isSoup: false,
allergens: [3, 7]
}
],
[
{
amount: "0,25l",
name: "Hovězí vývar s játrovými knedlíčky",
price: "35\xA0Kč",
isSoup: true,
allergens: [1, 3]
},
{
amount: "150g",
name: "Merguez klobáska, bílé fazole na kyselo, sázené vejce a vídeňská cibulka",
name: "Kovbojské fazole s klobásou a chlebem",
price: "125\xA0Kč",
isSoup: false,
allergens: [1, 3]
},
{
amount: "150g",
name: "Kuřecí steak s liškovou omáčkou a opečený brambor",
name: "Kuřecí rarášci s vařeným bramborem",
price: "135\xA0Kč",
isSoup: false,
allergens: [1, 7]
},
{
amount: "150g",
name: "Kaťák vepřové kostky s feferonou, cibulí, kečupem ,česnekem, smažené krokety",
name: "Hovězí pečeně na slanině s jasmínovou rýží",
price: "135\xA0Kč",
isSoup: false,
allergens: [3, 7]
}
],
[
{
amount: "0,25l",
name: "Čočková polévka",
name: "Dršťková polévka",
price: "35\xA0Kč",
isSoup: true,
allergens: [9, 12]
},
{
amount: "150g",
name: "Ovocné knedlíky s tvarohem",
name: "Tortilla s kuřecím masem, čedarem, zeleninou a papričkami jalapeňos",
price: "135\xA0Kč",
isSoup: false,
allergens: [1, 3, 7]
},
{
amount: "150g",
name: "Smažený vepřový řízek s bramborovým salátem",
name: "Segedínský guláš s kynutým knedlíkem",
price: "135\xA0Kč",
isSoup: false,
allergens: [1, 3, 7, 9, 10]
},
{
amount: "150g",
name: "Znojemský hovězí guláš s jasmínovou rýží",
name: "Filet z krůtích prsou, omáčka z modrého sýra, pečené brambory",
price: "145\xA0Kč",
isSoup: false,
allergens: [1, 9]
}
]
],
@@ -290,185 +268,161 @@ const MOCK_DATA = {
[
{
amount: "-",
name: "Batátový krém s chilli a kokosovým mlékem",
name: "Uzený vývar s kapustou",
price: "40\xA0Kč",
isSoup: true,
allergens: [1, 7]
},
{
amount: "-",
name: "Kuřecí stehno na paprice, knedlík",
name: "Čočka na kyselo, opečená klobása, okurka, chléb",
price: "130\xA0Kč",
isSoup: false,
allergens: [1, 3, 7]
},
{
amount: "-",
name: "Těstoviny se sušenými rajčaty a cuketou, parmezán",
name: "Smažená brokolice, brambory, tatarská omáčka",
price: "na\xA0váhu",
isSoup: false,
allergens: [1, 7]
},
{
amount: "-",
name: "Quesadilla s trham vepřovým masem, salát coleslaw, hranolky",
name: "Uzený vepřový bůček, bramborové pyré",
price: "na\xA0váhu",
isSoup: false,
allergens: [1, 7]
},
{
amount: "-",
name: "Smažený kuřecí řízek v sezamové strouhance, vařené brambory, wasabi majonéza",
name: "Kuřecí medailonky v sýrové omáčce, hranolky",
price: "na\xA0váhu",
isSoup: false,
allergens: [1, 3, 7]
}
],
[
{
amount: "-",
name: "Ovarová",
name: "Slepičí s nudlemi",
price: "40\xA0Kč",
isSoup: true,
allergens: [1]
},
{
amount: "-",
name: "Zapečené těstoviny s uzeným masem, okurka",
name: "Zvěřinový guláš, knedlík",
price: "130\xA0Kč",
isSoup: false,
allergens: [1, 3, 7]
},
{
amount: "-",
name: "Cheddarové kuličky s jalapeños, máslové brambory, tatarská omáčka",
name: "Čínské nudle se zeleninou a vejcem",
price: "na\xA0váhu",
isSoup: false,
allergens: [1, 3, 7]
},
{
amount: "-",
name: "Steak z krkovice s miso omáčkou, jasmínová rýže",
name: "Jitrnice/jelito, brambory, zelný salát s křenem, hořčice",
price: "na\xA0váhu",
isSoup: false,
allergens: [1, 6, 11]
},
{
amount: "-",
name: "Kuřecí supreme s bramborovo-mrkvovým pyré, restovaná cuketa",
name: "Vídeňská roštěná se smaženou cibulkou, jasmínová rýže",
price: "na\xA0váhu",
isSoup: false,
allergens: [7]
}
],
[
{
amount: "-",
name: "Hovězí s hráškem a rýží",
name: "Dýňový krém se smetanou",
price: "40\xA0Kč",
isSoup: true,
allergens: [9]
},
{
amount: "-",
name: "Rizoto s kuřecím masem a zeleninou, okurka, sýr",
name: "Kuřecí směs se zeleninou, rýže",
price: "130\xA0Kč",
isSoup: false,
allergens: [7, 9]
},
{
amount: "-",
name: "Smažené rýžové nudle Pad thai s arašídy, zeleninou a vejcem",
price: "na\xA0váhu",
isSoup: false,
allergens: [1, 3, 6, 8, 11]
},
{
amount: "-",
name: "Vykoštěné vepřové koleno s křenem a hořčicí, chléb",
name: "Tvarohové knedlíky s meruňkami, strouhaný tvaroh, máslo, cukr",
price: "na\xA0váhu",
isSoup: false,
},
{
amount: "-",
name: "Gordon bleu, hranolky, pikantní dip",
name: "Ovar, křen, hořčice, pečivo",
price: "na\xA0váhu",
isSoup: false,
},
{
amount: "-",
name: "Telecí holandský řízek s uzeným sýrem, bramborové pyré",
price: "na\xA0váhu",
isSoup: false,
allergens: [1, 3, 7]
}
],
[
{
amount: "-",
name: "Dýňová",
name: "Zeleninová s jáhly",
price: "40\xA0Kč",
isSoup: true,
allergens: [1, 7]
},
{
amount: "-",
name: "Uzená plec, křenová omáčka, knedlík",
name: "Rizoto s vepřovým masem, okurka",
price: "130\xA0Kč",
isSoup: false,
allergens: [1, 3, 7]
},
{
amount: "-",
name: "Palačinky s marmeládou přelité čokoládou, sypané cukrem",
name: "Tortellini s parmezánovou omáčkou",
price: "na\xA0váhu",
isSoup: false,
allergens: [1, 3, 7]
},
{
amount: "-",
name: "Smažený holandský řízek s bramborovou kaší a nakládanou zeleninou",
name: "Pečený prejt, brambory, zelný salát",
price: "na\xA0váhu",
isSoup: false,
allergens: [1, 3, 7]
},
{
amount: "-",
name: "Kuřecí jatýrka na smetaně s čerstvou majoránkou, jasmínová rýže",
name: "Chobotnice na grilu, grilovaná zelenina, bylinková bageta",
price: "na\xA0váhu",
isSoup: false,
allergens: [7]
}
],
[
{
amount: "-",
name: "Hovězí vývar s játrovými knedlíčky",
name: "Fazolová s uzeninou",
price: "40\xA0Kč",
isSoup: true,
allergens: [1, 3, 7, 9]
},
{
amount: "-",
name: "Kuřecí Kung-pao, jasmínová rýže",
name: "Krůtí perkelt, těstoviny",
price: "130\xA0Kč",
isSoup: false,
allergens: [1, 3, 5, 6]
},
{
amount: "-",
name: "Sýrové tortelliny s pažitkovou omáčkou",
name: "Grilovaný hermelín, bulgurový salát se zeleninou",
price: "na\xA0váhu",
isSoup: false,
allergens: [1, 3, 7]
},
{
amount: "-",
name: "Teriyaki losos burger s frisée salátem a citrusovou majonézou, bramborové lupínky",
name: "Zabijačkový guláš, karlovarský knedlík",
price: "na\xA0váhu",
isSoup: false,
allergens: [1, 3, 6, 7, 11]
},
{
amount: "-",
name: "Vepřové výpečky s červeným zelím, bramborové knedlíky se smaženou cibulkou",
name: "Vepřový plátek na žampionech, jasmínová rýže",
price: "na\xA0váhu",
isSoup: false,
allergens: [1, 3, 7]
}
]
],
@@ -561,91 +515,7 @@ const MOCK_DATA = {
isSoup: false,
}
],
],
'senkSerikova': [
[
{
amount: "-",
name: "Drůbeží vývar s masem a nudlemi",
price: "45\xA0Kč",
isSoup: true,
},
{
amount: "-",
name: "Vepřová pečeně se zelím a houskovým knedlíkem",
price: "155\xA0Kč",
isSoup: false,
},
{
amount: "-",
name: "Špagety s kuřecím masem, špenátem a smetanou",
price: "145\xA0Kč",
isSoup: false,
},
{
amount: "-",
name: "Medailonky z vepřové panenky s fazolkami se slaninou, šťouchané brambory",
price: "185\xA0Kč",
isSoup: false,
}
],
[
{
amount: "-",
name: "Mrkvová polévka se zázvorem a kokosovým mlékem",
price: "45\xA0Kč",
isSoup: true,
},
{
amount: "-",
name: "Hovězí po Burgundsku, bramborová kaše",
price: "155\xA0Kč",
isSoup: false,
}
],
[
{
amount: "-",
name: "Hovězí vývar s játrovými knedlíčky",
price: "45\xA0Kč",
isSoup: true,
},
{
amount: "-",
name: "Kuřecí plátky na sušených rajčatech, bylinkách a česneku, bramborová kaše",
price: "155\xA0Kč",
isSoup: false,
}
],
[
{
amount: "-",
name: "Kuřecí vývar s rýží",
price: "45\xA0Kč",
isSoup: true,
},
{
amount: "-",
name: "Rajská s plněnou paprikou, knedlík",
price: "170\xA0Kč",
isSoup: false,
}
],
[
{
amount: "-",
name: "Mexická fazolová polévka",
price: "45\xA0Kč",
isSoup: true,
},
{
amount: "-",
name: "Ragú z trhané kachny, onsen vejce, soté ze špenátu a ředkvičky, bramborové pyré, lanýžová sůl, zelený olej",
price: "189\xA0Kč",
isSoup: false,
}
],
],
]
}
// Mockovací data pro Pizza day
@@ -1402,7 +1272,7 @@ const MOCK_PIZZA_LIST = [
* Funkce vrací mock datu ve formátu YYYY-MM-DD
*/
export const getTodayMock = (): Date => {
return new Date('2025-01-10'); // pátek
return new Date('2025-01-08'); // pátek
}
export const getMenuSladovnickaMock = () => {
@@ -1421,35 +1291,6 @@ export const getMenuZastavkaUmichalaMock = () => {
return MOCK_DATA['zastavkaUmichala'];
}
export const getMenuSenkSerikovaMock = () => {
return MOCK_DATA['senkSerikova'];
}
export const getPizzaListMock = () => {
return MOCK_PIZZA_LIST;
}
export const getStatsMock = (): WeeklyStats => {
return [
{
date: '24.02.',
locations: { ...Object.keys(LunchChoice).reduce((prev, cur) => ({ ...prev, [cur]: Math.floor(Math.random() * 10) }), {}) }
},
{
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) }), {}) }
}
];
}

View File

@@ -1,59 +1,60 @@
import axios from 'axios';
/** Notifikace pro gotify*/
import { ClientData, GotifyServer, NotififaceInput, NotifikaceData } from '../../types';
import axios, { AxiosError } from 'axios';
import dotenv from 'dotenv';
import path from 'path';
import { getClientData, getToday } from "./service";
import { getUsersByLocation, getHumanTime } from "./utils";
import { NotifikaceData, NotifikaceInput, NotificationSettings } from '../../types';
import { getToday } from "./service";
import { formatDate, getUsersByLocation } from "./utils";
import getStorage from "./storage";
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
const storage = getStorage();
const ENVIRONMENT = process.env.NODE_ENV || 'production'
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
const storage = getStorage();
const NOTIFICATION_SETTINGS_PREFIX = 'notif';
const gotifyDataRaw = process.env.GOTIFY_SERVERS_AND_KEYS || "{}";
const gotifyData: GotifyServer[] = JSON.parse(gotifyDataRaw);
export const gotifyCall = async (data: NotififaceInput, gotifyServers?: GotifyServer[]): Promise<any[]> => {
if (!Array.isArray(gotifyServers)) {
return []
}
const urls = gotifyServers.flatMap(gotifyServer =>
gotifyServer.api_keys.map(apiKey => `${gotifyServer.server}/message?token=${apiKey}`));
/** Vrátí klíč pro uložení notifikačních nastavení uživatele. */
function getNotificationSettingsKey(login: string): string {
return `${NOTIFICATION_SETTINGS_PREFIX}_${login}`;
}
const dataPayload = {
title: "Luncher",
message: `${data.udalost} - spustil:${data.user}`,
priority: 7,
};
/** Vrátí nastavení notifikací pro daného uživatele. */
export async function getNotificationSettings(login: string): Promise<NotificationSettings> {
return await storage.getData<NotificationSettings>(getNotificationSettingsKey(login)) ?? {};
}
const headers = { "Content-Type": "application/json" };
/** Uloží nastavení notifikací pro daného uživatele. */
export async function saveNotificationSettings(login: string, settings: NotificationSettings): Promise<NotificationSettings> {
await storage.setData(getNotificationSettingsKey(login), settings);
return settings;
const promises = urls.map(url =>
axios.post(url, dataPayload, { headers }).then(response => {
response.data = {
success: true,
message: "Notifikace doručena",
};
return response;
}).catch(error => {
if (axios.isAxiosError(error)) {
const axiosError = error as AxiosError;
if (axiosError.response) {
axiosError.response.data = {
success: false,
message: "fail",
};
console.log(error)
return axiosError.response;
}
}
// Handle unknown error without a response
console.log(error, "unknown error");
})
);
return promises;
};
/** Odešle ntfy notifikaci na dané téma. */
async function ntfyCallToTopic(topic: string, message: string) {
const url = process.env.NTFY_HOST;
const username = process.env.NTFY_USERNAME;
const password = process.env.NTFY_PASSWD;
if (!url || !username || !password) {
return;
}
const token = Buffer.from(`${username}:${password}`, 'utf8').toString('base64');
try {
const response = await axios({
url: `${url}/${topic}`,
method: 'POST',
data: message,
headers: {
'Authorization': `Basic ${token}`,
'Tag': 'meat_on_bone'
}
});
console.log(response.data);
} catch (error) {
console.error(`Chyba při odesílání ntfy notifikace na topic ${topic}:`, error);
}
}
export const ntfyCall = async (data: NotifikaceInput) => {
export const ntfyCall = async (data: NotififaceInput) => {
const url = process.env.NTFY_HOST
const username = process.env.NTFY_USERNAME;
const password = process.env.NTFY_PASSWD;
@@ -69,7 +70,8 @@ export const ntfyCall = async (data: NotifikaceInput) => {
console.log("NTFY_PASSWD není definován v env")
return
}
let clientData = await getClientData(getToday());
const today = formatDate(getToday());
let clientData: ClientData = await storage.getData(today);
const userByCLocation = getUsersByLocation(clientData.choices, data.user)
const token = Buffer.from(`${username}:${password}`, 'utf8').toString('base64');
@@ -95,125 +97,26 @@ export const ntfyCall = async (data: NotifikaceInput) => {
return promises;
}
export const teamsCall = async (data: NotifikaceInput) => {
const url = process.env.TEAMS_WEBHOOK_URL;
const title = data.udalost;
let time = new Date();
time.setTime(time.getTime() + 1000 * 60);
const message = 'Odcházíme v ' + getHumanTime(time) + ', ' + data.user;
const card = {
'@type': 'MessageCard',
'@context': 'http://schema.org/extensions',
'themeColor': "0072C6", // light blue
summary: 'Summary description',
sections: [
{
activityTitle: title,
text: message,
},
],
};
if (!url) {
console.log("TEAMS_WEBHOOK_URL není definován v env")
return
}
try {
const response = await axios.post(url, card, {
headers: {
'content-type': 'application/vnd.microsoft.teams.card.o365connector'
},
});
return `${response.status} - ${response.statusText}`;
} catch (err) {
return err;
}
}
/** Odešle Teams notifikaci na daný webhook URL. */
async function teamsCallToUrl(webhookUrl: string, data: NotifikaceInput) {
const title = data.udalost;
let time = new Date();
time.setTime(time.getTime() + 1000 * 60);
const message = 'Odcházíme v ' + getHumanTime(time) + ', ' + data.user;
const card = {
'@type': 'MessageCard',
'@context': 'http://schema.org/extensions',
'themeColor': "0072C6",
summary: 'Summary description',
sections: [
{
activityTitle: title,
text: message,
},
],
};
try {
await axios.post(webhookUrl, card, {
headers: {
'content-type': 'application/vnd.microsoft.teams.card.o365connector'
},
});
} catch (error) {
console.error(`Chyba při odesílání Teams notifikace:`, error);
}
}
/** Odešle Discord notifikaci na daný webhook URL. */
async function discordCall(webhookUrl: string, data: NotifikaceInput) {
let time = new Date();
time.setTime(time.getTime() + 1000 * 60);
const message = `🍖 **${data.udalost}** — ${data.user} (odchod v ${getHumanTime(time)})`;
try {
await axios.post(webhookUrl, {
content: message,
}, {
headers: {
'Content-Type': 'application/json',
},
});
} catch (error) {
console.error(`Chyba při odesílání Discord notifikace:`, error);
}
}
/** Zavolá notifikace na všechny konfigurované způsoby notifikace, přetížení proměných na false pro jednotlivé způsoby je vypne*/
export const callNotifikace = async ({ input, teams = true, gotify = false, ntfy = true }: NotifikaceData) => {
const notifications: Promise<any>[] = [];
// Globální notifikace (zpětně kompatibilní)
const notifications = [];
if (ntfy) {
const ntfyPromises = await ntfyCall(input);
if (ntfyPromises) {
notifications.push(...ntfyPromises);
}
}
/* Zatím není
if (teams) {
const teamsPromises = await teamsCall(input);
if (teamsPromises) {
notifications.push(Promise.resolve(teamsPromises));
}
}
notifications.push(teamsCall(input));
}*/
// Per-user notifikace: najdeme uživatele se stejnou lokací a odešleme dle jejich nastavení
const clientData = await getClientData(getToday());
const usersToNotify = getUsersByLocation(clientData.choices, input.user);
for (const user of usersToNotify) {
if (user === input.user) continue; // Neposíláme notifikaci spouštějícímu uživateli
const userSettings = await getNotificationSettings(user);
if (!userSettings.enabledEvents?.includes(input.udalost)) continue;
// Add more notifications as necessary
if (userSettings.ntfyTopic) {
notifications.push(ntfyCallToTopic(userSettings.ntfyTopic, `${input.udalost} - spustil: ${input.user}`));
}
if (userSettings.discordWebhookUrl) {
notifications.push(discordCall(userSettings.discordWebhookUrl, input));
}
if (userSettings.teamsWebhookUrl) {
notifications.push(teamsCallToUrl(userSettings.teamsWebhookUrl, input));
}
//gotify bych řekl, že už je deprecated
if (gotify) {
const gotifyPromises = await gotifyCall(input, gotifyData);
notifications.push(...gotifyPromises);
}
try {
@@ -221,5 +124,6 @@ export const callNotifikace = async ({ input, teams = true, gotify = false, ntfy
return results;
} catch (error) {
console.error("Error in callNotifikace: ", error);
// Handle the error as needed
}
};

View File

@@ -1,13 +1,12 @@
import { formatDate } from "./utils";
import { callNotifikace } from "./notifikace";
import { generateQr } from "./qr";
import { ClientData, PizzaDayState, UdalostEnum, Pizza, PizzaSize, Order, PizzaOrder, DayData } from "../../types";
import getStorage from "./storage";
import { downloadPizzy } from "./chefie";
import { getClientData, getToday, initIfNeeded } from "./service";
import { Pizza, ClientData, PizzaDayState, PizzaSize, PizzaOrder, PizzaVariant, UdalostEnum, PendingQr } from "../../types/gen/types.gen";
import { getToday, initIfNeeded } from "./service";
const storage = getStorage();
const PENDING_QR_PREFIX = 'pending_qr';
/**
* Vrátí seznam dostupných pizz pro dnešní den.
@@ -15,7 +14,8 @@ const PENDING_QR_PREFIX = 'pending_qr';
*/
export async function getPizzaList(): Promise<Pizza[] | undefined> {
await initIfNeeded();
let clientData = await getClientData(getToday());
const today = formatDate(getToday());
let clientData: DayData = await storage.getData(today);
if (!clientData.pizzaList) {
const mock = process.env.MOCK_DATA === 'true';
clientData = await savePizzaList(await downloadPizzy(mock));
@@ -31,9 +31,9 @@ export async function getPizzaList(): Promise<Pizza[] | undefined> {
export async function savePizzaList(pizzaList: Pizza[]): Promise<ClientData> {
await initIfNeeded();
const today = formatDate(getToday());
const clientData = await getClientData(getToday());
const clientData: DayData = await storage.getData(today);
clientData.pizzaList = pizzaList;
clientData.pizzaListLastUpdate = formatDate(new Date());
clientData.pizzaListLastUpdate = new Date();
await storage.setData(today, clientData);
return clientData;
}
@@ -43,14 +43,14 @@ export async function savePizzaList(pizzaList: Pizza[]): Promise<ClientData> {
*/
export async function createPizzaDay(creator: string): Promise<ClientData> {
await initIfNeeded();
const clientData = await getClientData(getToday());
const today = formatDate(getToday());
const clientData: DayData = await storage.getData(today);
if (clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den již existuje");
}
// TODO berka rychlooprava, vyřešit lépe - stahovat jednou, na jediném místě!
const pizzaList = await getPizzaList();
const data: ClientData = { pizzaDay: { state: PizzaDayState.CREATED, creator, orders: [] }, pizzaList, ...clientData };
const today = formatDate(getToday());
await storage.setData(today, data);
callNotifikace({ input: { udalost: UdalostEnum.ZAHAJENA_PIZZA, user: creator } })
return data;
@@ -60,7 +60,8 @@ export async function createPizzaDay(creator: string): Promise<ClientData> {
* Smaže pizza day pro aktuální den.
*/
export async function deletePizzaDay(login: string): Promise<ClientData> {
const clientData = await getClientData(getToday());
const today = formatDate(getToday());
const clientData: DayData = await storage.getData(today);
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
@@ -68,7 +69,6 @@ export async function deletePizzaDay(login: string): Promise<ClientData> {
throw Error("Login uživatele se neshoduje se zakladatelem Pizza Day");
}
delete clientData.pizzaDay;
const today = formatDate(getToday());
await storage.setData(today, clientData);
return clientData;
}
@@ -82,93 +82,60 @@ export async function deletePizzaDay(login: string): Promise<ClientData> {
*/
export async function addPizzaOrder(login: string, pizza: Pizza, size: PizzaSize) {
const today = formatDate(getToday());
const clientData = await getClientData(getToday());
const clientData: DayData = await storage.getData(today);
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED);
}
let order: PizzaOrder | undefined = clientData.pizzaDay?.orders?.find(o => o.customer === login);
let order: Order | undefined = clientData.pizzaDay.orders.find(o => o.customer === login);
if (!order) {
order = {
customer: login,
pizzaList: [],
totalPrice: 0,
hasQr: false,
}
clientData.pizzaDay.orders ??= [];
clientData.pizzaDay.orders.push(order);
}
const pizzaOrder: PizzaVariant = {
const pizzaOrder: PizzaOrder = {
varId: size.varId,
name: pizza.name,
size: size.size,
price: size.price,
}
order.pizzaList ??= [];
order.pizzaList.push(pizzaOrder);
order.totalPrice += pizzaOrder.price;
await storage.setData(today, clientData);
return clientData;
}
/**
* Odstraní všechny pizzy uživatele (celou jeho objednávku).
* Pokud Pizza day neexistuje nebo není ve stavu CREATED, neudělá nic.
*
* @param login login uživatele
* @param date datum, pro které se objednávka odstraňuje (výchozí je dnešek)
* @returns aktuální data pro klienta
*/
export async function removeAllUserPizzas(login: string, date?: Date) {
const usedDate = date ?? getToday();
const today = formatDate(usedDate);
const clientData = await getClientData(usedDate);
if (!clientData.pizzaDay) {
return clientData; // Pizza day neexistuje, není co mazat
}
if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
return clientData; // Pizza day není ve stavu CREATED, nelze mazat
}
const orderIndex = clientData.pizzaDay.orders!.findIndex(o => o.customer === login);
if (orderIndex >= 0) {
clientData.pizzaDay.orders!.splice(orderIndex, 1);
await storage.setData(today, clientData);
}
return clientData;
}
/**
* Odstraní danou objednávku pizzy.
*
* @param login login uživatele
* @param pizzaOrder objednávka pizzy
*/
export async function removePizzaOrder(login: string, pizzaOrder: PizzaVariant) {
export async function removePizzaOrder(login: string, pizzaOrder: PizzaOrder) {
const today = formatDate(getToday());
const clientData = await getClientData(getToday());
const clientData: DayData = await storage.getData(today);
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
const orderIndex = clientData.pizzaDay.orders!.findIndex(o => o.customer === login);
const orderIndex = clientData.pizzaDay.orders.findIndex(o => o.customer === login);
if (orderIndex < 0) {
throw Error("Nebyly nalezeny žádné objednávky pro uživatele " + login);
}
const order = clientData.pizzaDay.orders![orderIndex];
const index = order.pizzaList!.findIndex(o => o.name === pizzaOrder.name && o.size === pizzaOrder.size);
const order = clientData.pizzaDay.orders[orderIndex];
const index = order.pizzaList.findIndex(o => o.name === pizzaOrder.name && o.size === pizzaOrder.size);
if (index < 0) {
throw Error("Objednávka s danými parametry nebyla nalezena");
}
const price = order.pizzaList![index].price;
order.pizzaList!.splice(index, 1);
const price = order.pizzaList[index].price;
order.pizzaList.splice(index, 1);
order.totalPrice -= price;
if (order.pizzaList!.length == 0) {
clientData.pizzaDay.orders!.splice(orderIndex, 1);
if (order.pizzaList.length == 0) {
clientData.pizzaDay.orders.splice(orderIndex, 1);
}
await storage.setData(today, clientData);
return clientData;
@@ -182,7 +149,7 @@ export async function removePizzaOrder(login: string, pizzaOrder: PizzaVariant)
*/
export async function lockPizzaDay(login: string) {
const today = formatDate(getToday());
const clientData = await getClientData(getToday());
const clientData: DayData = await storage.getData(today);
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
@@ -205,7 +172,7 @@ export async function lockPizzaDay(login: string) {
*/
export async function unlockPizzaDay(login: string) {
const today = formatDate(getToday());
const clientData = await getClientData(getToday());
const clientData: DayData = await storage.getData(today);
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
@@ -228,7 +195,7 @@ export async function unlockPizzaDay(login: string) {
*/
export async function finishPizzaOrder(login: string) {
const today = formatDate(getToday());
const clientData = await getClientData(getToday());
const clientData: DayData = await storage.getData(today);
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
@@ -253,7 +220,7 @@ export async function finishPizzaOrder(login: string) {
*/
export async function finishPizzaDelivery(login: string, bankAccount?: string, bankAccountHolder?: string) {
const today = formatDate(getToday());
const clientData = await getClientData(getToday());
const clientData: DayData = await storage.getData(today);
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
@@ -267,18 +234,11 @@ export async function finishPizzaDelivery(login: string, bankAccount?: string, b
// Vygenerujeme QR kód, pokud k tomu máme data
if (bankAccount?.length && bankAccountHolder?.length) {
for (const order of clientData.pizzaDay.orders!) {
for (const order of clientData.pizzaDay.orders) {
if (order.customer !== login) { // zatím platí creator = objednávající, a pro toho nemá QR kód smysl
let message = order.pizzaList!.map(pizza => `Pizza ${pizza.name} (${pizza.size})`).join(', ');
let message = order.pizzaList.map(pizza => `Pizza ${pizza.name} (${pizza.size})`).join(', ');
await generateQr(order.customer, bankAccount, bankAccountHolder, order.totalPrice, message);
order.hasQr = true;
// Uložíme nevyřízený QR kód pro persistentní zobrazení
await addPendingQr(order.customer, {
date: today,
creator: login,
totalPrice: order.totalPrice,
purpose: message,
});
}
}
}
@@ -295,15 +255,15 @@ export async function finishPizzaDelivery(login: string, bankAccount?: string, b
*/
export async function updatePizzaDayNote(login: string, note?: string) {
const today = formatDate(getToday());
let clientData = await getClientData(getToday());
let clientData: DayData = await storage.getData(today);
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED);
}
const myOrder = clientData.pizzaDay.orders!.find(o => o.customer === login);
if (!myOrder?.pizzaList?.length) {
const myOrder = clientData.pizzaDay.orders.find(o => o.customer === login);
if (!myOrder || !myOrder.pizzaList.length) {
throw Error("Pizza day neobsahuje žádné objednávky uživatele " + login);
}
myOrder.note = note;
@@ -322,7 +282,7 @@ export async function updatePizzaDayNote(login: string, note?: string) {
*/
export async function updatePizzaFee(login: string, targetLogin: string, text?: string, price?: number) {
const today = formatDate(getToday());
let clientData = await getClientData(getToday());
let clientData: DayData = await storage.getData(today);
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
@@ -332,8 +292,8 @@ export async function updatePizzaFee(login: string, targetLogin: string, text?:
if (clientData.pizzaDay.creator !== login) {
throw Error("Příplatky může měnit pouze zakladatel Pizza day");
}
const targetOrder = clientData.pizzaDay.orders!.find(o => o.customer === targetLogin);
if (!targetOrder?.pizzaList?.length) {
const targetOrder = clientData.pizzaDay.orders.find(o => o.customer === targetLogin);
if (!targetOrder || !targetOrder.pizzaList.length) {
throw Error(`Pizza day neobsahuje žádné objednávky uživatele ${targetLogin}`);
}
if (!price) {
@@ -342,44 +302,7 @@ export async function updatePizzaFee(login: string, targetLogin: string, text?:
targetOrder.fee = { text, price };
}
// Přepočet ceny
targetOrder.totalPrice = targetOrder.pizzaList.reduce((price, pizzaOrder) => price + pizzaOrder.price, 0) + (targetOrder.fee?.price ?? 0);
targetOrder.totalPrice = targetOrder.pizzaList.reduce((price, pizzaOrder) => price + pizzaOrder.price, 0) + (targetOrder.fee?.price || 0);
await storage.setData(today, clientData);
return clientData;
}
/**
* Vrátí klíč pro uložení nevyřízených QR kódů uživatele.
*/
function getPendingQrKey(login: string): string {
return `${PENDING_QR_PREFIX}_${login}`;
}
/**
* Přidá nevyřízený QR kód pro uživatele.
*/
export async function addPendingQr(login: string, pendingQr: PendingQr): Promise<void> {
const key = getPendingQrKey(login);
const existing = await storage.getData<PendingQr[]>(key) ?? [];
// Nepřidáváme duplicity pro stejný den
if (!existing.some(qr => qr.date === pendingQr.date)) {
existing.push(pendingQr);
await storage.setData(key, existing);
}
}
/**
* Vrátí nevyřízené QR kódy pro uživatele.
*/
export async function getPendingQrs(login: string): Promise<PendingQr[]> {
return await storage.getData<PendingQr[]>(getPendingQrKey(login)) ?? [];
}
/**
* Označí QR kód pro daný den jako uhrazený (odstraní ho ze seznamu nevyřízených).
*/
export async function dismissPendingQr(login: string, date: string): Promise<void> {
const key = getPendingQrKey(login);
const existing = await storage.getData<PendingQr[]>(key) ?? [];
const filtered = existing.filter(qr => qr.date !== date);
await storage.setData(key, filtered);
}

View File

@@ -1,163 +0,0 @@
import webpush from 'web-push';
import getStorage from './storage';
import { getClientData, getToday } from './service';
import { getIsWeekend } from './utils';
import { LunchChoices } from '../../types';
const storage = getStorage();
const REGISTRY_KEY = 'push_reminder_registry';
interface RegistryEntry {
time: string;
subscription: webpush.PushSubscription;
}
type Registry = Record<string, RegistryEntry>;
/** Mapa login → datum (YYYY-MM-DD), kdy byl uživatel naposledy upozorněn. */
const remindedToday = new Map<string, string>();
function getTodayDateString(): string {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
}
function getCurrentTimeHHMM(): string {
const now = new Date();
return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
}
/** Zjistí, zda má uživatel zvolenou nějakou možnost stravování. */
function userHasChoice(choices: LunchChoices, login: string): boolean {
for (const locationKey of Object.keys(choices)) {
const locationChoices = choices[locationKey as keyof LunchChoices];
if (locationChoices && login in locationChoices) {
return true;
}
}
return false;
}
async function getRegistry(): Promise<Registry> {
return await storage.getData<Registry>(REGISTRY_KEY) ?? {};
}
async function saveRegistry(registry: Registry): Promise<void> {
await storage.setData(REGISTRY_KEY, registry);
}
/** Přidá nebo aktualizuje push subscription pro uživatele. */
export async function subscribePush(login: string, subscription: webpush.PushSubscription, reminderTime: string): Promise<void> {
const registry = await getRegistry();
registry[login] = { time: reminderTime, subscription };
await saveRegistry(registry);
console.log(`Push reminder: uživatel ${login} přihlášen k připomínkám v ${reminderTime}`);
}
/** Odebere push subscription pro uživatele. */
export async function unsubscribePush(login: string): Promise<void> {
const registry = await getRegistry();
delete registry[login];
await saveRegistry(registry);
remindedToday.delete(login);
console.log(`Push reminder: uživatel ${login} odhlášen z připomínek`);
}
/** Vrátí veřejný VAPID klíč. */
export function getVapidPublicKey(): string | undefined {
return process.env.VAPID_PUBLIC_KEY;
}
/** Najde login uživatele podle push subscription endpointu. */
export async function findLoginByEndpoint(endpoint: string): Promise<string | undefined> {
const registry = await getRegistry();
for (const [login, entry] of Object.entries(registry)) {
if (entry.subscription.endpoint === endpoint) {
return login;
}
}
return undefined;
}
/** Zkontroluje a odešle připomínky uživatelům, kteří si nezvolili oběd. */
async function checkAndSendReminders(): Promise<void> {
// Přeskočit víkendy
if (getIsWeekend(getToday())) {
return;
}
const registry = await getRegistry();
const entries = Object.entries(registry);
if (entries.length === 0) {
return;
}
const currentTime = getCurrentTimeHHMM();
const todayStr = getTodayDateString();
// Získáme data pro dnešek jednou pro všechny uživatele
let clientData;
try {
clientData = await getClientData(getToday());
} catch (e) {
console.error('Push reminder: chyba při získávání dat', e);
return;
}
for (const [login, entry] of entries) {
// Ještě nedosáhl čas připomínky
if (currentTime < entry.time) {
continue;
}
// Už jsme dnes připomenuli
if (remindedToday.get(login) === todayStr) {
continue;
}
// Uživatel už má zvolenou možnost
if (clientData.choices && userHasChoice(clientData.choices, login)) {
continue;
}
// Odešleme push notifikaci
try {
await webpush.sendNotification(
entry.subscription,
JSON.stringify({
title: 'Luncher',
body: 'Ještě nemáte zvolený oběd!',
})
);
remindedToday.set(login, todayStr);
console.log(`Push reminder: odeslána připomínka uživateli ${login}`);
} catch (error: any) {
if (error.statusCode === 410 || error.statusCode === 404) {
// Subscription expirovala nebo je neplatná — odebereme z registry
console.log(`Push reminder: subscription uživatele ${login} expirovala, odebírám`);
delete registry[login];
await saveRegistry(registry);
} else {
console.error(`Push reminder: chyba při odesílání notifikace uživateli ${login}:`, error);
}
}
}
}
/** Spustí scheduler pro kontrolu a odesílání připomínek každou minutu. */
export function startReminderScheduler(): void {
const publicKey = process.env.VAPID_PUBLIC_KEY;
const privateKey = process.env.VAPID_PRIVATE_KEY;
const subject = process.env.VAPID_SUBJECT;
if (!publicKey || !privateKey || !subject) {
console.log('Push reminder: VAPID klíče nejsou nastaveny, scheduler nebude spuštěn');
return;
}
webpush.setVapidDetails(subject, publicKey, privateKey);
// Spustíme kontrolu každou minutu
setInterval(checkAndSendReminders, 60_000);
console.log('Push reminder: scheduler spuštěn');
}

View File

@@ -1,12 +1,7 @@
import axios from "axios";
import { load } from 'cheerio';
import { getMenuSladovnickaMock, getMenuTechTowerMock, getMenuUMotlikuMock, getMenuZastavkaUmichalaMock, getMenuSenkSerikovaMock } from "./mock";
import { formatDate } from "./utils";
import { Food } from "../../types/gen/types.gen";
export class StaleWeekError extends Error {
constructor(public food: Food[][]) { super('Data jsou z jiného týdne'); }
}
import { Food } from "../../types";
import { getMenuSladovnickaMock, getMenuTechTowerMock, getMenuUMotlikuMock, getMenuZastavkaUmichalaMock } from "./mock";
// Fráze v názvech jídel, které naznačují že se jedná o polévku
const SOUP_NAMES = [
@@ -27,7 +22,7 @@ const SOUP_NAMES = [
const DAYS_IN_WEEK = ['pondělí', 'úterý', 'středa', 'čtvrtek', 'pátek', 'sobota', 'neděle'];
// URL na týdenní menu jednotlivých restaurací
const SLADOVNICKA_URL = 'https://sladovnicka.unasplzenchutna.cz/cz/#denni-nabidka';
const SLADOVNICKA_URL = 'https://sladovnicka.unasplzenchutna.cz/cz/denni-nabidka';
const U_MOTLIKU_URL = 'https://www.umotliku.cz/menu';
const TECHTOWER_URL = 'https://www.equifarm.cz/restaurace-techtower';
const ZASTAVKAUMICHALA_URL = 'https://www.zastavkaumichala.cz';
@@ -57,28 +52,6 @@ const sanitizeText = (text: string): string => {
return text.replace('\t', '').replace(' , ', ', ').trim();
}
/**
* Parsuje čísla alergenů z názvu jídla a vrací vyčištěný název spolu s polem alergenů.
* Alergeny jsou očekávány na konci názvu ve formátu číslic oddělených čárkami.
*
* @param name původní název jídla
* @returns objekt obsahující vyčištěný název a pole alergenů
*/
const parseAllergens = (name: string): { cleanName: string, allergens: number[] } => {
// Regex pro nalezení čísel na konci řetězce oddělených čárkami a případnými mezerami
const regex = /\s+(\d+(?:\s*,\s*\d+)*)\s*$/;
const match = regex.exec(name);
if (match) {
const allergenString = match[1];
const allergens = allergenString.split(',').map(num => Number.parseInt(num.trim(), 10)).filter(num => !Number.isNaN(num));
const cleanName = name.replace(regex, '').trim();
return { cleanName, allergens };
}
return { cleanName: name, allergens: [] };
}
/**
* Stáhne a vrátí aktuální HTML z dané URL.
*
@@ -104,70 +77,84 @@ export const getMenuSladovnicka = async (firstDayOfWeek: Date, mock: boolean = f
const html = await getHtml(SLADOVNICKA_URL);
const $ = load(html);
// Zjistíme, které dny jsou k dispozici z tab elementů
const tabElements = $('#daily-menu-tab-list').children('button[id^="daily-menu-tab-"]');
const availableDays: { [dayIndex: number]: number } = {}; // mapování dayIndex -> contentIndex
tabElements.each((contentIndex, tabElement) => {
const dayText = $(tabElement).find('.daily-menu-tab__day').text().toLowerCase();
const dayIndex = DAYS_IN_WEEK.indexOf(dayText);
if (dayIndex !== -1 && dayIndex < 5) { // pouze pracovní dny (0-4)
availableDays[dayIndex] = contentIndex;
}
});
const menuContentElements = $('#daily-menu-content-list').children('.daily-menu-content__content').not('.daily-menu-content__content--static');
const list = $('ul.tab-links').children();
const result: Food[][] = [];
// Inicializujeme všechny pracovní dny (0-4) prázdnými poli
for (let dayIndex = 0; dayIndex < 5; dayIndex++) {
result[dayIndex] = [];
const currentDate = new Date(firstDayOfWeek);
currentDate.setDate(firstDayOfWeek.getDate() + dayIndex);
const searchedDayText = `${currentDate.getDate()}.${currentDate.getMonth() + 1}.${capitalize(DAYS_IN_WEEK[dayIndex])}`;
// Najdeme index pro vstupní datum (např. při svátcích bude posunutý)
// TODO validovat, že vstupní datum je v aktuálním týdnu
// TODO tenhle způsob je zbytečně komplikovaný - stačilo by hledat rovnou v div.tab-content, protože každý den tam má datum taky (akorát je print-only)
let index = undefined;
list.each((i, dayRow) => {
const rowText = $(dayRow).first().text().trim();
if (rowText === searchedDayText) {
index = i;
return;
}
})
if (index === undefined) {
// Pravděpodobně svátek, nebo je zavřeno
result[dayIndex] = [{
amount: undefined,
name: "Pro daný den nebyla nalezena denní nabídka",
price: "",
isSoup: false,
}];
continue;
}
// Projdeme pouze dostupné dny
for (const [dayIndex, contentIndex] of Object.entries(availableDays)) {
const dayIndexNum = Number.parseInt(dayIndex);
const contentIndexNum = contentIndex;
// Dle dohledaného indexu najdeme správný tabpanel
const rows = $('div.tab-content').children();
if (index >= rows.length) {
throw Error("V HTML nebyl nalezen řádek menu pro index " + index);
}
const tabPanel = $(rows.get(index));
if (contentIndexNum >= menuContentElements.length) {
continue; // Přeskočíme, pokud content element neexistuje
// Opětovná validace, že daný tabpanel je pro vstupní datum
const headers = tabPanel.find('h2');
if (headers.length !== 3) {
throw Error("Neočekávaný počet elementů h2 v menu pro datum " + searchedDayText + ", očekávány 3, ale nalezeno bylo " + headers.length);
}
const dayText = $(headers.get(0)).text().trim();
if (dayText !== searchedDayText) {
throw Error("Neočekávaný datum na řádce nalezeného dne: '" + dayText + "', ale očekáváno bylo '" + searchedDayText + "'");
}
const contentElement = $(menuContentElements[contentIndexNum]);
const itemElement = contentElement.find('.daily-menu-content__item');
const table = itemElement.find('table.daily-menu-content__table tbody');
const rows = table.children('tr');
// V tabpanelu očekáváme dvě tabulky - pro polévku a pro hlavní jídlo
const tables = tabPanel.find('table');
if (tables.length !== 2) {
throw Error("Neočekávaný počet tabulek na řádce nalezeného dne: " + tables.length + ", ale očekávány byly 2");
}
const currentDayFood: Food[] = [];
// Projdeme všechny řádky - první je polévka, zbytek jsou hlavní jídla
rows.each((i, row) => {
const cells = $(row).children('td');
if (cells.length !== 3) {
return; // Přeskočíme řádky s nesprávnou strukturou
// Polévka - div -> table -> tbody -> tr -> 3x td
const soupCells = $(tables.get(0)).children().first().children().first().children();
if (soupCells.length !== 3) {
throw Error("Neočekávaný počet buněk v tabulce polévky: " + soupCells.length + ", ale očekávány byly 3");
}
const amount = sanitizeText($(cells.get(0)).text());
const nameRaw = sanitizeText($(cells.get(1)).text());
const price = sanitizeText($(cells.get(2)).text().replace(' ', '\xA0'));
const parsed = parseAllergens(nameRaw);
// Přeskočíme prázdné řádky
if (parsed.cleanName.trim().length > 0) {
currentDayFood.push({
amount,
name: parsed.cleanName,
price,
isSoup: i === 0, // První řádek je polévka
allergens: parsed.allergens.length > 0 ? parsed.allergens : undefined,
amount: sanitizeText($(soupCells.get(0)).text()),
name: sanitizeText($(soupCells.get(1)).text()),
price: sanitizeText($(soupCells.get(2)).text().replace(' ', '\xA0')),
isSoup: true,
});
// Hlavní jídla - div -> table -> tbody -> 3x tr
const mainCourseRows = $(tables.get(1)).children().first().children();
mainCourseRows.each((i, foodRow) => {
const foodCells = $(foodRow).children();
if (foodCells.length !== 3) {
throw Error("Neočekávaný počet buněk v řádku jídla: " + foodCells.length + ", ale očekávány byly 3");
}
currentDayFood.push({
amount: sanitizeText($(foodCells.get(0)).text()),
name: sanitizeText($(foodCells.get(1)).text()),
price: sanitizeText($(foodCells.get(2)).text().replace(' ', '\xA0')),
isSoup: false,
});
result[dayIndexNum] = currentDayFood;
})
result[dayIndex] = currentDayFood;
}
return result;
}
@@ -189,7 +176,7 @@ export const getMenuUMotliku = async (firstDayOfWeek: Date, mock: boolean = fals
// Najdeme první tabulku, nad kterou je v H3 datum začínající co nejdřívějším dnem v aktuálním týdnu
const tables = $('table.table.table-hover.Xtable-striped');
let usedTable;
let usedDate = new Date(firstDayOfWeek);
let usedDate = new Date(firstDayOfWeek.getTime());
for (let i = 0; i < 4; i++) {
const dayOfWeekString = `${usedDate.getDate()}.${usedDate.getMonth() + 1}.`;
for (const tableNode of tables) {
@@ -209,7 +196,7 @@ export const getMenuUMotliku = async (firstDayOfWeek: Date, mock: boolean = fals
if (usedTable == null) {
const firstDayOfWeekString = `${firstDayOfWeek.getDate()}.${firstDayOfWeek.getMonth() + 1}.`;
throw new Error(`Nepodařilo se najít tabulku pro týden začínající ${firstDayOfWeekString}`);
throw Error(`Nepodařilo se najít tabulku pro týden začínající ${firstDayOfWeekString}`);
}
const body = usedTable.children().first();
@@ -242,11 +229,11 @@ export const getMenuUMotliku = async (firstDayOfWeek: Date, mock: boolean = fals
} else if (foodType === 'Hlavní jídlo') {
isSoup = false;
} else {
throw new Error("Neočekáváný typ jídla: " + foodType);
throw Error("Neočekáváný typ jídla: " + foodType);
}
} else {
if (children.length !== 3) {
throw new Error("Neočekávaný počet child elementů pro jídlo: " + children.length + ", očekávány 3");
throw Error("Neočekávaný počet child elementů pro jídlo: " + children.length + ", očekávány 3");
}
const amount = sanitizeText($(children.get(0)).text());
const name = sanitizeText($(children.get(1)).text());
@@ -299,11 +286,12 @@ export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = fal
})
}
if (!font) {
throw new Error('Chyba: nenalezen <font> pro obědy v HTML Techtower.');
throw Error('Chyba: nenalezen <font> pro obědy v HTML Techtower.');
}
const result: Food[][] = [];
const siblings = secondTry ? $(font).parent().parent().parent().siblings('p') : $(font).parent().parent().siblings();
// TODO validovat, že v textu nalezeného <font> je rozsah, do kterého spadá vstupní datum
const siblings = secondTry ? $(font).parent().siblings() : $(font).parent().parent().siblings();
let parsing = false;
let currentDayIndex = 0;
for (let i = 0; i < siblings.length; i++) {
@@ -321,45 +309,24 @@ export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = fal
continue;
}
let price = 'na\xA0váhu';
let nameRaw = text.replace('•', '');
let name = text.replace('•', '');
if (text.toLowerCase().endsWith('kč')) {
const tmp = text.replace('\xA0', ' ').split(' ');
const split = [tmp.slice(0, -2).join(' ')].concat(tmp.slice(-2));
price = `${split.slice(1)[0]}\xA0Kč`
nameRaw = split[0].replace('•', '');
} else if (text.toLowerCase().endsWith(',-')) {
const tmp = text.replace('\xA0', ' ').split(' ');
const split = [tmp.slice(0, -1).join(' ')].concat(tmp.slice(-1));
price = `${split.slice(1)[0].replace(',-', '')}\xA0Kč`
nameRaw = split[0].replace('•', '');
name = split[0].replace('•', '');
}
if (nameRaw.endsWith('')|| nameRaw.endsWith('—')) {
nameRaw = nameRaw.slice(0, -1).trim();
if (result[currentDayIndex] == null) {
result[currentDayIndex] = [];
}
const parsed = parseAllergens(nameRaw);
result[currentDayIndex] ??= [];
result[currentDayIndex].push({
amount: '-',
name: parsed.cleanName,
name,
price,
isSoup: isTextSoupName(parsed.cleanName),
allergens: parsed.allergens.length > 0 ? parsed.allergens : undefined,
isSoup: isTextSoupName(name),
})
}
}
// Validace, zda text hlavičky obsahuje datum odpovídající požadovanému týdnu
const headerText = $(font).text().trim();
const dateMatch = headerText.match(/(\d{1,2})\.(\d{1,2})\./);
if (dateMatch) {
const foundDay = parseInt(dateMatch[1]);
const foundMonth = parseInt(dateMatch[2]) - 1; // JS months are 0-based
if (foundDay !== firstDayOfWeek.getDate() || foundMonth !== firstDayOfWeek.getMonth()) {
throw new StaleWeekError(result);
}
}
return result;
}
@@ -375,33 +342,29 @@ export const getMenuZastavkaUmichala = async (firstDayOfWeek: Date, mock: boolea
return getMenuZastavkaUmichalaMock();
}
const today = new Date();
today.setHours(0,0,0,0);
const headers = {
"Cookie": "_nss=1; PHPSESSID=9e37de17e0326b0942613d6e67a30e69",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36",
};
const nowDate = new Date().getDate();
const result: Food[][] = [];
for (let dayIndex = 0; dayIndex < 5; dayIndex++) {
const currentDate = new Date(firstDayOfWeek);
currentDate.setDate(firstDayOfWeek.getDate() + dayIndex);
currentDate.setHours(0,0,0,0);
if (currentDate < today || (currentDate.getTime() === today.getTime() && new Date().getHours() >= 14)) {
// if (currentDate < now) {
if (currentDate.getDate() !== nowDate) {
result[dayIndex] = [{
amount: undefined,
name: "Pro tento den není uveřejněna nabídka jídel",
price: "",
isSoup: false,
}];
continue;
} else {
const url = (currentDate.getTime() === today.getTime())
? ZASTAVKAUMICHALA_URL
: ZASTAVKAUMICHALA_URL + '/?do=dailyMenu-changeDate&dailyMenu-dateString=' + formatDate(currentDate, 'DD.MM.YYYY');
const html = await axios.get(url, {
headers,
}).then(res => res.data).then(content => content);
// let dateString = formatDate(currentDate, 'DD.MM.YYYY');
// const html = await getHtml(ZASTAVKAUMICHALA_URL + '/?do=dailyMenu-changeDate&dailyMenu-dateString=' + dateString);
const html = await getHtml(ZASTAVKAUMICHALA_URL);
const $ = load(html);
// const row = $($('.foodsList li')[0]).text();
const currentDayFood: Food[] = [];
$('.foodsList li').each((index, element) => {
currentDayFood.push({
@@ -416,56 +379,3 @@ export const getMenuZastavkaUmichala = async (firstDayOfWeek: Date, mock: boolea
}
return result;
}
/**
* Získá obědovou nabídku SenkSerikova pro jeden týden.
*
* @param firstDayOfWeek první den v týdnu, pro který získat menu
* @param mock zda vrátit mock data
* @returns seznam jídel pro dané datum
*/
export const getMenuSenkSerikova = async (firstDayOfWeek: Date, mock: boolean = false): Promise<Food[][]> => {
if (mock) {
return getMenuSenkSerikovaMock();
}
const decoder = new TextDecoder('windows-1250');
const html = await axios.get(SENKSERIKOVA_URL, {
responseType: 'arraybuffer',
responseEncoding: 'binary'
}).then(res => decoder.decode(new Uint8Array(res.data))).then(content => content);
const $ = load(html);
const today = new Date();
today.setHours(0,0,0,0);
const currentDate = new Date(firstDayOfWeek);
const result: Food[][] = [];
let dayIndex = 0;
currentDate.setHours(0,0,0,0);
while (currentDate < today) {
result[dayIndex] = [{
amount: undefined,
name: "Pro tento den není uveřejněna nabídka jídel",
price: "",
isSoup: false,
}];
dayIndex = dayIndex + 1;
currentDate.setDate(firstDayOfWeek.getDate() + dayIndex);
}
$('.menicka').each((i, element) => {
const currentDayFood: Food[] = [];
$(element).find('.popup-gallery li').each((j, element) => {
const rawName = $(element).children('div.polozka').text();
const nameWithoutNumber = rawName.replace(/^\d+\.\s*/, '');
currentDayFood.push({
amount: '-',
name: nameWithoutNumber,
price: $(element).children('div.cena').text().replaceAll(' ', '\xA0'),
isSoup: $(element).hasClass('polevka'),
});
});
result[dayIndex++] = currentDayFood;
});
return result;
}

View File

@@ -1,50 +0,0 @@
import express, { Request, Response } from "express";
import fs from "fs";
import path from "path";
const router = express.Router();
const CHANGELOGS_DIR = path.resolve(__dirname, "../../changelogs");
// In-memory cache: datum → seznam změn
const cache: Record<string, string[]> = {};
function loadAllChangelogs(): Record<string, string[]> {
let files: string[];
try {
files = fs.readdirSync(CHANGELOGS_DIR).filter(f => f.endsWith(".json"));
} catch {
return {};
}
for (const file of files) {
const date = file.replace(".json", "");
if (!cache[date]) {
const content = fs.readFileSync(path.join(CHANGELOGS_DIR, file), "utf-8");
cache[date] = JSON.parse(content);
}
}
return cache;
}
router.get("/", (req: Request, res: Response) => {
const all = loadAllChangelogs();
const since = typeof req.query.since === "string" ? req.query.since : undefined;
// Seřazení od nejnovějšího po nejstarší
const sortedDates = Object.keys(all).sort((a, b) => b.localeCompare(a));
const filteredDates = since
? sortedDates.filter(date => date > since)
: sortedDates;
const result: Record<string, string[]> = {};
for (const date of filteredDates) {
result[date] = all[date];
}
res.status(200).json(result);
});
export default router;

View File

@@ -1,197 +0,0 @@
import express, { Request } from "express";
import { getDateForWeekIndex, getData, getRestaurantMenu, getToday, initIfNeeded } from "../service";
import { formatDate, getDayOfWeekIndex } from "../utils";
import getStorage from "../storage";
import { getWebsocket } from "../websocket";
import { getLogin } from "../auth";
import { parseToken } from "../utils";
import webpush from 'web-push';
const router = express.Router();
const storage = getStorage();
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
// Seznam náhodných jmen pro generování mock dat
const MOCK_NAMES = [
'Alice', 'Bob', 'Charlie', 'David', 'Eva', 'Filip', 'Gita', 'Honza',
'Ivana', 'Jakub', 'Kamila', 'Lukáš', 'Markéta', 'Nikola', 'Ondřej',
'Petra', 'Quido', 'Radek', 'Simona', 'Tomáš', 'Ursula', 'Viktor',
'Wanda', 'Xaver', 'Yvona', 'Zdeněk', 'Aneta', 'Boris', 'Cecílie', 'Daniel'
];
// Volby stravování pro mock data
const LUNCH_CHOICES = [
'SLADOVNICKA',
'TECHTOWER',
'ZASTAVKAUMICHALA',
'SENKSERIKOVA',
'OBJEDNAVAM',
'NEOBEDVAM',
'ROZHODUJI',
];
// Restaurace s menu
const RESTAURANTS_WITH_MENU = [
'SLADOVNICKA',
'TECHTOWER',
'ZASTAVKAUMICHALA',
'SENKSERIKOVA',
];
/**
* Middleware pro kontrolu DEV režimu
*/
function requireDevMode(req: any, res: any, next: any) {
if (ENVIRONMENT !== 'development') {
return res.status(403).json({ error: 'Tento endpoint je dostupný pouze ve vývojovém režimu' });
}
next();
}
router.use(requireDevMode);
/**
* Vygeneruje mock data pro testování.
*/
router.post("/generate", async (req: Request<{}, any, any>, res, next) => {
try {
const dayIndex = req.body?.dayIndex ?? getDayOfWeekIndex(getToday());
const count = req.body?.count ?? Math.floor(Math.random() * 16) + 5; // 5-20
if (dayIndex < 0 || dayIndex > 4) {
return res.status(400).json({ error: 'Neplatný index dne (0-4)' });
}
const date = getDateForWeekIndex(dayIndex);
await initIfNeeded(date);
const dateKey = formatDate(date);
const data = await storage.getData<any>(dateKey);
// Získání menu restaurací pro vybraný den
const menus: { [key: string]: any } = {};
for (const restaurant of RESTAURANTS_WITH_MENU) {
const menu = await getRestaurantMenu(restaurant as any, date);
if (menu?.food?.length) {
menus[restaurant] = menu.food;
}
}
// Vygenerování náhodných uživatelů
const usedNames = new Set<string>();
for (let i = 0; i < count && usedNames.size < MOCK_NAMES.length; i++) {
// Vybereme náhodné jméno, které ještě nebylo použito
let name: string;
do {
name = MOCK_NAMES[Math.floor(Math.random() * MOCK_NAMES.length)];
} while (usedNames.has(name));
usedNames.add(name);
// Vybereme náhodnou volbu stravování
const choice = LUNCH_CHOICES[Math.floor(Math.random() * LUNCH_CHOICES.length)];
// Inicializace struktury pro volbu
data.choices[choice] ??= {};
const userChoice: any = {
trusted: false,
selectedFoods: [],
};
// Pokud má restaurace menu, vybereme náhodné jídlo
if (RESTAURANTS_WITH_MENU.includes(choice) && menus[choice]?.length) {
const foods = menus[choice];
// Vybereme náhodné jídlo (ne polévku)
const mainFoods = foods.filter((f: any) => !f.isSoup);
if (mainFoods.length > 0) {
const randomFoodIndex = foods.indexOf(mainFoods[Math.floor(Math.random() * mainFoods.length)]);
userChoice.selectedFoods = [randomFoodIndex];
}
}
data.choices[choice][name] = userChoice;
}
await storage.setData(dateKey, data);
// Odeslat aktualizovaná data přes WebSocket
const clientData = await getData(date);
getWebsocket().emit("message", clientData);
res.status(200).json({ success: true, count: usedNames.size, dayIndex });
} catch (e: any) {
next(e);
}
});
/**
* Smaže všechny volby pro daný den.
*/
router.post("/clear", async (req: Request<{}, any, any>, res, next) => {
try {
const dayIndex = req.body?.dayIndex ?? getDayOfWeekIndex(getToday());
if (dayIndex < 0 || dayIndex > 4) {
return res.status(400).json({ error: 'Neplatný index dne (0-4)' });
}
const date = getDateForWeekIndex(dayIndex);
await initIfNeeded(date);
const dateKey = formatDate(date);
const data = await storage.getData<any>(dateKey);
// Vymažeme všechny volby
data.choices = {};
await storage.setData(dateKey, data);
// Odeslat aktualizovaná data přes WebSocket
const clientData = await getData(date);
getWebsocket().emit("message", clientData);
res.status(200).json({ success: true, dayIndex });
} catch (e: any) {
next(e);
}
});
/** Vrátí obsah push reminder registry (pro ladění). */
router.get("/pushRegistry", async (_req, res, next) => {
try {
const registry = await storage.getData<any>('push_reminder_registry') ?? {};
const sanitized = Object.fromEntries(
Object.entries(registry).map(([login, entry]: [string, any]) => [
login,
{ time: entry.time, endpoint: entry.subscription?.endpoint?.slice(0, 60) + '…' }
])
);
res.status(200).json(sanitized);
} catch (e: any) { next(e) }
});
/** Okamžitě odešle test push notifikaci přihlášenému uživateli (pro ladění). */
router.post("/testPush", async (req, res, next) => {
const login = getLogin(parseToken(req));
try {
const registry = await storage.getData<any>('push_reminder_registry') ?? {};
const entry = registry[login];
if (!entry) {
return res.status(404).json({ error: `Uživatel ${login} nemá uloženou push subscription. Nastav připomínku v nastavení.` });
}
const publicKey = process.env.VAPID_PUBLIC_KEY;
const privateKey = process.env.VAPID_PRIVATE_KEY;
const subject = process.env.VAPID_SUBJECT;
if (!publicKey || !privateKey || !subject) {
return res.status(503).json({ error: 'VAPID klíče nejsou nastaveny' });
}
webpush.setVapidDetails(subject, publicKey, privateKey);
await webpush.sendNotification(
entry.subscription,
JSON.stringify({ title: 'Luncher test', body: 'Push notifikace fungují!' })
);
res.status(200).json({ ok: true });
} catch (e: any) { next(e) }
});
export default router;

View File

@@ -1,11 +1,11 @@
import express, { NextFunction } from "express";
import { getLogin } from "../auth";
import { getLogin, getTrusted } from "../auth";
import { parseToken } from "../utils";
import path from "path";
import fs from "fs";
import { EasterEgg } from "../../../types/gen/types.gen";
import { EasterEgg } from "../../../types";
const EASTER_EGGS_JSON_PATH = path.join(__dirname, "../../resources/.easter-eggs.json");
const EASTER_EGGS_JSON_PATH = path.join(__dirname, "../../.easter-eggs.json");
const IMAGES_PATH = '../../resources/easterEggs';
type EasterEggsJson = {
@@ -34,12 +34,17 @@ function generateUrl() {
*/
function getEasterEggImage(req: any, res: any, next: NextFunction) {
const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req));
try {
// TODO vrátit!
// if (trusted) {
if (true) {
if (login in easterEggs) {
const imagePath = easterEggs[login][Math.floor(Math.random() * easterEggs[login].length)].path;
res.sendFile(path.join(__dirname, IMAGES_PATH, imagePath));
return;
}
}
res.sendStatus(404);
} catch (e: any) { next(e) }
}
@@ -119,7 +124,7 @@ let easterEggs: EasterEggsJson;
if (fs.existsSync(EASTER_EGGS_JSON_PATH)) {
const content = fs.readFileSync(EASTER_EGGS_JSON_PATH, 'utf-8');
easterEggs = JSON.parse(content);
for (const [_, eggs] of Object.entries(easterEggs)) {
for (const [key, eggs] of Object.entries(easterEggs)) {
for (const easterEgg of eggs) {
const url = generateUrl();
easterEgg.url = url;
@@ -133,12 +138,17 @@ if (fs.existsSync(EASTER_EGGS_JSON_PATH)) {
// Získání náhodného easter eggu pro přihlášeného uživatele
router.get("/", async (req, res, next) => {
const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req));
try {
// TODO vrátit!
// if (trusted) {
if (true) {
if (easterEggs && login in easterEggs) {
const randomEasterEgg = easterEggs[login][Math.floor(Math.random() * easterEggs[login].length)];
const { path, startOffset, endOffset, ...strippedEasterEgg } = randomEasterEgg; // Path klient k ničemu nepotřebuje a nemá ho znát
return res.status(200).json({ ...strippedEasterEgg, ...getRandomPosition(startOffset, endOffset) });
}
}
return res.status(200).send();
} catch (e: any) { next(e) }
});

View File

@@ -1,51 +1,10 @@
import express, { Request, Response } from "express";
import express, { Request } from "express";
import { getLogin, getTrusted } from "../auth";
import { addChoice, getDateForWeekIndex, getToday, removeChoice, removeChoices, updateDepartureTime, updateNote, fetchRestaurantWeekMenuData, saveRestaurantWeekMenu, updateBuyer } from "../service";
import { getDayOfWeekIndex, parseToken, getFirstWorkDayOfWeek } from "../utils";
import { addChoice, addVolatileData, getDateForWeekIndex, getToday, removeChoice, removeChoices, updateDepartureTime, updateNote } from "../service";
import { getDayOfWeekIndex, parseToken } from "../utils";
import { getWebsocket } from "../websocket";
import { callNotifikace } from "../notifikace";
import { AddChoiceData, ChangeDepartureTimeData, RemoveChoiceData, RemoveChoicesData, UdalostEnum, UpdateNoteData } from "../../../types/gen/types.gen";
// RateLimit na refresh endpoint
interface RateLimitEntry {
count: number;
resetTime: number;
}
const rateLimits: Record<string, RateLimitEntry> = {};
const RATE_LIMIT = 1; // maximální počet požadavků za minutu
const RATE_LIMIT_WINDOW = 30 * 60 * 1000; // je to v ms (x * 1min)
// Kontrola ratelimitu
function checkRateLimit(key: string, limit: number = RATE_LIMIT): boolean {
const now = Date.now();
// Vyčištění starých záznamů
Object.keys(rateLimits).forEach(k => {
if (rateLimits[k].resetTime < now) {
delete rateLimits[k];
}
});
// Kontrola, že záznam existuje a platí
if (rateLimits[key] && rateLimits[key].resetTime > now) {
// Záznam platí a kontroluje se limit
if (rateLimits[key].count >= limit) {
return false; // Překročen limit
}
// ++ xd
rateLimits[key].count++;
return true;
} else {
// + klic
rateLimits[key] = {
count: 1,
resetTime: now + RATE_LIMIT_WINDOW
};
return true;
}
}
import { AddChoiceRequest, ChangeDepartureTimeRequest, IDayIndex, RemoveChoiceRequest, RemoveChoicesRequest, UdalostEnum, UpdateNoteRequest } from "../../../types";
/**
* Ověří a vrátí index dne v týdnu z požadavku, za předpokladu, že byl předán, a je zároveň
@@ -54,7 +13,7 @@ function checkRateLimit(key: string, limit: number = RATE_LIMIT): boolean {
* @param req request
* @returns index dne v týdnu
*/
const parseValidateFutureDayIndex = (req: Request<{}, any, AddChoiceData["body"] | UpdateNoteData["body"]>) => {
const parseValidateFutureDayIndex = (req: Request<{}, any, IDayIndex>) => {
if (req.body.dayIndex == null) {
throw Error(`Nebyl předán index dne v týdnu.`);
}
@@ -71,7 +30,7 @@ const parseValidateFutureDayIndex = (req: Request<{}, any, AddChoiceData["body"]
const router = express.Router();
router.post("/addChoice", async (req: Request<{}, any, AddChoiceData["body"]>, res, next) => {
router.post("/addChoice", async (req: Request<{}, any, AddChoiceRequest>, res, next) => {
const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req));
let date = undefined;
@@ -86,12 +45,12 @@ router.post("/addChoice", async (req: Request<{}, any, AddChoiceData["body"]>, r
}
try {
const data = await addChoice(login, trusted, req.body.locationKey, req.body.foodIndex, date);
getWebsocket().emit("message", data);
getWebsocket().emit("message", await addVolatileData(data));
return res.status(200).json(data);
} catch (e: any) { next(e) }
});
router.post("/removeChoices", async (req: Request<{}, any, RemoveChoicesData["body"]>, res, next) => {
router.post("/removeChoices", async (req: Request<{}, any, RemoveChoicesRequest>, res, next) => {
const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req));
let date = undefined;
@@ -106,12 +65,12 @@ router.post("/removeChoices", async (req: Request<{}, any, RemoveChoicesData["bo
}
try {
const data = await removeChoices(login, trusted, req.body.locationKey, date);
getWebsocket().emit("message", data);
getWebsocket().emit("message", await addVolatileData(data));
res.status(200).json(data);
} catch (e: any) { next(e) }
});
router.post("/removeChoice", async (req: Request<{}, any, RemoveChoiceData["body"]>, res, next) => {
router.post("/removeChoice", async (req: Request<{}, any, RemoveChoiceRequest>, res, next) => {
const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req));
let date = undefined;
@@ -126,12 +85,12 @@ router.post("/removeChoice", async (req: Request<{}, any, RemoveChoiceData["body
}
try {
const data = await removeChoice(login, trusted, req.body.locationKey, req.body.foodIndex, date);
getWebsocket().emit("message", data);
getWebsocket().emit("message", await addVolatileData(data));
res.status(200).json(data);
} catch (e: any) { next(e) }
});
router.post("/updateNote", async (req: Request<{}, any, UpdateNoteData["body"]>, res, next) => {
router.post("/updateNote", async (req: Request<{}, any, UpdateNoteRequest>, res, next) => {
const login = getLogin(parseToken(req));
const trusted = getTrusted(parseToken(req));
const note = req.body.note;
@@ -150,12 +109,12 @@ router.post("/updateNote", async (req: Request<{}, any, UpdateNoteData["body"]>,
date = getDateForWeekIndex(dayIndex);
}
const data = await updateNote(login, trusted, note, date);
getWebsocket().emit("message", data);
getWebsocket().emit("message", await addVolatileData(data));
res.status(200).json(data);
} catch (e: any) { next(e) }
});
router.post("/changeDepartureTime", async (req: Request<{}, any, ChangeDepartureTimeData["body"]>, res, next) => {
router.post("/changeDepartureTime", async (req: Request<{}, any, ChangeDepartureTimeRequest>, res, next) => {
const login = getLogin(parseToken(req));
let date = undefined;
if (req.body.dayIndex != null) {
@@ -169,7 +128,7 @@ router.post("/changeDepartureTime", async (req: Request<{}, any, ChangeDeparture
}
try {
const data = await updateDepartureTime(login, req.body?.time, date);
getWebsocket().emit("message", data);
getWebsocket().emit("message", await addVolatileData(data));
res.status(200).json(data);
} catch (e: any) { next(e) }
});
@@ -177,106 +136,9 @@ router.post("/changeDepartureTime", async (req: Request<{}, any, ChangeDeparture
router.post("/jdemeObed", async (req, res, next) => {
const login = getLogin(parseToken(req));
try {
await callNotifikace({ input: { user: login, udalost: UdalostEnum.JDEME_NA_OBED }, gotify: false })
await callNotifikace({ input: { user: login, udalost: UdalostEnum.JDEME_OBED }, gotify: false })
res.status(200).json({});
} catch (e: any) { next(e) }
});
router.post("/updateBuyer", async (req, res, next) => {
const login = getLogin(parseToken(req));
try {
const data = await updateBuyer(login);
getWebsocket().emit("message", data);
res.status(200).json({});
} catch (e: any) { next(e) }
});
// /api/food/refresh?type=week (přihlášený uživatel, nebo ?heslo=... pro bypass rate limitu)
export const refreshMetoda = async (req: Request, res: Response) => {
const { type, heslo } = req.query as { type?: string; heslo?: string };
const bypassPassword = process.env.REFRESH_BYPASS_PASSWORD;
const isBypass = !!bypassPassword && heslo === bypassPassword;
if (!isBypass) {
try {
getLogin(parseToken(req));
} catch {
return res.status(403).json({ error: "Přihlaste se prosím" });
}
}
if (!checkRateLimit("refresh") && !isBypass) {
return res.status(429).json({ error: "Refresh už se zavolal, chvíli počkej :))" });
}
if (type !== "week" && type !== "day") {
return res.status(400).json({ error: "Neznámý typ refresh" });
}
if (type === "day") {
return res.status(400).json({ error: "ještě neumim TODO..." });
}
try {
// Pro všechny restaurace refreshni menu na aktuální týden
const restaurants = ["SLADOVNICKA", "TECHTOWER", "ZASTAVKAUMICHALA", "SENKSERIKOVA"] as const;
const firstDay = getFirstWorkDayOfWeek(getToday());
const results: Record<string, any> = {};
const successfulRestaurants: string[] = [];
const failedRestaurants: string[] = [];
// Nejdříve načíst všechna data bez ukládání
for (const rest of restaurants) {
try {
const weekData = await fetchRestaurantWeekMenuData(rest, firstDay);
results[rest] = weekData;
// Kontrola validity dat
if (weekData && weekData.length > 0 &&
weekData.some(dayMenu => dayMenu && dayMenu.length > 0)) {
successfulRestaurants.push(rest);
} else {
failedRestaurants.push(rest);
results[rest] = { error: "Žádná validní data" };
}
} catch (error) {
failedRestaurants.push(rest);
results[rest] = { error: `Chyba při načítání: ${error}` };
}
}
// Pokud se nepodařilo načíst žádnou restauraci
if (successfulRestaurants.length === 0) {
return res.status(400).json({
error: "Nepodařilo se získat validní data z žádné restaurace",
failed: failedRestaurants,
results: results
});
}
// Uložit pouze validní data
for (const rest of successfulRestaurants) {
try {
await saveRestaurantWeekMenu(rest as any, firstDay, results[rest]);
} catch (error) {
console.error(`Chyba při ukládání dat pro ${rest}:`, error);
}
}
// Připravit odpověď
const response: any = {
ok: true,
refreshed: results,
successful: successfulRestaurants
};
if (failedRestaurants.length > 0) {
response.warning = `Nepodařilo se načíst: ${failedRestaurants.join(', ')}`;
response.failed = failedRestaurants;
}
res.status(200).json(response);
} catch (e: any) {
res.status(500).json({ error: e?.message || "Chyba při refreshi" });
}
}
router.get("/refresh", refreshMetoda);
export default router;

View File

@@ -1,86 +0,0 @@
import express, { Request } from "express";
import { getLogin } from "../auth";
import { parseToken } from "../utils";
import { getNotificationSettings, saveNotificationSettings } from "../notifikace";
import { subscribePush, unsubscribePush, getVapidPublicKey, findLoginByEndpoint } from "../pushReminder";
import { addChoice } from "../service";
import { getWebsocket } from "../websocket";
import { UpdateNotificationSettingsData } from "../../../types";
const router = express.Router();
/** Vrátí nastavení notifikací pro přihlášeného uživatele. */
router.get("/settings", async (req, res, next) => {
const login = getLogin(parseToken(req));
try {
const settings = await getNotificationSettings(login);
res.status(200).json(settings);
} catch (e: any) { next(e) }
});
/** Uloží nastavení notifikací pro přihlášeného uživatele. */
router.post("/settings", async (req: Request<{}, any, UpdateNotificationSettingsData["body"]>, res, next) => {
const login = getLogin(parseToken(req));
try {
const settings = await saveNotificationSettings(login, {
ntfyTopic: req.body.ntfyTopic,
discordWebhookUrl: req.body.discordWebhookUrl,
teamsWebhookUrl: req.body.teamsWebhookUrl,
enabledEvents: req.body.enabledEvents,
reminderTime: req.body.reminderTime,
});
res.status(200).json(settings);
} catch (e: any) { next(e) }
});
/** Vrátí veřejný VAPID klíč pro registraci push notifikací. */
router.get("/push/vapidKey", (req, res) => {
const key = getVapidPublicKey();
if (!key) {
return res.status(503).json({ error: "Push notifikace nejsou nakonfigurovány" });
}
res.status(200).json({ key });
});
/** Přihlásí uživatele k push připomínkám. */
router.post("/push/subscribe", async (req, res, next) => {
const login = getLogin(parseToken(req));
try {
if (!req.body.subscription) {
return res.status(400).json({ error: "Nebyla předána push subscription" });
}
if (!req.body.reminderTime) {
return res.status(400).json({ error: "Nebyl předán čas připomínky" });
}
await subscribePush(login, req.body.subscription, req.body.reminderTime);
res.status(200).json({});
} catch (e: any) { next(e) }
});
/** Odhlásí uživatele z push připomínek. */
router.post("/push/unsubscribe", async (req, res, next) => {
const login = getLogin(parseToken(req));
try {
await unsubscribePush(login);
res.status(200).json({});
} catch (e: any) { next(e) }
});
/** Rychlá akce z push notifikace — nastaví volbu bez JWT (identita přes subscription endpoint). */
router.post("/push/quickChoice", async (req, res, next) => {
try {
const { endpoint } = req.body;
if (!endpoint) {
return res.status(400).json({ error: "Nebyl předán endpoint" });
}
const login = await findLoginByEndpoint(endpoint);
if (!login) {
return res.status(404).json({ error: "Subscription nenalezena" });
}
const data = await addChoice(login, false, 'NEOBEDVAM', undefined, undefined);
getWebsocket().emit("message", data);
res.status(200).json({});
} catch (e: any) { next(e) }
});
export default router;

View File

@@ -1,9 +1,10 @@
import express, { Request } from "express";
import { getLogin } from "../auth";
import { createPizzaDay, deletePizzaDay, getPizzaList, addPizzaOrder, removePizzaOrder, lockPizzaDay, unlockPizzaDay, finishPizzaOrder, finishPizzaDelivery, updatePizzaDayNote, updatePizzaFee, dismissPendingQr } from "../pizza";
import { createPizzaDay, deletePizzaDay, getPizzaList, addPizzaOrder, removePizzaOrder, lockPizzaDay, unlockPizzaDay, finishPizzaOrder, finishPizzaDelivery, updatePizzaDayNote, updatePizzaFee } from "../pizza";
import { parseToken } from "../utils";
import { getWebsocket } from "../websocket";
import { AddPizzaData, DismissQrData, FinishDeliveryData, RemovePizzaData, UpdatePizzaDayNoteData, UpdatePizzaFeeData } from "../../../types";
import { addVolatileData } from "../service";
import { AddPizzaRequest, FinishDeliveryRequest, RemovePizzaRequest, UpdatePizzaDayNoteRequest, UpdatePizzaFeeRequest } from "../../../types";
const router = express.Router();
@@ -12,17 +13,17 @@ router.post("/create", async (req: Request<{}, any, undefined>, res) => {
const login = getLogin(parseToken(req));
const data = await createPizzaDay(login);
res.status(200).json(data);
getWebsocket().emit("message", data);
getWebsocket().emit("message", await addVolatileData(data));
});
/** Smaže pizza day pro aktuální den, za předpokladu že existuje. */
router.post("/delete", async (req: Request<{}, any, undefined>, res) => {
const login = getLogin(parseToken(req));
const data = await deletePizzaDay(login);
getWebsocket().emit("message", data);
getWebsocket().emit("message", await addVolatileData(data));
});
router.post("/add", async (req: Request<{}, any, AddPizzaData["body"]>, res) => {
router.post("/add", async (req: Request<{}, any, AddPizzaRequest>, res) => {
const login = getLogin(parseToken(req));
if (isNaN(req.body?.pizzaIndex)) {
throw Error("Nebyl předán index pizzy");
@@ -43,82 +44,70 @@ router.post("/add", async (req: Request<{}, any, AddPizzaData["body"]>, res) =>
throw Error("Neplatný index velikosti pizzy: " + pizzaSizeIndex);
}
const data = await addPizzaOrder(login, pizzy[pizzaIndex], pizzy[pizzaIndex].sizes[pizzaSizeIndex]);
getWebsocket().emit("message", data);
getWebsocket().emit("message", await addVolatileData(data));
res.status(200).json({});
});
router.post("/remove", async (req: Request<{}, any, RemovePizzaData["body"]>, res) => {
router.post("/remove", async (req: Request<{}, any, RemovePizzaRequest>, res) => {
const login = getLogin(parseToken(req));
if (!req.body?.pizzaOrder) {
throw Error("Nebyla předána objednávka");
}
const data = await removePizzaOrder(login, req.body?.pizzaOrder);
getWebsocket().emit("message", data);
getWebsocket().emit("message", await addVolatileData(data));
res.status(200).json({});
});
router.post("/lock", async (req: Request<{}, any, undefined>, res) => {
const login = getLogin(parseToken(req));
const data = await lockPizzaDay(login);
getWebsocket().emit("message", data);
getWebsocket().emit("message", await addVolatileData(data));
res.status(200).json({});
});
router.post("/unlock", async (req: Request<{}, any, undefined>, res) => {
const login = getLogin(parseToken(req));
const data = await unlockPizzaDay(login);
getWebsocket().emit("message", data);
getWebsocket().emit("message", await addVolatileData(data));
res.status(200).json({});
});
router.post("/finishOrder", async (req: Request<{}, any, undefined>, res) => {
const login = getLogin(parseToken(req));
const data = await finishPizzaOrder(login);
getWebsocket().emit("message", data);
getWebsocket().emit("message", await addVolatileData(data));
res.status(200).json({});
});
router.post("/finishDelivery", async (req: Request<{}, any, FinishDeliveryData["body"]>, res) => {
router.post("/finishDelivery", async (req: Request<{}, any, FinishDeliveryRequest>, res) => {
const login = getLogin(parseToken(req));
const data = await finishPizzaDelivery(login, req.body.bankAccount, req.body.bankAccountHolder);
getWebsocket().emit("message", data);
getWebsocket().emit("message", await addVolatileData(data));
res.status(200).json({});
});
router.post("/updatePizzaDayNote", async (req: Request<{}, any, UpdatePizzaDayNoteData["body"]>, res, next) => {
router.post("/updatePizzaDayNote", async (req: Request<{}, any, UpdatePizzaDayNoteRequest>, res, next) => {
const login = getLogin(parseToken(req));
try {
if (req.body.note && req.body.note.length > 70) {
throw Error("Poznámka může mít maximálně 70 znaků");
}
const data = await updatePizzaDayNote(login, req.body.note);
getWebsocket().emit("message", data);
getWebsocket().emit("message", await addVolatileData(data));
res.status(200).json(data);
} catch (e: any) { next(e) }
});
router.post("/updatePizzaFee", async (req: Request<{}, any, UpdatePizzaFeeData["body"]>, res, next) => {
router.post("/updatePizzaFee", async (req: Request<{}, any, UpdatePizzaFeeRequest>, res, next) => {
const login = getLogin(parseToken(req));
if (!req.body.login) {
return res.status(400).json({ error: "Nebyl předán login cílového uživatele" });
}
try {
const data = await updatePizzaFee(login, req.body.login, req.body.text, req.body.price);
getWebsocket().emit("message", data);
getWebsocket().emit("message", await addVolatileData(data));
res.status(200).json(data);
} catch (e: any) { next(e) }
});
/** Označí QR kód jako uhrazený. */
router.post("/dismissQr", async (req: Request<{}, any, DismissQrData["body"]>, res, next) => {
const login = getLogin(parseToken(req));
if (!req.body.date) {
return res.status(400).json({ error: "Nebyl předán datum" });
}
try {
await dismissPendingQr(login, req.body.date);
res.status(200).json({});
} catch (e: any) { next(e) }
});
export default router;

View File

@@ -1,64 +0,0 @@
import express, { Request } from "express";
import { getLogin } from "../auth";
import { parseToken, formatDate } from "../utils";
import { generateQr } from "../qr";
import { addPendingQr } from "../pizza";
import { GenerateQrData } from "../../../types";
const router = express.Router();
/**
* Vygeneruje QR kódy pro platbu vybraným uživatelům.
*/
router.post("/generate", async (req: Request<{}, any, GenerateQrData["body"]>, res, next) => {
const login = getLogin(parseToken(req));
try {
const { recipients, bankAccount, bankAccountHolder } = req.body;
if (!recipients || !Array.isArray(recipients) || recipients.length === 0) {
return res.status(400).json({ error: "Nebyl předán seznam příjemců" });
}
if (!bankAccount) {
return res.status(400).json({ error: "Nebylo předáno číslo účtu" });
}
if (!bankAccountHolder) {
return res.status(400).json({ error: "Nebylo předáno jméno držitele účtu" });
}
const today = formatDate(new Date());
for (const recipient of recipients) {
if (!recipient.login) {
return res.status(400).json({ error: "Příjemce nemá vyplněný login" });
}
if (!recipient.purpose || recipient.purpose.trim().length === 0) {
return res.status(400).json({ error: `Příjemce ${recipient.login} nemá vyplněný účel platby` });
}
if (typeof recipient.amount !== 'number' || recipient.amount <= 0) {
return res.status(400).json({ error: `Příjemce ${recipient.login} má neplatnou částku` });
}
// Validace max 2 desetinná místa
const amountStr = recipient.amount.toString();
if (amountStr.includes('.') && amountStr.split('.')[1].length > 2) {
return res.status(400).json({ error: `Částka pro ${recipient.login} má více než 2 desetinná místa` });
}
// Vygenerovat QR kód
await generateQr(recipient.login, bankAccount, bankAccountHolder, recipient.amount, recipient.purpose);
// Uložit jako nevyřízený QR kód
await addPendingQr(recipient.login, {
date: today,
creator: login,
totalPrice: recipient.amount,
purpose: recipient.purpose,
});
}
res.status(200).json({ success: true, count: recipients.length });
} catch (e: any) {
next(e);
}
});
export default router;

View File

@@ -1,22 +0,0 @@
import express, { Request, Response } from "express";
import { getLogin } from "../auth";
import { parseToken } from "../utils";
import { getStats } from "../stats";
import { WeeklyStats } from "../../../types/gen/types.gen";
const router = express.Router();
router.get("/", async (req: Request<{}, any, undefined>, res: Response<WeeklyStats>) => {
getLogin(parseToken(req));
if (typeof req.query.startDate === 'string' && typeof req.query.endDate === 'string') {
try {
const data = await getStats(req.query.startDate, req.query.endDate);
return res.status(200).json(data);
} catch (e) {
// necháme to zatím spadnout na 400
}
}
res.sendStatus(400);
});
export default router;

View File

@@ -1,18 +1,18 @@
import express, { Request } from "express";
import express, { Request, Response } from "express";
import { getLogin } from "../auth";
import { parseToken } from "../utils";
import { getUserVotes, updateFeatureVote, getVotingStats } from "../voting";
import { GetVotesData, UpdateVoteData } from "../../../types";
import { getUserVotes, updateFeatureVote } from "../voting";
import { FeatureRequest, UpdateFeatureVoteRequest } from "../../../types";
const router = express.Router();
router.get("/getVotes", async (req: Request<{}, any, GetVotesData["body"]>, res) => {
router.get("/getVotes", async (req: Request<{}, any, undefined>, res: Response<FeatureRequest[]>) => {
const login = getLogin(parseToken(req));
const data = await getUserVotes(login);
res.status(200).json(data);
});
router.post("/updateVote", async (req: Request<{}, any, UpdateVoteData["body"]>, res, next) => {
router.post("/updateVote", async (req: Request<{}, any, UpdateFeatureVoteRequest>, res, next) => {
const login = getLogin(parseToken(req));
if (req.body?.option == null || req.body?.active == null) {
res.status(400).json({ error: "Chybné parametry volání" });
@@ -23,11 +23,4 @@ router.post("/updateVote", async (req: Request<{}, any, UpdateVoteData["body"]>,
} catch (e: any) { next(e) }
});
router.get("/stats", async (req, res, next) => {
try {
const data = await getVotingStats();
res.status(200).json(data);
} catch (e: any) { next(e) }
});
export default router;

View File

@@ -1,9 +1,8 @@
import { InsufficientPermissions, PizzaDayConflictError, formatDate, getDayOfWeekIndex, getFirstWorkDayOfWeek, getHumanDate, getIsWeekend, getWeekNumber } from "./utils";
import { InsufficientPermissions, formatDate, getDayOfWeekIndex, getFirstWorkDayOfWeek, getHumanDate, getIsWeekend, getWeekNumber } from "./utils";
import { ClientData, Restaurants, RestaurantDailyMenu, DepartureTime, DayData, WeekMenu, LocationKey, DayOfWeekIndex, daysOfWeeksIndices } from "../../types";
import getStorage from "./storage";
import { getMenuSladovnicka, getMenuTechTower, getMenuZastavkaUmichala, getMenuSenkSerikova, StaleWeekError } from "./restaurants";
import { getMenuSladovnicka, getMenuTechTower, getMenuUMotliku, getMenuZastavkaUmichala } from "./restaurants";
import { getTodayMock } from "./mock";
import { removeAllUserPizzas } from "./pizza";
import { ClientData, DepartureTime, LunchChoice, PizzaDayState, Restaurant, RestaurantDayMenu, WeekMenu } from "../../types/gen/types.gen";
const storage = getStorage();
const MENU_PREFIX = 'menu';
@@ -32,31 +31,44 @@ export const getDateForWeekIndex = (index: number) => {
function getEmptyData(date?: Date): ClientData {
const usedDate = date || getToday();
return {
todayDayIndex: getDayOfWeekIndex(getToday()),
date: getHumanDate(usedDate),
isWeekend: getIsWeekend(usedDate),
dayIndex: getDayOfWeekIndex(usedDate),
weekIndex: getDayOfWeekIndex(usedDate),
choices: {},
};
}
/**
* Přidá k datům "dopočítaná" data, která nejsou přímo uložena v databázi.
*
* @param data data z databáze
* @returns obohacená data
*/
export async function addVolatileData(data: ClientData): Promise<ClientData> {
data.todayWeekIndex = getDayOfWeekIndex(getToday());
return data;
}
/**
* Vrátí veškerá klientská data pro předaný den, nebo aktuální den, pokud není předán.
*/
export async function getData(date?: Date): Promise<ClientData> {
const clientData = await getClientData(date);
const targetDate = date ?? getToday();
const dateString = formatDate(targetDate);
const data: DayData = await storage.getData(dateString) || getEmptyData(date);
let clientData: ClientData = { ...data };
clientData.menus = {
SLADOVNICKA: await getRestaurantMenu('SLADOVNICKA', date),
// UMOTLIKU: await getRestaurantMenu('UMOTLIKU', date),
TECHTOWER: await getRestaurantMenu('TECHTOWER', date),
ZASTAVKAUMICHALA: await getRestaurantMenu('ZASTAVKAUMICHALA', date),
SENKSERIKOVA: await getRestaurantMenu('SENKSERIKOVA', date),
[Restaurants.SLADOVNICKA]: await getRestaurantMenu(Restaurants.SLADOVNICKA, date),
// [Restaurants.UMOTLIKU]: await getRestaurantMenu(Restaurants.UMOTLIKU, date),
[Restaurants.TECHTOWER]: await getRestaurantMenu(Restaurants.TECHTOWER, date),
[Restaurants.ZASTAVKAUMICHALA]: await getRestaurantMenu(Restaurants.ZASTAVKAUMICHALA, date),
}
clientData = await addVolatileData(clientData);
return clientData;
}
/**
* Vrátí klíč, pod kterým je uloženo menu pro týden příslušící předanému datu.
* Vrátí klíč, pod kterým je uloženo menu pro předané datum.
*
* @param date datum
* @returns databázový klíč
@@ -67,117 +79,25 @@ function getMenuKey(date: Date) {
}
/**
* Vrátí menu všech podniků pro celý týden do kterého spadá předané datum, pokud již existují.
* Vrátí menu restaurací pro předané datum, pokud již existují.
*
* @param date datum
* @returns menu restaurací pro týden příslušící předanému datu
* @returns menu restaurací pro předané datum
*/
async function getMenu(date: Date): Promise<WeekMenu | undefined> {
return await storage.getData<WeekMenu | undefined>(getMenuKey(date));
return await storage.getData(getMenuKey(date));
}
// TODO přesun do restaurants.ts
/**
* Načte menu dané restaurace pro celý týden bez ukládání do storage.
* Používá se pro validaci dat před uložením.
*
* @param restaurant restaurace
* @param firstDay první pracovní den týdne
* @returns pole menu pro jednotlivé dny týdne
*/
export async function fetchRestaurantWeekMenuData(restaurant: Restaurant, firstDay: Date): Promise<any[]> {
return await fetchRestaurantWeekMenu(restaurant, firstDay);
}
/**
* Uloží týdenní menu restaurace do storage.
*
* @param restaurant restaurace
* @param date datum z týdne, pro který ukládat menu
* @param weekData data týdenního menu
*/
export async function saveRestaurantWeekMenu(restaurant: Restaurant, date: Date, weekData: any[]): Promise<void> {
const now = new Date().getTime();
let weekMenu = await getMenu(date);
weekMenu ??= [{}, {}, {}, {}, {}];
// Inicializace struktury pro restauraci
for (let i = 0; i < 5; i++) {
weekMenu[i] ??= {};
weekMenu[i][restaurant] ??= {
lastUpdate: now,
closed: false,
food: [],
};
}
// Uložení dat pro všechny dny
for (let i = 0; i < weekData.length && i < weekMenu.length; i++) {
weekMenu[i][restaurant]!.food = weekData[i];
weekMenu[i][restaurant]!.lastUpdate = now;
// Detekce uzavření pro každou restauraci
switch (restaurant) {
case 'SLADOVNICKA':
if (weekData[i].length === 1 && weekData[i][0].name.toLowerCase() === 'pro daný den nebyla nalezena denní nabídka') {
weekMenu[i][restaurant]!.closed = true;
}
break;
case 'TECHTOWER':
if (weekData[i]?.length === 1 && weekData[i][0].name.toLowerCase() === 'svátek') {
weekMenu[i][restaurant]!.closed = true;
}
break;
case 'ZASTAVKAUMICHALA':
if (weekData[i]?.length === 1 && weekData[i][0].name === 'Pro tento den není uveřejněna nabídka jídel.') {
weekMenu[i][restaurant]!.closed = true;
}
break;
case 'SENKSERIKOVA':
if (weekData[i]?.length === 1 && weekData[i][0].name === 'Pro tento den nebylo zadáno menu.') {
weekMenu[i][restaurant]!.closed = true;
}
break;
}
}
// Uložení do storage
await storage.setData(getMenuKey(date), weekMenu);
}
/**
* Načte menu dané restaurace pro celý týden bez ukládání do storage.
*
* @param restaurant restaurace
* @param firstDay první pracovní den týdne
* @returns pole menu pro jednotlivé dny týdne
*/
async function fetchRestaurantWeekMenu(restaurant: Restaurant, firstDay: Date): Promise<any[]> {
const mock = process.env.MOCK_DATA === 'true';
switch (restaurant) {
case 'SLADOVNICKA':
return await getMenuSladovnicka(firstDay, mock);
case 'TECHTOWER':
return await getMenuTechTower(firstDay, mock);
case 'ZASTAVKAUMICHALA':
return await getMenuZastavkaUmichala(firstDay, mock);
case 'SENKSERIKOVA':
return await getMenuSenkSerikova(firstDay, mock);
default:
throw new Error(`Nepodporovaná restaurace: ${restaurant}`);
}
}
/**
* Vrátí menu dané restaurace pro předaný den.
* Pokud neexistuje, provede stažení menu pro příslušný týden a uložení do DB.
*
* @param restaurant restaurace
* @param date datum, ke kterému získat menu
* @param forceRefresh příznak vynuceného obnovení
* @param mock příznak, zda chceme pouze mock data
*/
export async function getRestaurantMenu(restaurant: Restaurant, date?: Date, forceRefresh = false): Promise<RestaurantDayMenu> {
export async function getRestaurantMenu(restaurant: Restaurants, date?: Date): Promise<RestaurantDailyMenu> {
const usedDate = date ?? getToday();
const dayOfWeekIndex = getDayOfWeekIndex(usedDate);
const now = new Date().getTime();
@@ -189,100 +109,87 @@ export async function getRestaurantMenu(restaurant: Restaurant, date?: Date, for
};
}
let weekMenu = await getMenu(usedDate);
weekMenu ??= [{}, {}, {}, {}, {}];
for (let i = 0; i < 5; i++) {
weekMenu[i] ??= {};
weekMenu[i][restaurant] ??= {
let menus = await getMenu(usedDate);
if (menus == null) {
menus = {};
}
daysOfWeeksIndices.forEach(i => {
if (menus[i] == null) {
menus[i] = {};
}
if (menus[i][restaurant] == null) {
menus[i][restaurant] = {
lastUpdate: now,
closed: false,
food: [],
};
}
const MENU_REFETCH_TTL_MS = 60 * 60 * 1000; // 1 hour
const existingMenu = weekMenu[dayOfWeekIndex][restaurant];
const lastFetchExpired = !existingMenu?.lastUpdate ||
existingMenu.lastUpdate === now || // freshly initialized, never fetched
(now - existingMenu.lastUpdate) > MENU_REFETCH_TTL_MS;
const shouldFetch = forceRefresh ||
(!existingMenu?.food?.length && !existingMenu?.closed && lastFetchExpired);
if (shouldFetch) {
})
if (!menus[dayOfWeekIndex]) {
menus[dayOfWeekIndex] = {};
}
if (!menus[dayOfWeekIndex][restaurant]?.food?.length) {
const firstDay = getFirstWorkDayOfWeek(usedDate);
try {
const restaurantWeekFood = await fetchRestaurantWeekMenu(restaurant, firstDay);
// Aktualizace menu pro všechny dny
for (let i = 0; i < restaurantWeekFood.length && i < weekMenu.length; i++) {
weekMenu[i][restaurant]!.food = restaurantWeekFood[i];
weekMenu[i][restaurant]!.lastUpdate = now;
weekMenu[i][restaurant]!.isStale = false;
// Detekce uzavření pro každou restauraci
const mock = process.env.MOCK_DATA === 'true';
switch (restaurant) {
case 'SLADOVNICKA':
if (restaurantWeekFood[i].length === 1 && restaurantWeekFood[i][0].name.toLowerCase() === 'pro daný den nebyla nalezena denní nabídka') {
weekMenu[i][restaurant]!.closed = true;
}
break;
case 'TECHTOWER':
if (restaurantWeekFood[i]?.length === 1 && restaurantWeekFood[i][0].name.toLowerCase() === 'svátek') {
weekMenu[i][restaurant]!.closed = true;
}
break;
case 'ZASTAVKAUMICHALA':
if (restaurantWeekFood[i]?.length === 1 && restaurantWeekFood[i][0].name === 'Pro tento den není uveřejněna nabídka jídel.') {
weekMenu[i][restaurant]!.closed = true;
}
break;
case 'SENKSERIKOVA':
if (restaurantWeekFood[i]?.length === 1 && restaurantWeekFood[i][0].name === 'Pro tento den nebylo zadáno menu.') {
weekMenu[i][restaurant]!.closed = true;
}
break;
case Restaurants.SLADOVNICKA:
try {
// TODO tady jsme v háji, protože z následujících metod vracíme arbitrárně dlouhé pole (musíme vracet omezené na maximálně 0-7 prvků)
const sladovnickaFood = await getMenuSladovnicka(firstDay, mock);
for (let i = 0; i < sladovnickaFood.length; i++) {
menus[i][restaurant]!.food = sladovnickaFood[i];
// Velice chatrný a nespolehlivý způsob detekce uzavření...
if (sladovnickaFood[i].length === 1 && sladovnickaFood[i][0].name.toLowerCase() === 'pro daný den nebyla nalezena denní nabídka') {
menus[i][restaurant]!.closed = true;
}
}
// Uložení do storage
await storage.setData(getMenuKey(usedDate), weekMenu);
} catch (e: any) {
if (e instanceof StaleWeekError) {
for (let i = 0; i < e.food.length && i < weekMenu.length; i++) {
weekMenu[i][restaurant]!.food = e.food[i];
weekMenu[i][restaurant]!.lastUpdate = now;
weekMenu[i][restaurant]!.isStale = true;
console.error("Selhalo načtení jídel pro podnik Sladovnická", e);
}
await storage.setData(getMenuKey(usedDate), weekMenu);
} else {
console.error(`Selhalo načtení jídel pro podnik ${restaurant}`, e);
break;
// case Restaurants.UMOTLIKU:
// try {
// const uMotlikuFood = await getMenuUMotliku(firstDay, mock);
// for (let i = 0; i < uMotlikuFood.length; i++) {
// menus[i][restaurant]!.food = uMotlikuFood[i];
// if (uMotlikuFood[i].length === 1 && uMotlikuFood[i][0].name.toLowerCase() === 'zavřeno') {
// menus[i][restaurant]!.closed = true;
// }
// }
// } catch (e: any) {
// console.error("Selhalo načtení jídel pro podnik U Motlíků", e);
// }
// break;
case Restaurants.TECHTOWER:
try {
const techTowerFood = await getMenuTechTower(firstDay, mock);
for (let i = 0; i < techTowerFood.length; i++) {
menus[i][restaurant]!.food = techTowerFood[i];
if (techTowerFood[i]?.length === 1 && techTowerFood[i][0].name.toLowerCase() === 'svátek') {
menus[i][restaurant]!.closed = true;
}
}
break;
} catch (e: any) {
console.error("Selhalo načtení jídel pro podnik TechTower", e);
}
const result = weekMenu[dayOfWeekIndex][restaurant]!;
result.warnings = generateMenuWarnings(result);
return result;
case Restaurants.ZASTAVKAUMICHALA:
try {
const zastavkaUmichalaFood = await getMenuZastavkaUmichala(firstDay, mock);
for (let i = 0; i < zastavkaUmichalaFood.length; i++) {
menus[i][restaurant]!.food = zastavkaUmichalaFood[i];
if (zastavkaUmichalaFood[i]?.length === 1 && zastavkaUmichalaFood[i][0].name === 'Pro tento den není uveřejněna nabídka jídel.') {
menus[i][restaurant]!.closed = true;
}
/**
* Generuje varování o kvalitě/úplnosti dat menu restaurace.
*/
function generateMenuWarnings(menu: RestaurantDayMenu): string[] {
const warnings: string[] = [];
if (!menu.food?.length || menu.closed) {
return warnings;
}
if (menu.isStale) {
warnings.push('Data jsou z minulého týdne');
break;
} catch (e: any) {
console.error("Selhalo načtení jídel pro podnik Zastávka u Michala", e);
}
const hasSoup = menu.food.some(f => f.isSoup);
if (!hasSoup) {
warnings.push('Chybí polévka');
}
const missingPrice = menu.food.some(f => !f.isSoup && (!f.price || f.price.trim() === ''));
if (missingPrice) {
warnings.push('U některých jídel chybí cena');
await storage.setData(getMenuKey(usedDate), menus);
}
return warnings;
return menus[dayOfWeekIndex][restaurant]!;
}
/**
@@ -307,9 +214,9 @@ export async function initIfNeeded(date?: Date) {
* @param date datum, ke kterému se volba vztahuje
* @returns
*/
export async function removeChoices(login: string, trusted: boolean, locationKey: LunchChoice, date?: Date) {
export async function removeChoices(login: string, trusted: boolean, locationKey: LocationKey, date?: Date) {
const selectedDay = formatDate(date ?? getToday());
let data = await getClientData(date);
let data: DayData = await storage.getData(selectedDay);
validateTrusted(data, login, trusted);
if (locationKey in data.choices) {
if (data.choices[locationKey] && login in data.choices[locationKey]) {
@@ -334,15 +241,15 @@ export async function removeChoices(login: string, trusted: boolean, locationKey
* @param date datum, ke kterému se volba vztahuje
* @returns
*/
export async function removeChoice(login: string, trusted: boolean, locationKey: LunchChoice, foodIndex: number, date?: Date) {
export async function removeChoice(login: string, trusted: boolean, locationKey: LocationKey, foodIndex: number, date?: Date) {
const selectedDay = formatDate(date ?? getToday());
let data = await getClientData(date);
let data: DayData = await storage.getData(selectedDay);
validateTrusted(data, login, trusted);
if (locationKey in data.choices) {
if (data.choices[locationKey] && login in data.choices[locationKey]) {
const index = data.choices[locationKey][login].selectedFoods?.indexOf(foodIndex);
if (index != null && index > -1) {
data.choices[locationKey][login].selectedFoods?.splice(index, 1);
const index = data.choices[locationKey][login].options.indexOf(foodIndex);
if (index > -1) {
data.choices[locationKey][login].options.splice(index, 1)
await storage.setData(selectedDay, data);
}
}
@@ -351,26 +258,20 @@ export async function removeChoice(login: string, trusted: boolean, locationKey:
}
/**
* Odstraní kompletně volbu uživatele, vyjma ignoredLocationKey (pokud byla předána a existuje).
* Odstraní kompletně volbu uživatele.
*
* @param login login uživatele
* @param date datum, ke kterému se volby vztahují
* @param ignoredLocationKey volba, která nebude odstraněna, pokud existuje
*/
async function removeChoiceIfPresent(login: string, date?: Date, ignoredLocationKey?: LunchChoice) {
const usedDate = date ?? getToday();
let data = await getClientData(usedDate);
async function removeChoiceIfPresent(login: string, date: string) {
let data: DayData = await storage.getData(date);
for (const key of Object.keys(data.choices)) {
const locationKey = key as LunchChoice;
if (ignoredLocationKey != null && ignoredLocationKey == locationKey) {
continue;
}
const locationKey = key as LocationKey;
if (data.choices[locationKey] && login in data.choices[locationKey]) {
delete data.choices[locationKey][login];
if (Object.keys(data.choices[locationKey]).length === 0) {
delete data.choices[locationKey];
}
await storage.setData(formatDate(usedDate), data);
await storage.setData(date, data);
}
}
return data;
@@ -409,87 +310,36 @@ function validateTrusted(data: ClientData, login: string, trusted: boolean) {
* @param date datum, ke kterému se volba vztahuje
* @returns aktuální data
*/
export async function addChoice(login: string, trusted: boolean, locationKey: LunchChoice, foodIndex?: number, date?: Date) {
export async function addChoice(login: string, trusted: boolean, locationKey: LocationKey, foodIndex?: number, date?: Date) {
const usedDate = date ?? getToday();
await initIfNeeded(usedDate);
let data = await getClientData(usedDate);
const selectedDate = formatDate(usedDate);
let data: DayData = await storage.getData(selectedDate);
validateTrusted(data, login, trusted);
await validateFoodIndex(locationKey, foodIndex, date);
// Pokud uživatel měl vybranou PIZZA a mění na něco jiného
const hadPizzaChoice = data.choices.PIZZA && login in data.choices.PIZZA;
if (hadPizzaChoice && locationKey !== LunchChoice.PIZZA && foodIndex == null) {
// Kontrola, zda existuje Pizza day a uživatel je jeho zakladatel
if (data.pizzaDay && data.pizzaDay.creator === login) {
// Pokud Pizza day není ve stavu CREATED, nelze změnit volbu
if (data.pizzaDay.state !== PizzaDayState.CREATED) {
throw new PizzaDayConflictError(
`Nelze změnit volbu. Pizza day je ve stavu "${data.pizzaDay.state}" a musí být nejprve dokončen nebo smazán.`
);
}
// Pizza day je ve stavu CREATED - bude smazán frontendem po potvrzení uživatelem
// (frontend volá nejprve deletePizzaDay, pak teprve addChoice)
}
// Smažeme pizzy uživatele (pokud Pizza day nebyl založen tímto uživatelem,
// nebo byl již smazán frontendem)
await removeAllUserPizzas(login, usedDate);
// Znovu načteme data, protože removeAllUserPizzas je upravila
data = await getClientData(usedDate);
}
// Pokud měníme pouze lokaci, mažeme případné předchozí
if (foodIndex == null) {
data = await removeChoiceIfPresent(login, usedDate);
} else {
// Mažeme případné ostatní volby (měla by být maximálně jedna)
data = await removeChoiceIfPresent(login, usedDate, locationKey);
data = await removeChoiceIfPresent(login, selectedDate);
}
// TODO vytáhnout inicializaci "prázdné struktury" do vlastní funkce
data.choices[locationKey] ??= {};
if (!(data.choices[locationKey])) {
data.choices[locationKey] = {}
}
if (!(login in data.choices[locationKey])) {
if (!data.choices[locationKey]) {
data.choices[locationKey] = {}
}
data.choices[locationKey][login] = {
trusted,
selectedFoods: []
options: []
};
}
if (foodIndex != null && !data.choices[locationKey][login].selectedFoods?.includes(foodIndex)) {
data.choices[locationKey][login].selectedFoods?.push(foodIndex);
if (foodIndex != null && !data.choices[locationKey][login].options.includes(foodIndex)) {
data.choices[locationKey][login].options.push(foodIndex);
}
const selectedDate = formatDate(usedDate);
await storage.setData(selectedDate, data);
return data;
}
/**
* Zvaliduje platnost indexu jídla pro vybranou lokalitu a datum.
*
* @param locationKey vybraná lokalita
* @param foodIndex index jídla pro danou lokalitu
* @param date datum, pro které je validace prováděna
*/
async function validateFoodIndex(locationKey: LunchChoice, foodIndex?: number, date?: Date) {
if (foodIndex != null) {
if (typeof foodIndex !== 'number') {
throw Error(`Neplatný index ${foodIndex} typu ${typeof foodIndex}`);
}
if (foodIndex < 0) {
throw Error(`Neplatný index ${foodIndex}`);
}
if (!Object.keys(Restaurant).includes(locationKey)) {
throw Error(`Neplatný index ${foodIndex} pro lokalitu ${locationKey} nepodporující indexy`);
}
const usedDate = date ?? getToday();
const menu = await getRestaurantMenu(locationKey as Restaurant, usedDate);
if (menu.food?.length && foodIndex > (menu.food.length - 1)) {
throw new Error(`Neplatný index ${foodIndex} pro lokalitu ${locationKey}`);
}
}
}
/**
* Aktualizuje poznámku k aktuálně vybrané možnosti.
*
@@ -501,16 +351,16 @@ async function validateFoodIndex(locationKey: LunchChoice, foodIndex?: number, d
export async function updateNote(login: string, trusted: boolean, note?: string, date?: Date) {
const usedDate = date ?? getToday();
await initIfNeeded(usedDate);
let data = await getClientData(usedDate);
const selectedDate = formatDate(usedDate);
let data: DayData = await storage.getData(selectedDate);
validateTrusted(data, login, trusted);
const userEntry = data.choices != null && Object.entries(data.choices).find(entry => entry[1][login] != null);
if (userEntry) {
if (!note?.length) {
if (!note || !note.length) {
delete userEntry[1][login].note;
} else {
userEntry[1][login].note = note;
}
const selectedDate = formatDate(usedDate);
await storage.setData(selectedDate, data);
}
return data;
@@ -524,8 +374,8 @@ export async function updateNote(login: string, trusted: boolean, note?: string,
* @param date datum, ke kterému se čas vztahuje
*/
export async function updateDepartureTime(login: string, time?: string, date?: Date) {
const usedDate = date ?? getToday();
let clientData = await getClientData(usedDate);
const selectedDate = formatDate(date ?? getToday());
let clientData: DayData = await storage.getData(selectedDate);
const found = Object.values(clientData.choices).find(location => login in location);
// TODO validace, že se jedná o restauraci
if (found) {
@@ -537,41 +387,7 @@ export async function updateDepartureTime(login: string, time?: string, date?: D
}
found[login].departureTime = time;
}
await storage.setData(formatDate(usedDate), clientData);
await storage.setData(selectedDate, clientData);
}
return clientData;
}
/**
* Nastaví/odnastaví uživatele jako objednatele pro dnešní den.
* Objednatelů může být více.
*
* @param login přihlašovací jméno uživatele
*/
export async function updateBuyer(login: string) {
const usedDate = getToday();
let clientData = await getClientData(usedDate);
const userEntry = clientData.choices?.['OBJEDNAVAM']?.[login];
if (!userEntry) {
throw new Error("Nelze nastavit objednatele pro uživatele s jinou volbou než \"Budu objednávat\"");
}
userEntry.isBuyer = !(userEntry.isBuyer || false);
await storage.setData(formatDate(usedDate), clientData);
return clientData;
}
/**
* Vrátí data pro klienta pro předaný nebo aktuální den.
*
* @param date datum pro který vrátit data, pokud není vyplněno, je použit dnešní den
* @returns data pro klienta
*/
export async function getClientData(date?: Date): Promise<ClientData> {
const targetDate = date ?? getToday();
const dateString = formatDate(targetDate);
const clientData = await storage.getData<ClientData>(dateString) || getEmptyData(date);
return {
...clientData,
todayDayIndex: getDayOfWeekIndex(getToday()),
}
}

View File

@@ -1,54 +0,0 @@
import { DailyStats, LunchChoice, WeeklyStats } from "../../types/gen/types.gen";
import { getStatsMock } from "./mock";
import { getClientData } from "./service";
import getStorage from "./storage";
const storage = getStorage();
/**
* Vypočte a vrátí statistiky jednotlivých možností pro předaný rozsah dat.
*
* @param startDate počáteční datum
* @param endDate koncové datum
* @returns statistiky pro zadaný rozsah dat
*/
export async function getStats(startDate: string, endDate: string): Promise<WeeklyStats> {
if (process.env.MOCK_DATA === 'true') {
return getStatsMock();
}
const start = new Date(startDate);
const end = new Date(endDate);
// Dočasná validace, aby to někdo ručně neshodil
const daysDiff = ((end as any) - (start as any)) / (1000 * 60 * 60 * 24);
if (daysDiff > 4) {
throw Error('Neplatný rozsah');
}
const today = new Date();
today.setHours(23, 59, 59, 999);
if (end > today) {
throw Error('Nelze načíst statistiky pro budoucí datum');
}
const result = [];
for (const date = start; date <= end; date.setDate(date.getDate() + 1)) {
const locationsStats: DailyStats = {
// TODO vytáhnout do utils funkce
date: `${String(date.getDate()).padStart(2, "0")}.${String(date.getMonth() + 1).padStart(2, "0")}.`,
locations: {}
}
const data = await getClientData(date);
if (data?.choices) {
Object.keys(data.choices).forEach(locationKey => {
if (!locationsStats.locations) {
locationsStats.locations = {}
}
// TODO dořešit, tohle je zmatek a té hlášce Sonaru nerozumím
locationsStats.locations[locationKey as LunchChoice] = Object.keys(data.choices[locationKey as LunchChoice]!).length;
})
}
result.push(locationsStats);
}
return result as WeeklyStats;
}

View File

@@ -1,3 +1,5 @@
import { ClientData } from "../../../types";
/**
* Interface pro úložiště dat.
*
@@ -5,12 +7,6 @@
* Postupem času lze předělat pro efektivnější využití Redis.
*/
export interface StorageInterface {
/**
* Inicializuje úložiště, pokud je potřeba (např. připojení k databázi).
*/
initialize?(): Promise<void>;
/**
* Vrátí příznak, zda existují data pro předaný klíč.
* @param key klíč, pro který zjišťujeme data (typicky datum)
@@ -21,7 +17,7 @@ export interface StorageInterface {
* Vrátí veškerá data pro předaný klíč.
* @param key klíč, pro který vrátit data (typicky datum)
*/
getData<Type>(key: string): Promise<Type | undefined>;
getData<Type>(key: string): Promise<Type>;
/**
* Uloží data pod předaný klíč.

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