Compare commits
32 Commits
5f903797f1
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
17132d4124
|
|||
|
f28f127a92
|
|||
|
fb84bff687
|
|||
|
c85842267a
|
|||
|
c2bbf7ea60
|
|||
|
318d188495
|
|||
|
a26d6cf85c
|
|||
|
640c7ed41d
|
|||
|
a166634db8
|
|||
| 916766450a | |||
| 5e596c3b99 | |||
| 3ba5fdd086 | |||
| 03f4e438a3 | |||
| b591411d10 | |||
|
8a588cf486
|
|||
|
0e4dc061b8
|
|||
|
7fd3ba0fc4
|
|||
| 94b8f0a452 | |||
| 3e6ecd4e6a | |||
| f12dc7b562 | |||
| 8aef00ab05 | |||
|
d91c8db49c
|
|||
| d8714b2086 | |||
| c7f78cf2c9 | |||
| 1efe2b8f7d | |||
| 5f03471541 | |||
| 21d7224fb4 | |||
| abc3d070cc | |||
| cca751752d | |||
| d2f45be2d3 | |||
| 936b33cc80 | |||
| 774be3df6d |
Vendored
+67
@@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"label": "types: openapi-ts",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "yarn openapi-ts",
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}/types"
|
||||||
|
},
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "silent",
|
||||||
|
"panel": "dedicated"
|
||||||
|
},
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "server: startReload",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "yarn startReload",
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}/server",
|
||||||
|
"env": {
|
||||||
|
"NODE_ENV": "development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"isBackground": true,
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "always",
|
||||||
|
"panel": "dedicated",
|
||||||
|
"group": "dev"
|
||||||
|
},
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "client: vite",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "yarn start",
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}/client"
|
||||||
|
},
|
||||||
|
"isBackground": true,
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "always",
|
||||||
|
"panel": "dedicated",
|
||||||
|
"group": "dev"
|
||||||
|
},
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "dev: server+client",
|
||||||
|
"dependsOn": [
|
||||||
|
"server: startReload",
|
||||||
|
"client: vite"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "dev: all",
|
||||||
|
"dependsOrder": "sequence",
|
||||||
|
"dependsOn": [
|
||||||
|
"types: openapi-ts",
|
||||||
|
"dev: server+client"
|
||||||
|
],
|
||||||
|
"problemMatcher": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
# TODO
|
|
||||||
- [ ] HTTP_REMOTE_TRUSTED_IPS se nikde nevalidují, hlavičky jsou přijímány odkudkoli
|
|
||||||
- [ ] V případě zapnutí přihlašování přes trusted headers nefunguje standardní přihlášení (nevrátí žádnou odpověď)
|
|
||||||
- [ ] Nemělo by se jít dostat na přihlašovací formulář (měla by tam být nanejvýš hláška nebo přesměrování)
|
|
||||||
- [ ] Možnost úhrady celé útraty jednou osobou
|
|
||||||
- Základní myšlenka: jedna osoba uhradí celou útratu (v zájmu rychlosti odbavení), ostatním se automaticky vygeneruje QR kód, kterým následně uhradí svoji část útraty
|
|
||||||
- Obecně to bude problém např. pokud si někdo objedná něco navíc (pití apod.)
|
|
||||||
- [ ] Tlačítko "Uhradit" u každého řádku podniku - platí ten, kdo kliknul
|
|
||||||
- [ ] Zobrazeno bude pouze, pokud má daný uživatel nastaveno číslo účtu
|
|
||||||
- [ ] Dialog pro zadání spropitného, které se následně rozpočte rovnoměrně všem strávníkům
|
|
||||||
- [ ] Generování a zobrazení QR kódů ostatním strávníkům
|
|
||||||
- [ ] Umožnit u každého strávníka připočíst vlastní částku (např. za pití)
|
|
||||||
- [ ] Umožnit (např. zaškrtávátky) vybrat, za koho bude zaplaceno (pokud někdo bude platit zvlášť)
|
|
||||||
- [ ] Podpora pro notifikace v externích systémech (Gotify, Discord, MS Teams)
|
|
||||||
- [ ] Umožnit zadat URL/tokeny uživatelem
|
|
||||||
- [ ] Umožnit uživatelsky konfigurovat typy notifikací, které se budou odesílat
|
|
||||||
- [ ] Zavést notifikace typu "Jdeme na oběd"
|
|
||||||
- [ ] Notifikaci dostanou pouze uživatelé, kteří mají vybranou stejnou lokalitu
|
|
||||||
- [ ] Vylepšit parsery restaurací
|
|
||||||
- [ ] Sladovnická
|
|
||||||
- [ ] Zbytečná prvotní validace indexu, datum konkrétního dne je i v samotné tabulce s jídly, viz TODO v parseru
|
|
||||||
- [ ] U Motlíků
|
|
||||||
- [ ] Validovat, že vstupní datum je zahrnuto v rozsahu uvedeném nad tabulkou (např. '12.6.-16.6.')
|
|
||||||
- [ ] Jídelní lístek se stahuje jednou každý den, teoreticky by stačilo jednou týdně (za předpokladu, že se během týdne nemění)
|
|
||||||
- [ ] TechTower
|
|
||||||
- [ ] Validovat, že vstupní datum je zahrnuto v rozsahu uvedeném nad tabulkou (typicky 'Obědy 12. 6. - 16. 6. 2023 (každý den vždy i obědový bufet)')
|
|
||||||
- [ ] Jídelní lístek se stahuje v rámci prvního požadavku daný den, ale často se jídelní lístek na stránkách aktualizuje až v průběhu pondělního dopoledne a ten zobrazený je proto neaktuální
|
|
||||||
- Stránka neposílá hlavičku o času poslední modifikace, takže o to se nelze opřít
|
|
||||||
- Nevím aktuálně jak řešit jinak, než častějším scrapováním celé stránky
|
|
||||||
- [X] Někdy jsou v názvech jídel přebytečné mezery kolem čárek ( , )
|
|
||||||
- [ ] Nasazení nové verze v Docker smaže veškerá data (protože data.json není vystrčený ven z kontejneru)
|
|
||||||
- [ ] Zavést složku /data
|
|
||||||
- [ ] Mazat z databáze data z minulosti, aktuálně je to k ničemu
|
|
||||||
- [ ] Skripty pro snadné spuštění vývoje na Windows (ekvivalent ./run_dev.sh)
|
|
||||||
- [ ] Implementovat Pizza day
|
|
||||||
- [ ] Zobrazit upozornění před smazáním/zamknutím/odemknutím pizza day
|
|
||||||
- [ ] Pizzy se samy budou při naklikání přidávat do košíku
|
|
||||||
- [ ] Nutno nejprve vyřešit předávání PHPSESSIONID cookie na pizzachefie.cz pomocí fetch()
|
|
||||||
- [ ] Ceny krabic za pizzu jsou napevno v kódu - problém, pokud se někdy změní
|
|
||||||
- [X] Umožnit u Pizza day ručně připočíst cenu za přísady
|
|
||||||
- [X] Prvotní načtení pizz při založení Pizza Day trvá a nic se během toho nezobrazuje (např. loader)
|
|
||||||
- [X] Po doručení zobrazit komu zaplatit (kdo objednával)
|
|
||||||
- [x] Zbytečně nescrapovat každý den pizzy z Pizza Chefie, dokud není založen Pizza Day
|
|
||||||
- [x] Umožnit uzamčení objednávek zakladatelem
|
|
||||||
- [x] Možnost uložení čísla účtu
|
|
||||||
- [x] Automatické generování a zobrazení QR kódů
|
|
||||||
- [x] https://qr-platba.cz/pro-vyvojare/restful-api/
|
|
||||||
- [x] Zobrazovat celkovou cenu objednávky pod tabulkou objednávek
|
|
||||||
- [x] Umožnit přidat k objednávce poznámku (např. "bez oliv")
|
|
||||||
- [x] Negenerovat QR kód pro objednávajícího
|
|
||||||
- [X] Možnost náhledu na ostatní dny v týdnu (např. pomocí šipek)
|
|
||||||
- [X] Možnost výběru oběda na následující dny v týdnu
|
|
||||||
- [X] Umožnit vybrat libovolný čas odchodu
|
|
||||||
- [X] Validace zadání smysluplného času (ideálně i klientská)
|
|
||||||
- [x] Umožnit smazání aktuální volby "popelnicí", místo nutnosti vybrat prázdnou položku v selectu
|
|
||||||
- [x] Přívětivější možnost odhlašování
|
|
||||||
- [x] Vyřešit responzivní design pro použití na mobilu
|
|
||||||
- [x] Vyndat URL na Food API do .env
|
|
||||||
- [x] Neselhat při nedostupnosti nebo chybě z Food API
|
|
||||||
- [x] Dokončit docker-compose pro kompletní funkčnost
|
|
||||||
- [x] Vylepšit dokumentaci projektu
|
|
||||||
- [x] Popsat závislosti, co je nutné provést před vývojem a postup spuštění pro vývoj
|
|
||||||
- [x] Popsat dostupné env
|
|
||||||
- [x] Přesunout autentizaci na server (JWT?)
|
|
||||||
- [x] Zavést .env.template a přidat .env do .gitignore
|
|
||||||
- [x] Zkrášlit dialog pro vyplnění čísla účtu, vypadá mizerně
|
|
||||||
- [x] Zbavit se Food API, potřebnou funkcionalitu zahrnout do serveru
|
|
||||||
- [x] Vyřešit API mezi serverem a klientem, aby nebyl v obou projektech duplicitní kód (viz types.ts a Types.tsx)
|
|
||||||
- [X] Vybraná jídla strávníků zobrazovat v samostatném sloupci
|
|
||||||
- [X] Umožnit výběr/zadání preferovaného času odchodu na oběd
|
|
||||||
- Hodí se např. pokud má někdo schůzky
|
|
||||||
- [X] Ukládat dostupné pizzy do DB místo souborů
|
|
||||||
- [X] Ukládat jídla do DB místo souborů
|
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-bootstrap": "^2.10.10",
|
"react-bootstrap": "^2.10.10",
|
||||||
|
"react-datepicker": "^9.1.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-jwt": "^1.3.0",
|
"react-jwt": "^1.3.0",
|
||||||
"react-modal": "^3.16.3",
|
"react-modal": "^3.16.3",
|
||||||
|
|||||||
+9
-10
@@ -7,6 +7,7 @@ self.addEventListener('push', (event) => {
|
|||||||
body: data.body,
|
body: data.body,
|
||||||
icon: '/favicon.ico',
|
icon: '/favicon.ico',
|
||||||
tag: 'lunch-reminder',
|
tag: 'lunch-reminder',
|
||||||
|
data: { login: data.login, token: data.token },
|
||||||
actions: [
|
actions: [
|
||||||
{ action: 'neobedvam', title: 'Mám vlastní/neobědvám' },
|
{ action: 'neobedvam', title: 'Mám vlastní/neobědvám' },
|
||||||
],
|
],
|
||||||
@@ -18,28 +19,26 @@ self.addEventListener('notificationclick', (event) => {
|
|||||||
event.notification.close();
|
event.notification.close();
|
||||||
|
|
||||||
if (event.action === 'neobedvam') {
|
if (event.action === 'neobedvam') {
|
||||||
event.waitUntil(
|
const { login, token } = event.notification.data ?? {};
|
||||||
self.registration.pushManager.getSubscription().then((subscription) => {
|
if (login && token) {
|
||||||
if (!subscription) return;
|
event.waitUntil(
|
||||||
return fetch('/api/notifications/push/quickChoice', {
|
fetch('/api/notifications/push/quickChoice', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ endpoint: subscription.endpoint }),
|
body: JSON.stringify({ login, token }),
|
||||||
});
|
})
|
||||||
})
|
);
|
||||||
);
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
self.clients.matchAll({ type: 'window' }).then((clientList) => {
|
self.clients.matchAll({ type: 'window' }).then((clientList) => {
|
||||||
// Pokud je již otevřené okno, zaostříme na něj
|
|
||||||
for (const client of clientList) {
|
for (const client of clientList) {
|
||||||
if (client.url.includes(self.location.origin) && 'focus' in client) {
|
if (client.url.includes(self.location.origin) && 'focus' in client) {
|
||||||
return client.focus();
|
return client.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Jinak otevřeme nové
|
|
||||||
return self.clients.openWindow('/');
|
return self.clients.openWindow('/');
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -226,6 +226,7 @@ body {
|
|||||||
&:hover svg {
|
&:hover svg {
|
||||||
transform: rotate(15deg);
|
transform: rotate(15deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -278,6 +279,105 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Varianta navigace mezi dny na stránce objednávání – šipky kolem date pickeru
|
||||||
|
.order-day-navigator {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
// react-datepicker obaluje input do wrapperu – necháme ho zabrat jen potřebnou šířku
|
||||||
|
.react-datepicker-wrapper {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-date-input {
|
||||||
|
width: 160px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zvýraznění dnů, ve kterých existuje alespoň jedna objednávka – tečka pod číslem dne
|
||||||
|
.react-datepicker__day.luncher-order-day {
|
||||||
|
position: relative;
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 2px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--luncher-primary, #0d6efd);
|
||||||
|
}
|
||||||
|
|
||||||
|
// U vybraného dne (tmavé pozadí) je tečka světlá, aby byla vidět
|
||||||
|
&.react-datepicker__day--selected::after,
|
||||||
|
&.react-datepicker__day--keyboard-selected::after {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vybraný den používá akcentovou barvu aplikace (v obou režimech), místo výchozí modré
|
||||||
|
.react-datepicker__day--selected,
|
||||||
|
.react-datepicker__day--keyboard-selected {
|
||||||
|
background-color: var(--luncher-primary);
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--luncher-primary-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tmavý režim kalendáře (react-datepicker) – navázáno na CSS proměnné motivu
|
||||||
|
[data-bs-theme="dark"] {
|
||||||
|
.react-datepicker {
|
||||||
|
background-color: var(--luncher-bg-card);
|
||||||
|
border-color: var(--luncher-border);
|
||||||
|
color: var(--luncher-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-datepicker__header {
|
||||||
|
background-color: var(--luncher-bg-hover);
|
||||||
|
border-bottom-color: var(--luncher-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-datepicker__current-month,
|
||||||
|
.react-datepicker__day-name,
|
||||||
|
.react-datepicker__day,
|
||||||
|
.react-datepicker-year-header {
|
||||||
|
color: var(--luncher-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-datepicker__day:hover,
|
||||||
|
.react-datepicker__month-text:hover {
|
||||||
|
background-color: var(--luncher-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-datepicker__day--today {
|
||||||
|
color: var(--luncher-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-datepicker__day--disabled,
|
||||||
|
.react-datepicker__day--outside-month {
|
||||||
|
color: var(--luncher-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Šipky pro přepínání měsíců
|
||||||
|
.react-datepicker__navigation-icon::before {
|
||||||
|
border-color: var(--luncher-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Špička popoveru (SVG) míří do hlavičky – sladíme barvy.
|
||||||
|
// !important kvůli vyšší specificitě knihovního pravidla [data-placement].
|
||||||
|
.react-datepicker__triangle {
|
||||||
|
fill: var(--luncher-bg-hover) !important;
|
||||||
|
color: var(--luncher-bg-hover) !important;
|
||||||
|
stroke: var(--luncher-border) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// FOOD TABLES - CARD STYLE
|
// FOOD TABLES - CARD STYLE
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
+43
-52
@@ -1,6 +1,6 @@
|
|||||||
import React, { useContext, useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
import React, { useContext, useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||||
import { EVENT_DISCONNECT, EVENT_MESSAGE, SocketContext } from './context/socket';
|
import { EVENT_DISCONNECT, EVENT_MESSAGE, EVENT_PENDING_QR, SocketContext } from './context/socket';
|
||||||
import { useAuth } from './context/auth';
|
import { useAuth } from './context/auth';
|
||||||
import Login from './Login';
|
import Login from './Login';
|
||||||
import { Alert, Button, Col, Form, Row, Table } from 'react-bootstrap';
|
import { Alert, Button, Col, Form, Row, Table } from 'react-bootstrap';
|
||||||
@@ -13,13 +13,15 @@ import './App.scss';
|
|||||||
import { faCircleCheck, faNoteSticky, faTrashCan, faComment } from '@fortawesome/free-regular-svg-icons';
|
import { faCircleCheck, faNoteSticky, faTrashCan, faComment } from '@fortawesome/free-regular-svg-icons';
|
||||||
import { useSettings } from './context/settings';
|
import { useSettings } from './context/settings';
|
||||||
import Footer from './components/Footer';
|
import Footer from './components/Footer';
|
||||||
import { faBasketShopping, faChainBroken, faChevronLeft, faChevronRight, faGear, faMoneyBillTransfer, faSatelliteDish, faSearch, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons';
|
import { faArrowUpRightFromSquare, faBasketShopping, faChainBroken, faChevronLeft, faChevronRight, faGear, faMoneyBillTransfer, faSatelliteDish, faSearch, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import Loader from './components/Loader';
|
import Loader from './components/Loader';
|
||||||
import { getHumanDateTime, isInTheFuture, formatDateString } from './Utils';
|
import { getHumanDateTime, isInTheFuture } from './Utils';
|
||||||
import NoteModal from './components/modals/NoteModal';
|
import NoteModal from './components/modals/NoteModal';
|
||||||
import PayForAllModal from './components/modals/PayForAllModal';
|
import PayForAllModal from './components/modals/PayForAllModal';
|
||||||
|
import PendingPayments from './components/PendingPayments';
|
||||||
import { useEasterEgg } from './context/eggs';
|
import { useEasterEgg } from './context/eggs';
|
||||||
import { ClientData, Food, PizzaOrder, DepartureTime, PizzaDayState, Restaurant, RestaurantDayMenu, RestaurantDayMenuMap, LunchChoice, LocationLunchChoicesMap, UserLunchChoice, PizzaVariant, getData, getEasterEggImage, addPizza, removePizza, updatePizzaDayNote, createPizzaDay, deletePizzaDay, lockPizzaDay, unlockPizzaDay, finishOrder, finishDelivery, addChoice, jdemeObed, removeChoices, removeChoice, updateNote, changeDepartureTime, setBuyer, dismissQr, generateQr } from '../../types';
|
import { ClientData, Food, MealSlot, PendingQr, PizzaOrder, DepartureTime, PizzaDayState, Restaurant, RestaurantDayMenu, RestaurantDayMenuMap, LunchChoice, LocationLunchChoicesMap, UserLunchChoice, PizzaVariant, getData, getEasterEggImage, addPizza, removePizza, updatePizzaDayNote, createPizzaDay, deletePizzaDay, lockPizzaDay, unlockPizzaDay, finishOrder, finishDelivery, addChoice, jdemeObed, removeChoices, removeChoice, updateNote, changeDepartureTime, setBuyer, generateQr } from '../../types';
|
||||||
import { getLunchChoiceName } from './enums';
|
import { getLunchChoiceName } from './enums';
|
||||||
// import FallingLeaves, { LEAF_PRESETS, LEAF_COLOR_THEMES } from './FallingLeaves';
|
// import FallingLeaves, { LEAF_PRESETS, LEAF_COLOR_THEMES } from './FallingLeaves';
|
||||||
// import './FallingLeaves.scss';
|
// import './FallingLeaves.scss';
|
||||||
@@ -59,6 +61,7 @@ const EASTER_EGG_DEFAULT_DURATION = 0.75;
|
|||||||
function App() {
|
function App() {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [easterEgg, _] = useEasterEgg(auth);
|
const [easterEgg, _] = useEasterEgg(auth);
|
||||||
const [isConnected, setIsConnected] = useState<boolean>(false);
|
const [isConnected, setIsConnected] = useState<boolean>(false);
|
||||||
const [data, setData] = useState<ClientData>();
|
const [data, setData] = useState<ClientData>();
|
||||||
@@ -126,19 +129,31 @@ function App() {
|
|||||||
});
|
});
|
||||||
socket.on(EVENT_MESSAGE, (newData: ClientData) => {
|
socket.on(EVENT_MESSAGE, (newData: ClientData) => {
|
||||||
// console.log("Přijata nová data ze socketu", newData);
|
// console.log("Přijata nová data ze socketu", newData);
|
||||||
|
if (newData.slot === MealSlot.EXTRA) return;
|
||||||
// Aktualizujeme pouze, pokud jsme dostali data pro den, který máme aktuálně zobrazený
|
// Aktualizujeme pouze, pokud jsme dostali data pro den, který máme aktuálně zobrazený
|
||||||
if (dayIndexRef.current == null || newData.dayIndex === dayIndexRef.current) {
|
if (dayIndexRef.current == null || newData.dayIndex === dayIndexRef.current) {
|
||||||
setData(newData);
|
setData(newData);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
socket.on(EVENT_PENDING_QR, (pendingQr: PendingQr) => {
|
||||||
|
setData(prev => prev ? { ...prev, pendingQrs: [...(prev.pendingQrs ?? []), pendingQr] } : prev);
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
socket.off(EVENT_CONNECT);
|
socket.off(EVENT_CONNECT);
|
||||||
socket.off(EVENT_DISCONNECT);
|
socket.off(EVENT_DISCONNECT);
|
||||||
socket.off(EVENT_MESSAGE);
|
socket.off(EVENT_MESSAGE);
|
||||||
|
socket.off(EVENT_PENDING_QR);
|
||||||
}
|
}
|
||||||
}, [socket]);
|
}, [socket]);
|
||||||
|
|
||||||
|
// Připojení do osobní socket místnosti po přihlášení
|
||||||
|
useEffect(() => {
|
||||||
|
if (auth?.login) {
|
||||||
|
socket.emit('join', auth.login);
|
||||||
|
}
|
||||||
|
}, [auth?.login, socket]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!auth?.login || !data?.choices) {
|
if (!auth?.login || !data?.choices) {
|
||||||
return
|
return
|
||||||
@@ -437,7 +452,7 @@ function App() {
|
|||||||
data.pizzaList?.forEach((pizza, index) => {
|
data.pizzaList?.forEach((pizza, index) => {
|
||||||
const group: SelectSearchOption = { name: pizza.name, type: "group", items: [] }
|
const group: SelectSearchOption = { name: pizza.name, type: "group", items: [] }
|
||||||
pizza.sizes.forEach((size, sizeIndex) => {
|
pizza.sizes.forEach((size, sizeIndex) => {
|
||||||
const name = `${size.size} (${size.price} Kč)`;
|
const name = `${size.size} (${size.price / 100} Kč)`;
|
||||||
const value = `pizza|${index}|${sizeIndex}`;
|
const value = `pizza|${index}|${sizeIndex}`;
|
||||||
group.items?.push({ name, value });
|
group.items?.push({ name, value });
|
||||||
})
|
})
|
||||||
@@ -446,7 +461,7 @@ function App() {
|
|||||||
if (data.salatList?.length) {
|
if (data.salatList?.length) {
|
||||||
const salatGroup: SelectSearchOption = { name: "Saláty", type: "group", items: [] }
|
const salatGroup: SelectSearchOption = { name: "Saláty", type: "group", items: [] }
|
||||||
data.salatList.forEach((salat, index) => {
|
data.salatList.forEach((salat, index) => {
|
||||||
salatGroup.items?.push({ name: `${salat.name} (${salat.price} Kč)`, value: `salat|${index}` });
|
salatGroup.items?.push({ name: `${salat.name} (${salat.price / 100} Kč)`, value: `salat|${index}` });
|
||||||
});
|
});
|
||||||
suggestions.push(salatGroup);
|
suggestions.push(salatGroup);
|
||||||
}
|
}
|
||||||
@@ -684,15 +699,15 @@ function App() {
|
|||||||
{locationPickCount >= 2 && auth.login && loginObject[auth.login] !== undefined
|
{locationPickCount >= 2 && auth.login && loginObject[auth.login] !== undefined
|
||||||
&& locationKey !== LunchChoice.PIZZA && locationKey !== LunchChoice.NEOBEDVAM && locationKey !== LunchChoice.ROZHODUJI
|
&& locationKey !== LunchChoice.PIZZA && locationKey !== LunchChoice.NEOBEDVAM && locationKey !== LunchChoice.ROZHODUJI
|
||||||
&& settings?.bankAccount && settings?.holderName && (
|
&& settings?.bankAccount && settings?.holderName && (
|
||||||
<span title='Zaplatit za všechny a vygenerovat QR kódy ostatním' className="ms-2">
|
<span title='Zaplatit za všechny a vygenerovat QR kódy ostatním' className="ms-2">
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon={faMoneyBillTransfer}
|
icon={faMoneyBillTransfer}
|
||||||
onClick={() => setPayForAllLocationKey(locationKey)}
|
onClick={() => setPayForAllLocationKey(locationKey)}
|
||||||
className='action-icon'
|
className='action-icon'
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className='p-0'>
|
<td className='p-0'>
|
||||||
<Table className="nested-table">
|
<Table className="nested-table">
|
||||||
@@ -720,6 +735,9 @@ function App() {
|
|||||||
markAsBuyer();
|
markAsBuyer();
|
||||||
}} icon={faBasketShopping} className={isBuyer ? 'buyer-icon' : 'action-icon'} style={{ cursor: 'pointer' }} />
|
}} icon={faBasketShopping} className={isBuyer ? 'buyer-icon' : 'action-icon'} style={{ cursor: 'pointer' }} />
|
||||||
</span>}
|
</span>}
|
||||||
|
{login === auth.login && locationKey === LunchChoice.OBJEDNAVAM && <span title='Přejít na stránku objednávek'>
|
||||||
|
<FontAwesomeIcon onClick={() => navigate('/objednani')} icon={faArrowUpRightFromSquare} className='action-icon' style={{ cursor: 'pointer' }} />
|
||||||
|
</span>}
|
||||||
{login !== auth.login && locationKey === LunchChoice.OBJEDNAVAM && isBuyer && <span title='Objednávající'>
|
{login !== auth.login && locationKey === LunchChoice.OBJEDNAVAM && isBuyer && <span title='Objednávající'>
|
||||||
<FontAwesomeIcon onClick={() => {
|
<FontAwesomeIcon onClick={() => {
|
||||||
copyNote(userPayload.note!);
|
copyNote(userPayload.note!);
|
||||||
@@ -869,48 +887,21 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<PizzaOrderList state={data.pizzaDay.state!} orders={data.pizzaDay.orders!} onDelete={handlePizzaDelete} creator={data.pizzaDay.creator!} />
|
<PizzaOrderList state={data.pizzaDay.state!} orders={data.pizzaDay.orders!} onDelete={handlePizzaDelete} creator={data.pizzaDay.creator!} />
|
||||||
{
|
|
||||||
data.pizzaDay.state === PizzaDayState.DELIVERED && myOrder?.hasQr && (() => {
|
|
||||||
const pizzaQr = data.pendingQrs?.find(qr => qr.creator === data.pizzaDay?.creator);
|
|
||||||
return pizzaQr ? (
|
|
||||||
<div className='qr-code'>
|
|
||||||
<h3>QR platba</h3>
|
|
||||||
<img src={`/api/qr?login=${auth.login}&id=${pizzaQr.id}`} alt='QR kód' />
|
|
||||||
</div>
|
|
||||||
) : null;
|
|
||||||
})()
|
|
||||||
}
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
{data.pendingQrs && data.pendingQrs.length > 0 &&
|
<PendingPayments
|
||||||
<div className='pizza-section fade-in mt-4'>
|
pendingQrs={data.pendingQrs}
|
||||||
<h3>Nevyřízené platby</h3>
|
login={auth.login}
|
||||||
<p>Máte neuhrazené platby.</p>
|
onDismissed={async () => {
|
||||||
{data.pendingQrs.map(qr => (
|
const response = await getData({ query: { dayIndex } });
|
||||||
<div key={qr.id} className='qr-code mb-3'>
|
if (response.data) {
|
||||||
<p>
|
setData(response.data);
|
||||||
<strong>{formatDateString(qr.date)}</strong> — {qr.creator} ({qr.totalPrice} Kč)
|
}
|
||||||
{qr.purpose && <><br /><span className="text-muted">{qr.purpose}</span></>}
|
}}
|
||||||
</p>
|
/>
|
||||||
<img src={`/api/qr?login=${auth.login}&id=${qr.id}`} alt='QR kód' />
|
|
||||||
<div className='mt-2'>
|
|
||||||
<Button variant="success" onClick={async () => {
|
|
||||||
await dismissQr({ body: { id: qr.id } });
|
|
||||||
const response = await getData({ query: { dayIndex } });
|
|
||||||
if (response.data) {
|
|
||||||
setData(response.data);
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
Zaplatil jsem
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</>
|
</>
|
||||||
</div>
|
</div>
|
||||||
{/* <FallingLeaves
|
{/* <FallingLeaves
|
||||||
|
|||||||
@@ -5,14 +5,31 @@ import { SnowOverlay } from 'react-snow-overlay';
|
|||||||
import { ToastContainer } from "react-toastify";
|
import { ToastContainer } from "react-toastify";
|
||||||
import { SocketContext, socket } from "./context/socket";
|
import { SocketContext, socket } from "./context/socket";
|
||||||
import StatsPage from "./pages/StatsPage";
|
import StatsPage from "./pages/StatsPage";
|
||||||
|
import OrderGroupsPage from "./pages/OrderGroupsPage";
|
||||||
|
import SuggestionsPage from "./pages/SuggestionsPage";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
|
||||||
export const STATS_URL = '/stats';
|
export const STATS_URL = '/stats';
|
||||||
|
export const OBJEDNANI_URL = '/objednani';
|
||||||
|
export const NAVRHY_URL = '/navrhy';
|
||||||
|
|
||||||
export default function AppRoutes() {
|
export default function AppRoutes() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path={STATS_URL} element={<StatsPage />} />
|
<Route path={STATS_URL} element={<StatsPage />} />
|
||||||
|
<Route path={NAVRHY_URL} element={
|
||||||
|
<ProvideSettings>
|
||||||
|
<SuggestionsPage />
|
||||||
|
</ProvideSettings>
|
||||||
|
} />
|
||||||
|
<Route path={OBJEDNANI_URL} element={
|
||||||
|
<ProvideSettings>
|
||||||
|
<SocketContext.Provider value={socket}>
|
||||||
|
<OrderGroupsPage />
|
||||||
|
<ToastContainer />
|
||||||
|
</SocketContext.Provider>
|
||||||
|
</ProvideSettings>
|
||||||
|
} />
|
||||||
<Route path="/" element={
|
<Route path="/" element={
|
||||||
<ProvideSettings>
|
<ProvideSettings>
|
||||||
<SocketContext.Provider value={socket}>
|
<SocketContext.Provider value={socket}>
|
||||||
|
|||||||
@@ -109,4 +109,17 @@ export function getHumanDate(date: Date) {
|
|||||||
export function formatDateString(dateString: string): string {
|
export function formatDateString(dateString: string): string {
|
||||||
const [year, month, day] = dateString.split('-');
|
const [year, month, day] = dateString.split('-');
|
||||||
return `${day}.${month}.${year}`;
|
return `${day}.${month}.${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Očistí zprávu (účel platby) pro QR platbu – musí odpovídat serverové logice (qr.ts):
|
||||||
|
* transliteruje diakritiku na základní písmena (š→s, č→c, ...), odstraní znaky mimo
|
||||||
|
* ISO 8859-1 a hvězdičku (oddělovač polí v QR platbě) a ořízne na max. 60 znaků.
|
||||||
|
*/
|
||||||
|
export function sanitizeQrMessage(message: string): string {
|
||||||
|
const sanitized = message
|
||||||
|
.normalize('NFD').replace(/[\u0300-\u036f]/g, '') // diakritika → základní písmeno
|
||||||
|
.replace(/[^\x00-\xff]/g, '') // znaky mimo ISO 8859-1
|
||||||
|
.replace(/\*/g, ''); // '*' je v QR platbě oddělovač
|
||||||
|
return sanitized.length > 60 ? sanitized.substring(0, 60) : sanitized;
|
||||||
}
|
}
|
||||||
@@ -3,15 +3,15 @@ import { Navbar, Nav, NavDropdown, Modal, Button } from "react-bootstrap";
|
|||||||
import { useAuth } from "../context/auth";
|
import { useAuth } from "../context/auth";
|
||||||
import SettingsModal from "./modals/SettingsModal";
|
import SettingsModal from "./modals/SettingsModal";
|
||||||
import { useSettings, ThemePreference } from "../context/settings";
|
import { useSettings, ThemePreference } from "../context/settings";
|
||||||
import FeaturesVotingModal from "./modals/FeaturesVotingModal";
|
import HuePicker from "./HuePicker";
|
||||||
import PizzaCalculatorModal from "./modals/PizzaCalculatorModal";
|
import PizzaCalculatorModal from "./modals/PizzaCalculatorModal";
|
||||||
import RefreshMenuModal from "./modals/RefreshMenuModal";
|
import RefreshMenuModal from "./modals/RefreshMenuModal";
|
||||||
import GenerateQrModal from "./modals/GenerateQrModal";
|
import GenerateQrModal from "./modals/GenerateQrModal";
|
||||||
import GenerateMockDataModal from "./modals/GenerateMockDataModal";
|
import GenerateMockDataModal from "./modals/GenerateMockDataModal";
|
||||||
import ClearMockDataModal from "./modals/ClearMockDataModal";
|
import ClearMockDataModal from "./modals/ClearMockDataModal";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { STATS_URL } from "../AppRoutes";
|
import { STATS_URL, OBJEDNANI_URL, NAVRHY_URL } from "../AppRoutes";
|
||||||
import { FeatureRequest, getVotes, updateVote, LunchChoices, getChangelogs } from "../../../types";
|
import { LunchChoices, getChangelogs } from "../../../types";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons";
|
import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { formatDateString } from "../Utils";
|
import { formatDateString } from "../Utils";
|
||||||
@@ -30,7 +30,6 @@ export default function Header({ choices, dayIndex }: Props) {
|
|||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [settingsModalOpen, setSettingsModalOpen] = useState<boolean>(false);
|
const [settingsModalOpen, setSettingsModalOpen] = useState<boolean>(false);
|
||||||
const [votingModalOpen, setVotingModalOpen] = useState<boolean>(false);
|
|
||||||
const [pizzaModalOpen, setPizzaModalOpen] = useState<boolean>(false);
|
const [pizzaModalOpen, setPizzaModalOpen] = useState<boolean>(false);
|
||||||
const [refreshMenuModalOpen, setRefreshMenuModalOpen] = useState<boolean>(false);
|
const [refreshMenuModalOpen, setRefreshMenuModalOpen] = useState<boolean>(false);
|
||||||
const [changelogModalOpen, setChangelogModalOpen] = useState<boolean>(false);
|
const [changelogModalOpen, setChangelogModalOpen] = useState<boolean>(false);
|
||||||
@@ -38,35 +37,8 @@ export default function Header({ choices, dayIndex }: Props) {
|
|||||||
const [qrModalOpen, setQrModalOpen] = useState<boolean>(false);
|
const [qrModalOpen, setQrModalOpen] = useState<boolean>(false);
|
||||||
const [generateMockModalOpen, setGenerateMockModalOpen] = useState<boolean>(false);
|
const [generateMockModalOpen, setGenerateMockModalOpen] = useState<boolean>(false);
|
||||||
const [clearMockModalOpen, setClearMockModalOpen] = useState<boolean>(false);
|
const [clearMockModalOpen, setClearMockModalOpen] = useState<boolean>(false);
|
||||||
const [featureVotes, setFeatureVotes] = useState<FeatureRequest[] | undefined>([]);
|
|
||||||
|
|
||||||
// Zjistíme aktuální efektivní téma (pro zobrazení správné ikony)
|
const effectiveDark = settings?.effectiveDark ?? false;
|
||||||
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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (auth?.login) {
|
|
||||||
getVotes().then(response => {
|
|
||||||
setFeatureVotes(response.data);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [auth?.login]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!auth?.login) return;
|
if (!auth?.login) return;
|
||||||
@@ -85,10 +57,6 @@ export default function Header({ choices, dayIndex }: Props) {
|
|||||||
setSettingsModalOpen(false);
|
setSettingsModalOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
const closeVotingModal = () => {
|
|
||||||
setVotingModalOpen(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
const closePizzaModal = () => {
|
const closePizzaModal = () => {
|
||||||
setPizzaModalOpen(false);
|
setPizzaModalOpen(false);
|
||||||
}
|
}
|
||||||
@@ -110,8 +78,7 @@ export default function Header({ choices, dayIndex }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const toggleTheme = () => {
|
const toggleTheme = () => {
|
||||||
// Přepínáme mezi light a dark (ignorujeme system pro jednoduchost)
|
const newTheme: ThemePreference = effectiveDark ? 'light' : 'dark';
|
||||||
const newTheme: ThemePreference = effectiveTheme === 'dark' ? 'light' : 'dark';
|
|
||||||
settings?.setThemePreference(newTheme);
|
settings?.setThemePreference(newTheme);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,17 +143,6 @@ export default function Header({ choices, dayIndex }: Props) {
|
|||||||
closeSettingsModal();
|
closeSettingsModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveFeatureVote = async (option: FeatureRequest, active: boolean) => {
|
|
||||||
await updateVote({ body: { option, active } });
|
|
||||||
const votes = [...featureVotes || []];
|
|
||||||
if (active) {
|
|
||||||
votes.push(option);
|
|
||||||
} else {
|
|
||||||
votes.splice(votes.indexOf(option), 1);
|
|
||||||
}
|
|
||||||
setFeatureVotes(votes);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Navbar variant='dark' expand="lg">
|
return <Navbar variant='dark' expand="lg">
|
||||||
<Navbar.Brand href="/">Luncher</Navbar.Brand>
|
<Navbar.Brand href="/">Luncher</Navbar.Brand>
|
||||||
<Navbar.Toggle aria-controls="basic-navbar-nav" />
|
<Navbar.Toggle aria-controls="basic-navbar-nav" />
|
||||||
@@ -195,18 +151,24 @@ export default function Header({ choices, dayIndex }: Props) {
|
|||||||
<button
|
<button
|
||||||
className="theme-toggle"
|
className="theme-toggle"
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
title={effectiveTheme === 'dark' ? 'Přepnout na světlý režim' : 'Přepnout na tmavý režim'}
|
title={effectiveDark ? 'Přepnout na světlý režim' : 'Přepnout na tmavý režim'}
|
||||||
aria-label="Přepnout barevný motiv"
|
aria-label="Přepnout světlý/tmavý režim"
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={effectiveTheme === 'dark' ? faSun : faMoon} />
|
<FontAwesomeIcon icon={effectiveDark ? faSun : faMoon} />
|
||||||
</button>
|
</button>
|
||||||
|
<HuePicker
|
||||||
|
accentHue={settings?.accentHue ?? 142}
|
||||||
|
isDark={effectiveDark}
|
||||||
|
onChange={hue => settings?.setAccentHue(hue)}
|
||||||
|
/>
|
||||||
<NavDropdown align="end" title={auth?.login} id="basic-nav-dropdown">
|
<NavDropdown align="end" title={auth?.login} id="basic-nav-dropdown">
|
||||||
<NavDropdown.Item onClick={() => setSettingsModalOpen(true)}>Nastavení</NavDropdown.Item>
|
<NavDropdown.Item onClick={() => setSettingsModalOpen(true)}>Nastavení</NavDropdown.Item>
|
||||||
<NavDropdown.Item onClick={() => setRefreshMenuModalOpen(true)}>Přenačtení menu</NavDropdown.Item>
|
<NavDropdown.Item onClick={() => setRefreshMenuModalOpen(true)}>Přenačtení menu</NavDropdown.Item>
|
||||||
<NavDropdown.Item onClick={() => setVotingModalOpen(true)}>Hlasovat o nových funkcích</NavDropdown.Item>
|
<NavDropdown.Item onClick={() => navigate(NAVRHY_URL)}>Návrhy na vylepšení</NavDropdown.Item>
|
||||||
<NavDropdown.Item onClick={() => setPizzaModalOpen(true)}>Pizza kalkulačka</NavDropdown.Item>
|
<NavDropdown.Item onClick={() => setPizzaModalOpen(true)}>Pizza kalkulačka</NavDropdown.Item>
|
||||||
<NavDropdown.Item onClick={handleQrMenuClick}>Generování QR kódů</NavDropdown.Item>
|
<NavDropdown.Item onClick={handleQrMenuClick}>Generování QR kódů</NavDropdown.Item>
|
||||||
<NavDropdown.Item onClick={() => navigate(STATS_URL)}>Statistiky</NavDropdown.Item>
|
<NavDropdown.Item onClick={() => navigate(STATS_URL)}>Statistiky</NavDropdown.Item>
|
||||||
|
<NavDropdown.Item onClick={() => navigate(OBJEDNANI_URL)}>Objednání</NavDropdown.Item>
|
||||||
<NavDropdown.Item onClick={() => {
|
<NavDropdown.Item onClick={() => {
|
||||||
getChangelogs().then(response => {
|
getChangelogs().then(response => {
|
||||||
const entries = response.data ?? {};
|
const entries = response.data ?? {};
|
||||||
@@ -232,7 +194,6 @@ export default function Header({ choices, dayIndex }: Props) {
|
|||||||
</Navbar.Collapse>
|
</Navbar.Collapse>
|
||||||
<SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} />
|
<SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} />
|
||||||
<RefreshMenuModal isOpen={refreshMenuModalOpen} onClose={closeRefreshMenuModal} />
|
<RefreshMenuModal isOpen={refreshMenuModalOpen} onClose={closeRefreshMenuModal} />
|
||||||
<FeaturesVotingModal isOpen={votingModalOpen} onClose={closeVotingModal} onChange={saveFeatureVote} initialValues={featureVotes} />
|
|
||||||
<PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} />
|
<PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} />
|
||||||
{choices && settings?.bankAccount && settings?.holderName && (
|
{choices && settings?.bankAccount && settings?.holderName && (
|
||||||
<GenerateQrModal
|
<GenerateQrModal
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
.hue-picker-dropdown {
|
||||||
|
.dropdown-toggle {
|
||||||
|
background: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
color: var(--luncher-navbar-text) !important;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--luncher-radius-sm);
|
||||||
|
transition: var(--luncher-transition);
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1) !important;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.3) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hue-picker-panel {
|
||||||
|
padding: 0 !important;
|
||||||
|
min-width: 240px;
|
||||||
|
|
||||||
|
.hue-picker-inner {
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hue-picker-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--luncher-text-secondary);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hue-slider {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
hsl(0 70% 50%), hsl(30 70% 50%), hsl(60 70% 50%), hsl(90 70% 50%),
|
||||||
|
hsl(120 70% 50%), hsl(150 70% 50%), hsl(180 70% 50%), hsl(210 70% 50%),
|
||||||
|
hsl(240 70% 50%), hsl(270 70% 50%), hsl(300 70% 50%), hsl(330 70% 50%), hsl(360 70% 50%)
|
||||||
|
);
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
|
||||||
|
&::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: white;
|
||||||
|
border: 2px solid rgba(0, 0, 0, 0.25);
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-moz-range-thumb {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: white;
|
||||||
|
border: 2px solid rgba(0, 0, 0, 0.25);
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hue-presets {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
|
||||||
|
.hue-swatch {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
transition: transform 0.15s ease, border-color 0.15s ease;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: var(--luncher-text);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hue-preview {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding-top: 4px;
|
||||||
|
border-top: 1px solid var(--luncher-border);
|
||||||
|
|
||||||
|
.hue-preview-chip {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: var(--luncher-radius-sm);
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--luncher-text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { Dropdown } from 'react-bootstrap';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faPalette } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import './HuePicker.scss';
|
||||||
|
|
||||||
|
const PRESETS = [
|
||||||
|
{ hue: 142, label: 'Zelená' },
|
||||||
|
{ hue: 217, label: 'Modrá' },
|
||||||
|
{ hue: 263, label: 'Fialová' },
|
||||||
|
{ hue: 0, label: 'Červená' },
|
||||||
|
{ hue: 28, label: 'Oranžová' },
|
||||||
|
{ hue: 340, label: 'Růžová' },
|
||||||
|
];
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
accentHue: number;
|
||||||
|
isDark: boolean;
|
||||||
|
onChange: (hue: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function swatchColor(hue: number, isDark: boolean): string {
|
||||||
|
return `hsl(${hue} 70% ${isDark ? 55 : 38}%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HuePicker({ accentHue, isDark, onChange }: Props) {
|
||||||
|
return (
|
||||||
|
<Dropdown align="end" autoClose="outside" className="hue-picker-dropdown">
|
||||||
|
<Dropdown.Toggle
|
||||||
|
as="button"
|
||||||
|
className="theme-toggle"
|
||||||
|
aria-label="Barva zvýraznění"
|
||||||
|
title="Barva zvýraznění"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faPalette} />
|
||||||
|
</Dropdown.Toggle>
|
||||||
|
<Dropdown.Menu className="hue-picker-panel">
|
||||||
|
<div className="hue-picker-inner">
|
||||||
|
<div className="hue-picker-label">Barva zvýraznění</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={360}
|
||||||
|
value={accentHue}
|
||||||
|
onChange={e => onChange(parseInt(e.target.value, 10))}
|
||||||
|
className="hue-slider"
|
||||||
|
aria-label="Odstín barvy zvýraznění"
|
||||||
|
/>
|
||||||
|
<div className="hue-presets">
|
||||||
|
{PRESETS.map(p => (
|
||||||
|
<button
|
||||||
|
key={p.hue}
|
||||||
|
className={`hue-swatch${accentHue === p.hue ? ' active' : ''}`}
|
||||||
|
style={{ background: swatchColor(p.hue, isDark) }}
|
||||||
|
title={p.label}
|
||||||
|
onClick={() => onChange(p.hue)}
|
||||||
|
aria-label={p.label}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="hue-preview">
|
||||||
|
<div
|
||||||
|
className="hue-preview-chip"
|
||||||
|
style={{ background: swatchColor(accentHue, isDark) }}
|
||||||
|
/>
|
||||||
|
<span>Aktuální barva zvýraznění</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from 'react-bootstrap';
|
||||||
|
import { PendingQr, dismissQr } from '../../../types';
|
||||||
|
import { formatDateString } from '../Utils';
|
||||||
|
import ConfirmModal from './modals/ConfirmModal';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
pendingQrs?: PendingQr[];
|
||||||
|
login?: string;
|
||||||
|
// Zavolá se po úspěšném potvrzení platby, aby si rodič mohl znovu načíst data
|
||||||
|
onDismissed?: () => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sekce "Nevyřízené platby" – zobrazí QR kódy neuhrazených plateb přihlášeného uživatele
|
||||||
|
// včetně tlačítka "Zaplatil jsem" a potvrzovacího dialogu. Sdíleno hlavní stránkou i stránkou objednávek.
|
||||||
|
export default function PendingPayments({ pendingQrs, login, onDismissed }: Readonly<Props>) {
|
||||||
|
const [dismissQrId, setDismissQrId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
if (!pendingQrs || pendingQrs.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='pizza-section fade-in mt-4'>
|
||||||
|
<h3>Nevyřízené platby</h3>
|
||||||
|
<p>Máte neuhrazené platby.</p>
|
||||||
|
{pendingQrs.map(qr => (
|
||||||
|
<div key={qr.id} className='qr-code mb-3'>
|
||||||
|
<p>
|
||||||
|
<strong>{formatDateString(qr.date)}</strong> — {qr.creator} ({qr.totalPrice / 100} Kč)
|
||||||
|
{qr.purpose && <><br /><span className="text-muted">{qr.purpose}</span></>}
|
||||||
|
</p>
|
||||||
|
<img src={`/api/qr?login=${login}&id=${qr.id}`} alt='QR kód' />
|
||||||
|
<div className='mt-2'>
|
||||||
|
<Button variant="success" onClick={() => setDismissQrId(qr.id)}>
|
||||||
|
Zaplatil jsem
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={dismissQrId !== null}
|
||||||
|
title="Potvrzení platby"
|
||||||
|
message="Opravdu jste zaplatili? QR kód bude odstraněn."
|
||||||
|
confirmLabel="Zaplatil jsem"
|
||||||
|
confirmVariant="success"
|
||||||
|
onClose={() => setDismissQrId(null)}
|
||||||
|
onConfirm={async () => {
|
||||||
|
if (!dismissQrId) return;
|
||||||
|
const id = dismissQrId;
|
||||||
|
setDismissQrId(null);
|
||||||
|
await dismissQr({ body: { id } });
|
||||||
|
await onDismissed?.();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -48,7 +48,7 @@ export default function PizzaOrderList({ state, orders, onDelete, creator }: Rea
|
|||||||
borderTop: '2px solid var(--luncher-border)'
|
borderTop: '2px solid var(--luncher-border)'
|
||||||
}}>
|
}}>
|
||||||
<td colSpan={4} style={{ padding: '16px 20px', border: 'none' }}>Celkem</td>
|
<td colSpan={4} style={{ padding: '16px 20px', border: 'none' }}>Celkem</td>
|
||||||
<td style={{ padding: '16px 20px', border: 'none', textAlign: 'right', color: 'var(--luncher-primary)' }}>{`${total} Kč`}</td>
|
<td style={{ padding: '16px 20px', border: 'none', textAlign: 'right', color: 'var(--luncher-primary)' }}>{`${total / 100} Kč`}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export default function PizzaOrderRow({ creator, order, state, onDelete, onFeeMo
|
|||||||
<td>{order.customer}</td>
|
<td>{order.customer}</td>
|
||||||
<td>{order.pizzaList!.map<React.ReactNode>(pizzaOrder =>
|
<td>{order.pizzaList!.map<React.ReactNode>(pizzaOrder =>
|
||||||
<span key={pizzaOrder.name}>
|
<span key={pizzaOrder.name}>
|
||||||
{`${pizzaOrder.name}, ${pizzaOrder.size} (${pizzaOrder.price} Kč)`}
|
{`${pizzaOrder.name}, ${pizzaOrder.size} (${pizzaOrder.price / 100} Kč)`}
|
||||||
{auth?.login === order.customer && state === PizzaDayState.CREATED &&
|
{auth?.login === order.customer && state === PizzaDayState.CREATED &&
|
||||||
<span title='Odstranit'>
|
<span title='Odstranit'>
|
||||||
<FontAwesomeIcon onClick={() => {
|
<FontAwesomeIcon onClick={() => {
|
||||||
@@ -38,10 +38,10 @@ export default function PizzaOrderRow({ creator, order, state, onDelete, onFeeMo
|
|||||||
.reduce((prev, curr, index) => [prev, <br key={`br-${index}`} />, curr])}
|
.reduce((prev, curr, index) => [prev, <br key={`br-${index}`} />, curr])}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ maxWidth: "200px" }}>{order.note ?? '-'}</td>
|
<td style={{ maxWidth: "200px" }}>{order.note ?? '-'}</td>
|
||||||
<td style={{ maxWidth: "200px" }}>{order.fee?.price ? `${order.fee.price} Kč${order.fee.text ? ` (${order.fee.text})` : ''}` : '-'}</td>
|
<td style={{ maxWidth: "200px" }}>{order.fee?.price ? `${order.fee.price / 100} Kč${order.fee.text ? ` (${order.fee.text})` : ''}` : '-'}</td>
|
||||||
<td>
|
<td>
|
||||||
{order.totalPrice} Kč{auth?.login === creator && state === PizzaDayState.CREATED && <span title='Nastavit příplatek'><FontAwesomeIcon onClick={() => { setIsFeeModalOpen(true) }} className='action-icon' icon={faMoneyBill1} /></span>}
|
{order.totalPrice / 100} Kč{auth?.login === creator && state === PizzaDayState.CREATED && <span title='Nastavit příplatek'><FontAwesomeIcon onClick={() => { setIsFeeModalOpen(true) }} className='action-icon' icon={faMoneyBill1} /></span>}
|
||||||
</td>
|
</td>
|
||||||
<PizzaAdditionalFeeModal customerName={order.customer} isOpen={isFeeModalOpen} onClose={() => setIsFeeModalOpen(false)} onSave={saveFees} initialValues={{ text: order.fee?.text, price: order.fee?.price?.toString() }} />
|
<PizzaAdditionalFeeModal customerName={order.customer} isOpen={isFeeModalOpen} onClose={() => setIsFeeModalOpen(false)} onSave={saveFees} initialValues={{ text: order.fee?.text, price: order.fee?.price != null ? String(order.fee.price / 100) : undefined }} />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Modal, Button, Form } from "react-bootstrap";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (title: string, description: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Modální dialog pro přidání nového návrhu na vylepšení. */
|
||||||
|
export default function AddSuggestionModal({ isOpen, onClose, onSubmit }: Readonly<Props>) {
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setTitle("");
|
||||||
|
setDescription("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
reset();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!title.trim() || !description.trim()) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await onSubmit(title.trim(), description.trim());
|
||||||
|
reset();
|
||||||
|
onClose();
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal show={isOpen} onHide={handleClose} size="lg">
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title><h2>Nový návrh na vylepšení</h2></Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>Název</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
type="text"
|
||||||
|
placeholder="Stručný název návrhu"
|
||||||
|
value={title}
|
||||||
|
maxLength={120}
|
||||||
|
onChange={e => setTitle(e.target.value)}
|
||||||
|
onKeyDown={e => e.stopPropagation()}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Form.Text className="text-muted">Krátký, výstižný název navrhované úpravy.</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>Popis</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
as="textarea"
|
||||||
|
rows={5}
|
||||||
|
placeholder="Detailní popis navrhované úpravy, řešení apod."
|
||||||
|
value={description}
|
||||||
|
onChange={e => setDescription(e.target.value)}
|
||||||
|
onKeyDown={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button variant="secondary" onClick={handleClose}>
|
||||||
|
Storno
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={submitting || !title.trim() || !description.trim()}>
|
||||||
|
Přidat
|
||||||
|
</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Modal, Button } from "react-bootstrap";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
confirmVariant?: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ConfirmModal({ isOpen, title, message, confirmLabel = "Potvrdit", confirmVariant = "primary", onConfirm, onClose }: Readonly<Props>) {
|
||||||
|
return (
|
||||||
|
<Modal show={isOpen} onHide={onClose}>
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title>{title}</Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>{message}</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button variant="secondary" onClick={onClose}>Zrušit</Button>
|
||||||
|
<Button variant={confirmVariant} onClick={onConfirm}>{confirmLabel}</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Modal, Button, Form, Table, Alert } from "react-bootstrap";
|
||||||
|
import { updateGroupFees, OrderGroup, OrderGroupMember } from "../../../../types";
|
||||||
|
import { computeFeeShare, computeMemberTotal, countActiveMembers, isActiveMember } from "../../utils/groupFees";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
group: OrderGroup;
|
||||||
|
onSaved: (data: any) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseHal(s: string): number {
|
||||||
|
const n = parseFloat(s.replace(',', '.'));
|
||||||
|
return isNaN(n) || n < 0 ? 0 : Math.round(n * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePercent(s: string): number {
|
||||||
|
const n = parseFloat(s.replace(',', '.'));
|
||||||
|
return isNaN(n) || n < 0 ? 0 : Math.round(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditGroupFeesModal({ isOpen, onClose, group, onSaved }: Readonly<Props>) {
|
||||||
|
const [fees, setFees] = useState('');
|
||||||
|
const [shipping, setShipping] = useState('');
|
||||||
|
const [tip, setTip] = useState('');
|
||||||
|
const [discountType, setDiscountType] = useState<'percent' | 'fixed'>('percent');
|
||||||
|
const [discountValue, setDiscountValue] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
setFees(group.fees ? String(group.fees / 100) : '');
|
||||||
|
setShipping(group.shipping ? String(group.shipping / 100) : '');
|
||||||
|
setTip(group.tip ? String(group.tip / 100) : '');
|
||||||
|
setDiscountType((group.discountType as 'percent' | 'fixed') ?? 'percent');
|
||||||
|
setDiscountValue(group.discountValue
|
||||||
|
? ((group.discountType as string) === 'fixed' ? String(group.discountValue / 100) : String(group.discountValue))
|
||||||
|
: '');
|
||||||
|
setError(null);
|
||||||
|
}, [isOpen, group]);
|
||||||
|
|
||||||
|
const memberEntries = Object.entries(group.members) as [string, OrderGroupMember][];
|
||||||
|
// Poplatky se dělí jen mezi aktivní strávníky (kdo si reálně něco objednal).
|
||||||
|
const activeCount = countActiveMembers(group.members);
|
||||||
|
|
||||||
|
const feesNum = parseHal(fees);
|
||||||
|
const shippingNum = parseHal(shipping);
|
||||||
|
const tipNum = parseHal(tip);
|
||||||
|
const discountNum = discountType === 'percent' ? parsePercent(discountValue) : parseHal(discountValue);
|
||||||
|
const totalFees = feesNum + shippingNum + tipNum;
|
||||||
|
const feeShare = computeFeeShare(totalFees, activeCount);
|
||||||
|
const feeParams = { totalFees, discountType, discountValue: discountNum };
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await updateGroupFees({
|
||||||
|
body: {
|
||||||
|
id: group.id,
|
||||||
|
fees: feesNum,
|
||||||
|
shipping: shippingNum,
|
||||||
|
tip: tipNum,
|
||||||
|
discountType: discountNum > 0 ? discountType : undefined,
|
||||||
|
discountValue: discountNum > 0 ? discountNum : undefined,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (res.error) {
|
||||||
|
setError((res.error as any).error || 'Nastala chyba');
|
||||||
|
} else {
|
||||||
|
onSaved(res.data);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message || 'Nastala chyba');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal show={isOpen} onHide={onClose} size="lg">
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title><h2>Poplatky skupiny — {group.name}</h2></Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
{error && (
|
||||||
|
<Alert variant="danger" onClose={() => setError(null)} dismissible>{error}</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="d-flex gap-3 flex-wrap mb-3">
|
||||||
|
<Form.Group>
|
||||||
|
<Form.Label>Poplatky (Kč)</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
type="number" min={0} step={0.01}
|
||||||
|
value={fees} onChange={e => setFees(e.target.value)}
|
||||||
|
placeholder="0" style={{ width: 110 }}
|
||||||
|
onKeyDown={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Group>
|
||||||
|
<Form.Label>Doprava (Kč)</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
type="number" min={0} step={0.01}
|
||||||
|
value={shipping} onChange={e => setShipping(e.target.value)}
|
||||||
|
placeholder="0" style={{ width: 110 }}
|
||||||
|
onKeyDown={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Group>
|
||||||
|
<Form.Label>Spropitné (Kč)</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
type="number" min={0} step={0.01}
|
||||||
|
value={tip} onChange={e => setTip(e.target.value)}
|
||||||
|
placeholder="0" style={{ width: 110 }}
|
||||||
|
onKeyDown={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="d-flex gap-3 align-items-end flex-wrap mb-3">
|
||||||
|
<Form.Group>
|
||||||
|
<Form.Label>Sleva</Form.Label>
|
||||||
|
<div className="d-flex gap-2 align-items-center">
|
||||||
|
<Form.Select
|
||||||
|
value={discountType}
|
||||||
|
onChange={e => setDiscountType(e.target.value as 'percent' | 'fixed')}
|
||||||
|
style={{ width: 160 }}
|
||||||
|
>
|
||||||
|
<option value="percent">Procentuální (%)</option>
|
||||||
|
<option value="fixed">Pevná částka (Kč)</option>
|
||||||
|
</Form.Select>
|
||||||
|
<Form.Control
|
||||||
|
type="number" min={0} step={discountType === 'percent' ? 1 : 0.01}
|
||||||
|
value={discountValue} onChange={e => setDiscountValue(e.target.value)}
|
||||||
|
placeholder="0" style={{ width: 100 }}
|
||||||
|
onKeyDown={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<span className="text-muted">{discountType === 'percent' ? '%' : 'Kč'}</span>
|
||||||
|
</div>
|
||||||
|
</Form.Group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
<h6>Náhled celkových částek ({activeCount} {activeCount === 1 ? 'strávník' : 'strávníků'} s objednávkou, {feeShare > 0 ? `poplatek ${feeShare / 100} Kč/os.` : 'bez poplatku'})</h6>
|
||||||
|
<Table size="sm" bordered>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Člen</th>
|
||||||
|
<th className="text-end">Základ</th>
|
||||||
|
<th className="text-end">Příplatek</th>
|
||||||
|
<th className="text-end">Poplatek</th>
|
||||||
|
<th className="text-end">Sleva</th>
|
||||||
|
<th className="text-end fw-bold">Celkem</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{memberEntries.map(([login, member]) => {
|
||||||
|
const base = member.amount ?? 0;
|
||||||
|
const surcharge = member.surchargeAmount ?? 0;
|
||||||
|
const active = isActiveMember(member);
|
||||||
|
const total = computeMemberTotal(member, feeParams, feeShare, activeCount);
|
||||||
|
// Sleva i poplatek se týkají jen aktivních strávníků.
|
||||||
|
const discount = active && discountNum > 0
|
||||||
|
? (discountType === 'percent'
|
||||||
|
? Math.round((base + surcharge) * discountNum / 100)
|
||||||
|
: Math.round(discountNum / activeCount))
|
||||||
|
: 0;
|
||||||
|
return (
|
||||||
|
<tr key={login} className={active ? '' : 'text-muted'}>
|
||||||
|
<td><strong>{login}</strong>{!active && <small className="ms-1">(jen objednává)</small>}</td>
|
||||||
|
<td className="text-end">{base > 0 ? `${base / 100} Kč` : '—'}</td>
|
||||||
|
<td className="text-end">{surcharge > 0 ? `${surcharge / 100} Kč` : '—'}</td>
|
||||||
|
<td className="text-end">{active && feeShare > 0 ? `${feeShare / 100} Kč` : '—'}</td>
|
||||||
|
<td className="text-end text-danger">{discount > 0 ? `-${discount / 100} Kč` : '—'}</td>
|
||||||
|
<td className="text-end fw-bold">{total > 0 ? `${total / 100} Kč` : '—'}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button variant="secondary" onClick={onClose} disabled={loading}>Storno</Button>
|
||||||
|
<Button variant="primary" onClick={handleSave} disabled={loading}>
|
||||||
|
{loading ? 'Ukládám...' : 'Uložit'}
|
||||||
|
</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { Modal, Button, Form } from "react-bootstrap"
|
|
||||||
import { FeatureRequest } from "../../../../types";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
isOpen: boolean,
|
|
||||||
onClose: () => void,
|
|
||||||
onChange: (option: FeatureRequest, active: boolean) => void,
|
|
||||||
initialValues?: FeatureRequest[],
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Modální dialog pro hlasování o nových funkcích. */
|
|
||||||
export default function FeaturesVotingModal({ isOpen, onClose, onChange, initialValues }: Readonly<Props>) {
|
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
onChange(e.currentTarget.value as FeatureRequest, e.currentTarget.checked);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Modal show={isOpen} onHide={onClose} size="lg">
|
|
||||||
<Modal.Header closeButton>
|
|
||||||
<Modal.Title>
|
|
||||||
Hlasujte pro nové funkce
|
|
||||||
<p style={{ fontSize: '12px' }}>Je možno vybrat maximálně 4 možnosti</p>
|
|
||||||
</Modal.Title>
|
|
||||||
</Modal.Header>
|
|
||||||
<Modal.Body>
|
|
||||||
{(Object.keys(FeatureRequest) as Array<keyof typeof FeatureRequest>).map(key => {
|
|
||||||
return <Form.Check
|
|
||||||
key={key}
|
|
||||||
type='checkbox'
|
|
||||||
id={key}
|
|
||||||
label={FeatureRequest[key]}
|
|
||||||
onChange={handleChange}
|
|
||||||
value={key}
|
|
||||||
defaultChecked={initialValues?.includes(key as FeatureRequest)}
|
|
||||||
/>
|
|
||||||
})}
|
|
||||||
<p className="mt-3" style={{ fontSize: '12px' }}>Něco jiného? Dejte vědět.</p>
|
|
||||||
</Modal.Body>
|
|
||||||
<Modal.Footer>
|
|
||||||
<Button variant="primary" onClick={onClose}>
|
|
||||||
Zavřít
|
|
||||||
</Button>
|
|
||||||
</Modal.Footer>
|
|
||||||
</Modal>
|
|
||||||
}
|
|
||||||
@@ -33,9 +33,7 @@ function parseAmount(s: string): number | null {
|
|||||||
if (!s || s.trim().length === 0) return null;
|
if (!s || s.trim().length === 0) return null;
|
||||||
const n = parseFloat(s);
|
const n = parseFloat(s);
|
||||||
if (isNaN(n) || n < 0) return null;
|
if (isNaN(n) || n < 0) return null;
|
||||||
const parts = s.split('.');
|
return Math.round(n * 100);
|
||||||
if (parts.length === 2 && parts[1].length > 2) return null;
|
|
||||||
return Math.round(n * 100) / 100;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PayForAllModal({ isOpen, onClose, locationName, locationChoices, menu, payerLogin, bankAccount, bankAccountHolder }: Readonly<Props>) {
|
export default function PayForAllModal({ isOpen, onClose, locationName, locationChoices, menu, payerLogin, bankAccount, bankAccountHolder }: Readonly<Props>) {
|
||||||
@@ -55,11 +53,11 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
|
|||||||
let baseAmountParseFailed = false;
|
let baseAmountParseFailed = false;
|
||||||
if (menu) {
|
if (menu) {
|
||||||
for (const idx of selectedFoods) {
|
for (const idx of selectedFoods) {
|
||||||
const price = parsePriceCzk(menu.food?.[idx]?.price);
|
const priceKc = parsePriceCzk(menu.food?.[idx]?.price);
|
||||||
if (price === null) {
|
if (priceKc === null) {
|
||||||
baseAmountParseFailed = true;
|
baseAmountParseFailed = true;
|
||||||
} else {
|
} else {
|
||||||
baseAmount += price;
|
baseAmount += Math.round(priceKc * 100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,13 +82,19 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
|
|||||||
if (includedDiners.length === 0) return 0;
|
if (includedDiners.length === 0) return 0;
|
||||||
const tip = parseAmount(tipTotal);
|
const tip = parseAmount(tipTotal);
|
||||||
if (tip === null || tip === 0) return 0;
|
if (tip === null || tip === 0) return 0;
|
||||||
return Math.round((tip / includedDiners.length) * 100) / 100;
|
const totalPeople = includedDiners.length + 1;
|
||||||
|
return Math.round(tip / totalPeople);
|
||||||
|
})();
|
||||||
|
const payerTipShare = (() => {
|
||||||
|
const tip = parseAmount(tipTotal);
|
||||||
|
if (!tip) return 0;
|
||||||
|
return tip - tipPerPerson * includedDiners.length;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const getTotal = (d: DinerEntry): number => {
|
const getTotal = (d: DinerEntry): number => {
|
||||||
const surcharge = parseAmount(d.surchargeAmount) ?? 0;
|
const surcharge = parseAmount(d.surchargeAmount) ?? 0;
|
||||||
const tip = d.included && d.login !== payerLogin ? tipPerPerson : 0;
|
const tip = d.login === payerLogin ? payerTipShare : tipPerPerson;
|
||||||
return Math.round((d.baseAmount + surcharge + tip) * 100) / 100;
|
return d.baseAmount + surcharge + tip;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInclude = useCallback((login: string, checked: boolean) => {
|
const handleInclude = useCallback((login: string, checked: boolean) => {
|
||||||
@@ -116,11 +120,6 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
|
|||||||
setError(`Celková částka pro ${d.login} musí být kladná`);
|
setError(`Celková částka pro ${d.login} musí být kladná`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const amountStr = total.toString();
|
|
||||||
if (amountStr.includes('.') && amountStr.split('.')[1].length > 2) {
|
|
||||||
setError(`Částka pro ${d.login} má více než 2 desetinná místa`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const foods = d.selectedFoods.map(i => menu?.food?.[i]?.name).filter(Boolean).join(', ');
|
const foods = d.selectedFoods.map(i => menu?.food?.[i]?.name).filter(Boolean).join(', ');
|
||||||
const purposeBase = `Oběd ${locationName}${foods ? ` — ${foods}` : ''}`;
|
const purposeBase = `Oběd ${locationName}${foods ? ` — ${foods}` : ''}`;
|
||||||
recipients.push({
|
recipients.push({
|
||||||
@@ -167,7 +166,7 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
|
|||||||
</Alert>
|
</Alert>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p>Zaplatili jste za skupinu v restauraci. Nastavte příplatky a dýško, poté vygenerujte QR kódy pro ostatní.</p>
|
<p>Zaplatili jste za skupinu v restauraci. Nastavte příplatky a společné poplatky, poté vygenerujte QR kódy pro ostatní.</p>
|
||||||
|
|
||||||
{!hasMenu && (
|
{!hasMenu && (
|
||||||
<Alert variant="info">
|
<Alert variant="info">
|
||||||
@@ -194,7 +193,7 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
|
|||||||
<th>Strávník</th>
|
<th>Strávník</th>
|
||||||
<th>Jídla</th>
|
<th>Jídla</th>
|
||||||
<th style={{ width: 220 }}>Příplatek</th>
|
<th style={{ width: 220 }}>Příplatek</th>
|
||||||
<th style={{ width: 90 }}>Dýško</th>
|
<th style={{ width: 90 }}>Poplatek</th>
|
||||||
<th style={{ width: 90 }}>Celkem</th>
|
<th style={{ width: 90 }}>Celkem</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -220,40 +219,38 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
|
|||||||
<td>
|
<td>
|
||||||
<small>
|
<small>
|
||||||
{foodNames || <span className="text-muted">—</span>}
|
{foodNames || <span className="text-muted">—</span>}
|
||||||
{hasMenu && d.baseAmount > 0 && <span className="text-muted"> ({d.baseAmount} Kč)</span>}
|
{hasMenu && d.baseAmount > 0 && <span className="text-muted"> ({d.baseAmount / 100} Kč)</span>}
|
||||||
{d.baseAmountParseFailed && <span className="text-warning"> ⚠</span>}
|
{d.baseAmountParseFailed && <span className="text-warning"> ⚠</span>}
|
||||||
</small>
|
</small>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{!isPayer && (
|
<div className="d-flex gap-1">
|
||||||
<div className="d-flex gap-1">
|
<Form.Control
|
||||||
<Form.Control
|
type="text"
|
||||||
type="text"
|
placeholder="popis"
|
||||||
placeholder="popis"
|
value={d.surchargeText}
|
||||||
value={d.surchargeText}
|
onChange={e => handleSurchargeText(d.login, e.target.value)}
|
||||||
onChange={e => handleSurchargeText(d.login, e.target.value)}
|
disabled={!isPayer && !d.included}
|
||||||
disabled={!d.included}
|
size="sm"
|
||||||
size="sm"
|
onKeyDown={e => e.stopPropagation()}
|
||||||
onKeyDown={e => e.stopPropagation()}
|
/>
|
||||||
/>
|
<Form.Control
|
||||||
<Form.Control
|
type="text"
|
||||||
type="text"
|
placeholder="Kč"
|
||||||
placeholder="Kč"
|
value={d.surchargeAmount}
|
||||||
value={d.surchargeAmount}
|
onChange={e => handleSurchargeAmount(d.login, e.target.value)}
|
||||||
onChange={e => handleSurchargeAmount(d.login, e.target.value)}
|
disabled={!isPayer && !d.included}
|
||||||
disabled={!d.included}
|
size="sm"
|
||||||
size="sm"
|
style={{ width: 70 }}
|
||||||
style={{ width: 70 }}
|
onKeyDown={e => e.stopPropagation()}
|
||||||
onKeyDown={e => e.stopPropagation()}
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="text-end">
|
<td className="text-end">
|
||||||
{!isPayer && d.included ? `${tipPerPerson} Kč` : '—'}
|
{(() => { const s = isPayer ? payerTipShare : tipPerPerson; return s > 0 ? `${s / 100} Kč` : '—'; })()}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-end fw-bold">
|
<td className="text-end fw-bold">
|
||||||
{!isPayer ? `${total} Kč` : '—'}
|
{`${total / 100} Kč`}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
@@ -262,7 +259,7 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
|
|||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
<div className="d-flex align-items-center gap-2 mt-2">
|
<div className="d-flex align-items-center gap-2 mt-2">
|
||||||
<label className="mb-0 text-nowrap">Dýško celkem (Kč):</label>
|
<label className="mb-0 text-nowrap">Poplatky celkem (Kč):</label>
|
||||||
<Form.Control
|
<Form.Control
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
@@ -274,7 +271,7 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
|
|||||||
/>
|
/>
|
||||||
<small className="text-muted">
|
<small className="text-muted">
|
||||||
{includedDiners.length > 0 && tipPerPerson > 0
|
{includedDiners.length > 0 && tipPerPerson > 0
|
||||||
? `(${tipPerPerson} Kč / osoba)`
|
? `(${tipPerPerson / 100} Kč / osoba)`
|
||||||
: ''}
|
: ''}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Modal, Button, Form, Table, Alert } from "react-bootstrap";
|
||||||
|
import { generateQr, OrderGroup, OrderGroupMember, QrRecipient } from "../../../../types";
|
||||||
|
import { sanitizeQrMessage } from "../../Utils";
|
||||||
|
import { computeFeeShare, computeMemberTotal, countActiveMembers, isActiveMember } from "../../utils/groupFees";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
group: OrderGroup;
|
||||||
|
payerLogin: string;
|
||||||
|
bankAccount: string;
|
||||||
|
bankAccountHolder: string;
|
||||||
|
groupId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DinerEntry = {
|
||||||
|
login: string;
|
||||||
|
member: OrderGroupMember;
|
||||||
|
included: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PayForGroupModal({ isOpen, onClose, onSuccess, group, payerLogin, bankAccount, bankAccountHolder, groupId }: Readonly<Props>) {
|
||||||
|
const [diners, setDiners] = useState<DinerEntry[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
const entries: DinerEntry[] = (Object.entries(group.members) as [string, OrderGroupMember][]).map(([login, member]) => ({
|
||||||
|
login,
|
||||||
|
member,
|
||||||
|
// Standardně zahrnout všechny, kdo nejsou plátce a něco si objednali.
|
||||||
|
included: login !== payerLogin && isActiveMember(member),
|
||||||
|
}));
|
||||||
|
setDiners(entries);
|
||||||
|
setError(null);
|
||||||
|
setSuccess(false);
|
||||||
|
}, [isOpen, group, payerLogin]);
|
||||||
|
|
||||||
|
const fees = group.fees ?? 0;
|
||||||
|
const shipping = group.shipping ?? 0;
|
||||||
|
const tip = group.tip ?? 0;
|
||||||
|
const totalFees = fees + shipping + tip;
|
||||||
|
// Poplatky se dělí jen mezi aktivní strávníky (kdo si reálně něco objednal).
|
||||||
|
const activeCount = countActiveMembers(group.members);
|
||||||
|
const feeShare = computeFeeShare(totalFees, activeCount);
|
||||||
|
const feeParams = { totalFees, discountType: group.discountType, discountValue: group.discountValue ?? 0 };
|
||||||
|
|
||||||
|
const getMemberTotal = (entry: DinerEntry): number =>
|
||||||
|
computeMemberTotal(entry.member, feeParams, feeShare, activeCount);
|
||||||
|
|
||||||
|
const includedNonPayers = diners.filter(d => d.included && d.login !== payerLogin);
|
||||||
|
|
||||||
|
const handleInclude = (login: string, checked: boolean) => {
|
||||||
|
setDiners(prev => prev.map(d => d.login === login ? { ...d, included: checked } : d));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
setError(null);
|
||||||
|
const recipients: QrRecipient[] = [];
|
||||||
|
|
||||||
|
for (const d of diners) {
|
||||||
|
if (!d.included || d.login === payerLogin) continue;
|
||||||
|
const total = getMemberTotal(d);
|
||||||
|
if (total <= 0) {
|
||||||
|
setError(`Celková částka pro ${d.login} musí být kladná`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const note = d.member.note?.trim();
|
||||||
|
recipients.push({
|
||||||
|
login: d.login,
|
||||||
|
purpose: sanitizeQrMessage(note || `Objednávka ${group.name}`),
|
||||||
|
amount: total,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recipients.length === 0) {
|
||||||
|
setError("Nebyl vybrán žádný příjemce");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await generateQr({
|
||||||
|
body: { recipients, bankAccount, bankAccountHolder, ...(groupId ? { groupId } : {}) },
|
||||||
|
});
|
||||||
|
if (response.error) {
|
||||||
|
setError((response.error as any).error || 'Nastala chyba při generování QR kódů');
|
||||||
|
} else {
|
||||||
|
setSuccess(true);
|
||||||
|
onSuccess?.();
|
||||||
|
setTimeout(() => onClose(), 2000);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message || 'Nastala chyba při generování QR kódů');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasFees = totalFees > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal show={isOpen} onHide={onClose} size="lg">
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title><h2>Generovat QR — {group.name}</h2></Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
{success ? (
|
||||||
|
<Alert variant="success">
|
||||||
|
QR kódy byly úspěšně vygenerovány! Uživatelé je uvidí v sekci „Nevyřízené platby".
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p>Zaplatili jste za skupinu. Vyberte, komu vygenerovat QR kód k úhradě.</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert variant="danger" onClose={() => setError(null)} dismissible>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasFees && (
|
||||||
|
<div className="d-flex gap-3 mb-2 text-muted" style={{ fontSize: '0.9em' }}>
|
||||||
|
{fees > 0 && <span>Poplatky: <strong>{fees / 100} Kč</strong></span>}
|
||||||
|
{shipping > 0 && <span>Doprava: <strong>{shipping / 100} Kč</strong></span>}
|
||||||
|
{tip > 0 && <span>Spropitné: <strong>{tip / 100} Kč</strong></span>}
|
||||||
|
<span>→ {feeShare / 100} Kč/os.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{group.discountValue != null && group.discountValue > 0 && (
|
||||||
|
<div className="mb-2 text-success" style={{ fontSize: '0.9em' }}>
|
||||||
|
Sleva: {group.discountType === 'percent' ? `${group.discountValue}%` : `${group.discountValue / 100} Kč`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Table striped bordered hover responsive size="sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: 40 }}></th>
|
||||||
|
<th>Člen</th>
|
||||||
|
<th style={{ width: 90 }} className="text-end">Základ</th>
|
||||||
|
<th style={{ width: 90 }} className="text-end">Příplatek</th>
|
||||||
|
{hasFees && <th style={{ width: 90 }} className="text-end">Poplatek</th>}
|
||||||
|
<th style={{ width: 90 }} className="text-end fw-bold">Celkem</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{diners.map(d => {
|
||||||
|
const isPayer = d.login === payerLogin;
|
||||||
|
const active = isActiveMember(d.member);
|
||||||
|
const total = getMemberTotal(d);
|
||||||
|
const surcharge = d.member.surchargeAmount ?? 0;
|
||||||
|
return (
|
||||||
|
<tr key={d.login} className={(!d.included && !isPayer) || !active ? 'text-muted' : ''}>
|
||||||
|
<td className="text-center">
|
||||||
|
{isPayer ? (
|
||||||
|
<small className="text-muted">plátce</small>
|
||||||
|
) : !active ? (
|
||||||
|
<small className="text-muted">jen objednává</small>
|
||||||
|
) : (
|
||||||
|
<Form.Check
|
||||||
|
type="checkbox"
|
||||||
|
checked={d.included}
|
||||||
|
onChange={e => handleInclude(d.login, e.target.checked)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<strong>{d.login}</strong>
|
||||||
|
{d.member.surchargeText && (
|
||||||
|
<small className="text-muted ms-1">({d.member.surchargeText})</small>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="text-end">
|
||||||
|
{(d.member.amount ?? 0) > 0 ? `${d.member.amount! / 100} Kč` : <span className="text-muted">—</span>}
|
||||||
|
</td>
|
||||||
|
<td className="text-end">
|
||||||
|
{surcharge > 0 ? `${surcharge / 100} Kč` : <span className="text-muted">—</span>}
|
||||||
|
</td>
|
||||||
|
{hasFees && (
|
||||||
|
<td className="text-end">
|
||||||
|
{active && feeShare > 0 ? `${feeShare / 100} Kč` : '—'}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
<td className="text-end fw-bold">
|
||||||
|
{total > 0 ? `${total / 100} Kč` : <span className="text-muted">—</span>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
{!success && (
|
||||||
|
<>
|
||||||
|
<span className="me-auto text-muted">Příjemci: {includedNonPayers.length}</span>
|
||||||
|
<Button variant="secondary" onClick={onClose} disabled={loading}>Storno</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={loading || includedNonPayers.length === 0}
|
||||||
|
>
|
||||||
|
{loading ? 'Generuji...' : 'Vygenerovat QR'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<Button variant="secondary" onClick={onClose}>Zavřít</Button>
|
||||||
|
)}
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,12 +15,12 @@ export default function PizzaAdditionalFeeModal({ customerName, isOpen, onClose,
|
|||||||
const priceRef = useRef<HTMLInputElement>(null);
|
const priceRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const doSubmit = () => {
|
const doSubmit = () => {
|
||||||
onSave(customerName, textRef.current?.value, Number.parseInt(priceRef.current?.value ?? "0"));
|
onSave(customerName, textRef.current?.value, Math.round(Number.parseFloat(priceRef.current?.value ?? "0") * 100));
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
onSave(customerName, textRef.current?.value, Number.parseInt(priceRef.current?.value ?? "0"));
|
onSave(customerName, textRef.current?.value, Math.round(Number.parseFloat(priceRef.current?.value ?? "0") * 100));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Modal, Button, Form, ListGroup, Alert } from "react-bootstrap";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faTrashCan } from "@fortawesome/free-regular-svg-icons";
|
||||||
|
import { addStore, deleteStore } from "../../../../types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
stores: string[];
|
||||||
|
onStoresChanged: (stores: string[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChanged }: Readonly<Props>) {
|
||||||
|
const [newName, setNewName] = useState('');
|
||||||
|
const [heslo, setHeslo] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
if (!newName.trim()) return;
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await addStore({ body: { name: newName.trim(), heslo } });
|
||||||
|
if (res.error) {
|
||||||
|
setError((res.error as any).error || 'Nastala chyba');
|
||||||
|
} else if (res.data) {
|
||||||
|
onStoresChanged(res.data as string[]);
|
||||||
|
setNewName('');
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message || 'Nastala chyba');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = async (name: string) => {
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await deleteStore({ body: { name, heslo } });
|
||||||
|
if (res.error) {
|
||||||
|
setError((res.error as any).error || 'Nastala chyba');
|
||||||
|
} else if (res.data) {
|
||||||
|
onStoresChanged(res.data as string[]);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message || 'Nastala chyba');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal show={isOpen} onHide={onClose}>
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title><h2>Správa obchodů</h2></Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
{error && (
|
||||||
|
<Alert variant="danger" onClose={() => setError(null)} dismissible>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>Admin heslo</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
type="password"
|
||||||
|
placeholder="Heslo"
|
||||||
|
value={heslo}
|
||||||
|
onChange={e => setHeslo(e.target.value)}
|
||||||
|
onKeyDown={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
<h6>Přidat obchod</h6>
|
||||||
|
<div className="d-flex gap-2 mb-3">
|
||||||
|
<Form.Control
|
||||||
|
type="text"
|
||||||
|
placeholder="Název obchodu"
|
||||||
|
value={newName}
|
||||||
|
onChange={e => setNewName(e.target.value)}
|
||||||
|
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleAdd(); }}
|
||||||
|
/>
|
||||||
|
<Button variant="primary" onClick={handleAdd} disabled={loading || !newName.trim() || !heslo}>
|
||||||
|
Přidat
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h6>Aktuální seznam</h6>
|
||||||
|
{stores.length === 0 ? (
|
||||||
|
<p className="text-muted">Žádné obchody v seznamu</p>
|
||||||
|
) : (
|
||||||
|
<ListGroup>
|
||||||
|
{stores.map(s => (
|
||||||
|
<ListGroup.Item key={s} className="d-flex justify-content-between align-items-center">
|
||||||
|
{s}
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faTrashCan}
|
||||||
|
className="action-icon"
|
||||||
|
title="Odebrat"
|
||||||
|
onClick={() => handleRemove(s)}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
</ListGroup.Item>
|
||||||
|
))}
|
||||||
|
</ListGroup>
|
||||||
|
)}
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button variant="secondary" onClick={onClose}>Zavřít</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Modal, Button } from "react-bootstrap";
|
||||||
|
import { Suggestion } from "../../../../types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
suggestion?: Suggestion;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Modální dialog zobrazující celý detail návrhu na vylepšení. */
|
||||||
|
export default function SuggestionDetailModal({ suggestion, onClose }: Readonly<Props>) {
|
||||||
|
return (
|
||||||
|
<Modal show={!!suggestion} onHide={onClose} size="lg">
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title><h2>{suggestion?.title}</h2></Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
<p className="text-muted mb-3">
|
||||||
|
Navrhovatel: <strong>{suggestion?.author}</strong> · Hlasy: <strong>{suggestion?.voteScore}</strong>
|
||||||
|
</p>
|
||||||
|
<p style={{ whiteSpace: "pre-wrap" }}>{suggestion?.description}</p>
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button variant="secondary" onClick={onClose}>
|
||||||
|
Zavřít
|
||||||
|
</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ const BANK_ACCOUNT_NUMBER_KEY = 'bank_account_number';
|
|||||||
const BANK_ACCOUNT_HOLDER_KEY = 'bank_account_holder_name';
|
const BANK_ACCOUNT_HOLDER_KEY = 'bank_account_holder_name';
|
||||||
const HIDE_SOUPS_KEY = 'hide_soups';
|
const HIDE_SOUPS_KEY = 'hide_soups';
|
||||||
const THEME_KEY = 'theme_preference';
|
const THEME_KEY = 'theme_preference';
|
||||||
|
const ACCENT_HUE_KEY = 'accent_hue';
|
||||||
|
const LEGACY_COLOR_THEME_KEY = 'color_theme';
|
||||||
|
|
||||||
export type ThemePreference = 'system' | 'light' | 'dark';
|
export type ThemePreference = 'system' | 'light' | 'dark';
|
||||||
|
|
||||||
@@ -12,10 +14,13 @@ export type SettingsContextProps = {
|
|||||||
holderName?: string,
|
holderName?: string,
|
||||||
hideSoups?: boolean,
|
hideSoups?: boolean,
|
||||||
themePreference: ThemePreference,
|
themePreference: ThemePreference,
|
||||||
|
accentHue: number,
|
||||||
|
effectiveDark: boolean,
|
||||||
setBankAccountNumber: (accountNumber?: string) => void,
|
setBankAccountNumber: (accountNumber?: string) => void,
|
||||||
setBankAccountHolderName: (holderName?: string) => void,
|
setBankAccountHolderName: (holderName?: string) => void,
|
||||||
setHideSoupsOption: (hideSoups?: boolean) => void,
|
setHideSoupsOption: (hideSoups?: boolean) => void,
|
||||||
setThemePreference: (theme: ThemePreference) => void,
|
setThemePreference: (theme: ThemePreference) => void,
|
||||||
|
setAccentHue: (hue: number) => void,
|
||||||
}
|
}
|
||||||
|
|
||||||
type ContextProps = {
|
type ContextProps = {
|
||||||
@@ -45,11 +50,74 @@ function getInitialTheme(): ThemePreference {
|
|||||||
return 'system';
|
return 'system';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getInitialAccentHue(): number {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(ACCENT_HUE_KEY);
|
||||||
|
if (saved !== null) {
|
||||||
|
const n = parseInt(saved, 10);
|
||||||
|
if (!isNaN(n) && n >= 0 && n <= 360) return n;
|
||||||
|
}
|
||||||
|
// Migrace ze starého string formátu (green/blue/purple)
|
||||||
|
const old = localStorage.getItem(LEGACY_COLOR_THEME_KEY);
|
||||||
|
if (old === 'blue') return 217;
|
||||||
|
if (old === 'purple') return 263;
|
||||||
|
} catch {
|
||||||
|
// localStorage nedostupný
|
||||||
|
}
|
||||||
|
return 142;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Převod HSL na relativní jas dle WCAG (pro výpočet kontrastu s bílým textem)
|
||||||
|
function hslToRelativeLuminance(h: number, s: number, l: number): number {
|
||||||
|
const sn = s / 100, ln = l / 100;
|
||||||
|
const a = sn * Math.min(ln, 1 - ln);
|
||||||
|
const ch = (n: number) => {
|
||||||
|
const k = (n + h / 30) % 12;
|
||||||
|
return ln - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
|
||||||
|
};
|
||||||
|
const toLinear = (c: number) => c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||||
|
return 0.2126 * toLinear(ch(0)) + 0.7152 * toLinear(ch(8)) + 0.0722 * toLinear(ch(4));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Najde nejnižší světlost, při které má barva dostatečný kontrast s bílým textem (WCAG AA 4.5:1)
|
||||||
|
function adjustedL(hue: number, sat: number, targetL: number): number {
|
||||||
|
let l = targetL;
|
||||||
|
while (l >= 5) {
|
||||||
|
const lum = hslToRelativeLuminance(hue, sat, l);
|
||||||
|
if (1.05 / (lum + 0.05) >= 4.5) return l;
|
||||||
|
l -= 1;
|
||||||
|
}
|
||||||
|
return l;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAccentColors(hue: number, isDark: boolean): void {
|
||||||
|
const sat = 70;
|
||||||
|
const baseL = adjustedL(hue, sat, isDark ? 55 : 38);
|
||||||
|
const hoverL = isDark ? Math.min(baseL + 10, 80) : Math.max(baseL - 10, 10);
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.style.setProperty('--luncher-primary', `hsl(${hue} ${sat}% ${baseL}%)`);
|
||||||
|
root.style.setProperty('--luncher-primary-hover', `hsl(${hue} ${sat}% ${hoverL}%)`);
|
||||||
|
root.style.setProperty('--luncher-primary-light', isDark
|
||||||
|
? `hsl(${hue} 60% 12%)`
|
||||||
|
: `hsl(${hue} 60% 92%)`);
|
||||||
|
root.style.setProperty('--luncher-action-icon', `hsl(${hue} ${sat}% ${baseL}%)`);
|
||||||
|
root.style.setProperty('--luncher-success', `hsl(${hue} ${sat}% ${baseL}%)`);
|
||||||
|
}
|
||||||
|
|
||||||
function useProvideSettings(): SettingsContextProps {
|
function useProvideSettings(): SettingsContextProps {
|
||||||
const [bankAccount, setBankAccount] = useState<string | undefined>();
|
const [bankAccount, setBankAccount] = useState<string | undefined>();
|
||||||
const [holderName, setHolderName] = useState<string | undefined>();
|
const [holderName, setHolderName] = useState<string | undefined>();
|
||||||
const [hideSoups, setHideSoups] = useState<boolean | undefined>();
|
const [hideSoups, setHideSoups] = useState<boolean | undefined>();
|
||||||
const [themePreference, setTheme] = useState<ThemePreference>(getInitialTheme);
|
const [themePreference, setTheme] = useState<ThemePreference>(getInitialTheme);
|
||||||
|
const [accentHue, setHue] = useState<number>(getInitialAccentHue);
|
||||||
|
const [effectiveDark, setEffectiveDark] = useState<boolean>(() => {
|
||||||
|
try {
|
||||||
|
const pref = localStorage.getItem(THEME_KEY) as ThemePreference | null;
|
||||||
|
if (pref === 'dark') return true;
|
||||||
|
if (pref === 'light') return false;
|
||||||
|
} catch { /* noop */ }
|
||||||
|
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const accountNumber = localStorage.getItem(BANK_ACCOUNT_NUMBER_KEY);
|
const accountNumber = localStorage.getItem(BANK_ACCOUNT_NUMBER_KEY);
|
||||||
@@ -95,24 +163,27 @@ function useProvideSettings(): SettingsContextProps {
|
|||||||
}, [themePreference]);
|
}, [themePreference]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const applyTheme = (theme: 'light' | 'dark') => {
|
const applyTheme = (dark: boolean) => {
|
||||||
document.documentElement.setAttribute('data-bs-theme', theme);
|
document.documentElement.setAttribute('data-bs-theme', dark ? 'dark' : 'light');
|
||||||
|
setEffectiveDark(dark);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (themePreference === 'system') {
|
if (themePreference === 'system') {
|
||||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
applyTheme(mediaQuery.matches ? 'dark' : 'light');
|
applyTheme(mq.matches);
|
||||||
|
const handler = (e: MediaQueryListEvent) => applyTheme(e.matches);
|
||||||
const handler = (e: MediaQueryListEvent) => {
|
mq.addEventListener('change', handler);
|
||||||
applyTheme(e.matches ? 'dark' : 'light');
|
return () => mq.removeEventListener('change', handler);
|
||||||
};
|
|
||||||
mediaQuery.addEventListener('change', handler);
|
|
||||||
return () => mediaQuery.removeEventListener('change', handler);
|
|
||||||
} else {
|
} else {
|
||||||
applyTheme(themePreference);
|
applyTheme(themePreference === 'dark');
|
||||||
}
|
}
|
||||||
}, [themePreference]);
|
}, [themePreference]);
|
||||||
|
|
||||||
|
// Aplikuje accent barvy při změně hue nebo přepnutí světlý/tmavý
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(ACCENT_HUE_KEY, String(accentHue));
|
||||||
|
applyAccentColors(accentHue, effectiveDark);
|
||||||
|
}, [accentHue, effectiveDark]);
|
||||||
|
|
||||||
function setBankAccountNumber(bankAccount?: string) {
|
function setBankAccountNumber(bankAccount?: string) {
|
||||||
setBankAccount(bankAccount);
|
setBankAccount(bankAccount);
|
||||||
}
|
}
|
||||||
@@ -129,14 +200,21 @@ function useProvideSettings(): SettingsContextProps {
|
|||||||
setTheme(theme);
|
setTheme(theme);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setAccentHue(hue: number) {
|
||||||
|
setHue(hue);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bankAccount,
|
bankAccount,
|
||||||
holderName,
|
holderName,
|
||||||
hideSoups,
|
hideSoups,
|
||||||
themePreference,
|
themePreference,
|
||||||
|
accentHue,
|
||||||
|
effectiveDark,
|
||||||
setBankAccountNumber,
|
setBankAccountNumber,
|
||||||
setBankAccountHolderName,
|
setBankAccountHolderName,
|
||||||
setHideSoupsOption,
|
setHideSoupsOption,
|
||||||
setThemePreference,
|
setThemePreference,
|
||||||
|
setAccentHue,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,3 +18,4 @@ export const SocketContext = React.createContext();
|
|||||||
export const EVENT_CONNECT = 'connect';
|
export const EVENT_CONNECT = 'connect';
|
||||||
export const EVENT_DISCONNECT = 'disconnect';
|
export const EVENT_DISCONNECT = 'disconnect';
|
||||||
export const EVENT_MESSAGE = 'message';
|
export const EVENT_MESSAGE = 'message';
|
||||||
|
export const EVENT_PENDING_QR = 'pendingQr';
|
||||||
|
|||||||
@@ -0,0 +1,755 @@
|
|||||||
|
import { useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||||
|
import { Alert, Badge, Button, Card, Form, Modal, OverlayTrigger, Table, Tooltip } from 'react-bootstrap';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faTrashCan } from '@fortawesome/free-regular-svg-icons';
|
||||||
|
import { faBasketShopping, faChevronLeft, faChevronRight, faCircleCheck, faClockRotateLeft, faGear, faLock, faLockOpen, faSearch, faUserPlus } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import DatePicker, { registerLocale } from 'react-datepicker';
|
||||||
|
import { cs } from 'date-fns/locale';
|
||||||
|
import 'react-datepicker/dist/react-datepicker.css';
|
||||||
|
import {
|
||||||
|
ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember, PendingQr,
|
||||||
|
getData, createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, getOrderDates,
|
||||||
|
} from '../../../types';
|
||||||
|
import { computeFeeShare, computeMemberTotal, countActiveMembers } from '../utils/groupFees';
|
||||||
|
import { EVENT_MESSAGE, EVENT_PENDING_QR, SocketContext } from '../context/socket';
|
||||||
|
import { useAuth } from '../context/auth';
|
||||||
|
import { useSettings } from '../context/settings';
|
||||||
|
import { formatDate, formatDateString } from '../Utils';
|
||||||
|
import Login from '../Login';
|
||||||
|
import Header from '../components/Header';
|
||||||
|
import Footer from '../components/Footer';
|
||||||
|
import Loader from '../components/Loader';
|
||||||
|
import StoreAdminModal from '../components/modals/StoreAdminModal';
|
||||||
|
import PayForGroupModal from '../components/modals/PayForGroupModal';
|
||||||
|
import EditGroupFeesModal from '../components/modals/EditGroupFeesModal';
|
||||||
|
import PendingPayments from '../components/PendingPayments';
|
||||||
|
|
||||||
|
const SLOT = MealSlot.EXTRA;
|
||||||
|
const TIME_REGEX = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||||
|
|
||||||
|
// Český lokál pro date picker (názvy měsíců/dnů, pondělí jako první den)
|
||||||
|
registerLocale('cs', cs);
|
||||||
|
|
||||||
|
/** Vrátí ISO datum (YYYY-MM-DD) posunuté o předaný počet dní. */
|
||||||
|
function shiftIsoDate(iso: string, days: number): string {
|
||||||
|
const date = new Date(`${iso}T00:00:00`);
|
||||||
|
date.setDate(date.getDate() + days);
|
||||||
|
return formatDate(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Převede ISO datum (YYYY-MM-DD) na lokální Date (půlnoc), nebo null. */
|
||||||
|
function isoToDate(iso?: string): Date | null {
|
||||||
|
return iso ? new Date(`${iso}T00:00:00`) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateBadge(state: GroupState) {
|
||||||
|
const map: Record<GroupState, { bg: string; label: string }> = {
|
||||||
|
[GroupState.OPEN]: { bg: 'success', label: 'Otevřeno' },
|
||||||
|
[GroupState.LOCKED]: { bg: 'warning', label: 'Uzamčeno' },
|
||||||
|
[GroupState.ORDERED]: { bg: 'secondary', label: 'Objednáno' },
|
||||||
|
};
|
||||||
|
const { bg, label } = map[state] ?? { bg: 'light', label: state };
|
||||||
|
return <Badge bg={bg}>{label}</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OrderGroupsPage() {
|
||||||
|
const auth = useAuth();
|
||||||
|
const settings = useSettings();
|
||||||
|
const socket = useContext(SocketContext);
|
||||||
|
const [data, setData] = useState<ClientData | undefined>();
|
||||||
|
const [failure, setFailure] = useState(false);
|
||||||
|
// Vybrané datum pro zobrazení historie (undefined = aktuální den)
|
||||||
|
const [selectedDate, setSelectedDate] = useState<string | undefined>();
|
||||||
|
// ISO datum dnešního dne dle serveru (horní hranice navigace), zjištěné při prvním načtení
|
||||||
|
const [todayIso, setTodayIso] = useState<string | undefined>();
|
||||||
|
// Ref pro socket handler – aby věděl, zda se zobrazuje historie (na ní se živé aktualizace neaplikují)
|
||||||
|
const selectedDateRef = useRef<string | undefined>(undefined);
|
||||||
|
// ISO data dnů, ve kterých existuje aspoň jedna objednávka (pro zvýraznění v date pickeru)
|
||||||
|
const [orderDates, setOrderDates] = useState<string[]>([]);
|
||||||
|
const [newGroupName, setNewGroupName] = useState('');
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [adminModalOpen, setAdminModalOpen] = useState(false);
|
||||||
|
const [editAmounts, setEditAmounts] = useState<Record<string, string>>({});
|
||||||
|
const [editNotes, setEditNotes] = useState<Record<string, string>>({});
|
||||||
|
const [editSurcharges, setEditSurcharges] = useState<Record<string, { text: string; amount: string }>>({});
|
||||||
|
const [editTimes, setEditTimes] = useState<Record<string, { orderedAt: string; deliveryAt: string }>>({});
|
||||||
|
const [payModal, setPayModal] = useState<OrderGroup | null>(null);
|
||||||
|
const [feesModal, setFeesModal] = useState<OrderGroup | null>(null);
|
||||||
|
const [confirmOrderGroup, setConfirmOrderGroup] = useState<OrderGroup | null>(null);
|
||||||
|
const [pageError, setPageError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchData = async (date?: string) => {
|
||||||
|
try {
|
||||||
|
const r = await getData({ query: { slot: SLOT, date } });
|
||||||
|
if (r.data) {
|
||||||
|
setData(r.data);
|
||||||
|
// Při zobrazení aktuálního dne si zapamatujeme dnešní ISO datum jako horní hranici navigace
|
||||||
|
if (!date && r.data.isoDate) setTodayIso(r.data.isoDate);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setFailure(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Načte dny s objednávkou pro zvýraznění v date pickeru
|
||||||
|
const fetchOrderDates = async () => {
|
||||||
|
const r = await getOrderDates();
|
||||||
|
if (r.data?.dates) setOrderDates(r.data.dates);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
selectedDateRef.current = selectedDate;
|
||||||
|
}, [selectedDate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!auth?.login) return;
|
||||||
|
fetchData(selectedDate);
|
||||||
|
}, [auth?.login, selectedDate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!auth?.login) return;
|
||||||
|
fetchOrderDates();
|
||||||
|
}, [auth?.login]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
socket.on(EVENT_MESSAGE, (newData: ClientData) => {
|
||||||
|
// Živé aktualizace se týkají vždy dneška – při zobrazení historie je ignorujeme
|
||||||
|
if (selectedDateRef.current) return;
|
||||||
|
if (newData.slot === SLOT) setData(prev => ({
|
||||||
|
...newData,
|
||||||
|
stores: newData.stores ?? prev?.stores,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
// Nová nevyřízená platba (QR kód) – připojíme do dat, aby se zobrazila i bez znovunačtení stránky
|
||||||
|
socket.on(EVENT_PENDING_QR, (pendingQr: PendingQr) => {
|
||||||
|
if (selectedDateRef.current) return;
|
||||||
|
setData(prev => prev ? { ...prev, pendingQrs: [...(prev.pendingQrs ?? []), pendingQr] } : prev);
|
||||||
|
});
|
||||||
|
return () => { socket.off(EVENT_MESSAGE); socket.off(EVENT_PENDING_QR); };
|
||||||
|
}, [socket]);
|
||||||
|
|
||||||
|
// Navigace mezi dny pomocí klávesových šipek (←/→), obdobně jako na hlavní stránce
|
||||||
|
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||||
|
// Ignorujeme, pokud uživatel právě píše do formulářového pole
|
||||||
|
const tag = (e.target as HTMLElement)?.tagName;
|
||||||
|
if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') return;
|
||||||
|
const currentIso = data?.isoDate;
|
||||||
|
if (!currentIso) return;
|
||||||
|
if (e.keyCode === 37) {
|
||||||
|
// Předchozí den – do minulosti bez omezení
|
||||||
|
setSelectedDate(shiftIsoDate(currentIso, -1));
|
||||||
|
} else if (e.keyCode === 39 && todayIso != null && currentIso < todayIso) {
|
||||||
|
// Následující den – nejvýše po dnešek (na dnešek přes undefined kvůli živým aktualizacím)
|
||||||
|
const target = shiftIsoDate(currentIso, 1);
|
||||||
|
setSelectedDate(target >= todayIso ? undefined : target);
|
||||||
|
}
|
||||||
|
}, [data?.isoDate, todayIso]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [handleKeyDown]);
|
||||||
|
|
||||||
|
const refresh = async (fn: () => Promise<any>): Promise<boolean> => {
|
||||||
|
setPageError(null);
|
||||||
|
const result = await fn();
|
||||||
|
if (result?.error) {
|
||||||
|
setPageError((result.error as any).error || 'Nastala chyba');
|
||||||
|
await fetchData();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (result?.data) {
|
||||||
|
setData(result.data);
|
||||||
|
socket.emit?.('message', result.data as ClientData);
|
||||||
|
}
|
||||||
|
await fetchData();
|
||||||
|
// Sada dnů s objednávkou se mohla změnit (vytvoření/smazání skupiny)
|
||||||
|
fetchOrderDates();
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!newGroupName || !auth?.login) return;
|
||||||
|
setCreating(true);
|
||||||
|
const ok = await refresh(() => createGroup({ body: { name: newGroupName } }));
|
||||||
|
if (ok) setNewGroupName('');
|
||||||
|
setCreating(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJoin = (groupId: string) =>
|
||||||
|
refresh(() => addGroupMember({ body: { id: groupId } }));
|
||||||
|
|
||||||
|
const handleToggleLock = (group: OrderGroup) => {
|
||||||
|
const next = group.state === GroupState.OPEN ? GroupState.LOCKED : GroupState.OPEN;
|
||||||
|
return refresh(() => setGroupState({ body: { id: group.id, state: next } }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmOrdered = async (group: OrderGroup) => {
|
||||||
|
setConfirmOrderGroup(null);
|
||||||
|
await refresh(() => setGroupState({ body: { id: group.id, state: GroupState.ORDERED } }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevertOrdered = (group: OrderGroup) =>
|
||||||
|
refresh(() => setGroupState({ body: { id: group.id, state: GroupState.LOCKED } }));
|
||||||
|
|
||||||
|
const handleDelete = (groupId: string) =>
|
||||||
|
refresh(() => deleteGroup({ body: { id: groupId } }));
|
||||||
|
|
||||||
|
const handleSaveAmount = async (groupId: string, login: string) => {
|
||||||
|
const key = `${groupId}:${login}`;
|
||||||
|
const raw = editAmounts[key];
|
||||||
|
const n = parseFloat(raw ?? '');
|
||||||
|
if (!raw || isNaN(n) || n < 0) {
|
||||||
|
setPageError('Zadejte platnou kladnou částku');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ok = await refresh(() => updateGroupMember({ body: { id: groupId, login, amount: Math.round(n * 100) } }));
|
||||||
|
if (ok) setEditAmounts(prev => { const next = { ...prev }; delete next[key]; return next; });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveNote = async (groupId: string, login: string) => {
|
||||||
|
const key = `${groupId}:${login}`;
|
||||||
|
const note = editNotes[key] ?? '';
|
||||||
|
const ok = await refresh(() => updateGroupMember({ body: { id: groupId, login, note } }));
|
||||||
|
if (ok) setEditNotes(prev => { const next = { ...prev }; delete next[key]; return next; });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveSurcharge = async (groupId: string, login: string) => {
|
||||||
|
const key = `${groupId}:${login}`;
|
||||||
|
const surchargeText = editSurcharges[key]?.text ?? '';
|
||||||
|
const rawAmount = editSurcharges[key]?.amount ?? '';
|
||||||
|
const surchargeAmount = rawAmount === '' ? 0 : parseFloat(rawAmount.replace(',', '.'));
|
||||||
|
if (rawAmount !== '' && (isNaN(surchargeAmount) || surchargeAmount < 0)) {
|
||||||
|
setPageError('Zadejte platnou výši příplatku');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ok = await refresh(() => updateGroupMember({ body: { id: groupId, login, surchargeText, surchargeAmount: rawAmount === '' ? 0 : Math.round(surchargeAmount * 100) } }));
|
||||||
|
if (ok) setEditSurcharges(prev => { const next = { ...prev }; delete next[key]; return next; });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveTimes = async (group: OrderGroup) => {
|
||||||
|
const times = editTimes[group.id];
|
||||||
|
if (!times) return;
|
||||||
|
const { orderedAt, deliveryAt } = times;
|
||||||
|
if (orderedAt && !TIME_REGEX.test(orderedAt)) {
|
||||||
|
setPageError('Čas objednání musí být ve formátu HH:MM');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (deliveryAt && !TIME_REGEX.test(deliveryAt)) {
|
||||||
|
setPageError('Čas doručení musí být ve formátu HH:MM');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ok = await refresh(() => updateGroupTimes({ body: { id: group.id, orderedAt, deliveryAt } }));
|
||||||
|
if (ok) setEditTimes(prev => { const next = { ...prev }; delete next[group.id]; return next; });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pozn.: tyto funkce se volají až v renderu, kde je k dispozici `selectedDate`.
|
||||||
|
// Historie (jiný než aktuální den) je vždy read-only.
|
||||||
|
const canEditMember = (group: OrderGroup, targetLogin: string) => {
|
||||||
|
if (selectedDate) return false;
|
||||||
|
if (group.state === GroupState.ORDERED) return false;
|
||||||
|
if (auth?.login === group.creatorLogin) return true;
|
||||||
|
if (auth?.login === targetLogin && group.state === GroupState.OPEN) return true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const canManageMembers = (group: OrderGroup) => {
|
||||||
|
if (selectedDate) return false;
|
||||||
|
if (group.state === GroupState.ORDERED) return false;
|
||||||
|
if (auth?.login === group.creatorLogin) return true;
|
||||||
|
return group.state === GroupState.OPEN;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!auth?.login) return <Login />;
|
||||||
|
|
||||||
|
if (failure) return (
|
||||||
|
<Loader icon={faSearch} description="Nepodařilo se načíst data" animation="fa-beat" />
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!data) return (
|
||||||
|
<Loader icon={faSearch} description="Načítám..." animation="fa-bounce" />
|
||||||
|
);
|
||||||
|
|
||||||
|
const stores = data.stores ?? [];
|
||||||
|
const groups = data.groups ?? [];
|
||||||
|
|
||||||
|
// Zobrazené datum a režim historie (vše read-only, pokud nejde o aktuální den)
|
||||||
|
const displayedIso = data.isoDate;
|
||||||
|
const isToday = !selectedDate || (todayIso != null && displayedIso === todayIso);
|
||||||
|
const isReadOnly = !isToday;
|
||||||
|
const canGoNext = todayIso != null && displayedIso != null && displayedIso < todayIso;
|
||||||
|
|
||||||
|
const goToDay = (offset: number) => {
|
||||||
|
if (!displayedIso) return;
|
||||||
|
const target = shiftIsoDate(displayedIso, offset);
|
||||||
|
// Na dnešek (či dál) se vracíme přes undefined, aby se obnovily živé aktualizace
|
||||||
|
setSelectedDate(todayIso != null && target >= todayIso ? undefined : target);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDatePick = (value: string) => {
|
||||||
|
if (!value) return;
|
||||||
|
setSelectedDate(todayIso != null && value >= todayIso ? undefined : value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dny s objednávkou jako Date objekty pro zvýraznění v kalendáři
|
||||||
|
const highlightedOrderDates = orderDates
|
||||||
|
.map(d => isoToDate(d))
|
||||||
|
.filter((d): d is Date => d != null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-container">
|
||||||
|
<Header choices={data.choices} />
|
||||||
|
<div className="wrapper">
|
||||||
|
<div className="d-flex align-items-center justify-content-between mb-1">
|
||||||
|
<h1 className="title mb-0">Objednání</h1>
|
||||||
|
<Button variant="outline-secondary" size="sm" onClick={() => setAdminModalOpen(true)} title="Správa obchodů">
|
||||||
|
<FontAwesomeIcon icon={faGear} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p style={{ color: 'var(--luncher-text-muted)' }}>Skupinové objednávky z obchodů a restaurací</p>
|
||||||
|
|
||||||
|
{/* Navigace mezi dny – šipky kolem výběru data (i klávesami ←/→) */}
|
||||||
|
<div className="day-navigator order-day-navigator">
|
||||||
|
<span title="Předchozí den">
|
||||||
|
<FontAwesomeIcon icon={faChevronLeft} onClick={() => goToDay(-1)} />
|
||||||
|
</span>
|
||||||
|
<DatePicker
|
||||||
|
selected={isoToDate(displayedIso)}
|
||||||
|
onChange={(d: Date | null) => handleDatePick(d ? formatDate(d) : '')}
|
||||||
|
maxDate={isoToDate(todayIso) ?? undefined}
|
||||||
|
highlightDates={[{ 'luncher-order-day': highlightedOrderDates }]}
|
||||||
|
locale="cs"
|
||||||
|
dateFormat="d. M. yyyy"
|
||||||
|
calendarStartDay={1}
|
||||||
|
popperPlacement="bottom"
|
||||||
|
className={`form-control text-center fw-semibold order-date-input ${isReadOnly ? 'text-muted' : ''}`}
|
||||||
|
/>
|
||||||
|
<span title="Následující den">
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faChevronRight}
|
||||||
|
style={{ visibility: canGoNext ? 'visible' : 'hidden' }}
|
||||||
|
onClick={() => canGoNext && goToDay(1)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isReadOnly && (
|
||||||
|
<Alert variant="secondary" className="d-flex align-items-center gap-2 py-2">
|
||||||
|
<FontAwesomeIcon icon={faClockRotateLeft} />
|
||||||
|
<span>
|
||||||
|
Prohlížíte historii ze dne <strong>{displayedIso ? formatDateString(displayedIso) : data.date}</strong> – data jsou pouze pro čtení.
|
||||||
|
</span>
|
||||||
|
<Button variant="link" size="sm" className="p-0 ms-auto" onClick={() => setSelectedDate(undefined)}>
|
||||||
|
Zpět na dnešek
|
||||||
|
</Button>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pageError && (
|
||||||
|
<Alert variant="danger" dismissible onClose={() => setPageError(null)} className="mt-2">
|
||||||
|
{pageError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="content-wrapper">
|
||||||
|
<div className="content" style={{ maxWidth: 1200 }}>
|
||||||
|
{/* Vytvoření nové skupiny – pouze pro aktuální den */}
|
||||||
|
{!isReadOnly && (
|
||||||
|
<div className="choice-section fade-in mb-4">
|
||||||
|
<h5>Vytvořit skupinu</h5>
|
||||||
|
{stores.length === 0 ? (
|
||||||
|
<p className="text-muted">
|
||||||
|
Nejsou přidány žádné obchody.{' '}
|
||||||
|
<Button variant="link" size="sm" className="p-0" onClick={() => setAdminModalOpen(true)}>
|
||||||
|
Přidat obchod
|
||||||
|
</Button>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="d-flex gap-2 align-items-end flex-wrap">
|
||||||
|
<Form.Select
|
||||||
|
value={newGroupName}
|
||||||
|
onChange={e => setNewGroupName(e.target.value)}
|
||||||
|
style={{ maxWidth: 260 }}
|
||||||
|
>
|
||||||
|
<option value="">— vyberte obchod —</option>
|
||||||
|
{stores.map(s => <option key={s} value={s}>{s}</option>)}
|
||||||
|
</Form.Select>
|
||||||
|
<Button variant="primary" onClick={handleCreate} disabled={creating || !newGroupName}>
|
||||||
|
Vytvořit skupinu
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Seznam skupin */}
|
||||||
|
{groups.length === 0 && (
|
||||||
|
<p className="text-muted fade-in">
|
||||||
|
{isReadOnly ? 'Pro tento den nejsou žádné skupiny.' : 'Zatím žádné skupiny pro dnešní den.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{groups.map(group => {
|
||||||
|
const login = auth!.login ?? '';
|
||||||
|
const isCreator = login === group.creatorLogin;
|
||||||
|
const isMember = login in group.members;
|
||||||
|
const isOrdered = group.state === GroupState.ORDERED;
|
||||||
|
const isLocked = group.state === GroupState.LOCKED;
|
||||||
|
const memberEntries = Object.entries(group.members) as [string, OrderGroupMember][];
|
||||||
|
const editingTimes = group.id in editTimes;
|
||||||
|
|
||||||
|
const totalFees = (group.fees ?? 0) + (group.shipping ?? 0) + (group.tip ?? 0);
|
||||||
|
// Poplatky se dělí jen mezi aktivní strávníky (kdo si reálně něco objednal).
|
||||||
|
const activeCount = countActiveMembers(group.members);
|
||||||
|
const feeShare = computeFeeShare(totalFees, activeCount);
|
||||||
|
const feeParams = { totalFees, discountType: group.discountType, discountValue: group.discountValue ?? 0 };
|
||||||
|
const getMemberTotal = (m: OrderGroupMember) =>
|
||||||
|
computeMemberTotal(m, feeParams, feeShare, activeCount);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={group.id} className="mb-3 fade-in">
|
||||||
|
<Card.Header className="d-flex justify-content-between align-items-center">
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
<strong>{group.name}</strong>
|
||||||
|
{stateBadge(group.state)}
|
||||||
|
<small className="text-muted">zakladatel: {group.creatorLogin}</small>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex gap-2">
|
||||||
|
{!isReadOnly && isCreator && !isOrdered && (
|
||||||
|
<>
|
||||||
|
<Button variant="outline-info" size="sm" onClick={() => setFeesModal(group)} title="Upravit poplatky a slevu">
|
||||||
|
Poplatky
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline-secondary" size="sm" onClick={() => handleToggleLock(group)} title={isLocked ? 'Odemknout' : 'Uzamknout'}>
|
||||||
|
<FontAwesomeIcon icon={isLocked ? faLockOpen : faLock} />
|
||||||
|
</Button>
|
||||||
|
{isLocked && (
|
||||||
|
<Button variant="outline-primary" size="sm" onClick={() => setConfirmOrderGroup(group)}>
|
||||||
|
Objednáno
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="outline-danger" size="sm" onClick={() => handleDelete(group.id)} title="Smazat skupinu">
|
||||||
|
<FontAwesomeIcon icon={faTrashCan} />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!isReadOnly && isCreator && isOrdered && (
|
||||||
|
<>
|
||||||
|
{settings?.bankAccount && settings?.holderName && !group.qrGenerated && (
|
||||||
|
<Button variant="primary" size="sm" onClick={() => setPayModal(group)}>
|
||||||
|
<FontAwesomeIcon icon={faBasketShopping} className="me-1" />
|
||||||
|
Generovat QR
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="outline-warning" size="sm" onClick={() => handleRevertOrdered(group)} title="Vrátit na Uzamčeno (smaže QR kódy)">
|
||||||
|
<FontAwesomeIcon icon={faLockOpen} />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!isReadOnly && !isMember && !isOrdered && !isLocked && (
|
||||||
|
<Button variant="outline-success" size="sm" onClick={() => handleJoin(group.id)}>
|
||||||
|
<FontAwesomeIcon icon={faUserPlus} className="me-1" />
|
||||||
|
Přidat se
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Body className="p-0">
|
||||||
|
<Table className="mb-0" size="sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Člen</th>
|
||||||
|
<th style={{ width: 180 }}>Částka (bez slev)</th>
|
||||||
|
<th style={{ width: 220 }}>Příplatek</th>
|
||||||
|
<th>Poznámka</th>
|
||||||
|
<th style={{ width: 160 }}>Celkem (s poplatky)</th>
|
||||||
|
<th style={{ width: 40 }}></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{memberEntries.map(([memberLogin, member]) => {
|
||||||
|
const key = `${group.id}:${memberLogin}`;
|
||||||
|
const editingAmount = key in editAmounts;
|
||||||
|
const editingNote = key in editNotes;
|
||||||
|
const editingSurcharge = key in editSurcharges;
|
||||||
|
const canEdit = canEditMember(group, memberLogin);
|
||||||
|
const memberTotal = getMemberTotal(member);
|
||||||
|
return (
|
||||||
|
<tr key={memberLogin}>
|
||||||
|
<td>
|
||||||
|
<span className="user-info">
|
||||||
|
<strong>{memberLogin}</strong>
|
||||||
|
{memberLogin === group.creatorLogin && (
|
||||||
|
<OverlayTrigger placement="top" overlay={<Tooltip>Zakladatel / objednávající</Tooltip>}>
|
||||||
|
<span className="ms-1"><FontAwesomeIcon icon={faBasketShopping} className="buyer-icon" /></span>
|
||||||
|
</OverlayTrigger>
|
||||||
|
)}
|
||||||
|
{member.paid && (
|
||||||
|
<OverlayTrigger placement="top" overlay={<Tooltip>Zaplaceno</Tooltip>}>
|
||||||
|
<span className="ms-1"><FontAwesomeIcon icon={faCircleCheck} className="text-success" /></span>
|
||||||
|
</OverlayTrigger>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{canEdit && editingAmount ? (
|
||||||
|
<div className="d-flex gap-1">
|
||||||
|
<Form.Control
|
||||||
|
type="number"
|
||||||
|
size="sm"
|
||||||
|
value={editAmounts[key]}
|
||||||
|
onChange={e => setEditAmounts(prev => ({ ...prev, [key]: e.target.value }))}
|
||||||
|
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveAmount(group.id, memberLogin); if (e.key === 'Escape') setEditAmounts(prev => { const n = { ...prev }; delete n[key]; return n; }); }}
|
||||||
|
style={{ width: 95 }}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Button size="sm" variant="outline-success" onClick={() => handleSaveAmount(group.id, memberLogin)}>✓</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
style={{ cursor: canEdit ? 'pointer' : undefined }}
|
||||||
|
onClick={() => canEdit && setEditAmounts(prev => ({ ...prev, [key]: member.amount != null ? String(member.amount / 100) : '' }))}
|
||||||
|
title={canEdit ? 'Klikněte pro úpravu' : undefined}
|
||||||
|
>
|
||||||
|
{member.amount != null ? `${member.amount / 100} Kč` : <span className="text-muted">—</span>}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{canEdit && editingSurcharge ? (
|
||||||
|
<div className="d-flex gap-1">
|
||||||
|
<Form.Control
|
||||||
|
type="text"
|
||||||
|
size="sm"
|
||||||
|
placeholder="popis"
|
||||||
|
value={editSurcharges[key]?.text ?? ''}
|
||||||
|
onChange={e => setEditSurcharges(prev => ({ ...prev, [key]: { ...prev[key], text: e.target.value } }))}
|
||||||
|
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveSurcharge(group.id, memberLogin); if (e.key === 'Escape') setEditSurcharges(prev => { const n = { ...prev }; delete n[key]; return n; }); }}
|
||||||
|
style={{ width: 80 }}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Form.Control
|
||||||
|
type="number"
|
||||||
|
size="sm"
|
||||||
|
placeholder="Kč"
|
||||||
|
value={editSurcharges[key]?.amount ?? ''}
|
||||||
|
onChange={e => setEditSurcharges(prev => ({ ...prev, [key]: { ...prev[key], amount: e.target.value } }))}
|
||||||
|
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveSurcharge(group.id, memberLogin); if (e.key === 'Escape') setEditSurcharges(prev => { const n = { ...prev }; delete n[key]; return n; }); }}
|
||||||
|
style={{ width: 60 }}
|
||||||
|
/>
|
||||||
|
<Button size="sm" variant="outline-success" onClick={() => handleSaveSurcharge(group.id, memberLogin)}>✓</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
style={{ cursor: canEdit ? 'pointer' : undefined }}
|
||||||
|
onClick={() => canEdit && setEditSurcharges(prev => ({ ...prev, [key]: { text: member.surchargeText ?? '', amount: member.surchargeAmount != null ? String(member.surchargeAmount / 100) : '' } }))}
|
||||||
|
title={canEdit ? 'Klikněte pro úpravu příplatku' : undefined}
|
||||||
|
>
|
||||||
|
{member.surchargeAmount != null && member.surchargeAmount > 0 ? (
|
||||||
|
<small>{member.surchargeText ? `${member.surchargeText}: ` : ''}<strong>{member.surchargeAmount / 100} Kč</strong></small>
|
||||||
|
) : (
|
||||||
|
<small className="text-muted">—</small>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{canEdit && editingNote ? (
|
||||||
|
<div className="d-flex gap-1">
|
||||||
|
<Form.Control
|
||||||
|
type="text"
|
||||||
|
size="sm"
|
||||||
|
value={editNotes[key]}
|
||||||
|
onChange={e => setEditNotes(prev => ({ ...prev, [key]: e.target.value }))}
|
||||||
|
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveNote(group.id, memberLogin); if (e.key === 'Escape') setEditNotes(prev => { const n = { ...prev }; delete n[key]; return n; }); }}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Button size="sm" variant="outline-success" onClick={() => handleSaveNote(group.id, memberLogin)}>✓</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
style={{ cursor: canEdit ? 'pointer' : undefined }}
|
||||||
|
onClick={() => canEdit && setEditNotes(prev => ({ ...prev, [key]: member.note ?? '' }))}
|
||||||
|
title={canEdit ? 'Klikněte pro úpravu poznámky' : undefined}
|
||||||
|
>
|
||||||
|
<small className="text-muted">{member.note || '—'}</small>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="text-end">
|
||||||
|
<small className={memberTotal > 0 ? 'fw-bold' : 'text-muted'}>
|
||||||
|
{memberTotal > 0 ? `${memberTotal / 100} Kč` : '—'}
|
||||||
|
</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="d-flex gap-1 justify-content-end">
|
||||||
|
{canManageMembers(group) && (isCreator || memberLogin === login) && (memberLogin !== group.creatorLogin) && (
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faTrashCan}
|
||||||
|
className="action-icon"
|
||||||
|
title={memberLogin === login ? 'Odhlásit se' : 'Odebrat z skupiny'}
|
||||||
|
onClick={() => refresh(() => removeGroupMember({ body: { id: group.id, login: memberLogin } }))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
{(() => {
|
||||||
|
const sumBase = memberEntries.reduce((sum, [, m]) => sum + (m.amount ?? 0) + (m.surchargeAmount ?? 0), 0);
|
||||||
|
const dv = group.discountValue ?? 0;
|
||||||
|
const totalDiscount = dv > 0
|
||||||
|
? (group.discountType === 'percent' ? Math.round(sumBase * dv / 100) : dv)
|
||||||
|
: 0;
|
||||||
|
const groupTotal = sumBase + totalFees - totalDiscount;
|
||||||
|
return groupTotal > 0 ? (
|
||||||
|
<tfoot>
|
||||||
|
<tr style={{ fontWeight: 700, borderTop: '2px solid var(--luncher-border)' }}>
|
||||||
|
<td colSpan={4} className="text-end" style={{ fontSize: '0.9em' }}>Celkem za skupinu:</td>
|
||||||
|
<td className="text-end">{groupTotal / 100} Kč</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
{/* Souhrn poplatků a slevy */}
|
||||||
|
{(totalFees > 0 || (group.discountValue != null && group.discountValue > 0)) && (
|
||||||
|
<div className="px-3 py-2 border-top d-flex gap-3 flex-wrap" style={{ fontSize: '0.85em', color: 'var(--luncher-text-muted)' }}>
|
||||||
|
{group.fees != null && group.fees > 0 && <span>Poplatky: <strong>{group.fees / 100} Kč</strong></span>}
|
||||||
|
{group.shipping != null && group.shipping > 0 && <span>Doprava: <strong>{group.shipping / 100} Kč</strong></span>}
|
||||||
|
{group.tip != null && group.tip > 0 && <span>Spropitné: <strong>{group.tip / 100} Kč</strong></span>}
|
||||||
|
{feeShare > 0 && <span>→ <strong>{feeShare / 100} Kč</strong>/os.</span>}
|
||||||
|
{group.discountValue != null && group.discountValue > 0 && (
|
||||||
|
<span className="text-success">
|
||||||
|
Sleva: <strong>{group.discountType === 'percent' ? `${group.discountValue}%` : `${group.discountValue / 100} Kč`}</strong>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Časy objednání a doručení */}
|
||||||
|
{isOrdered && (
|
||||||
|
<div className="px-3 py-2 border-top">
|
||||||
|
{!isReadOnly && isCreator && editingTimes ? (
|
||||||
|
<div className="d-flex align-items-center gap-3 flex-wrap">
|
||||||
|
<div className="d-flex align-items-center gap-1">
|
||||||
|
<small className="text-muted text-nowrap">Objednáno v:</small>
|
||||||
|
<Form.Control
|
||||||
|
type="text"
|
||||||
|
size="sm"
|
||||||
|
placeholder="HH:MM"
|
||||||
|
value={editTimes[group.id]?.orderedAt ?? ''}
|
||||||
|
onChange={e => setEditTimes(prev => ({ ...prev, [group.id]: { ...prev[group.id], orderedAt: e.target.value } }))}
|
||||||
|
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveTimes(group); }}
|
||||||
|
style={{ width: 75 }}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex align-items-center gap-1">
|
||||||
|
<small className="text-muted text-nowrap">Doručení v:</small>
|
||||||
|
<Form.Control
|
||||||
|
type="text"
|
||||||
|
size="sm"
|
||||||
|
placeholder="HH:MM"
|
||||||
|
value={editTimes[group.id]?.deliveryAt ?? ''}
|
||||||
|
onChange={e => setEditTimes(prev => ({ ...prev, [group.id]: { ...prev[group.id], deliveryAt: e.target.value } }))}
|
||||||
|
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveTimes(group); }}
|
||||||
|
style={{ width: 75 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline-success" onClick={() => handleSaveTimes(group)}>Uložit</Button>
|
||||||
|
<Button size="sm" variant="outline-secondary" onClick={() => setEditTimes(prev => { const n = { ...prev }; delete n[group.id]; return n; })}>Zrušit</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="d-flex align-items-center gap-3 flex-wrap"
|
||||||
|
style={{ cursor: !isReadOnly && isCreator ? 'pointer' : undefined }}
|
||||||
|
onClick={() => !isReadOnly && isCreator && setEditTimes(prev => ({ ...prev, [group.id]: { orderedAt: group.orderedAt ?? '', deliveryAt: group.deliveryAt ?? '' } }))}
|
||||||
|
title={!isReadOnly && isCreator ? 'Klikněte pro úpravu časů' : undefined}
|
||||||
|
>
|
||||||
|
<small className="text-muted">
|
||||||
|
Objednáno v: <strong>{group.orderedAt ?? '—'}</strong>
|
||||||
|
</small>
|
||||||
|
<small className="text-muted">
|
||||||
|
Doručení v: <strong>{group.deliveryAt ?? '—'}</strong>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Nevyřízené platby přihlášeného uživatele – jen v režimu aktuálního dne */}
|
||||||
|
{!isReadOnly && (
|
||||||
|
<PendingPayments
|
||||||
|
pendingQrs={data.pendingQrs}
|
||||||
|
login={auth.login}
|
||||||
|
onDismissed={() => fetchData()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Footer />
|
||||||
|
|
||||||
|
{/* Potvrzovací dialog pro přechod do stavu Objednáno */}
|
||||||
|
<Modal show={!!confirmOrderGroup} onHide={() => setConfirmOrderGroup(null)} centered>
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title>Potvrdit objednání</Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
Opravdu chcete označit skupinu <strong>{confirmOrderGroup?.name}</strong> jako objednanou?
|
||||||
|
Tato akce uzavře skupinu a zaznamená čas objednání.
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button variant="secondary" onClick={() => setConfirmOrderGroup(null)}>Zrušit</Button>
|
||||||
|
<Button variant="primary" onClick={() => confirmOrderGroup && handleConfirmOrdered(confirmOrderGroup)}>
|
||||||
|
Objednáno
|
||||||
|
</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<StoreAdminModal
|
||||||
|
isOpen={adminModalOpen}
|
||||||
|
onClose={() => setAdminModalOpen(false)}
|
||||||
|
stores={stores}
|
||||||
|
onStoresChanged={updated => setData(prev => prev ? { ...prev, stores: updated } : prev)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{payModal && settings?.bankAccount && settings?.holderName && (
|
||||||
|
<PayForGroupModal
|
||||||
|
isOpen={!!payModal}
|
||||||
|
onClose={() => setPayModal(null)}
|
||||||
|
onSuccess={() => fetchData()}
|
||||||
|
group={payModal}
|
||||||
|
groupId={payModal.id}
|
||||||
|
payerLogin={auth.login}
|
||||||
|
bankAccount={settings.bankAccount}
|
||||||
|
bankAccountHolder={settings.holderName}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{feesModal && (
|
||||||
|
<EditGroupFeesModal
|
||||||
|
isOpen={!!feesModal}
|
||||||
|
onClose={() => setFeesModal(null)}
|
||||||
|
group={feesModal}
|
||||||
|
onSaved={newData => {
|
||||||
|
if (newData) {
|
||||||
|
setData(newData);
|
||||||
|
socket.emit?.('message', newData as ClientData);
|
||||||
|
}
|
||||||
|
setFeesModal(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -89,67 +89,4 @@
|
|||||||
.recharts-cartesian-grid-vertical line {
|
.recharts-cartesian-grid-vertical line {
|
||||||
stroke: var(--luncher-border);
|
stroke: var(--luncher-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.voting-stats-section {
|
|
||||||
margin-top: 48px;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 800px;
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--luncher-text);
|
|
||||||
margin-bottom: 16px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.voting-stats-table {
|
|
||||||
width: 100%;
|
|
||||||
background: var(--luncher-bg-card);
|
|
||||||
border-radius: var(--luncher-radius-lg);
|
|
||||||
box-shadow: var(--luncher-shadow);
|
|
||||||
border: 1px solid var(--luncher-border-light);
|
|
||||||
overflow: hidden;
|
|
||||||
border-collapse: collapse;
|
|
||||||
|
|
||||||
th {
|
|
||||||
background: var(--luncher-primary);
|
|
||||||
color: #ffffff;
|
|
||||||
padding: 12px 20px;
|
|
||||||
text-align: left;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
text-align: center;
|
|
||||||
width: 120px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
td {
|
|
||||||
padding: 12px 20px;
|
|
||||||
border-bottom: 1px solid var(--luncher-border-light);
|
|
||||||
color: var(--luncher-text);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
text-align: center;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--luncher-primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody tr {
|
|
||||||
transition: var(--luncher-transition);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--luncher-bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:last-child td {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import Header from "../components/Header";
|
|||||||
import { useAuth } from "../context/auth";
|
import { useAuth } from "../context/auth";
|
||||||
import Login from "../Login";
|
import Login from "../Login";
|
||||||
import { formatDate, getFirstWorkDayOfWeek, getHumanDate, getLastWorkDayOfWeek } from "../Utils";
|
import { formatDate, getFirstWorkDayOfWeek, getHumanDate, getLastWorkDayOfWeek } from "../Utils";
|
||||||
import { WeeklyStats, LunchChoice, VotingStats, FeatureRequest, getStats, getVotingStats } from "../../../types";
|
import { WeeklyStats, LunchChoice, getStats } from "../../../types";
|
||||||
import Loader from "../components/Loader";
|
import Loader from "../components/Loader";
|
||||||
import { faChevronLeft, faChevronRight, faGear } from "@fortawesome/free-solid-svg-icons";
|
import { faChevronLeft, faChevronRight, faGear } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { Legend, Line, LineChart, Tooltip, XAxis, YAxis } from "recharts";
|
import { Legend, Line, LineChart, Tooltip, XAxis, YAxis } from "recharts";
|
||||||
@@ -32,7 +32,6 @@ export default function StatsPage() {
|
|||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const [dateRange, setDateRange] = useState<Date[]>();
|
const [dateRange, setDateRange] = useState<Date[]>();
|
||||||
const [data, setData] = useState<WeeklyStats>();
|
const [data, setData] = useState<WeeklyStats>();
|
||||||
const [votingStats, setVotingStats] = useState<VotingStats>();
|
|
||||||
|
|
||||||
// Prvotní nastavení aktuálního týdne
|
// Prvotní nastavení aktuálního týdne
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -49,19 +48,6 @@ export default function StatsPage() {
|
|||||||
}
|
}
|
||||||
}, [dateRange]);
|
}, [dateRange]);
|
||||||
|
|
||||||
// Načtení statistik hlasování
|
|
||||||
useEffect(() => {
|
|
||||||
getVotingStats().then(response => {
|
|
||||||
setVotingStats(response.data);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const sortedVotingStats = useMemo(() => {
|
|
||||||
if (!votingStats) return [];
|
|
||||||
return Object.entries(votingStats)
|
|
||||||
.sort((a, b) => (b[1] as number) - (a[1] as number));
|
|
||||||
}, [votingStats]);
|
|
||||||
|
|
||||||
const renderLine = (location: LunchChoice) => {
|
const renderLine = (location: LunchChoice) => {
|
||||||
const index = Object.values(LunchChoice).indexOf(location);
|
const index = Object.values(LunchChoice).indexOf(location);
|
||||||
return <Line key={location} name={getLunchChoiceName(location)} type="monotone" dataKey={data => data.locations[location] ?? 0} stroke={COLORS[index]} strokeWidth={STROKE_WIDTH} />
|
return <Line key={location} name={getLunchChoiceName(location)} type="monotone" dataKey={data => data.locations[location] ?? 0} stroke={COLORS[index]} strokeWidth={STROKE_WIDTH} />
|
||||||
@@ -142,27 +128,6 @@ export default function StatsPage() {
|
|||||||
<Tooltip />
|
<Tooltip />
|
||||||
<Legend />
|
<Legend />
|
||||||
</LineChart>
|
</LineChart>
|
||||||
{sortedVotingStats.length > 0 && (
|
|
||||||
<div className="voting-stats-section">
|
|
||||||
<h2>Hlasování o funkcích</h2>
|
|
||||||
<table className="voting-stats-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Funkce</th>
|
|
||||||
<th>Počet hlasů</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{sortedVotingStats.map(([feature, count]) => (
|
|
||||||
<tr key={feature}>
|
|
||||||
<td>{FeatureRequest[feature as keyof typeof FeatureRequest] ?? feature}</td>
|
|
||||||
<td>{count as number}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<Footer />
|
<Footer />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
.suggestions-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 32px 24px;
|
||||||
|
min-height: calc(100vh - 140px);
|
||||||
|
background: var(--luncher-bg);
|
||||||
|
|
||||||
|
.suggestions-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 900px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--luncher-text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestions-info {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 12px 0 24px;
|
||||||
|
color: var(--luncher-text-secondary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestions-empty {
|
||||||
|
color: var(--luncher-text-secondary);
|
||||||
|
margin-top: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestions-table {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 900px;
|
||||||
|
background: var(--luncher-bg-card);
|
||||||
|
border-radius: var(--luncher-radius-lg);
|
||||||
|
box-shadow: var(--luncher-shadow);
|
||||||
|
border: 1px solid var(--luncher-border-light);
|
||||||
|
overflow: hidden;
|
||||||
|
border-collapse: collapse;
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: var(--luncher-primary);
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 12px 20px;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-bottom: 1px solid var(--luncher-border-light);
|
||||||
|
color: var(--luncher-text);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-score {
|
||||||
|
text-align: center;
|
||||||
|
width: 80px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
td.col-score {
|
||||||
|
color: var(--luncher-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-actions {
|
||||||
|
text-align: center;
|
||||||
|
width: 150px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--luncher-transition);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--luncher-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vote-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: var(--luncher-radius-sm, 6px);
|
||||||
|
color: var(--luncher-text-secondary);
|
||||||
|
transition: var(--luncher-transition);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--luncher-bg-hover);
|
||||||
|
color: var(--luncher-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.vote-up.active {
|
||||||
|
color: #2e7d32;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.vote-down.active {
|
||||||
|
color: #c62828;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.delete-btn:hover {
|
||||||
|
color: #c62828;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Button, OverlayTrigger, Tooltip } from "react-bootstrap";
|
||||||
|
import { ToastContainer } from "react-toastify";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faThumbsUp, faThumbsDown, faTrash, faPlus, faGear } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import Header from "../components/Header";
|
||||||
|
import Footer from "../components/Footer";
|
||||||
|
import Loader from "../components/Loader";
|
||||||
|
import { useAuth } from "../context/auth";
|
||||||
|
import Login from "../Login";
|
||||||
|
import AddSuggestionModal from "../components/modals/AddSuggestionModal";
|
||||||
|
import SuggestionDetailModal from "../components/modals/SuggestionDetailModal";
|
||||||
|
import {
|
||||||
|
Suggestion,
|
||||||
|
VoteDirection,
|
||||||
|
listSuggestions,
|
||||||
|
addSuggestion,
|
||||||
|
voteSuggestion,
|
||||||
|
deleteSuggestion,
|
||||||
|
} from "../../../types";
|
||||||
|
import "./SuggestionsPage.scss";
|
||||||
|
|
||||||
|
export default function SuggestionsPage() {
|
||||||
|
const auth = useAuth();
|
||||||
|
const [suggestions, setSuggestions] = useState<Suggestion[]>();
|
||||||
|
const [addModalOpen, setAddModalOpen] = useState(false);
|
||||||
|
const [detail, setDetail] = useState<Suggestion>();
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
if (!auth?.login) return;
|
||||||
|
const response = await listSuggestions();
|
||||||
|
setSuggestions(response.data ?? []);
|
||||||
|
}, [auth?.login]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reload();
|
||||||
|
}, [reload]);
|
||||||
|
|
||||||
|
const handleAdd = async (title: string, description: string) => {
|
||||||
|
const response = await addSuggestion({ body: { title, description } });
|
||||||
|
if (response.data) {
|
||||||
|
setSuggestions(response.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVote = async (id: string, direction: VoteDirection) => {
|
||||||
|
const response = await voteSuggestion({ body: { id, direction } });
|
||||||
|
if (response.data) {
|
||||||
|
setSuggestions(response.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (suggestion: Suggestion) => {
|
||||||
|
if (!window.confirm(`Opravdu chcete smazat návrh „${suggestion.title}“? Smažou se i všechny jeho hlasy.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const response = await deleteSuggestion({ body: { id: suggestion.id } });
|
||||||
|
if (response.data) {
|
||||||
|
setSuggestions(response.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!auth?.login) {
|
||||||
|
return <Login />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!suggestions) {
|
||||||
|
return <Loader icon={faGear} description={"Načítám návrhy..."} animation={"fa-bounce"} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header />
|
||||||
|
<div className="suggestions-page">
|
||||||
|
<div className="suggestions-header">
|
||||||
|
<h1>Návrhy na vylepšení</h1>
|
||||||
|
<Button onClick={() => setAddModalOpen(true)}>
|
||||||
|
<FontAwesomeIcon icon={faPlus} /> Přidat návrh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="suggestions-info">
|
||||||
|
Zde můžete navrhovat vylepšení aplikace a hlasovat o návrzích ostatních. U každého návrhu je
|
||||||
|
zobrazeno jméno navrhovatele. Jména hlasujících jsou dostupná pouze administrátorům.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{suggestions.length === 0 ? (
|
||||||
|
<p className="suggestions-empty">Zatím nebyly přidány žádné návrhy. Buďte první!</p>
|
||||||
|
) : (
|
||||||
|
<table className="suggestions-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Navrhovatel</th>
|
||||||
|
<th>Název</th>
|
||||||
|
<th className="col-score">Hlasy</th>
|
||||||
|
<th className="col-actions">Akce</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{suggestions.map(suggestion => (
|
||||||
|
<OverlayTrigger
|
||||||
|
key={suggestion.id}
|
||||||
|
placement="top"
|
||||||
|
overlay={<Tooltip id={`tooltip-${suggestion.id}`}>{suggestion.description}</Tooltip>}
|
||||||
|
>
|
||||||
|
<tr onClick={() => setDetail(suggestion)}>
|
||||||
|
<td>{suggestion.author}</td>
|
||||||
|
<td>{suggestion.title}</td>
|
||||||
|
<td className="col-score">{suggestion.voteScore}</td>
|
||||||
|
<td className="col-actions" onClick={e => e.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`vote-btn vote-up ${suggestion.myVote === VoteDirection.UP ? "active" : ""}`}
|
||||||
|
title="Hlasovat pro"
|
||||||
|
onClick={() => handleVote(suggestion.id, VoteDirection.UP)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faThumbsUp} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`vote-btn vote-down ${suggestion.myVote === VoteDirection.DOWN ? "active" : ""}`}
|
||||||
|
title="Hlasovat proti"
|
||||||
|
onClick={() => handleVote(suggestion.id, VoteDirection.DOWN)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faThumbsDown} />
|
||||||
|
</button>
|
||||||
|
{suggestion.isMine && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="vote-btn delete-btn"
|
||||||
|
title="Smazat návrh"
|
||||||
|
onClick={() => handleDelete(suggestion)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faTrash} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</OverlayTrigger>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Footer />
|
||||||
|
<AddSuggestionModal isOpen={addModalOpen} onClose={() => setAddModalOpen(false)} onSubmit={handleAdd} />
|
||||||
|
<SuggestionDetailModal suggestion={detail} onClose={() => setDetail(undefined)} />
|
||||||
|
<ToastContainer />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { OrderGroup, OrderGroupMember } from "../../../types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pomocné funkce pro výpočet částek ve skupinových objednávkách.
|
||||||
|
*
|
||||||
|
* Klíčové pravidlo: poplatky (balné + doprava + spropitné) se rozpočítávají
|
||||||
|
* pouze mezi "aktivní" strávníky — tedy ty, kteří si reálně něco objednali.
|
||||||
|
* Kdo si nic neobjedná (typicky objednávající, který nakupuje jen pro ostatní),
|
||||||
|
* neplatí nic a nezapočítává se mu ani poměrná část poplatků.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Parametry poplatků a slevy potřebné k výpočtu částky člena. */
|
||||||
|
export type GroupFeeParams = {
|
||||||
|
/** Celkové poplatky skupiny v haléřích (balné + doprava + spropitné). */
|
||||||
|
totalFees: number;
|
||||||
|
/** Typ slevy ('percent' = procenta, 'fixed' = pevná částka v haléřích). */
|
||||||
|
discountType?: string;
|
||||||
|
/** Hodnota slevy — procenta, nebo pevná částka v haléřích dle discountType. */
|
||||||
|
discountValue?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Vrátí true, pokud si člen něco objednal (má kladnou částku nebo příplatek). */
|
||||||
|
export function isActiveMember(member: OrderGroupMember): boolean {
|
||||||
|
return (member.amount ?? 0) + (member.surchargeAmount ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Počet aktivních strávníků — jen mezi ně se dělí poplatky. */
|
||||||
|
export function countActiveMembers(members: OrderGroup["members"]): number {
|
||||||
|
return Object.values(members).filter(isActiveMember).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Celkové poplatky skupiny (balné + doprava + spropitné) v haléřích. */
|
||||||
|
export function totalGroupFees(group: OrderGroup): number {
|
||||||
|
return (group.fees ?? 0) + (group.shipping ?? 0) + (group.tip ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Poměrná část poplatků na jednoho aktivního strávníka v haléřích. */
|
||||||
|
export function computeFeeShare(totalFees: number, activeCount: number): number {
|
||||||
|
return activeCount > 0 ? Math.round(totalFees / activeCount) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Celková částka, kterou má člen zaplatit (v haléřích).
|
||||||
|
* Neaktivní člen (nic si neobjednal) platí 0 — nepodílí se ani na poplatcích.
|
||||||
|
*
|
||||||
|
* @param member člen skupiny
|
||||||
|
* @param params poplatky a sleva
|
||||||
|
* @param feeShare poměrná část poplatků na osobu (viz computeFeeShare)
|
||||||
|
* @param activeCount počet aktivních strávníků (dělitel pevné slevy)
|
||||||
|
*/
|
||||||
|
export function computeMemberTotal(
|
||||||
|
member: OrderGroupMember,
|
||||||
|
params: GroupFeeParams,
|
||||||
|
feeShare: number,
|
||||||
|
activeCount: number,
|
||||||
|
): number {
|
||||||
|
if (!isActiveMember(member)) return 0;
|
||||||
|
const base = member.amount ?? 0;
|
||||||
|
const surcharge = member.surchargeAmount ?? 0;
|
||||||
|
const discountValue = params.discountValue ?? 0;
|
||||||
|
const discount = discountValue > 0
|
||||||
|
? (params.discountType === 'percent'
|
||||||
|
? Math.round((base + surcharge) * discountValue / 100)
|
||||||
|
: Math.round(discountValue / activeCount))
|
||||||
|
: 0;
|
||||||
|
return base + surcharge + feeShare - discount;
|
||||||
|
}
|
||||||
@@ -428,6 +428,42 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz#9e585ab6086bef994c6e8a5b3a0481219ada862b"
|
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz#9e585ab6086bef994c6e8a5b3a0481219ada862b"
|
||||||
integrity sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==
|
integrity sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==
|
||||||
|
|
||||||
|
"@floating-ui/core@^1.7.5":
|
||||||
|
version "1.7.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.7.5.tgz#d4af157a03330af5a60e69da7a4692507ada0622"
|
||||||
|
integrity sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==
|
||||||
|
dependencies:
|
||||||
|
"@floating-ui/utils" "^0.2.11"
|
||||||
|
|
||||||
|
"@floating-ui/dom@^1.7.6":
|
||||||
|
version "1.7.6"
|
||||||
|
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.7.6.tgz#f915bba5abbb177e1f227cacee1b4d0634b187bf"
|
||||||
|
integrity sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==
|
||||||
|
dependencies:
|
||||||
|
"@floating-ui/core" "^1.7.5"
|
||||||
|
"@floating-ui/utils" "^0.2.11"
|
||||||
|
|
||||||
|
"@floating-ui/react-dom@^2.1.8":
|
||||||
|
version "2.1.8"
|
||||||
|
resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.1.8.tgz#5fb5a20d10aafb9505f38c24f38d00c8e1598893"
|
||||||
|
integrity sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==
|
||||||
|
dependencies:
|
||||||
|
"@floating-ui/dom" "^1.7.6"
|
||||||
|
|
||||||
|
"@floating-ui/react@^0.27.15":
|
||||||
|
version "0.27.19"
|
||||||
|
resolved "https://registry.yarnpkg.com/@floating-ui/react/-/react-0.27.19.tgz#d8d5d895b7cb97dac370bfbf55f3e630878fdf1f"
|
||||||
|
integrity sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==
|
||||||
|
dependencies:
|
||||||
|
"@floating-ui/react-dom" "^2.1.8"
|
||||||
|
"@floating-ui/utils" "^0.2.11"
|
||||||
|
tabbable "^6.0.0"
|
||||||
|
|
||||||
|
"@floating-ui/utils@^0.2.11":
|
||||||
|
version "0.2.11"
|
||||||
|
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.11.tgz#a269e055e40e2f45873bae9d1a2fdccbd314ea3f"
|
||||||
|
integrity sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==
|
||||||
|
|
||||||
"@fortawesome/fontawesome-common-types@7.1.0":
|
"@fortawesome/fontawesome-common-types@7.1.0":
|
||||||
version "7.1.0"
|
version "7.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.1.0.tgz#a4e0b7e40073d5fdef41182da1bc216a05875659"
|
resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.1.0.tgz#a4e0b7e40073d5fdef41182da1bc216a05875659"
|
||||||
@@ -1216,6 +1252,11 @@ d3-timer@^3.0.1:
|
|||||||
resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0"
|
resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0"
|
||||||
integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==
|
integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==
|
||||||
|
|
||||||
|
date-fns@^4.1.0:
|
||||||
|
version "4.4.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-4.4.0.tgz#806539edf45c616b2b76b5f78b88c56ed3c7e036"
|
||||||
|
integrity sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==
|
||||||
|
|
||||||
debug@^4.1.0, debug@^4.3.1, debug@~4.4.1:
|
debug@^4.1.0, debug@^4.3.1, debug@~4.4.1:
|
||||||
version "4.4.3"
|
version "4.4.3"
|
||||||
resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a"
|
resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a"
|
||||||
@@ -1626,6 +1667,15 @@ react-bootstrap@^2.10.10:
|
|||||||
uncontrollable "^7.2.1"
|
uncontrollable "^7.2.1"
|
||||||
warning "^4.0.3"
|
warning "^4.0.3"
|
||||||
|
|
||||||
|
react-datepicker@^9.1.0:
|
||||||
|
version "9.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-datepicker/-/react-datepicker-9.1.0.tgz#638c636780ae98f1930f87e1f76850de5fc37d5c"
|
||||||
|
integrity sha512-lOp+m5bc+ttgtB5MHEjwiVu4nlp4CvJLS/PG1OiOe5pmg9kV73pEqO8H0Geqvg2E8gjqTaL9eRhSe+ZpeKP3nA==
|
||||||
|
dependencies:
|
||||||
|
"@floating-ui/react" "^0.27.15"
|
||||||
|
clsx "^2.1.1"
|
||||||
|
date-fns "^4.1.0"
|
||||||
|
|
||||||
react-dom@^19.2.0:
|
react-dom@^19.2.0:
|
||||||
version "19.2.3"
|
version "19.2.3"
|
||||||
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.3.tgz#f0b61d7e5c4a86773889fcc1853af3ed5f215b17"
|
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.3.tgz#f0b61d7e5c4a86773889fcc1853af3ed5f215b17"
|
||||||
@@ -1881,6 +1931,11 @@ supports-color@^7.1.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
has-flag "^4.0.0"
|
has-flag "^4.0.0"
|
||||||
|
|
||||||
|
tabbable@^6.0.0:
|
||||||
|
version "6.4.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.4.0.tgz#36eb7a06d80b3924a22095daf45740dea3bf5581"
|
||||||
|
integrity sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==
|
||||||
|
|
||||||
tiny-invariant@^1.3.3:
|
tiny-invariant@^1.3.3:
|
||||||
version "1.3.3"
|
version "1.3.3"
|
||||||
resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz#46680b7a873a0d5d10005995eb90a70d74d60127"
|
resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz#46680b7a873a0d5d10005995eb90a70d74d60127"
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import path from 'path';
|
|||||||
// Use 127.0.0.1 explicitly — on Node.js 18+/Windows, `localhost` may resolve to ::1
|
// Use 127.0.0.1 explicitly — on Node.js 18+/Windows, `localhost` may resolve to ::1
|
||||||
// (IPv6) while the HTTP server only binds to 0.0.0.0 (IPv4), causing the webServer
|
// (IPv6) while the HTTP server only binds to 0.0.0.0 (IPv4), causing the webServer
|
||||||
// readiness poll to time out even though the server is listening.
|
// readiness poll to time out even though the server is listening.
|
||||||
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://127.0.0.1:3001';
|
// Port 3099 avoids conflicts with locally running Docker containers on 3001-3003.
|
||||||
|
// Override with E2E_PORT env var if needed.
|
||||||
|
const E2E_PORT = process.env.E2E_PORT ?? '3099';
|
||||||
|
const BASE_URL = process.env.E2E_BASE_URL ?? `http://127.0.0.1:${E2E_PORT}`;
|
||||||
|
|
||||||
// Server env vars injected for local runs. In CI these are set at the step level.
|
// Server env vars injected for local runs. In CI these are set at the step level.
|
||||||
const serverEnv: Record<string, string> = {
|
const serverEnv: Record<string, string> = {
|
||||||
@@ -15,6 +18,7 @@ const serverEnv: Record<string, string> = {
|
|||||||
HTTP_REMOTE_USER_ENABLED: 'true',
|
HTTP_REMOTE_USER_ENABLED: 'true',
|
||||||
HTTP_REMOTE_USER_HEADER_NAME: 'remote-user',
|
HTTP_REMOTE_USER_HEADER_NAME: 'remote-user',
|
||||||
HTTP_REMOTE_TRUSTED_IPS: process.env.HTTP_REMOTE_TRUSTED_IPS ?? '127.0.0.1,::1,::ffff:127.0.0.1',
|
HTTP_REMOTE_TRUSTED_IPS: process.env.HTTP_REMOTE_TRUSTED_IPS ?? '127.0.0.1,::1,::ffff:127.0.0.1',
|
||||||
|
PORT: E2E_PORT,
|
||||||
};
|
};
|
||||||
if (process.env.REDIS_HOST) {
|
if (process.env.REDIS_HOST) {
|
||||||
serverEnv.REDIS_HOST = process.env.REDIS_HOST;
|
serverEnv.REDIS_HOST = process.env.REDIS_HOST;
|
||||||
@@ -50,7 +54,7 @@ export default defineConfig({
|
|||||||
cwd: path.resolve(__dirname, '../server'),
|
cwd: path.resolve(__dirname, '../server'),
|
||||||
// Poll a dedicated health endpoint — polling '/' can stall in Express 5 when
|
// Poll a dedicated health endpoint — polling '/' can stall in Express 5 when
|
||||||
// server/public/ doesn't exist in the working directory (no finalhandler match).
|
// server/public/ doesn't exist in the working directory (no finalhandler match).
|
||||||
url: `http://127.0.0.1:3001/api/health`,
|
url: `http://127.0.0.1:${E2E_PORT}/api/health`,
|
||||||
timeout: 15_000,
|
timeout: 15_000,
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
env: serverEnv,
|
env: serverEnv,
|
||||||
|
|||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
# Spustí server a klienta v samostatných panelech jednoho okna Windows Terminalu.
|
||||||
|
# Vyžaduje Windows Terminal (wt.exe) — výchozí součást Windows 11.
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$ScriptDir = $PSScriptRoot
|
||||||
|
|
||||||
|
Push-Location (Join-Path $ScriptDir 'types')
|
||||||
|
try { yarn openapi-ts } finally { Pop-Location }
|
||||||
|
|
||||||
|
if (-not (Get-Command wt.exe -ErrorAction SilentlyContinue)) {
|
||||||
|
Write-Error "wt.exe (Windows Terminal) nebyl nalezen. Nainstalujte z Microsoft Store nebo použijte run_dev.sh v WSL."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$serverDir = Join-Path $ScriptDir 'server'
|
||||||
|
$clientDir = Join-Path $ScriptDir 'client'
|
||||||
|
|
||||||
|
# wt splits on ';' before respecting quoting, so encode the compound server command to avoid it
|
||||||
|
$serverCmd = '$env:NODE_ENV = ''development''; yarn startReload'
|
||||||
|
$serverCmdB64 = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($serverCmd))
|
||||||
|
|
||||||
|
wt -w 0 new-tab --title 'luncher-server' -d $serverDir pwsh -NoExit -EncodedCommand $serverCmdB64 `; `
|
||||||
|
split-pane -H --title 'luncher-client' -d $clientDir pwsh -NoExit -Command "yarn start"
|
||||||
@@ -47,4 +47,8 @@
|
|||||||
|
|
||||||
# Heslo pro bypass rate limitu na endpointu /api/food/refresh (pro skripty/admin).
|
# Heslo pro bypass rate limitu na endpointu /api/food/refresh (pro skripty/admin).
|
||||||
# Bez hesla může refresh volat každý přihlášený uživatel (podléhá rate limitu).
|
# Bez hesla může refresh volat každý přihlášený uživatel (podléhá rate limitu).
|
||||||
# REFRESH_BYPASS_PASSWORD=
|
# REFRESH_BYPASS_PASSWORD=
|
||||||
|
|
||||||
|
# Admin heslo pro správu seznamu obchodů na stránce /objednani.
|
||||||
|
# Bez hesla nelze přidávat ani odebírat obchody ze seznamu (POST/DELETE na /api/stores vrátí 403).
|
||||||
|
# ADMIN_PASSWORD=
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[
|
||||||
|
"Zobrazení nabídky salátů z Pizza Chefie"
|
||||||
|
]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[
|
||||||
|
"Možnost úhrady celého účtu jednou osobou s rozesláním QR kódů ostatním (na Pizza day)"
|
||||||
|
]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[
|
||||||
|
"Skupinové objednávky s QR platbou — stránka /objednani (více skupin, každá z jiného obchodu, stavový automat open/locked/ordered)"
|
||||||
|
]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[
|
||||||
|
"Možnost zobrazení objednávek z historie",
|
||||||
|
"Podpora neplatících osob u objednávání",
|
||||||
|
"Zobrazení neuhrazených plateb i na stránce objednávek",
|
||||||
|
"Oprava duplicitního zobrazení QR kódu u Pizza day",
|
||||||
|
"Odstranění diakritiky v platebních QR kódech"
|
||||||
|
]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[
|
||||||
|
"Nová stránka pro návrhy na vylepšení (dostupná z uživatelského menu)"
|
||||||
|
]
|
||||||
+8
-8
@@ -9,13 +9,13 @@ import jwt from 'jsonwebtoken';
|
|||||||
*/
|
*/
|
||||||
export function generateToken(login?: string, trusted?: boolean): string {
|
export function generateToken(login?: string, trusted?: boolean): string {
|
||||||
if (!process.env.JWT_SECRET) {
|
if (!process.env.JWT_SECRET) {
|
||||||
throw Error("Není vyplněna proměnná prostředí JWT_SECRET");
|
throw new Error("Není vyplněna proměnná prostředí JWT_SECRET");
|
||||||
}
|
}
|
||||||
if (process.env.JWT_SECRET.length < 32) {
|
if (process.env.JWT_SECRET.length < 32) {
|
||||||
throw Error("Proměnná prostředí JWT_SECRET musí být minimálně 32 znaků");
|
throw new Error("Proměnná prostředí JWT_SECRET musí být minimálně 32 znaků");
|
||||||
}
|
}
|
||||||
if (!login || login.trim().length === 0) {
|
if (!login || login.trim().length === 0) {
|
||||||
throw Error("Nebyl předán login");
|
throw new Error("Nebyl předán login");
|
||||||
}
|
}
|
||||||
const payload = { login, trusted: trusted || false, logoutUrl: process.env.LOGOUT_URL };
|
const payload = { login, trusted: trusted || false, logoutUrl: process.env.LOGOUT_URL };
|
||||||
return jwt.sign(payload, process.env.JWT_SECRET);
|
return jwt.sign(payload, process.env.JWT_SECRET);
|
||||||
@@ -28,7 +28,7 @@ export function generateToken(login?: string, trusted?: boolean): string {
|
|||||||
*/
|
*/
|
||||||
export function verify(token: string): boolean {
|
export function verify(token: string): boolean {
|
||||||
if (!process.env.JWT_SECRET) {
|
if (!process.env.JWT_SECRET) {
|
||||||
throw Error("Není vyplněna proměnná prostředí JWT_SECRET");
|
throw new Error("Není vyplněna proměnná prostředí JWT_SECRET");
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
jwt.verify(token, process.env.JWT_SECRET);
|
jwt.verify(token, process.env.JWT_SECRET);
|
||||||
@@ -45,10 +45,10 @@ export function verify(token: string): boolean {
|
|||||||
*/
|
*/
|
||||||
export function getLogin(token?: string): string {
|
export function getLogin(token?: string): string {
|
||||||
if (!process.env.JWT_SECRET) {
|
if (!process.env.JWT_SECRET) {
|
||||||
throw Error("Není vyplněna proměnná prostředí JWT_SECRET");
|
throw new Error("Není vyplněna proměnná prostředí JWT_SECRET");
|
||||||
}
|
}
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw Error("Nebyl předán token");
|
throw new Error("Nebyl předán token");
|
||||||
}
|
}
|
||||||
const payload: any = jwt.verify(token, process.env.JWT_SECRET);
|
const payload: any = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
return payload.login;
|
return payload.login;
|
||||||
@@ -61,10 +61,10 @@ export function getLogin(token?: string): string {
|
|||||||
*/
|
*/
|
||||||
export function getTrusted(token?: string): boolean {
|
export function getTrusted(token?: string): boolean {
|
||||||
if (!process.env.JWT_SECRET) {
|
if (!process.env.JWT_SECRET) {
|
||||||
throw Error("Není vyplněna proměnná prostředí JWT_SECRET");
|
throw new Error("Není vyplněna proměnná prostředí JWT_SECRET");
|
||||||
}
|
}
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw Error("Nebyl předán token");
|
throw new Error("Nebyl předán token");
|
||||||
}
|
}
|
||||||
const payload: any = jwt.verify(token, process.env.JWT_SECRET);
|
const payload: any = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
return payload.trusted || false;
|
return payload.trusted || false;
|
||||||
|
|||||||
@@ -28,16 +28,16 @@ const buildPizzaUrl = (pizzaUrl: string) => {
|
|||||||
return `${baseUrl}/${pizzaUrl}`;
|
return `${baseUrl}/${pizzaUrl}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ceny krabic dle velikosti
|
// Ceny krabic dle velikosti v haléřích
|
||||||
const boxPrices: { [key: string]: number } = {
|
const boxPrices: { [key: string]: number } = {
|
||||||
"30cm": 13,
|
"30cm": 1300,
|
||||||
"35cm": 15,
|
"35cm": 1500,
|
||||||
"40cm": 18,
|
"40cm": 1800,
|
||||||
"50cm": 25
|
"50cm": 2500
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cena obalu pro salát
|
// Cena obalu pro salát v haléřích
|
||||||
const SALAT_BOX_PRICE = 13;
|
const SALAT_BOX_PRICE = 1300;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stáhne a scrapne aktuální pizzy ze stránek Pizza Chefie.
|
* Stáhne a scrapne aktuální pizzy ze stránek Pizza Chefie.
|
||||||
@@ -79,7 +79,7 @@ export async function downloadPizzy(mock: boolean): Promise<Pizza[]> {
|
|||||||
a.each((i, elm) => {
|
a.each((i, elm) => {
|
||||||
const varId = Number.parseInt(elm.attribs.href.split('?varianta=')[1].trim());
|
const varId = Number.parseInt(elm.attribs.href.split('?varianta=')[1].trim());
|
||||||
const size = $($(elm).contents().get(0)).text().trim();
|
const size = $($(elm).contents().get(0)).text().trim();
|
||||||
const price = Number.parseInt($($(elm).contents().get(1)).text().trim().split(" Kč")[0]);
|
const price = Number.parseInt($($(elm).contents().get(1)).text().trim().split(" Kč")[0]) * 100;
|
||||||
sizes.push({ varId, size, pizzaPrice: price, boxPrice: boxPrices[size], price: price + boxPrices[size] });
|
sizes.push({ varId, size, pizzaPrice: price, boxPrice: boxPrices[size], price: price + boxPrices[size] });
|
||||||
})
|
})
|
||||||
result.push({
|
result.push({
|
||||||
@@ -119,7 +119,7 @@ export async function downloadSalaty(mock: boolean): Promise<Salat[]> {
|
|||||||
ingredients.push($(elm).text());
|
ingredients.push($(elm).text());
|
||||||
});
|
});
|
||||||
const priceText = $('.cena > span', salatHtml).first().text().trim();
|
const priceText = $('.cena > span', salatHtml).first().text().trim();
|
||||||
const price = Number.parseInt(priceText.split(' Kč')[0]);
|
const price = Number.parseInt(priceText.split(' Kč')[0]) * 100;
|
||||||
result.push({ name, ingredients, price: price + SALAT_BOX_PRICE });
|
result.push({ name, ingredients, price: price + SALAT_BOX_PRICE });
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import crypto from "crypto";
|
||||||
|
import getStorage from "./storage";
|
||||||
|
import { getClientData, getToday, initIfNeeded } from "./service";
|
||||||
|
import { getStores } from "./stores";
|
||||||
|
import { removePendingQrsByGroupId } from "./pizza";
|
||||||
|
import { ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember } from "../../types/gen/types.gen";
|
||||||
|
import { formatDate } from "./utils";
|
||||||
|
|
||||||
|
const storage = getStorage();
|
||||||
|
|
||||||
|
async function getExtraData(date?: Date): Promise<ClientData> {
|
||||||
|
await initIfNeeded(date, MealSlot.EXTRA);
|
||||||
|
const data = await getClientData(date, MealSlot.EXTRA);
|
||||||
|
data.stores = await getStores();
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExtraKey(date?: Date): string {
|
||||||
|
return `${formatDate(date ?? getToday())}_extra`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveExtraData(data: ClientData, date?: Date): Promise<ClientData> {
|
||||||
|
await storage.setData(getExtraKey(date), data);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findGroup(data: ClientData, id: string): OrderGroup | undefined {
|
||||||
|
return data.groups?.find(g => g.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrátí seznam ISO dat (YYYY-MM-DD), pro která existuje alespoň jedna objednávková skupina.
|
||||||
|
* Slouží ke zvýraznění dnů v date pickeru na stránce objednávání.
|
||||||
|
*/
|
||||||
|
export async function getOrderDates(): Promise<string[]> {
|
||||||
|
const EXTRA_SUFFIX = '_extra';
|
||||||
|
const keys = await storage.listKeys(EXTRA_SUFFIX);
|
||||||
|
const dates: string[] = [];
|
||||||
|
for (const key of keys) {
|
||||||
|
if (!key.endsWith(EXTRA_SUFFIX)) continue;
|
||||||
|
const data = await storage.getData<ClientData>(key);
|
||||||
|
if (data?.groups && data.groups.length > 0) {
|
||||||
|
dates.push(key.slice(0, -EXTRA_SUFFIX.length));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dates.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createGroup(creatorLogin: string, name: string, date?: Date): Promise<ClientData> {
|
||||||
|
const stores = await getStores();
|
||||||
|
if (!stores.some(s => s.toLowerCase() === name.trim().toLowerCase())) {
|
||||||
|
throw new Error('Obchod není v seznamu povolených obchodů');
|
||||||
|
}
|
||||||
|
const data = await getExtraData(date);
|
||||||
|
const canonical = stores.find(s => s.toLowerCase() === name.trim().toLowerCase())!;
|
||||||
|
const group: OrderGroup = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name: canonical,
|
||||||
|
creatorLogin,
|
||||||
|
state: GroupState.OPEN,
|
||||||
|
members: { [creatorLogin]: {} },
|
||||||
|
};
|
||||||
|
data.groups = [...(data.groups ?? []), group];
|
||||||
|
return saveExtraData(data, date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteGroup(login: string, groupId: string, date?: Date): Promise<ClientData> {
|
||||||
|
const data = await getExtraData(date);
|
||||||
|
const group = findGroup(data, groupId);
|
||||||
|
if (!group) throw new Error('Skupina nebyla nalezena');
|
||||||
|
if (group.creatorLogin !== login) throw new Error('Skupinu může smazat pouze zakladatel');
|
||||||
|
data.groups = (data.groups ?? []).filter(g => g.id !== groupId);
|
||||||
|
return saveExtraData(data, date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addGroupMember(login: string, groupId: string, targetLogin: string, date?: Date): Promise<ClientData> {
|
||||||
|
const data = await getExtraData(date);
|
||||||
|
const group = findGroup(data, groupId);
|
||||||
|
if (!group) throw new Error('Skupina nebyla nalezena');
|
||||||
|
if (group.state === GroupState.ORDERED) throw new Error('Skupinu ve stavu "objednáno" nelze upravovat');
|
||||||
|
if (login !== group.creatorLogin && login !== targetLogin) {
|
||||||
|
throw new Error('Přidat jiného uživatele může pouze zakladatel');
|
||||||
|
}
|
||||||
|
if (group.state === GroupState.LOCKED && login !== group.creatorLogin) {
|
||||||
|
throw new Error('Skupinu ve stavu "uzamčeno" může upravovat pouze zakladatel');
|
||||||
|
}
|
||||||
|
if (group.members[targetLogin]) throw new Error('Uživatel je již ve skupině');
|
||||||
|
group.members[targetLogin] = {};
|
||||||
|
return saveExtraData(data, date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeGroupMember(login: string, groupId: string, targetLogin: string, date?: Date): Promise<ClientData> {
|
||||||
|
const data = await getExtraData(date);
|
||||||
|
const group = findGroup(data, groupId);
|
||||||
|
if (!group) throw new Error('Skupina nebyla nalezena');
|
||||||
|
if (group.state === GroupState.ORDERED) throw new Error('Skupinu ve stavu "objednáno" nelze upravovat');
|
||||||
|
if (login !== group.creatorLogin && login !== targetLogin) {
|
||||||
|
throw new Error('Odebrat jiného uživatele může pouze zakladatel');
|
||||||
|
}
|
||||||
|
if (group.state === GroupState.LOCKED && login !== group.creatorLogin) {
|
||||||
|
throw new Error('Skupinu ve stavu "uzamčeno" může upravovat pouze zakladatel');
|
||||||
|
}
|
||||||
|
if (targetLogin === group.creatorLogin) throw new Error('Zakladatel skupiny nemůže být odebrán');
|
||||||
|
if (!group.members[targetLogin]) throw new Error('Uživatel není ve skupině');
|
||||||
|
delete group.members[targetLogin];
|
||||||
|
return saveExtraData(data, date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateGroupMember(login: string, groupId: string, targetLogin: string, patch: Partial<OrderGroupMember>, date?: Date): Promise<ClientData> {
|
||||||
|
const data = await getExtraData(date);
|
||||||
|
const group = findGroup(data, groupId);
|
||||||
|
if (!group) throw new Error('Skupina nebyla nalezena');
|
||||||
|
if (group.state === GroupState.ORDERED) throw new Error('Skupinu ve stavu "objednáno" nelze upravovat');
|
||||||
|
const isSelf = login === targetLogin;
|
||||||
|
const isCreator = login === group.creatorLogin;
|
||||||
|
if (!isSelf && !isCreator) throw new Error('Upravit jiného uživatele může pouze zakladatel');
|
||||||
|
if (!isCreator && group.state === GroupState.LOCKED) {
|
||||||
|
throw new Error('Skupinu ve stavu "uzamčeno" může upravovat pouze zakladatel');
|
||||||
|
}
|
||||||
|
if (!group.members[targetLogin]) throw new Error('Uživatel není ve skupině');
|
||||||
|
group.members[targetLogin] = { ...group.members[targetLogin], ...patch };
|
||||||
|
return saveExtraData(data, date);
|
||||||
|
}
|
||||||
|
|
||||||
|
const VALID_TRANSITIONS: Record<GroupState, GroupState[]> = {
|
||||||
|
[GroupState.OPEN]: [GroupState.LOCKED],
|
||||||
|
[GroupState.LOCKED]: [GroupState.OPEN, GroupState.ORDERED],
|
||||||
|
[GroupState.ORDERED]: [GroupState.LOCKED],
|
||||||
|
};
|
||||||
|
|
||||||
|
function getCurrentHHMM(): string {
|
||||||
|
const now = new Date();
|
||||||
|
return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setGroupState(login: string, groupId: string, newState: GroupState, date?: Date): Promise<ClientData> {
|
||||||
|
const data = await getExtraData(date);
|
||||||
|
const group = findGroup(data, groupId);
|
||||||
|
if (!group) throw new Error('Skupina nebyla nalezena');
|
||||||
|
if (group.creatorLogin !== login) throw new Error('Stav může měnit pouze zakladatel');
|
||||||
|
if (!VALID_TRANSITIONS[group.state].includes(newState)) {
|
||||||
|
throw new Error(`Nelze přejít ze stavu "${group.state}" do stavu "${newState}"`);
|
||||||
|
}
|
||||||
|
if (newState === GroupState.ORDERED) {
|
||||||
|
group.orderedAt = getCurrentHHMM();
|
||||||
|
}
|
||||||
|
if (group.state === GroupState.ORDERED && newState === GroupState.LOCKED) {
|
||||||
|
const memberLogins = Object.keys(group.members);
|
||||||
|
await removePendingQrsByGroupId(memberLogins, groupId);
|
||||||
|
group.orderedAt = undefined;
|
||||||
|
group.deliveryAt = undefined;
|
||||||
|
group.qrGenerated = undefined;
|
||||||
|
for (const ml of memberLogins) {
|
||||||
|
group.members[ml] = { ...group.members[ml], paid: undefined };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
group.state = newState;
|
||||||
|
return saveExtraData(data, date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markGroupQrGenerated(login: string, groupId: string, date?: Date): Promise<void> {
|
||||||
|
const data = await getExtraData(date);
|
||||||
|
const group = findGroup(data, groupId);
|
||||||
|
if (!group) throw new Error('Skupina nebyla nalezena');
|
||||||
|
if (group.creatorLogin !== login) throw new Error('QR kódy může generovat pouze zakladatel');
|
||||||
|
if (group.state !== GroupState.ORDERED) throw new Error('QR kódy lze generovat pouze ve stavu "objednáno"');
|
||||||
|
group.qrGenerated = true;
|
||||||
|
await saveExtraData(data, date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markGroupMemberPaid(login: string, groupId: string, date?: Date): Promise<ClientData | null> {
|
||||||
|
const data = await getExtraData(date);
|
||||||
|
const group = findGroup(data, groupId);
|
||||||
|
if (!group || !group.members[login]) return null;
|
||||||
|
group.members[login] = { ...group.members[login], paid: true };
|
||||||
|
return saveExtraData(data, date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateGroupFees(login: string, groupId: string, fees?: number, shipping?: number, tip?: number, discountType?: string, discountValue?: number, date?: Date): Promise<ClientData> {
|
||||||
|
const data = await getExtraData(date);
|
||||||
|
const group = findGroup(data, groupId);
|
||||||
|
if (!group) throw new Error('Skupina nebyla nalezena');
|
||||||
|
if (group.creatorLogin !== login) throw new Error('Poplatky může měnit pouze zakladatel');
|
||||||
|
if (group.state === GroupState.ORDERED) throw new Error('Skupinu ve stavu "objednáno" nelze upravovat');
|
||||||
|
if (fees !== undefined) group.fees = fees > 0 ? fees : undefined;
|
||||||
|
if (shipping !== undefined) group.shipping = shipping > 0 ? shipping : undefined;
|
||||||
|
if (tip !== undefined) group.tip = tip > 0 ? tip : undefined;
|
||||||
|
if (discountType !== undefined) group.discountType = (discountType as any) || undefined;
|
||||||
|
if (discountValue !== undefined) group.discountValue = discountValue > 0 ? discountValue : undefined;
|
||||||
|
return saveExtraData(data, date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateGroupTimes(login: string, groupId: string, orderedAt?: string, deliveryAt?: string, date?: Date): Promise<ClientData> {
|
||||||
|
const data = await getExtraData(date);
|
||||||
|
const group = findGroup(data, groupId);
|
||||||
|
if (!group) throw new Error('Skupina nebyla nalezena');
|
||||||
|
if (group.creatorLogin !== login) throw new Error('Časy může měnit pouze zakladatel');
|
||||||
|
if (orderedAt !== undefined) group.orderedAt = orderedAt || undefined;
|
||||||
|
if (deliveryAt !== undefined) group.deliveryAt = deliveryAt || undefined;
|
||||||
|
return saveExtraData(data, date);
|
||||||
|
}
|
||||||
+47
-12
@@ -1,32 +1,35 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import bodyParser from "body-parser";
|
import bodyParser from "body-parser";
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import { getData, getDateForWeekIndex, getToday } from "./service";
|
import { getData, addChoice, getDateForWeekIndex, getToday } from "./service";
|
||||||
|
import { MealSlot } from "../../types/gen/types.gen";
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { getQr } from "./qr";
|
import { getQr } from "./qr";
|
||||||
import { generateToken, getLogin, verify } from "./auth";
|
import { generateToken, getLogin, verify } from "./auth";
|
||||||
import { getIsWeekend, InsufficientPermissions, PizzaDayConflictError, parseToken } from "./utils";
|
import { getIsWeekend, InsufficientPermissions, PizzaDayConflictError, parseToken } from "./utils";
|
||||||
import { getPendingQrs } from "./pizza";
|
import { getPendingQrs } from "./pizza";
|
||||||
import { initWebsocket } from "./websocket";
|
import { initWebsocket, getWebsocket } from "./websocket";
|
||||||
import { startReminderScheduler } from "./pushReminder";
|
import { startReminderScheduler, verifyQuickChoiceToken } from "./pushReminder";
|
||||||
import { storageReady } from "./storage";
|
import { storageReady } from "./storage";
|
||||||
import pizzaDayRoutes from "./routes/pizzaDayRoutes";
|
import pizzaDayRoutes from "./routes/pizzaDayRoutes";
|
||||||
import foodRoutes, { refreshMetoda } from "./routes/foodRoutes";
|
import foodRoutes, { refreshMetoda } from "./routes/foodRoutes";
|
||||||
import votingRoutes from "./routes/votingRoutes";
|
import suggestionRoutes from "./routes/suggestionRoutes";
|
||||||
import easterEggRoutes from "./routes/easterEggRoutes";
|
import easterEggRoutes from "./routes/easterEggRoutes";
|
||||||
import statsRoutes from "./routes/statsRoutes";
|
import statsRoutes from "./routes/statsRoutes";
|
||||||
import notificationRoutes from "./routes/notificationRoutes";
|
import notificationRoutes from "./routes/notificationRoutes";
|
||||||
import qrRoutes from "./routes/qrRoutes";
|
import qrRoutes from "./routes/qrRoutes";
|
||||||
import devRoutes from "./routes/devRoutes";
|
import devRoutes from "./routes/devRoutes";
|
||||||
import changelogRoutes from "./routes/changelogRoutes";
|
import changelogRoutes from "./routes/changelogRoutes";
|
||||||
|
import groupRoutes from "./routes/groupRoutes";
|
||||||
|
import storeRoutes from "./routes/storeRoutes";
|
||||||
|
|
||||||
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
|
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
|
||||||
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
|
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
|
||||||
|
|
||||||
// Validace nastavení JWT tokenu - nemá bez něj smysl vůbec povolit server spustit
|
// Validace nastavení JWT tokenu - nemá bez něj smysl vůbec povolit server spustit
|
||||||
if (!process.env.JWT_SECRET) {
|
if (!process.env.JWT_SECRET) {
|
||||||
throw Error("Není vyplněna proměnná prostředí JWT_SECRET");
|
throw new Error("Není vyplněna proměnná prostředí JWT_SECRET");
|
||||||
}
|
}
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -80,12 +83,12 @@ app.post("/api/login", (req, res) => {
|
|||||||
if (remoteUser && remoteUser.length > 0) {
|
if (remoteUser && remoteUser.length > 0) {
|
||||||
res.status(200).json(generateToken(Buffer.from(remoteUser, 'latin1').toString(), true));
|
res.status(200).json(generateToken(Buffer.from(remoteUser, 'latin1').toString(), true));
|
||||||
} else {
|
} else {
|
||||||
throw Error("Je zapnuto přihlášení přes hlavičky, ale nepřišla hlavička nebo ??");
|
throw new Error("Je zapnuto přihlášení přes hlavičky, ale nepřišla hlavička nebo ??");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Klasická autentizace loginem
|
// Klasická autentizace loginem
|
||||||
if (!req.body?.login || req.body.login.trim().length === 0) {
|
if (!req.body?.login || req.body.login.trim().length === 0) {
|
||||||
throw Error("Nebyl předán login");
|
throw new Error("Nebyl předán login");
|
||||||
}
|
}
|
||||||
// TODO zavést podmínky pro délku loginu (min i max)
|
// TODO zavést podmínky pro délku loginu (min i max)
|
||||||
res.status(200).json(generateToken(req.body.login, false));
|
res.status(200).json(generateToken(req.body.login, false));
|
||||||
@@ -113,6 +116,22 @@ app.get("/api/qr", async (req, res) => {
|
|||||||
// Přeskočení auth pro refresh dat xd
|
// Přeskočení auth pro refresh dat xd
|
||||||
app.use("/api/food/refresh", refreshMetoda);
|
app.use("/api/food/refresh", refreshMetoda);
|
||||||
|
|
||||||
|
// Rychlá akce z push notifikace — autentizace pomocí HMAC tokenu z push payloadu (SW nemá přístup k JWT)
|
||||||
|
app.post("/api/notifications/push/quickChoice", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { login, token } = req.body ?? {};
|
||||||
|
if (!login || typeof login !== 'string' || !token || typeof token !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'Chybí login nebo token' });
|
||||||
|
}
|
||||||
|
if (!verifyQuickChoiceToken(login, token)) {
|
||||||
|
return res.status(403).json({ error: 'Neplatný token' });
|
||||||
|
}
|
||||||
|
const updatedData = await addChoice(login, false, 'NEOBEDVAM', undefined, undefined);
|
||||||
|
getWebsocket().emit("message", updatedData);
|
||||||
|
res.status(200).json({});
|
||||||
|
} catch (e: any) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
/** Middleware ověřující JWT token */
|
/** Middleware ověřující JWT token */
|
||||||
app.use("/api/", (req, res, next) => {
|
app.use("/api/", (req, res, next) => {
|
||||||
if (HTTP_REMOTE_USER_ENABLED) {
|
if (HTTP_REMOTE_USER_ENABLED) {
|
||||||
@@ -142,7 +161,15 @@ app.use("/api/", (req, res, next) => {
|
|||||||
/** Vrátí data pro aktuální den. */
|
/** Vrátí data pro aktuální den. */
|
||||||
app.get("/api/data", async (req, res) => {
|
app.get("/api/data", async (req, res) => {
|
||||||
let date = undefined;
|
let date = undefined;
|
||||||
if (req.query.dayIndex != null && typeof req.query.dayIndex === 'string') {
|
if (req.query.date != null && typeof req.query.date === 'string') {
|
||||||
|
// Konkrétní datum (YYYY-MM-DD) – umožňuje načtení historie i mimo aktuální týden
|
||||||
|
const parsed = new Date(`${req.query.date}T00:00:00`);
|
||||||
|
if (isNaN(parsed.getTime())) {
|
||||||
|
return res.status(400).json({ error: 'Neplatné datum' });
|
||||||
|
}
|
||||||
|
// Budoucnost ořízneme na dnešek – do budoucna historii nedává smysl zobrazovat
|
||||||
|
date = parsed.getTime() > getToday().getTime() ? getToday() : parsed;
|
||||||
|
} else if (req.query.dayIndex != null && typeof req.query.dayIndex === 'string') {
|
||||||
const index = parseInt(req.query.dayIndex);
|
const index = parseInt(req.query.dayIndex);
|
||||||
if (!isNaN(index)) {
|
if (!isNaN(index)) {
|
||||||
date = getDateForWeekIndex(parseInt(req.query.dayIndex));
|
date = getDateForWeekIndex(parseInt(req.query.dayIndex));
|
||||||
@@ -151,7 +178,11 @@ app.get("/api/data", async (req, res) => {
|
|||||||
// Na víkendu zobrazíme pátek místo hlášky "Užívejte víkend"
|
// Na víkendu zobrazíme pátek místo hlášky "Užívejte víkend"
|
||||||
date = getDateForWeekIndex(4);
|
date = getDateForWeekIndex(4);
|
||||||
}
|
}
|
||||||
const data = await getData(date);
|
const slotParam = typeof req.query.slot === 'string' ? req.query.slot as MealSlot : undefined;
|
||||||
|
if (slotParam && slotParam !== MealSlot.OBED && slotParam !== MealSlot.EXTRA) {
|
||||||
|
return res.status(400).json({ error: 'Neplatný slot' });
|
||||||
|
}
|
||||||
|
const data = await getData(date, slotParam);
|
||||||
// Připojíme nevyřízené QR kódy pro přihlášeného uživatele
|
// Připojíme nevyřízené QR kódy pro přihlášeného uživatele
|
||||||
try {
|
try {
|
||||||
const login = getLogin(parseToken(req));
|
const login = getLogin(parseToken(req));
|
||||||
@@ -168,16 +199,20 @@ app.get("/api/data", async (req, res) => {
|
|||||||
// Ostatní routes
|
// Ostatní routes
|
||||||
app.use("/api/pizzaDay", pizzaDayRoutes);
|
app.use("/api/pizzaDay", pizzaDayRoutes);
|
||||||
app.use("/api/food", foodRoutes);
|
app.use("/api/food", foodRoutes);
|
||||||
app.use("/api/voting", votingRoutes);
|
app.use("/api/suggestions", suggestionRoutes);
|
||||||
app.use("/api/easterEggs", easterEggRoutes);
|
app.use("/api/easterEggs", easterEggRoutes);
|
||||||
app.use("/api/stats", statsRoutes);
|
app.use("/api/stats", statsRoutes);
|
||||||
app.use("/api/notifications", notificationRoutes);
|
app.use("/api/notifications", notificationRoutes);
|
||||||
app.use("/api/qr", qrRoutes);
|
app.use("/api/qr", qrRoutes);
|
||||||
app.use("/api/dev", devRoutes);
|
app.use("/api/dev", devRoutes);
|
||||||
app.use("/api/changelogs", changelogRoutes);
|
app.use("/api/changelogs", changelogRoutes);
|
||||||
|
app.use("/api/groups", groupRoutes);
|
||||||
|
app.use("/api/stores", storeRoutes);
|
||||||
|
|
||||||
app.use('/stats', express.static('public'));
|
app.use(express.static(path.join(process.cwd(), 'public')));
|
||||||
app.use(express.static('public'));
|
app.get('*splat', (_req, res) => {
|
||||||
|
res.sendFile(path.join(process.cwd(), 'public', 'index.html'));
|
||||||
|
});
|
||||||
|
|
||||||
// Middleware pro zpracování chyb
|
// Middleware pro zpracování chyb
|
||||||
app.use((err: any, req: any, res: any, next: any) => {
|
app.use((err: any, req: any, res: any, next: any) => {
|
||||||
|
|||||||
+220
-220
@@ -661,30 +661,30 @@ const MOCK_PIZZA_LIST = [
|
|||||||
{
|
{
|
||||||
varId: 1,
|
varId: 1,
|
||||||
size: "30cm",
|
size: "30cm",
|
||||||
pizzaPrice: 138,
|
pizzaPrice: 13800,
|
||||||
boxPrice: 13,
|
boxPrice: 1300,
|
||||||
price: 151
|
price: 15100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 2,
|
varId: 2,
|
||||||
size: "35cm",
|
size: "35cm",
|
||||||
pizzaPrice: 166,
|
pizzaPrice: 16600,
|
||||||
boxPrice: 15,
|
boxPrice: 1500,
|
||||||
price: 181
|
price: 18100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 3,
|
varId: 3,
|
||||||
size: "40cm",
|
size: "40cm",
|
||||||
pizzaPrice: 223,
|
pizzaPrice: 22300,
|
||||||
boxPrice: 18,
|
boxPrice: 1800,
|
||||||
price: 241
|
price: 24100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 4,
|
varId: 4,
|
||||||
size: "50cm",
|
size: "50cm",
|
||||||
pizzaPrice: 306,
|
pizzaPrice: 30600,
|
||||||
boxPrice: 25,
|
boxPrice: 2500,
|
||||||
price: 331
|
price: 33100
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -700,30 +700,30 @@ const MOCK_PIZZA_LIST = [
|
|||||||
{
|
{
|
||||||
varId: 6,
|
varId: 6,
|
||||||
size: "30cm",
|
size: "30cm",
|
||||||
pizzaPrice: 142,
|
pizzaPrice: 14200,
|
||||||
boxPrice: 13,
|
boxPrice: 1300,
|
||||||
price: 155
|
price: 15500
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 7,
|
varId: 7,
|
||||||
size: "35cm",
|
size: "35cm",
|
||||||
pizzaPrice: 172,
|
pizzaPrice: 17200,
|
||||||
boxPrice: 15,
|
boxPrice: 1500,
|
||||||
price: 187
|
price: 18700
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 8,
|
varId: 8,
|
||||||
size: "40cm",
|
size: "40cm",
|
||||||
pizzaPrice: 233,
|
pizzaPrice: 23300,
|
||||||
boxPrice: 18,
|
boxPrice: 1800,
|
||||||
price: 251
|
price: 25100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 9,
|
varId: 9,
|
||||||
size: "50cm",
|
size: "50cm",
|
||||||
pizzaPrice: 316,
|
pizzaPrice: 31600,
|
||||||
boxPrice: 25,
|
boxPrice: 2500,
|
||||||
price: 341
|
price: 34100
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -741,30 +741,30 @@ const MOCK_PIZZA_LIST = [
|
|||||||
{
|
{
|
||||||
varId: 10,
|
varId: 10,
|
||||||
size: "30cm",
|
size: "30cm",
|
||||||
pizzaPrice: 142,
|
pizzaPrice: 14200,
|
||||||
boxPrice: 13,
|
boxPrice: 1300,
|
||||||
price: 155
|
price: 15500
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 11,
|
varId: 11,
|
||||||
size: "35cm",
|
size: "35cm",
|
||||||
pizzaPrice: 172,
|
pizzaPrice: 17200,
|
||||||
boxPrice: 15,
|
boxPrice: 1500,
|
||||||
price: 187
|
price: 18700
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 12,
|
varId: 12,
|
||||||
size: "40cm",
|
size: "40cm",
|
||||||
pizzaPrice: 233,
|
pizzaPrice: 23300,
|
||||||
boxPrice: 18,
|
boxPrice: 1800,
|
||||||
price: 251
|
price: 25100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 13,
|
varId: 13,
|
||||||
size: "50cm",
|
size: "50cm",
|
||||||
pizzaPrice: 316,
|
pizzaPrice: 31600,
|
||||||
boxPrice: 25,
|
boxPrice: 2500,
|
||||||
price: 341
|
price: 34100
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -780,30 +780,30 @@ const MOCK_PIZZA_LIST = [
|
|||||||
{
|
{
|
||||||
varId: 14,
|
varId: 14,
|
||||||
size: "30cm",
|
size: "30cm",
|
||||||
pizzaPrice: 142,
|
pizzaPrice: 14200,
|
||||||
boxPrice: 13,
|
boxPrice: 1300,
|
||||||
price: 155
|
price: 15500
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 15,
|
varId: 15,
|
||||||
size: "35cm",
|
size: "35cm",
|
||||||
pizzaPrice: 172,
|
pizzaPrice: 17200,
|
||||||
boxPrice: 15,
|
boxPrice: 1500,
|
||||||
price: 187
|
price: 18700
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 16,
|
varId: 16,
|
||||||
size: "40cm",
|
size: "40cm",
|
||||||
pizzaPrice: 233,
|
pizzaPrice: 23300,
|
||||||
boxPrice: 18,
|
boxPrice: 1800,
|
||||||
price: 251
|
price: 25100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 17,
|
varId: 17,
|
||||||
size: "50cm",
|
size: "50cm",
|
||||||
pizzaPrice: 294,
|
pizzaPrice: 29400,
|
||||||
boxPrice: 25,
|
boxPrice: 2500,
|
||||||
price: 319
|
price: 31900
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -821,30 +821,30 @@ const MOCK_PIZZA_LIST = [
|
|||||||
{
|
{
|
||||||
varId: 22,
|
varId: 22,
|
||||||
size: "30cm",
|
size: "30cm",
|
||||||
pizzaPrice: 162,
|
pizzaPrice: 16200,
|
||||||
boxPrice: 13,
|
boxPrice: 1300,
|
||||||
price: 175
|
price: 17500
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 23,
|
varId: 23,
|
||||||
size: "35cm",
|
size: "35cm",
|
||||||
pizzaPrice: 186,
|
pizzaPrice: 18600,
|
||||||
boxPrice: 15,
|
boxPrice: 1500,
|
||||||
price: 201
|
price: 20100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 24,
|
varId: 24,
|
||||||
size: "40cm",
|
size: "40cm",
|
||||||
pizzaPrice: 263,
|
pizzaPrice: 26300,
|
||||||
boxPrice: 18,
|
boxPrice: 1800,
|
||||||
price: 281
|
price: 28100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 25,
|
varId: 25,
|
||||||
size: "50cm",
|
size: "50cm",
|
||||||
pizzaPrice: 346,
|
pizzaPrice: 34600,
|
||||||
boxPrice: 25,
|
boxPrice: 2500,
|
||||||
price: 371
|
price: 37100
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -861,30 +861,30 @@ const MOCK_PIZZA_LIST = [
|
|||||||
{
|
{
|
||||||
varId: 26,
|
varId: 26,
|
||||||
size: "30cm",
|
size: "30cm",
|
||||||
pizzaPrice: 162,
|
pizzaPrice: 16200,
|
||||||
boxPrice: 13,
|
boxPrice: 1300,
|
||||||
price: 175
|
price: 17500
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 27,
|
varId: 27,
|
||||||
size: "35cm",
|
size: "35cm",
|
||||||
pizzaPrice: 186,
|
pizzaPrice: 18600,
|
||||||
boxPrice: 15,
|
boxPrice: 1500,
|
||||||
price: 201
|
price: 20100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 28,
|
varId: 28,
|
||||||
size: "40cm",
|
size: "40cm",
|
||||||
pizzaPrice: 263,
|
pizzaPrice: 26300,
|
||||||
boxPrice: 18,
|
boxPrice: 1800,
|
||||||
price: 281
|
price: 28100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 29,
|
varId: 29,
|
||||||
size: "50cm",
|
size: "50cm",
|
||||||
pizzaPrice: 346,
|
pizzaPrice: 34600,
|
||||||
boxPrice: 25,
|
boxPrice: 2500,
|
||||||
price: 371
|
price: 37100
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -902,30 +902,30 @@ const MOCK_PIZZA_LIST = [
|
|||||||
{
|
{
|
||||||
varId: 30,
|
varId: 30,
|
||||||
size: "30cm",
|
size: "30cm",
|
||||||
pizzaPrice: 162,
|
pizzaPrice: 16200,
|
||||||
boxPrice: 13,
|
boxPrice: 1300,
|
||||||
price: 175
|
price: 17500
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 31,
|
varId: 31,
|
||||||
size: "35cm",
|
size: "35cm",
|
||||||
pizzaPrice: 186,
|
pizzaPrice: 18600,
|
||||||
boxPrice: 15,
|
boxPrice: 1500,
|
||||||
price: 201
|
price: 20100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 32,
|
varId: 32,
|
||||||
size: "40cm",
|
size: "40cm",
|
||||||
pizzaPrice: 263,
|
pizzaPrice: 26300,
|
||||||
boxPrice: 18,
|
boxPrice: 1800,
|
||||||
price: 281
|
price: 28100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 33,
|
varId: 33,
|
||||||
size: "50cm",
|
size: "50cm",
|
||||||
pizzaPrice: 346,
|
pizzaPrice: 34600,
|
||||||
boxPrice: 25,
|
boxPrice: 2500,
|
||||||
price: 371
|
price: 37100
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -946,30 +946,30 @@ const MOCK_PIZZA_LIST = [
|
|||||||
{
|
{
|
||||||
varId: 34,
|
varId: 34,
|
||||||
size: "30cm",
|
size: "30cm",
|
||||||
pizzaPrice: 162,
|
pizzaPrice: 16200,
|
||||||
boxPrice: 13,
|
boxPrice: 1300,
|
||||||
price: 175
|
price: 17500
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 35,
|
varId: 35,
|
||||||
size: "35cm",
|
size: "35cm",
|
||||||
pizzaPrice: 186,
|
pizzaPrice: 18600,
|
||||||
boxPrice: 15,
|
boxPrice: 1500,
|
||||||
price: 201
|
price: 20100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 36,
|
varId: 36,
|
||||||
size: "40cm",
|
size: "40cm",
|
||||||
pizzaPrice: 263,
|
pizzaPrice: 26300,
|
||||||
boxPrice: 18,
|
boxPrice: 1800,
|
||||||
price: 281
|
price: 28100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 37,
|
varId: 37,
|
||||||
size: "50cm",
|
size: "50cm",
|
||||||
pizzaPrice: 346,
|
pizzaPrice: 34600,
|
||||||
boxPrice: 25,
|
boxPrice: 2500,
|
||||||
price: 371
|
price: 37100
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -987,30 +987,30 @@ const MOCK_PIZZA_LIST = [
|
|||||||
{
|
{
|
||||||
varId: 38,
|
varId: 38,
|
||||||
size: "30cm",
|
size: "30cm",
|
||||||
pizzaPrice: 162,
|
pizzaPrice: 16200,
|
||||||
boxPrice: 13,
|
boxPrice: 1300,
|
||||||
price: 175
|
price: 17500
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 39,
|
varId: 39,
|
||||||
size: "35cm",
|
size: "35cm",
|
||||||
pizzaPrice: 186,
|
pizzaPrice: 18600,
|
||||||
boxPrice: 15,
|
boxPrice: 1500,
|
||||||
price: 201
|
price: 20100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 40,
|
varId: 40,
|
||||||
size: "40cm",
|
size: "40cm",
|
||||||
pizzaPrice: 263,
|
pizzaPrice: 26300,
|
||||||
boxPrice: 18,
|
boxPrice: 1800,
|
||||||
price: 281
|
price: 28100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 41,
|
varId: 41,
|
||||||
size: "50cm",
|
size: "50cm",
|
||||||
pizzaPrice: 346,
|
pizzaPrice: 34600,
|
||||||
boxPrice: 25,
|
boxPrice: 2500,
|
||||||
price: 371
|
price: 37100
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -1028,30 +1028,30 @@ const MOCK_PIZZA_LIST = [
|
|||||||
{
|
{
|
||||||
varId: 42,
|
varId: 42,
|
||||||
size: "30cm",
|
size: "30cm",
|
||||||
pizzaPrice: 172,
|
pizzaPrice: 17200,
|
||||||
boxPrice: 13,
|
boxPrice: 1300,
|
||||||
price: 185
|
price: 18500
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 43,
|
varId: 43,
|
||||||
size: "35cm",
|
size: "35cm",
|
||||||
pizzaPrice: 212,
|
pizzaPrice: 21200,
|
||||||
boxPrice: 15,
|
boxPrice: 1500,
|
||||||
price: 227
|
price: 22700
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 44,
|
varId: 44,
|
||||||
size: "40cm",
|
size: "40cm",
|
||||||
pizzaPrice: 293,
|
pizzaPrice: 29300,
|
||||||
boxPrice: 18,
|
boxPrice: 1800,
|
||||||
price: 311
|
price: 31100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 45,
|
varId: 45,
|
||||||
size: "50cm",
|
size: "50cm",
|
||||||
pizzaPrice: 376,
|
pizzaPrice: 37600,
|
||||||
boxPrice: 25,
|
boxPrice: 2500,
|
||||||
price: 401
|
price: 40100
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -1069,30 +1069,30 @@ const MOCK_PIZZA_LIST = [
|
|||||||
{
|
{
|
||||||
varId: 46,
|
varId: 46,
|
||||||
size: "30cm",
|
size: "30cm",
|
||||||
pizzaPrice: 182,
|
pizzaPrice: 18200,
|
||||||
boxPrice: 13,
|
boxPrice: 1300,
|
||||||
price: 195
|
price: 19500
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 47,
|
varId: 47,
|
||||||
size: "35cm",
|
size: "35cm",
|
||||||
pizzaPrice: 222,
|
pizzaPrice: 22200,
|
||||||
boxPrice: 15,
|
boxPrice: 1500,
|
||||||
price: 237
|
price: 23700
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 48,
|
varId: 48,
|
||||||
size: "40cm",
|
size: "40cm",
|
||||||
pizzaPrice: 303,
|
pizzaPrice: 30300,
|
||||||
boxPrice: 18,
|
boxPrice: 1800,
|
||||||
price: 321
|
price: 32100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 49,
|
varId: 49,
|
||||||
size: "50cm",
|
size: "50cm",
|
||||||
pizzaPrice: 386,
|
pizzaPrice: 38600,
|
||||||
boxPrice: 25,
|
boxPrice: 2500,
|
||||||
price: 411
|
price: 41100
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -1114,30 +1114,30 @@ const MOCK_PIZZA_LIST = [
|
|||||||
{
|
{
|
||||||
varId: 50,
|
varId: 50,
|
||||||
size: "30cm",
|
size: "30cm",
|
||||||
pizzaPrice: 182,
|
pizzaPrice: 18200,
|
||||||
boxPrice: 13,
|
boxPrice: 1300,
|
||||||
price: 195
|
price: 19500
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 51,
|
varId: 51,
|
||||||
size: "35cm",
|
size: "35cm",
|
||||||
pizzaPrice: 222,
|
pizzaPrice: 22200,
|
||||||
boxPrice: 15,
|
boxPrice: 1500,
|
||||||
price: 237
|
price: 23700
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 52,
|
varId: 52,
|
||||||
size: "40cm",
|
size: "40cm",
|
||||||
pizzaPrice: 303,
|
pizzaPrice: 30300,
|
||||||
boxPrice: 18,
|
boxPrice: 1800,
|
||||||
price: 321
|
price: 32100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 53,
|
varId: 53,
|
||||||
size: "50cm",
|
size: "50cm",
|
||||||
pizzaPrice: 396,
|
pizzaPrice: 39600,
|
||||||
boxPrice: 25,
|
boxPrice: 2500,
|
||||||
price: 421
|
price: 42100
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -1156,30 +1156,30 @@ const MOCK_PIZZA_LIST = [
|
|||||||
{
|
{
|
||||||
varId: 54,
|
varId: 54,
|
||||||
size: "30cm",
|
size: "30cm",
|
||||||
pizzaPrice: 182,
|
pizzaPrice: 18200,
|
||||||
boxPrice: 13,
|
boxPrice: 1300,
|
||||||
price: 195
|
price: 19500
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 55,
|
varId: 55,
|
||||||
size: "35cm",
|
size: "35cm",
|
||||||
pizzaPrice: 222,
|
pizzaPrice: 22200,
|
||||||
boxPrice: 15,
|
boxPrice: 1500,
|
||||||
price: 237
|
price: 23700
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 56,
|
varId: 56,
|
||||||
size: "40cm",
|
size: "40cm",
|
||||||
pizzaPrice: 303,
|
pizzaPrice: 30300,
|
||||||
boxPrice: 18,
|
boxPrice: 1800,
|
||||||
price: 321
|
price: 32100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 57,
|
varId: 57,
|
||||||
size: "50cm",
|
size: "50cm",
|
||||||
pizzaPrice: 396,
|
pizzaPrice: 39600,
|
||||||
boxPrice: 25,
|
boxPrice: 2500,
|
||||||
price: 421
|
price: 42100
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -1199,30 +1199,30 @@ const MOCK_PIZZA_LIST = [
|
|||||||
{
|
{
|
||||||
varId: 58,
|
varId: 58,
|
||||||
size: "30cm",
|
size: "30cm",
|
||||||
pizzaPrice: 182,
|
pizzaPrice: 18200,
|
||||||
boxPrice: 13,
|
boxPrice: 1300,
|
||||||
price: 195
|
price: 19500
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 59,
|
varId: 59,
|
||||||
size: "35cm",
|
size: "35cm",
|
||||||
pizzaPrice: 222,
|
pizzaPrice: 22200,
|
||||||
boxPrice: 15,
|
boxPrice: 1500,
|
||||||
price: 237
|
price: 23700
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 60,
|
varId: 60,
|
||||||
size: "40cm",
|
size: "40cm",
|
||||||
pizzaPrice: 303,
|
pizzaPrice: 30300,
|
||||||
boxPrice: 18,
|
boxPrice: 1800,
|
||||||
price: 321
|
price: 32100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 61,
|
varId: 61,
|
||||||
size: "50cm",
|
size: "50cm",
|
||||||
pizzaPrice: 396,
|
pizzaPrice: 39600,
|
||||||
boxPrice: 25,
|
boxPrice: 2500,
|
||||||
price: 421
|
price: 42100
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -1241,30 +1241,30 @@ const MOCK_PIZZA_LIST = [
|
|||||||
{
|
{
|
||||||
varId: 62,
|
varId: 62,
|
||||||
size: "30cm",
|
size: "30cm",
|
||||||
pizzaPrice: 188,
|
pizzaPrice: 18800,
|
||||||
boxPrice: 13,
|
boxPrice: 1300,
|
||||||
price: 201
|
price: 20100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 63,
|
varId: 63,
|
||||||
size: "35cm",
|
size: "35cm",
|
||||||
pizzaPrice: 226,
|
pizzaPrice: 22600,
|
||||||
boxPrice: 15,
|
boxPrice: 1500,
|
||||||
price: 241
|
price: 24100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 64,
|
varId: 64,
|
||||||
size: "40cm",
|
size: "40cm",
|
||||||
pizzaPrice: 313,
|
pizzaPrice: 31300,
|
||||||
boxPrice: 18,
|
boxPrice: 1800,
|
||||||
price: 331
|
price: 33100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 65,
|
varId: 65,
|
||||||
size: "50cm",
|
size: "50cm",
|
||||||
pizzaPrice: 426,
|
pizzaPrice: 42600,
|
||||||
boxPrice: 25,
|
boxPrice: 2500,
|
||||||
price: 451
|
price: 45100
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -1283,30 +1283,30 @@ const MOCK_PIZZA_LIST = [
|
|||||||
{
|
{
|
||||||
varId: 66,
|
varId: 66,
|
||||||
size: "30cm",
|
size: "30cm",
|
||||||
pizzaPrice: 188,
|
pizzaPrice: 18800,
|
||||||
boxPrice: 13,
|
boxPrice: 1300,
|
||||||
price: 201
|
price: 20100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 67,
|
varId: 67,
|
||||||
size: "35cm",
|
size: "35cm",
|
||||||
pizzaPrice: 226,
|
pizzaPrice: 22600,
|
||||||
boxPrice: 15,
|
boxPrice: 1500,
|
||||||
price: 241
|
price: 24100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 68,
|
varId: 68,
|
||||||
size: "40cm",
|
size: "40cm",
|
||||||
pizzaPrice: 313,
|
pizzaPrice: 31300,
|
||||||
boxPrice: 18,
|
boxPrice: 1800,
|
||||||
price: 331
|
price: 33100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 69,
|
varId: 69,
|
||||||
size: "50cm",
|
size: "50cm",
|
||||||
pizzaPrice: 426,
|
pizzaPrice: 42600,
|
||||||
boxPrice: 25,
|
boxPrice: 2500,
|
||||||
price: 451
|
price: 45100
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -1327,30 +1327,30 @@ const MOCK_PIZZA_LIST = [
|
|||||||
{
|
{
|
||||||
varId: 309,
|
varId: 309,
|
||||||
size: "30cm",
|
size: "30cm",
|
||||||
pizzaPrice: 182,
|
pizzaPrice: 18200,
|
||||||
boxPrice: 13,
|
boxPrice: 1300,
|
||||||
price: 195
|
price: 19500
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 310,
|
varId: 310,
|
||||||
size: "35cm",
|
size: "35cm",
|
||||||
pizzaPrice: 222,
|
pizzaPrice: 22200,
|
||||||
boxPrice: 15,
|
boxPrice: 1500,
|
||||||
price: 237
|
price: 23700
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 311,
|
varId: 311,
|
||||||
size: "40cm",
|
size: "40cm",
|
||||||
pizzaPrice: 303,
|
pizzaPrice: 30300,
|
||||||
boxPrice: 18,
|
boxPrice: 1800,
|
||||||
price: 321
|
price: 32100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 312,
|
varId: 312,
|
||||||
size: "50cm",
|
size: "50cm",
|
||||||
pizzaPrice: 396,
|
pizzaPrice: 39600,
|
||||||
boxPrice: 25,
|
boxPrice: 2500,
|
||||||
price: 421
|
price: 42100
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -1369,30 +1369,30 @@ const MOCK_PIZZA_LIST = [
|
|||||||
{
|
{
|
||||||
varId: 394,
|
varId: 394,
|
||||||
size: "30cm",
|
size: "30cm",
|
||||||
pizzaPrice: 188,
|
pizzaPrice: 18800,
|
||||||
boxPrice: 13,
|
boxPrice: 1300,
|
||||||
price: 201
|
price: 20100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 395,
|
varId: 395,
|
||||||
size: "35cm",
|
size: "35cm",
|
||||||
pizzaPrice: 226,
|
pizzaPrice: 22600,
|
||||||
boxPrice: 15,
|
boxPrice: 1500,
|
||||||
price: 241
|
price: 24100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 396,
|
varId: 396,
|
||||||
size: "40cm",
|
size: "40cm",
|
||||||
pizzaPrice: 313,
|
pizzaPrice: 31300,
|
||||||
boxPrice: 18,
|
boxPrice: 1800,
|
||||||
price: 331
|
price: 33100
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
varId: 397,
|
varId: 397,
|
||||||
size: "50cm",
|
size: "50cm",
|
||||||
pizzaPrice: 426,
|
pizzaPrice: 42600,
|
||||||
boxPrice: 25,
|
boxPrice: 2500,
|
||||||
price: 451
|
price: 45100
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1434,22 +1434,22 @@ const MOCK_SALAT_LIST = [
|
|||||||
{
|
{
|
||||||
name: "Greek",
|
name: "Greek",
|
||||||
ingredients: ["Salát", "Černé olivy", "Paprika mix", "Červená cibule", "Rajčata", "Okurka salátová", "Jogurtový dresing"],
|
ingredients: ["Salát", "Černé olivy", "Paprika mix", "Červená cibule", "Rajčata", "Okurka salátová", "Jogurtový dresing"],
|
||||||
price: 174 + 13,
|
price: (174 + 13) * 100,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Caesar",
|
name: "Caesar",
|
||||||
ingredients: ["Salát", "Rajčata", "Kuřecí maso", "Krutony", "Parmazán", "Caesar dresing", "Olivový olej"],
|
ingredients: ["Salát", "Rajčata", "Kuřecí maso", "Krutony", "Parmazán", "Caesar dresing", "Olivový olej"],
|
||||||
price: 184 + 13,
|
price: (184 + 13) * 100,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Šopský salát",
|
name: "Šopský salát",
|
||||||
ingredients: ["Salátová okurka", "Rajčata", "Paprika mix", "Červená cibule", "Balkánský sýr"],
|
ingredients: ["Salátová okurka", "Rajčata", "Paprika mix", "Červená cibule", "Balkánský sýr"],
|
||||||
price: 164 + 13,
|
price: (164 + 13) * 100,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Těstovinový salát",
|
name: "Těstovinový salát",
|
||||||
ingredients: ["Penne", "Okurka", "Rajčata", "Paprika mix", "Kuřecí maso", "Jogurtový dresing"],
|
ingredients: ["Penne", "Okurka", "Rajčata", "Paprika mix", "Kuřecí maso", "Jogurtový dresing"],
|
||||||
price: 184 + 13,
|
price: (184 + 13) * 100,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+48
-31
@@ -74,7 +74,7 @@ export async function createPizzaDay(creator: string): Promise<ClientData> {
|
|||||||
await initIfNeeded();
|
await initIfNeeded();
|
||||||
const clientData = await getClientData(getToday());
|
const clientData = await getClientData(getToday());
|
||||||
if (clientData.pizzaDay) {
|
if (clientData.pizzaDay) {
|
||||||
throw Error("Pizza day pro dnešní den již existuje");
|
throw new Error("Pizza day pro dnešní den již existuje");
|
||||||
}
|
}
|
||||||
// TODO berka rychlooprava, vyřešit lépe - stahovat jednou, na jediném místě!
|
// TODO berka rychlooprava, vyřešit lépe - stahovat jednou, na jediném místě!
|
||||||
const [pizzaList, salatList] = await Promise.all([getPizzaList(), getSalatList()]);
|
const [pizzaList, salatList] = await Promise.all([getPizzaList(), getSalatList()]);
|
||||||
@@ -91,10 +91,10 @@ export async function createPizzaDay(creator: string): Promise<ClientData> {
|
|||||||
export async function deletePizzaDay(login: string): Promise<ClientData> {
|
export async function deletePizzaDay(login: string): Promise<ClientData> {
|
||||||
const clientData = await getClientData(getToday());
|
const clientData = await getClientData(getToday());
|
||||||
if (!clientData.pizzaDay) {
|
if (!clientData.pizzaDay) {
|
||||||
throw Error("Pizza day pro dnešní den neexistuje");
|
throw new Error("Pizza day pro dnešní den neexistuje");
|
||||||
}
|
}
|
||||||
if (clientData.pizzaDay.creator !== login) {
|
if (clientData.pizzaDay.creator !== login) {
|
||||||
throw Error("Login uživatele se neshoduje se zakladatelem Pizza Day");
|
throw new Error("Login uživatele se neshoduje se zakladatelem Pizza Day");
|
||||||
}
|
}
|
||||||
delete clientData.pizzaDay;
|
delete clientData.pizzaDay;
|
||||||
const today = formatDate(getToday());
|
const today = formatDate(getToday());
|
||||||
@@ -113,10 +113,10 @@ export async function addPizzaOrder(login: string, pizza: Pizza, size: PizzaSize
|
|||||||
const today = formatDate(getToday());
|
const today = formatDate(getToday());
|
||||||
const clientData = await getClientData(getToday());
|
const clientData = await getClientData(getToday());
|
||||||
if (!clientData.pizzaDay) {
|
if (!clientData.pizzaDay) {
|
||||||
throw Error("Pizza day pro dnešní den neexistuje");
|
throw new Error("Pizza day pro dnešní den neexistuje");
|
||||||
}
|
}
|
||||||
if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
|
if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
|
||||||
throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED);
|
throw new Error("Pizza day není ve stavu " + PizzaDayState.CREATED);
|
||||||
}
|
}
|
||||||
let order: PizzaOrder | undefined = clientData.pizzaDay?.orders?.find(o => o.customer === login);
|
let order: PizzaOrder | undefined = clientData.pizzaDay?.orders?.find(o => o.customer === login);
|
||||||
if (!order) {
|
if (!order) {
|
||||||
@@ -152,10 +152,10 @@ export async function addSalatOrder(login: string, salat: Salat) {
|
|||||||
const today = formatDate(getToday());
|
const today = formatDate(getToday());
|
||||||
const clientData = await getClientData(getToday());
|
const clientData = await getClientData(getToday());
|
||||||
if (!clientData.pizzaDay) {
|
if (!clientData.pizzaDay) {
|
||||||
throw Error("Pizza day pro dnešní den neexistuje");
|
throw new Error("Pizza day pro dnešní den neexistuje");
|
||||||
}
|
}
|
||||||
if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
|
if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
|
||||||
throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED);
|
throw new Error("Pizza day není ve stavu " + PizzaDayState.CREATED);
|
||||||
}
|
}
|
||||||
let order: PizzaOrder | undefined = clientData.pizzaDay?.orders?.find(o => o.customer === login);
|
let order: PizzaOrder | undefined = clientData.pizzaDay?.orders?.find(o => o.customer === login);
|
||||||
if (!order) {
|
if (!order) {
|
||||||
@@ -222,16 +222,16 @@ export async function removePizzaOrder(login: string, pizzaOrder: PizzaVariant)
|
|||||||
const today = formatDate(getToday());
|
const today = formatDate(getToday());
|
||||||
const clientData = await getClientData(getToday());
|
const clientData = await getClientData(getToday());
|
||||||
if (!clientData.pizzaDay) {
|
if (!clientData.pizzaDay) {
|
||||||
throw Error("Pizza day pro dnešní den neexistuje");
|
throw new 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) {
|
if (orderIndex < 0) {
|
||||||
throw Error("Nebyly nalezeny žádné objednávky pro uživatele " + login);
|
throw new Error("Nebyly nalezeny žádné objednávky pro uživatele " + login);
|
||||||
}
|
}
|
||||||
const order = clientData.pizzaDay.orders![orderIndex];
|
const order = clientData.pizzaDay.orders![orderIndex];
|
||||||
const index = order.pizzaList!.findIndex(o => o.name === pizzaOrder.name && o.size === pizzaOrder.size);
|
const index = order.pizzaList!.findIndex(o => o.name === pizzaOrder.name && o.size === pizzaOrder.size);
|
||||||
if (index < 0) {
|
if (index < 0) {
|
||||||
throw Error("Objednávka s danými parametry nebyla nalezena");
|
throw new Error("Objednávka s danými parametry nebyla nalezena");
|
||||||
}
|
}
|
||||||
const price = order.pizzaList![index].price;
|
const price = order.pizzaList![index].price;
|
||||||
order.pizzaList!.splice(index, 1);
|
order.pizzaList!.splice(index, 1);
|
||||||
@@ -253,13 +253,13 @@ export async function lockPizzaDay(login: string) {
|
|||||||
const today = formatDate(getToday());
|
const today = formatDate(getToday());
|
||||||
const clientData = await getClientData(getToday());
|
const clientData = await getClientData(getToday());
|
||||||
if (!clientData.pizzaDay) {
|
if (!clientData.pizzaDay) {
|
||||||
throw Error("Pizza day pro dnešní den neexistuje");
|
throw new Error("Pizza day pro dnešní den neexistuje");
|
||||||
}
|
}
|
||||||
if (clientData.pizzaDay.creator !== login) {
|
if (clientData.pizzaDay.creator !== login) {
|
||||||
throw Error("Pizza day není spravován uživatelem " + login);
|
throw new Error("Pizza day není spravován uživatelem " + login);
|
||||||
}
|
}
|
||||||
if (clientData.pizzaDay.state !== PizzaDayState.CREATED && clientData.pizzaDay.state !== PizzaDayState.ORDERED) {
|
if (clientData.pizzaDay.state !== PizzaDayState.CREATED && clientData.pizzaDay.state !== PizzaDayState.ORDERED) {
|
||||||
throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED + " nebo " + PizzaDayState.ORDERED);
|
throw new Error("Pizza day není ve stavu " + PizzaDayState.CREATED + " nebo " + PizzaDayState.ORDERED);
|
||||||
}
|
}
|
||||||
clientData.pizzaDay.state = PizzaDayState.LOCKED;
|
clientData.pizzaDay.state = PizzaDayState.LOCKED;
|
||||||
await storage.setData(today, clientData);
|
await storage.setData(today, clientData);
|
||||||
@@ -276,13 +276,13 @@ export async function unlockPizzaDay(login: string) {
|
|||||||
const today = formatDate(getToday());
|
const today = formatDate(getToday());
|
||||||
const clientData = await getClientData(getToday());
|
const clientData = await getClientData(getToday());
|
||||||
if (!clientData.pizzaDay) {
|
if (!clientData.pizzaDay) {
|
||||||
throw Error("Pizza day pro dnešní den neexistuje");
|
throw new Error("Pizza day pro dnešní den neexistuje");
|
||||||
}
|
}
|
||||||
if (clientData.pizzaDay.creator !== login) {
|
if (clientData.pizzaDay.creator !== login) {
|
||||||
throw Error("Pizza day není spravován uživatelem " + login);
|
throw new Error("Pizza day není spravován uživatelem " + login);
|
||||||
}
|
}
|
||||||
if (clientData.pizzaDay.state !== PizzaDayState.LOCKED) {
|
if (clientData.pizzaDay.state !== PizzaDayState.LOCKED) {
|
||||||
throw Error("Pizza day není ve stavu " + PizzaDayState.LOCKED);
|
throw new Error("Pizza day není ve stavu " + PizzaDayState.LOCKED);
|
||||||
}
|
}
|
||||||
clientData.pizzaDay.state = PizzaDayState.CREATED;
|
clientData.pizzaDay.state = PizzaDayState.CREATED;
|
||||||
await storage.setData(today, clientData);
|
await storage.setData(today, clientData);
|
||||||
@@ -299,13 +299,13 @@ export async function finishPizzaOrder(login: string) {
|
|||||||
const today = formatDate(getToday());
|
const today = formatDate(getToday());
|
||||||
const clientData = await getClientData(getToday());
|
const clientData = await getClientData(getToday());
|
||||||
if (!clientData.pizzaDay) {
|
if (!clientData.pizzaDay) {
|
||||||
throw Error("Pizza day pro dnešní den neexistuje");
|
throw new Error("Pizza day pro dnešní den neexistuje");
|
||||||
}
|
}
|
||||||
if (clientData.pizzaDay.creator !== login) {
|
if (clientData.pizzaDay.creator !== login) {
|
||||||
throw Error("Pizza day není spravován uživatelem " + login);
|
throw new Error("Pizza day není spravován uživatelem " + login);
|
||||||
}
|
}
|
||||||
if (clientData.pizzaDay.state !== PizzaDayState.LOCKED) {
|
if (clientData.pizzaDay.state !== PizzaDayState.LOCKED) {
|
||||||
throw Error("Pizza day není ve stavu " + PizzaDayState.LOCKED);
|
throw new Error("Pizza day není ve stavu " + PizzaDayState.LOCKED);
|
||||||
}
|
}
|
||||||
clientData.pizzaDay.state = PizzaDayState.ORDERED;
|
clientData.pizzaDay.state = PizzaDayState.ORDERED;
|
||||||
await storage.setData(today, clientData);
|
await storage.setData(today, clientData);
|
||||||
@@ -324,13 +324,13 @@ export async function finishPizzaDelivery(login: string, bankAccount?: string, b
|
|||||||
const today = formatDate(getToday());
|
const today = formatDate(getToday());
|
||||||
const clientData = await getClientData(getToday());
|
const clientData = await getClientData(getToday());
|
||||||
if (!clientData.pizzaDay) {
|
if (!clientData.pizzaDay) {
|
||||||
throw Error("Pizza day pro dnešní den neexistuje");
|
throw new Error("Pizza day pro dnešní den neexistuje");
|
||||||
}
|
}
|
||||||
if (clientData.pizzaDay.creator !== login) {
|
if (clientData.pizzaDay.creator !== login) {
|
||||||
throw Error("Pizza day není spravován uživatelem " + login);
|
throw new Error("Pizza day není spravován uživatelem " + login);
|
||||||
}
|
}
|
||||||
if (clientData.pizzaDay.state !== PizzaDayState.ORDERED) {
|
if (clientData.pizzaDay.state !== PizzaDayState.ORDERED) {
|
||||||
throw Error("Pizza day není ve stavu " + PizzaDayState.ORDERED);
|
throw new Error("Pizza day není ve stavu " + PizzaDayState.ORDERED);
|
||||||
}
|
}
|
||||||
clientData.pizzaDay.state = PizzaDayState.DELIVERED;
|
clientData.pizzaDay.state = PizzaDayState.DELIVERED;
|
||||||
|
|
||||||
@@ -342,7 +342,7 @@ export async function finishPizzaDelivery(login: string, bankAccount?: string, b
|
|||||||
let message = order.pizzaList!.map(item =>
|
let message = order.pizzaList!.map(item =>
|
||||||
item.category === 'salat' ? `Salát ${item.name}` : `Pizza ${item.name} (${item.size})`
|
item.category === 'salat' ? `Salát ${item.name}` : `Pizza ${item.name} (${item.size})`
|
||||||
).join(', ');
|
).join(', ');
|
||||||
await generateQr(order.customer, bankAccount, bankAccountHolder, order.totalPrice, message, id);
|
await generateQr(order.customer, bankAccount, bankAccountHolder, order.totalPrice / 100, message, id);
|
||||||
order.hasQr = true;
|
order.hasQr = true;
|
||||||
// Uložíme nevyřízený QR kód pro persistentní zobrazení
|
// Uložíme nevyřízený QR kód pro persistentní zobrazení
|
||||||
await addPendingQr(order.customer, {
|
await addPendingQr(order.customer, {
|
||||||
@@ -370,14 +370,14 @@ export async function updatePizzaDayNote(login: string, note?: string) {
|
|||||||
const today = formatDate(getToday());
|
const today = formatDate(getToday());
|
||||||
let clientData = await getClientData(getToday());
|
let clientData = await getClientData(getToday());
|
||||||
if (!clientData.pizzaDay) {
|
if (!clientData.pizzaDay) {
|
||||||
throw Error("Pizza day pro dnešní den neexistuje");
|
throw new Error("Pizza day pro dnešní den neexistuje");
|
||||||
}
|
}
|
||||||
if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
|
if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
|
||||||
throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED);
|
throw new Error("Pizza day není ve stavu " + PizzaDayState.CREATED);
|
||||||
}
|
}
|
||||||
const myOrder = clientData.pizzaDay.orders!.find(o => o.customer === login);
|
const myOrder = clientData.pizzaDay.orders!.find(o => o.customer === login);
|
||||||
if (!myOrder?.pizzaList?.length) {
|
if (!myOrder?.pizzaList?.length) {
|
||||||
throw Error("Pizza day neobsahuje žádné objednávky uživatele " + login);
|
throw new Error("Pizza day neobsahuje žádné objednávky uživatele " + login);
|
||||||
}
|
}
|
||||||
myOrder.note = note;
|
myOrder.note = note;
|
||||||
await storage.setData(today, clientData);
|
await storage.setData(today, clientData);
|
||||||
@@ -397,17 +397,17 @@ export async function updatePizzaFee(login: string, targetLogin: string, text?:
|
|||||||
const today = formatDate(getToday());
|
const today = formatDate(getToday());
|
||||||
let clientData = await getClientData(getToday());
|
let clientData = await getClientData(getToday());
|
||||||
if (!clientData.pizzaDay) {
|
if (!clientData.pizzaDay) {
|
||||||
throw Error("Pizza day pro dnešní den neexistuje");
|
throw new Error("Pizza day pro dnešní den neexistuje");
|
||||||
}
|
}
|
||||||
if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
|
if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
|
||||||
throw Error(`Pizza day není ve stavu ${PizzaDayState.CREATED}`);
|
throw new Error(`Pizza day není ve stavu ${PizzaDayState.CREATED}`);
|
||||||
}
|
}
|
||||||
if (clientData.pizzaDay.creator !== login) {
|
if (clientData.pizzaDay.creator !== login) {
|
||||||
throw Error("Příplatky může měnit pouze zakladatel Pizza day");
|
throw new Error("Příplatky může měnit pouze zakladatel Pizza day");
|
||||||
}
|
}
|
||||||
const targetOrder = clientData.pizzaDay.orders!.find(o => o.customer === targetLogin);
|
const targetOrder = clientData.pizzaDay.orders!.find(o => o.customer === targetLogin);
|
||||||
if (!targetOrder?.pizzaList?.length) {
|
if (!targetOrder?.pizzaList?.length) {
|
||||||
throw Error(`Pizza day neobsahuje žádné objednávky uživatele ${targetLogin}`);
|
throw new Error(`Pizza day neobsahuje žádné objednávky uživatele ${targetLogin}`);
|
||||||
}
|
}
|
||||||
if (!price) {
|
if (!price) {
|
||||||
delete targetOrder.fee;
|
delete targetOrder.fee;
|
||||||
@@ -449,10 +449,27 @@ export async function getPendingQrs(login: string): Promise<PendingQr[]> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Označí QR kód jako uhrazený (odstraní ho ze seznamu nevyřízených).
|
* Označí QR kód jako uhrazený (odstraní ho ze seznamu nevyřízených).
|
||||||
|
* Vrátí odstraněný QR kód, pokud byl nalezen.
|
||||||
*/
|
*/
|
||||||
export async function dismissPendingQr(login: string, id: string): Promise<void> {
|
export async function dismissPendingQr(login: string, id: string): Promise<PendingQr | undefined> {
|
||||||
const key = getPendingQrKey(login);
|
const key = getPendingQrKey(login);
|
||||||
const existing = await storage.getData<PendingQr[]>(key) ?? [];
|
const existing = await storage.getData<PendingQr[]>(key) ?? [];
|
||||||
|
const dismissed = existing.find(qr => qr.id === id);
|
||||||
const filtered = existing.filter(qr => qr.id !== id);
|
const filtered = existing.filter(qr => qr.id !== id);
|
||||||
await storage.setData(key, filtered);
|
await storage.setData(key, filtered);
|
||||||
|
return dismissed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Odstraní všechny nevyřízené QR kódy patřící dané skupině objednávky.
|
||||||
|
*/
|
||||||
|
export async function removePendingQrsByGroupId(logins: string[], groupId: string): Promise<void> {
|
||||||
|
for (const login of logins) {
|
||||||
|
const key = getPendingQrKey(login);
|
||||||
|
const existing = await storage.getData<PendingQr[]>(key) ?? [];
|
||||||
|
const filtered = existing.filter(qr => qr.groupId !== groupId);
|
||||||
|
if (filtered.length !== existing.length) {
|
||||||
|
await storage.setData(key, filtered);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+23
-20
@@ -1,4 +1,5 @@
|
|||||||
import webpush from 'web-push';
|
import webpush from 'web-push';
|
||||||
|
import crypto from 'crypto';
|
||||||
import getStorage from './storage';
|
import getStorage from './storage';
|
||||||
import { getClientData, getToday } from './service';
|
import { getClientData, getToday } from './service';
|
||||||
import { getIsWeekend } from './utils';
|
import { getIsWeekend } from './utils';
|
||||||
@@ -14,13 +15,10 @@ interface RegistryEntry {
|
|||||||
|
|
||||||
type Registry = Record<string, RegistryEntry>;
|
type Registry = Record<string, RegistryEntry>;
|
||||||
|
|
||||||
/** Mapa login → datum (YYYY-MM-DD), kdy byl uživatel naposledy upozorněn. */
|
/** Mapa login → timestamp (ms) posledního odeslání připomínky. */
|
||||||
const remindedToday = new Map<string, string>();
|
const lastReminded = new Map<string, number>();
|
||||||
|
|
||||||
function getTodayDateString(): string {
|
const REMINDER_COOLDOWN_MS = 60 * 60 * 1000; // 60 minut mezi připomínkami
|
||||||
const now = new Date();
|
|
||||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCurrentTimeHHMM(): string {
|
function getCurrentTimeHHMM(): string {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -59,7 +57,7 @@ export async function unsubscribePush(login: string): Promise<void> {
|
|||||||
const registry = await getRegistry();
|
const registry = await getRegistry();
|
||||||
delete registry[login];
|
delete registry[login];
|
||||||
await saveRegistry(registry);
|
await saveRegistry(registry);
|
||||||
remindedToday.delete(login);
|
lastReminded.delete(login);
|
||||||
console.log(`Push reminder: uživatel ${login} odhlášen z připomínek`);
|
console.log(`Push reminder: uživatel ${login} odhlášen z připomínek`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,17 +66,20 @@ export function getVapidPublicKey(): string | undefined {
|
|||||||
return process.env.VAPID_PUBLIC_KEY;
|
return process.env.VAPID_PUBLIC_KEY;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Najde login uživatele podle push subscription endpointu. */
|
function generateQuickChoiceToken(login: string): string {
|
||||||
export async function findLoginByEndpoint(endpoint: string): Promise<string | undefined> {
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
const registry = await getRegistry();
|
const secret = process.env.JWT_SECRET ?? '';
|
||||||
for (const [login, entry] of Object.entries(registry)) {
|
return crypto.createHmac('sha256', secret).update(`${login}:${today}`).digest('hex');
|
||||||
if (entry.subscription.endpoint === endpoint) {
|
|
||||||
return login;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ověří jednorázový token z push notifikace. */
|
||||||
|
export function verifyQuickChoiceToken(login: string, token: string): boolean {
|
||||||
|
if (!login || !token || token.length !== 64) return false;
|
||||||
|
const expected = generateQuickChoiceToken(login);
|
||||||
|
return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(token, 'hex'));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/** Zkontroluje a odešle připomínky uživatelům, kteří si nezvolili oběd. */
|
/** Zkontroluje a odešle připomínky uživatelům, kteří si nezvolili oběd. */
|
||||||
async function checkAndSendReminders(): Promise<void> {
|
async function checkAndSendReminders(): Promise<void> {
|
||||||
// Přeskočit víkendy
|
// Přeskočit víkendy
|
||||||
@@ -93,7 +94,6 @@ async function checkAndSendReminders(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentTime = getCurrentTimeHHMM();
|
const currentTime = getCurrentTimeHHMM();
|
||||||
const todayStr = getTodayDateString();
|
|
||||||
|
|
||||||
// Získáme data pro dnešek jednou pro všechny uživatele
|
// Získáme data pro dnešek jednou pro všechny uživatele
|
||||||
let clientData;
|
let clientData;
|
||||||
@@ -110,8 +110,9 @@ async function checkAndSendReminders(): Promise<void> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Už jsme dnes připomenuli
|
// Cooldown — nepřipomínat častěji než jednou za hodinu
|
||||||
if (remindedToday.get(login) === todayStr) {
|
const last = lastReminded.get(login) ?? 0;
|
||||||
|
if (Date.now() - last < REMINDER_COOLDOWN_MS) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,9 +128,11 @@ async function checkAndSendReminders(): Promise<void> {
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
title: 'Luncher',
|
title: 'Luncher',
|
||||||
body: 'Ještě nemáte zvolený oběd!',
|
body: 'Ještě nemáte zvolený oběd!',
|
||||||
|
login,
|
||||||
|
token: generateQuickChoiceToken(login),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
remindedToday.set(login, todayStr);
|
lastReminded.set(login, Date.now());
|
||||||
console.log(`Push reminder: odeslána připomínka uživateli ${login}`);
|
console.log(`Push reminder: odeslána připomínka uživateli ${login}`);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.statusCode === 410 || error.statusCode === 404) {
|
if (error.statusCode === 410 || error.statusCode === 404) {
|
||||||
|
|||||||
+22
-10
@@ -31,10 +31,10 @@ export function convertBbanToIban(bankAccountNumber: string): string {
|
|||||||
// Zatím napevno, nemá smysl řešit nic jiného než CZ
|
// Zatím napevno, nemá smysl řešit nic jiného než CZ
|
||||||
iban = iban.replace('C', '12').replace('Z', '35');
|
iban = iban.replace('C', '12').replace('Z', '35');
|
||||||
const remainder = BigInt(iban) % BigInt(97);
|
const remainder = BigInt(iban) % BigInt(97);
|
||||||
const checkDigits = BigInt(98) - remainder;
|
const checkDigits = (BigInt(98) - remainder).toString().padStart(2, '0');
|
||||||
iban = `${COUNTRY_CODE}${checkDigits.toString()}${bankCode}${prefix}${accountNumber}`;
|
iban = `${COUNTRY_CODE}${checkDigits}${bankCode}${prefix}${accountNumber}`;
|
||||||
if (iban.length !== 24) {
|
if (iban.length !== 24) {
|
||||||
throw Error("Neplatná délka sestaveného IBAN: " + iban.length + ", očekáváno 24");
|
throw new Error("Neplatná délka sestaveného IBAN: " + iban.length + ", očekáváno 24");
|
||||||
}
|
}
|
||||||
return iban;
|
return iban;
|
||||||
}
|
}
|
||||||
@@ -44,6 +44,24 @@ function createStorageKey(customerName: string, id: string): string {
|
|||||||
return `qr_${nameHash}_${id}`;
|
return `qr_${nameHash}_${id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Očistí zprávu (účel platby) pro QR platbu:
|
||||||
|
* - transliteruje diakritiku na základní písmena (š→s, č→c, ř→r, ...)
|
||||||
|
* - odstraní zbylé znaky mimo ISO 8859-1
|
||||||
|
* - odstraní '*', který v QR platbě slouží jako oddělovač polí
|
||||||
|
* - ořízne na max. 60 znaků
|
||||||
|
*
|
||||||
|
* @param message původní zpráva
|
||||||
|
* @returns očištěná zpráva vhodná pro QR platbu
|
||||||
|
*/
|
||||||
|
export function sanitizeQrMessage(message: string): string {
|
||||||
|
const sanitized = message
|
||||||
|
.normalize('NFD').replace(/[\u0300-\u036f]/g, '') // diakritika → základní písmeno
|
||||||
|
.replace(/[^\x00-\xff]/g, '') // znaky mimo ISO 8859-1
|
||||||
|
.replace(/\*/g, ''); // '*' je v QR platbě oddělovač
|
||||||
|
return sanitized.length > 60 ? sanitized.substring(0, 60) : sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vygeneruje a uloží obrázek platebního QR kódu do storage (Redis/JSON).
|
* Vygeneruje a uloží obrázek platebního QR kódu do storage (Redis/JSON).
|
||||||
* Data přežijí redeploy — není třeba persistentní filesystém.
|
* Data přežijí redeploy — není třeba persistentní filesystém.
|
||||||
@@ -56,13 +74,7 @@ function createStorageKey(customerName: string, id: string): string {
|
|||||||
* @param id unikátní identifikátor (UUID) tohoto QR kódu
|
* @param id unikátní identifikátor (UUID) tohoto QR kódu
|
||||||
*/
|
*/
|
||||||
export async function generateQr(customerName: string, bankAccountNumber: string, bankAccountHolder: string, amount: number, message: string, id: string): Promise<void> {
|
export async function generateQr(customerName: string, bankAccountNumber: string, bankAccountHolder: string, amount: number, message: string, id: string): Promise<void> {
|
||||||
// Zpráva pro příjemce nesmí dle standardu obsahovat '*' a být delší než 60 znaků
|
message = sanitizeQrMessage(message);
|
||||||
if (message.indexOf('*') >= 0) {
|
|
||||||
message = message.replace(/\*/g, '');
|
|
||||||
}
|
|
||||||
if (message.length > 60) {
|
|
||||||
message = message.substring(0, 60);
|
|
||||||
}
|
|
||||||
const payload = {
|
const payload = {
|
||||||
iban: convertBbanToIban(bankAccountNumber),
|
iban: convertBbanToIban(bankAccountNumber),
|
||||||
amount,
|
amount,
|
||||||
|
|||||||
+97
-42
@@ -314,53 +314,108 @@ export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = fal
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result: Food[][] = [];
|
const result: Food[][] = [];
|
||||||
const siblings = thirdTry
|
|
||||||
|
// Čtvrtý pokus (detekce): thirdTry našel <font>, ale nový formát má každý den v jednom <p>
|
||||||
|
// s položkami oddělenými <br> místo separátních <p> pro každou položku
|
||||||
|
const fourthTry = thirdTry && $(font).parent().siblings('p').toArray().some(el => {
|
||||||
|
const firstChild = $(el).contents().first();
|
||||||
|
return firstChild.is('strong') && DAYS_IN_WEEK.includes(firstChild.text().trim().toLocaleLowerCase());
|
||||||
|
});
|
||||||
|
|
||||||
|
const siblings = (fourthTry || thirdTry)
|
||||||
? $(font).parent().siblings('p')
|
? $(font).parent().siblings('p')
|
||||||
: secondTry
|
: secondTry
|
||||||
? $(font).parent().parent().parent().siblings('p')
|
? $(font).parent().parent().parent().siblings('p')
|
||||||
: $(font).parent().parent().siblings();
|
: $(font).parent().parent().siblings();
|
||||||
let parsing = false;
|
|
||||||
let currentDayIndex = 0;
|
|
||||||
for (let i = 0; i < siblings.length; i++) {
|
|
||||||
const text = $(siblings.get(i)).text().trim().replace('\t', '').replace('\n', ' ');
|
|
||||||
if (DAYS_IN_WEEK.includes(text.toLocaleLowerCase())) {
|
|
||||||
// Zjistíme aktuální index
|
|
||||||
currentDayIndex = DAYS_IN_WEEK.indexOf(text.toLocaleLowerCase());
|
|
||||||
if (!parsing) {
|
|
||||||
// Našli jsme libovolný den v týdnu a ještě neparsujeme, tak začneme
|
|
||||||
parsing = true;
|
|
||||||
}
|
|
||||||
} else if (parsing) {
|
|
||||||
if (text.length == 0) {
|
|
||||||
// Prázdná řádka - bývá na zcela náhodných místech ¯\_(ツ)_/¯
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let price = 'na\xA0váhu';
|
|
||||||
let nameRaw = 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('•', '');
|
|
||||||
}
|
|
||||||
if (nameRaw.endsWith('–')|| nameRaw.endsWith('—')) {
|
|
||||||
nameRaw = nameRaw.slice(0, -1).trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = parseAllergens(nameRaw);
|
if (fourthTry) {
|
||||||
result[currentDayIndex] ??= [];
|
siblings.each((_, el) => {
|
||||||
result[currentDayIndex].push({
|
const $el = $(el);
|
||||||
amount: '-',
|
const firstChild = $el.contents().first();
|
||||||
name: parsed.cleanName,
|
if (!firstChild.is('strong')) return;
|
||||||
price,
|
const dayName = firstChild.text().trim().toLocaleLowerCase();
|
||||||
isSoup: isTextSoupName(parsed.cleanName),
|
if (!DAYS_IN_WEEK.includes(dayName)) return;
|
||||||
allergens: parsed.allergens.length > 0 ? parsed.allergens : undefined,
|
|
||||||
})
|
const dayIndex = DAYS_IN_WEEK.indexOf(dayName);
|
||||||
|
result[dayIndex] ??= [];
|
||||||
|
|
||||||
|
const elHtml = $el.html() ?? '';
|
||||||
|
const itemLines = elHtml.split(/<br\s*\/?>/i).slice(1)
|
||||||
|
.map(part => $(`<span>${part}</span>`).text().trim())
|
||||||
|
.filter(line => line.length > 0);
|
||||||
|
|
||||||
|
for (const text of itemLines) {
|
||||||
|
let price = 'na\xA0váhu';
|
||||||
|
let nameRaw = 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('•', '');
|
||||||
|
}
|
||||||
|
if (nameRaw.endsWith('–') || nameRaw.endsWith('—')) {
|
||||||
|
nameRaw = nameRaw.slice(0, -1).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseAllergens(nameRaw);
|
||||||
|
result[dayIndex].push({
|
||||||
|
amount: '-',
|
||||||
|
name: parsed.cleanName,
|
||||||
|
price,
|
||||||
|
isSoup: isTextSoupName(parsed.cleanName),
|
||||||
|
allergens: parsed.allergens.length > 0 ? parsed.allergens : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
let parsing = false;
|
||||||
|
let currentDayIndex = 0;
|
||||||
|
for (let i = 0; i < siblings.length; i++) {
|
||||||
|
const text = $(siblings.get(i)).text().trim().replace('\t', '').replace('\n', ' ');
|
||||||
|
if (DAYS_IN_WEEK.includes(text.toLocaleLowerCase())) {
|
||||||
|
// Zjistíme aktuální index
|
||||||
|
currentDayIndex = DAYS_IN_WEEK.indexOf(text.toLocaleLowerCase());
|
||||||
|
if (!parsing) {
|
||||||
|
// Našli jsme libovolný den v týdnu a ještě neparsujeme, tak začneme
|
||||||
|
parsing = true;
|
||||||
|
}
|
||||||
|
} else if (parsing) {
|
||||||
|
if (text.length == 0) {
|
||||||
|
// Prázdná řádka - bývá na zcela náhodných místech ¯\_(ツ)_/¯
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let price = 'na\xA0váhu';
|
||||||
|
let nameRaw = 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('•', '');
|
||||||
|
}
|
||||||
|
if (nameRaw.endsWith('–')|| nameRaw.endsWith('—')) {
|
||||||
|
nameRaw = nameRaw.slice(0, -1).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseAllergens(nameRaw);
|
||||||
|
result[currentDayIndex] ??= [];
|
||||||
|
result[currentDayIndex].push({
|
||||||
|
amount: '-',
|
||||||
|
name: parsed.cleanName,
|
||||||
|
price,
|
||||||
|
isSoup: isTextSoupName(parsed.cleanName),
|
||||||
|
allergens: parsed.allergens.length > 0 ? parsed.allergens : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ router.post("/testPush", async (req, res, next) => {
|
|||||||
webpush.setVapidDetails(subject, publicKey, privateKey);
|
webpush.setVapidDetails(subject, publicKey, privateKey);
|
||||||
await webpush.sendNotification(
|
await webpush.sendNotification(
|
||||||
entry.subscription,
|
entry.subscription,
|
||||||
JSON.stringify({ title: 'Luncher test', body: 'Push notifikace fungují!' })
|
JSON.stringify({ title: 'Luncher test', body: 'Push notifikace fungují!', login })
|
||||||
);
|
);
|
||||||
res.status(200).json({ ok: true });
|
res.status(200).json({ ok: true });
|
||||||
} catch (e: any) { next(e) }
|
} catch (e: any) { next(e) }
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { addChoice, getDateForWeekIndex, getToday, removeChoice, removeChoices,
|
|||||||
import { getDayOfWeekIndex, parseToken, getFirstWorkDayOfWeek } from "../utils";
|
import { getDayOfWeekIndex, parseToken, getFirstWorkDayOfWeek } from "../utils";
|
||||||
import { getWebsocket } from "../websocket";
|
import { getWebsocket } from "../websocket";
|
||||||
import { callNotifikace } from "../notifikace";
|
import { callNotifikace } from "../notifikace";
|
||||||
import { AddChoiceData, ChangeDepartureTimeData, RemoveChoiceData, RemoveChoicesData, UdalostEnum, UpdateNoteData } from "../../../types/gen/types.gen";
|
import { AddChoiceData, ChangeDepartureTimeData, MealSlot, RemoveChoiceData, RemoveChoicesData, UdalostEnum, UpdateNoteData } from "../../../types/gen/types.gen";
|
||||||
|
|
||||||
|
|
||||||
// RateLimit na refresh endpoint
|
// RateLimit na refresh endpoint
|
||||||
@@ -56,24 +56,34 @@ function checkRateLimit(key: string, limit: number = RATE_LIMIT): boolean {
|
|||||||
*/
|
*/
|
||||||
const parseValidateFutureDayIndex = (req: Request<{}, any, AddChoiceData["body"] | UpdateNoteData["body"]>) => {
|
const parseValidateFutureDayIndex = (req: Request<{}, any, AddChoiceData["body"] | UpdateNoteData["body"]>) => {
|
||||||
if (req.body.dayIndex == null) {
|
if (req.body.dayIndex == null) {
|
||||||
throw Error(`Nebyl předán index dne v týdnu.`);
|
throw new Error(`Nebyl předán index dne v týdnu.`);
|
||||||
}
|
}
|
||||||
const todayDayIndex = getDayOfWeekIndex(getToday());
|
const todayDayIndex = getDayOfWeekIndex(getToday());
|
||||||
const dayIndex = req.body.dayIndex;
|
const dayIndex = req.body.dayIndex;
|
||||||
if (isNaN(dayIndex)) {
|
if (isNaN(dayIndex)) {
|
||||||
throw Error(`Neplatný index dne v týdnu: ${req.body.dayIndex}`);
|
throw new Error(`Neplatný index dne v týdnu: ${req.body.dayIndex}`);
|
||||||
}
|
}
|
||||||
if (dayIndex < todayDayIndex) {
|
if (dayIndex < todayDayIndex) {
|
||||||
throw Error(`Předaný index dne v týdnu (${dayIndex}) nesmí být nižší než dnešní den (${todayDayIndex})`);
|
throw new Error(`Předaný index dne v týdnu (${dayIndex}) nesmí být nižší než dnešní den (${todayDayIndex})`);
|
||||||
}
|
}
|
||||||
return dayIndex;
|
return dayIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parseSlot = (body: Record<string, any>): MealSlot | undefined => {
|
||||||
|
const slot = body?.slot;
|
||||||
|
if (slot != null && slot !== MealSlot.OBED) {
|
||||||
|
throw new Error(`Neplatný slot: ${slot}`);
|
||||||
|
}
|
||||||
|
return slot ?? undefined;
|
||||||
|
};
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.post("/addChoice", async (req: Request<{}, any, AddChoiceData["body"]>, res, next) => {
|
router.post("/addChoice", async (req: Request<{}, any, AddChoiceData["body"]>, res, next) => {
|
||||||
const login = getLogin(parseToken(req));
|
const login = getLogin(parseToken(req));
|
||||||
const trusted = getTrusted(parseToken(req));
|
const trusted = getTrusted(parseToken(req));
|
||||||
|
let slot: MealSlot | undefined;
|
||||||
|
try { slot = parseSlot(req.body); } catch (e: any) { return res.status(400).json({ error: e.message }); }
|
||||||
let date = undefined;
|
let date = undefined;
|
||||||
if (req.body.dayIndex != null) {
|
if (req.body.dayIndex != null) {
|
||||||
let dayIndex;
|
let dayIndex;
|
||||||
@@ -85,7 +95,7 @@ router.post("/addChoice", async (req: Request<{}, any, AddChoiceData["body"]>, r
|
|||||||
date = getDateForWeekIndex(dayIndex);
|
date = getDateForWeekIndex(dayIndex);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const data = await addChoice(login, trusted, req.body.locationKey, req.body.foodIndex, date);
|
const data = await addChoice(login, trusted, req.body.locationKey, req.body.foodIndex, date, slot);
|
||||||
getWebsocket().emit("message", data);
|
getWebsocket().emit("message", data);
|
||||||
return res.status(200).json(data);
|
return res.status(200).json(data);
|
||||||
} catch (e: any) { next(e) }
|
} catch (e: any) { next(e) }
|
||||||
@@ -94,6 +104,8 @@ router.post("/addChoice", async (req: Request<{}, any, AddChoiceData["body"]>, r
|
|||||||
router.post("/removeChoices", async (req: Request<{}, any, RemoveChoicesData["body"]>, res, next) => {
|
router.post("/removeChoices", async (req: Request<{}, any, RemoveChoicesData["body"]>, res, next) => {
|
||||||
const login = getLogin(parseToken(req));
|
const login = getLogin(parseToken(req));
|
||||||
const trusted = getTrusted(parseToken(req));
|
const trusted = getTrusted(parseToken(req));
|
||||||
|
let slot: MealSlot | undefined;
|
||||||
|
try { slot = parseSlot(req.body); } catch (e: any) { return res.status(400).json({ error: e.message }); }
|
||||||
let date = undefined;
|
let date = undefined;
|
||||||
if (req.body.dayIndex != null) {
|
if (req.body.dayIndex != null) {
|
||||||
let dayIndex;
|
let dayIndex;
|
||||||
@@ -105,7 +117,7 @@ router.post("/removeChoices", async (req: Request<{}, any, RemoveChoicesData["bo
|
|||||||
date = getDateForWeekIndex(dayIndex);
|
date = getDateForWeekIndex(dayIndex);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const data = await removeChoices(login, trusted, req.body.locationKey, date);
|
const data = await removeChoices(login, trusted, req.body.locationKey, date, slot);
|
||||||
getWebsocket().emit("message", data);
|
getWebsocket().emit("message", data);
|
||||||
res.status(200).json(data);
|
res.status(200).json(data);
|
||||||
} catch (e: any) { next(e) }
|
} catch (e: any) { next(e) }
|
||||||
@@ -114,6 +126,8 @@ router.post("/removeChoices", async (req: Request<{}, any, RemoveChoicesData["bo
|
|||||||
router.post("/removeChoice", async (req: Request<{}, any, RemoveChoiceData["body"]>, res, next) => {
|
router.post("/removeChoice", async (req: Request<{}, any, RemoveChoiceData["body"]>, res, next) => {
|
||||||
const login = getLogin(parseToken(req));
|
const login = getLogin(parseToken(req));
|
||||||
const trusted = getTrusted(parseToken(req));
|
const trusted = getTrusted(parseToken(req));
|
||||||
|
let slot: MealSlot | undefined;
|
||||||
|
try { slot = parseSlot(req.body); } catch (e: any) { return res.status(400).json({ error: e.message }); }
|
||||||
let date = undefined;
|
let date = undefined;
|
||||||
if (req.body.dayIndex != null) {
|
if (req.body.dayIndex != null) {
|
||||||
let dayIndex;
|
let dayIndex;
|
||||||
@@ -125,7 +139,7 @@ router.post("/removeChoice", async (req: Request<{}, any, RemoveChoiceData["body
|
|||||||
date = getDateForWeekIndex(dayIndex);
|
date = getDateForWeekIndex(dayIndex);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const data = await removeChoice(login, trusted, req.body.locationKey, req.body.foodIndex, date);
|
const data = await removeChoice(login, trusted, req.body.locationKey, req.body.foodIndex, date, slot);
|
||||||
getWebsocket().emit("message", data);
|
getWebsocket().emit("message", data);
|
||||||
res.status(200).json(data);
|
res.status(200).json(data);
|
||||||
} catch (e: any) { next(e) }
|
} catch (e: any) { next(e) }
|
||||||
@@ -135,9 +149,11 @@ router.post("/updateNote", async (req: Request<{}, any, UpdateNoteData["body"]>,
|
|||||||
const login = getLogin(parseToken(req));
|
const login = getLogin(parseToken(req));
|
||||||
const trusted = getTrusted(parseToken(req));
|
const trusted = getTrusted(parseToken(req));
|
||||||
const note = req.body.note;
|
const note = req.body.note;
|
||||||
|
let slot: MealSlot | undefined;
|
||||||
|
try { slot = parseSlot(req.body); } catch (e: any) { return res.status(400).json({ error: e.message }); }
|
||||||
try {
|
try {
|
||||||
if (note && note.length > 70) {
|
if (note && note.length > 70) {
|
||||||
throw Error("Poznámka může mít maximálně 70 znaků");
|
throw new Error("Poznámka může mít maximálně 70 znaků");
|
||||||
}
|
}
|
||||||
let date = undefined;
|
let date = undefined;
|
||||||
if (req.body.dayIndex != null) {
|
if (req.body.dayIndex != null) {
|
||||||
@@ -149,7 +165,7 @@ router.post("/updateNote", async (req: Request<{}, any, UpdateNoteData["body"]>,
|
|||||||
}
|
}
|
||||||
date = getDateForWeekIndex(dayIndex);
|
date = getDateForWeekIndex(dayIndex);
|
||||||
}
|
}
|
||||||
const data = await updateNote(login, trusted, note, date);
|
const data = await updateNote(login, trusted, note, date, slot);
|
||||||
getWebsocket().emit("message", data);
|
getWebsocket().emit("message", data);
|
||||||
res.status(200).json(data);
|
res.status(200).json(data);
|
||||||
} catch (e: any) { next(e) }
|
} catch (e: any) { next(e) }
|
||||||
@@ -184,8 +200,10 @@ router.post("/jdemeObed", async (req, res, next) => {
|
|||||||
|
|
||||||
router.post("/updateBuyer", async (req, res, next) => {
|
router.post("/updateBuyer", async (req, res, next) => {
|
||||||
const login = getLogin(parseToken(req));
|
const login = getLogin(parseToken(req));
|
||||||
|
let slot: MealSlot | undefined;
|
||||||
|
try { slot = parseSlot(req.body ?? {}); } catch (e: any) { return res.status(400).json({ error: e.message }); }
|
||||||
try {
|
try {
|
||||||
const data = await updateBuyer(login);
|
const data = await updateBuyer(login, slot);
|
||||||
getWebsocket().emit("message", data);
|
getWebsocket().emit("message", data);
|
||||||
res.status(200).json({});
|
res.status(200).json({});
|
||||||
} catch (e: any) { next(e) }
|
} catch (e: any) { next(e) }
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import express, { Request } from "express";
|
||||||
|
import { getLogin } from "../auth";
|
||||||
|
import { parseToken } from "../utils";
|
||||||
|
import { getWebsocket } from "../websocket";
|
||||||
|
import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, updateGroupFees, getOrderDates } from "../groups";
|
||||||
|
import { GroupState } from "../../../types/gen/types.gen";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
function broadcastExtra(data: any) {
|
||||||
|
getWebsocket().emit("message", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get("/dates", async (_req, res, next) => {
|
||||||
|
try {
|
||||||
|
const dates = await getOrderDates();
|
||||||
|
res.status(200).json({ dates });
|
||||||
|
} catch (e: any) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/create", async (req: Request, res, next) => {
|
||||||
|
const login = getLogin(parseToken(req));
|
||||||
|
const { name } = req.body ?? {};
|
||||||
|
if (!name || typeof name !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'Nebyl předán název skupiny' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await createGroup(login, name);
|
||||||
|
broadcastExtra(data);
|
||||||
|
res.status(200).json(data);
|
||||||
|
} catch (e: any) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/delete", async (req: Request, res, next) => {
|
||||||
|
const login = getLogin(parseToken(req));
|
||||||
|
const { id } = req.body ?? {};
|
||||||
|
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
|
||||||
|
try {
|
||||||
|
const data = await deleteGroup(login, id);
|
||||||
|
broadcastExtra(data);
|
||||||
|
res.status(200).json(data);
|
||||||
|
} catch (e: any) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/addMember", async (req: Request, res, next) => {
|
||||||
|
const login = getLogin(parseToken(req));
|
||||||
|
const { id, login: targetLogin } = req.body ?? {};
|
||||||
|
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
|
||||||
|
if (targetLogin !== undefined && (typeof targetLogin !== 'string' || targetLogin.trim() === '')) {
|
||||||
|
return res.status(400).json({ error: 'Neplatný login uživatele' });
|
||||||
|
}
|
||||||
|
const target = targetLogin ?? login;
|
||||||
|
try {
|
||||||
|
const data = await addGroupMember(login, id, target);
|
||||||
|
broadcastExtra(data);
|
||||||
|
res.status(200).json(data);
|
||||||
|
} catch (e: any) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/removeMember", async (req: Request, res, next) => {
|
||||||
|
const login = getLogin(parseToken(req));
|
||||||
|
const { id, login: targetLogin } = req.body ?? {};
|
||||||
|
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
|
||||||
|
if (!targetLogin) return res.status(400).json({ error: 'Nebyl předán login uživatele' });
|
||||||
|
try {
|
||||||
|
const data = await removeGroupMember(login, id, targetLogin);
|
||||||
|
broadcastExtra(data);
|
||||||
|
res.status(200).json(data);
|
||||||
|
} catch (e: any) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/updateMember", async (req: Request, res, next) => {
|
||||||
|
const login = getLogin(parseToken(req));
|
||||||
|
const { id, login: targetLogin, amount, note, surchargeText, surchargeAmount } = req.body ?? {};
|
||||||
|
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
|
||||||
|
if (!targetLogin) return res.status(400).json({ error: 'Nebyl předán login uživatele' });
|
||||||
|
const patch: Record<string, any> = {};
|
||||||
|
if (amount !== undefined) {
|
||||||
|
if (!Number.isInteger(amount) || amount < 0) {
|
||||||
|
return res.status(400).json({ error: 'Neplatná částka' });
|
||||||
|
}
|
||||||
|
patch.amount = amount;
|
||||||
|
}
|
||||||
|
if (note !== undefined) {
|
||||||
|
if (typeof note !== 'string') return res.status(400).json({ error: 'Neplatná poznámka' });
|
||||||
|
patch.note = note;
|
||||||
|
}
|
||||||
|
if (surchargeText !== undefined) {
|
||||||
|
if (typeof surchargeText !== 'string') return res.status(400).json({ error: 'Neplatný text příplatku' });
|
||||||
|
patch.surchargeText = surchargeText;
|
||||||
|
}
|
||||||
|
if (surchargeAmount !== undefined) {
|
||||||
|
if (!Number.isInteger(surchargeAmount) || surchargeAmount < 0) {
|
||||||
|
return res.status(400).json({ error: 'Neplatná výše příplatku' });
|
||||||
|
}
|
||||||
|
patch.surchargeAmount = surchargeAmount;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await updateGroupMember(login, id, targetLogin, patch);
|
||||||
|
broadcastExtra(data);
|
||||||
|
res.status(200).json(data);
|
||||||
|
} catch (e: any) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/setState", async (req: Request, res, next) => {
|
||||||
|
const login = getLogin(parseToken(req));
|
||||||
|
const { id, state } = req.body ?? {};
|
||||||
|
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
|
||||||
|
if (!state || !Object.values(GroupState).includes(state)) {
|
||||||
|
return res.status(400).json({ error: 'Neplatný stav skupiny' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await setGroupState(login, id, state as GroupState);
|
||||||
|
broadcastExtra(data);
|
||||||
|
res.status(200).json(data);
|
||||||
|
} catch (e: any) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/updateFees", async (req: Request, res, next) => {
|
||||||
|
const login = getLogin(parseToken(req));
|
||||||
|
const { id, fees, shipping, tip, discountType, discountValue } = req.body ?? {};
|
||||||
|
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
|
||||||
|
if (fees !== undefined && (!Number.isInteger(fees) || fees < 0)) {
|
||||||
|
return res.status(400).json({ error: 'Neplatná výše poplatků' });
|
||||||
|
}
|
||||||
|
if (shipping !== undefined && (!Number.isInteger(shipping) || shipping < 0)) {
|
||||||
|
return res.status(400).json({ error: 'Neplatná výše dopravy' });
|
||||||
|
}
|
||||||
|
if (tip !== undefined && (!Number.isInteger(tip) || tip < 0)) {
|
||||||
|
return res.status(400).json({ error: 'Neplatná výše spropitného' });
|
||||||
|
}
|
||||||
|
if (discountType !== undefined && discountType !== '' && !['percent', 'fixed'].includes(discountType)) {
|
||||||
|
return res.status(400).json({ error: 'Neplatný typ slevy' });
|
||||||
|
}
|
||||||
|
if (discountValue !== undefined && (!Number.isInteger(discountValue) || discountValue < 0)) {
|
||||||
|
return res.status(400).json({ error: 'Neplatná výše slevy' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await updateGroupFees(login, id, fees, shipping, tip, discountType, discountValue);
|
||||||
|
broadcastExtra(data);
|
||||||
|
res.status(200).json(data);
|
||||||
|
} catch (e: any) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/updateTimes", async (req: Request, res, next) => {
|
||||||
|
const login = getLogin(parseToken(req));
|
||||||
|
const { id, orderedAt, deliveryAt } = req.body ?? {};
|
||||||
|
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
|
||||||
|
const timeRegex = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||||
|
if (orderedAt !== undefined && orderedAt !== '' && !timeRegex.test(orderedAt)) {
|
||||||
|
return res.status(400).json({ error: 'Neplatný formát času objednání (očekáváno HH:MM)' });
|
||||||
|
}
|
||||||
|
if (deliveryAt !== undefined && deliveryAt !== '' && !timeRegex.test(deliveryAt)) {
|
||||||
|
return res.status(400).json({ error: 'Neplatný formát času doručení (očekáváno HH:MM)' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await updateGroupTimes(login, id, orderedAt, deliveryAt);
|
||||||
|
broadcastExtra(data);
|
||||||
|
res.status(200).json(data);
|
||||||
|
} catch (e: any) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -2,9 +2,7 @@ import express, { Request } from "express";
|
|||||||
import { getLogin } from "../auth";
|
import { getLogin } from "../auth";
|
||||||
import { parseToken } from "../utils";
|
import { parseToken } from "../utils";
|
||||||
import { getNotificationSettings, saveNotificationSettings } from "../notifikace";
|
import { getNotificationSettings, saveNotificationSettings } from "../notifikace";
|
||||||
import { subscribePush, unsubscribePush, getVapidPublicKey, findLoginByEndpoint } from "../pushReminder";
|
import { subscribePush, unsubscribePush, getVapidPublicKey } from "../pushReminder";
|
||||||
import { addChoice } from "../service";
|
|
||||||
import { getWebsocket } from "../websocket";
|
|
||||||
import { UpdateNotificationSettingsData } from "../../../types";
|
import { UpdateNotificationSettingsData } from "../../../types";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -66,21 +64,4 @@ router.post("/push/unsubscribe", async (req, res, next) => {
|
|||||||
} catch (e: any) { next(e) }
|
} 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;
|
export default router;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import express, { Request } from "express";
|
import express, { Request } from "express";
|
||||||
import { getLogin } from "../auth";
|
import { getLogin } from "../auth";
|
||||||
import { createPizzaDay, deletePizzaDay, getPizzaList, getSalatList, addPizzaOrder, addSalatOrder, removePizzaOrder, lockPizzaDay, unlockPizzaDay, finishPizzaOrder, finishPizzaDelivery, updatePizzaDayNote, updatePizzaFee, dismissPendingQr } from "../pizza";
|
import { createPizzaDay, deletePizzaDay, getPizzaList, getSalatList, addPizzaOrder, addSalatOrder, removePizzaOrder, lockPizzaDay, unlockPizzaDay, finishPizzaOrder, finishPizzaDelivery, updatePizzaDayNote, updatePizzaFee, dismissPendingQr } from "../pizza";
|
||||||
|
import { markGroupMemberPaid } from "../groups";
|
||||||
import { parseToken } from "../utils";
|
import { parseToken } from "../utils";
|
||||||
import { getWebsocket } from "../websocket";
|
import { getWebsocket } from "../websocket";
|
||||||
import { AddPizzaData, DismissQrData, FinishDeliveryData, RemovePizzaData, UpdatePizzaDayNoteData, UpdatePizzaFeeData } from "../../../types";
|
import { AddPizzaData, DismissQrData, FinishDeliveryData, RemovePizzaData, UpdatePizzaDayNoteData, UpdatePizzaFeeData } from "../../../types";
|
||||||
@@ -29,10 +30,10 @@ router.post("/add", async (req: Request<{}, any, AddPizzaData["body"]>, res) =>
|
|||||||
const salatIndex = req.body.salatIndex;
|
const salatIndex = req.body.salatIndex;
|
||||||
const salaty = await getSalatList();
|
const salaty = await getSalatList();
|
||||||
if (!salaty) {
|
if (!salaty) {
|
||||||
throw Error("Selhalo získání seznamu dostupných salátů.");
|
throw new Error("Selhalo získání seznamu dostupných salátů.");
|
||||||
}
|
}
|
||||||
if (!salaty[salatIndex]) {
|
if (!salaty[salatIndex]) {
|
||||||
throw Error("Neplatný index salátu: " + salatIndex);
|
throw new Error("Neplatný index salátu: " + salatIndex);
|
||||||
}
|
}
|
||||||
const data = await addSalatOrder(login, salaty[salatIndex]);
|
const data = await addSalatOrder(login, salaty[salatIndex]);
|
||||||
getWebsocket().emit("message", data);
|
getWebsocket().emit("message", data);
|
||||||
@@ -40,22 +41,22 @@ router.post("/add", async (req: Request<{}, any, AddPizzaData["body"]>, res) =>
|
|||||||
} else {
|
} else {
|
||||||
// Přidání pizzy
|
// Přidání pizzy
|
||||||
if (req.body?.pizzaIndex === undefined || isNaN(req.body.pizzaIndex)) {
|
if (req.body?.pizzaIndex === undefined || isNaN(req.body.pizzaIndex)) {
|
||||||
throw Error("Nebyl předán index pizzy ani salátu");
|
throw new Error("Nebyl předán index pizzy ani salátu");
|
||||||
}
|
}
|
||||||
const pizzaIndex = req.body.pizzaIndex;
|
const pizzaIndex = req.body.pizzaIndex;
|
||||||
if (req.body?.pizzaSizeIndex === undefined || isNaN(req.body.pizzaSizeIndex)) {
|
if (req.body?.pizzaSizeIndex === undefined || isNaN(req.body.pizzaSizeIndex)) {
|
||||||
throw Error("Nebyl předán index velikosti pizzy");
|
throw new Error("Nebyl předán index velikosti pizzy");
|
||||||
}
|
}
|
||||||
const pizzaSizeIndex = req.body.pizzaSizeIndex;
|
const pizzaSizeIndex = req.body.pizzaSizeIndex;
|
||||||
let pizzy = await getPizzaList();
|
let pizzy = await getPizzaList();
|
||||||
if (!pizzy) {
|
if (!pizzy) {
|
||||||
throw Error("Selhalo získání seznamu dostupných pizz.");
|
throw new Error("Selhalo získání seznamu dostupných pizz.");
|
||||||
}
|
}
|
||||||
if (!pizzy[pizzaIndex]) {
|
if (!pizzy[pizzaIndex]) {
|
||||||
throw Error("Neplatný index pizzy: " + pizzaIndex);
|
throw new Error("Neplatný index pizzy: " + pizzaIndex);
|
||||||
}
|
}
|
||||||
if (!pizzy[pizzaIndex].sizes[pizzaSizeIndex]) {
|
if (!pizzy[pizzaIndex].sizes[pizzaSizeIndex]) {
|
||||||
throw Error("Neplatný index velikosti pizzy: " + pizzaSizeIndex);
|
throw new Error("Neplatný index velikosti pizzy: " + pizzaSizeIndex);
|
||||||
}
|
}
|
||||||
const data = await addPizzaOrder(login, pizzy[pizzaIndex], pizzy[pizzaIndex].sizes[pizzaSizeIndex]);
|
const data = await addPizzaOrder(login, pizzy[pizzaIndex], pizzy[pizzaIndex].sizes[pizzaSizeIndex]);
|
||||||
getWebsocket().emit("message", data);
|
getWebsocket().emit("message", data);
|
||||||
@@ -66,7 +67,7 @@ router.post("/add", async (req: Request<{}, any, AddPizzaData["body"]>, res) =>
|
|||||||
router.post("/remove", async (req: Request<{}, any, RemovePizzaData["body"]>, res) => {
|
router.post("/remove", async (req: Request<{}, any, RemovePizzaData["body"]>, res) => {
|
||||||
const login = getLogin(parseToken(req));
|
const login = getLogin(parseToken(req));
|
||||||
if (!req.body?.pizzaOrder) {
|
if (!req.body?.pizzaOrder) {
|
||||||
throw Error("Nebyla předána objednávka");
|
throw new Error("Nebyla předána objednávka");
|
||||||
}
|
}
|
||||||
const data = await removePizzaOrder(login, req.body?.pizzaOrder);
|
const data = await removePizzaOrder(login, req.body?.pizzaOrder);
|
||||||
getWebsocket().emit("message", data);
|
getWebsocket().emit("message", data);
|
||||||
@@ -105,7 +106,7 @@ router.post("/updatePizzaDayNote", async (req: Request<{}, any, UpdatePizzaDayNo
|
|||||||
const login = getLogin(parseToken(req));
|
const login = getLogin(parseToken(req));
|
||||||
try {
|
try {
|
||||||
if (req.body.note && req.body.note.length > 70) {
|
if (req.body.note && req.body.note.length > 70) {
|
||||||
throw Error("Poznámka může mít maximálně 70 znaků");
|
throw new Error("Poznámka může mít maximálně 70 znaků");
|
||||||
}
|
}
|
||||||
const data = await updatePizzaDayNote(login, req.body.note);
|
const data = await updatePizzaDayNote(login, req.body.note);
|
||||||
getWebsocket().emit("message", data);
|
getWebsocket().emit("message", data);
|
||||||
@@ -132,7 +133,11 @@ router.post("/dismissQr", async (req: Request<{}, any, DismissQrData["body"]>, r
|
|||||||
return res.status(400).json({ error: "Nebyl předán identifikátor QR kódu" });
|
return res.status(400).json({ error: "Nebyl předán identifikátor QR kódu" });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await dismissPendingQr(login, req.body.id);
|
const dismissed = await dismissPendingQr(login, req.body.id);
|
||||||
|
if (dismissed?.groupId) {
|
||||||
|
const updatedExtra = await markGroupMemberPaid(login, dismissed.groupId);
|
||||||
|
if (updatedExtra) getWebsocket().emit("message", updatedExtra);
|
||||||
|
}
|
||||||
res.status(200).json({});
|
res.status(200).json({});
|
||||||
} catch (e: any) { next(e) }
|
} catch (e: any) { next(e) }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { getLogin } from "../auth";
|
|||||||
import { parseToken, formatDate } from "../utils";
|
import { parseToken, formatDate } from "../utils";
|
||||||
import { generateQr } from "../qr";
|
import { generateQr } from "../qr";
|
||||||
import { addPendingQr } from "../pizza";
|
import { addPendingQr } from "../pizza";
|
||||||
|
import { markGroupQrGenerated } from "../groups";
|
||||||
|
import { emitToUser } from "../websocket";
|
||||||
import { GenerateQrData } from "../../../types";
|
import { GenerateQrData } from "../../../types";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
|
|
||||||
@@ -14,7 +16,7 @@ const router = express.Router();
|
|||||||
router.post("/generate", async (req: Request<{}, any, GenerateQrData["body"]>, res, next) => {
|
router.post("/generate", async (req: Request<{}, any, GenerateQrData["body"]>, res, next) => {
|
||||||
const login = getLogin(parseToken(req));
|
const login = getLogin(parseToken(req));
|
||||||
try {
|
try {
|
||||||
const { recipients, bankAccount, bankAccountHolder } = req.body;
|
const { recipients, bankAccount, bankAccountHolder, groupId } = req.body;
|
||||||
|
|
||||||
if (!recipients || !Array.isArray(recipients) || recipients.length === 0) {
|
if (!recipients || !Array.isArray(recipients) || recipients.length === 0) {
|
||||||
return res.status(400).json({ error: "Nebyl předán seznam příjemců" });
|
return res.status(400).json({ error: "Nebyl předán seznam příjemců" });
|
||||||
@@ -35,27 +37,29 @@ router.post("/generate", async (req: Request<{}, any, GenerateQrData["body"]>, r
|
|||||||
if (!recipient.purpose || recipient.purpose.trim().length === 0) {
|
if (!recipient.purpose || recipient.purpose.trim().length === 0) {
|
||||||
return res.status(400).json({ error: `Příjemce ${recipient.login} nemá vyplněný účel platby` });
|
return res.status(400).json({ error: `Příjemce ${recipient.login} nemá vyplněný účel platby` });
|
||||||
}
|
}
|
||||||
if (typeof recipient.amount !== 'number' || recipient.amount <= 0) {
|
if (!Number.isInteger(recipient.amount) || recipient.amount <= 0) {
|
||||||
return res.status(400).json({ error: `Příjemce ${recipient.login} má neplatnou částku` });
|
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
|
// Vygenerovat QR kód
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
await generateQr(recipient.login, bankAccount, bankAccountHolder, recipient.amount, recipient.purpose, id);
|
await generateQr(recipient.login, bankAccount, bankAccountHolder, recipient.amount / 100, recipient.purpose, id);
|
||||||
|
|
||||||
// Uložit jako nevyřízený QR kód
|
// Uložit jako nevyřízený QR kód a okamžitě doručit příjemci
|
||||||
await addPendingQr(recipient.login, {
|
const pendingQr = {
|
||||||
id,
|
id,
|
||||||
date: today,
|
date: today,
|
||||||
creator: login,
|
creator: login,
|
||||||
totalPrice: recipient.amount,
|
totalPrice: recipient.amount,
|
||||||
purpose: recipient.purpose,
|
purpose: recipient.purpose,
|
||||||
});
|
...(groupId ? { groupId } : {}),
|
||||||
|
};
|
||||||
|
await addPendingQr(recipient.login, pendingQr);
|
||||||
|
emitToUser(recipient.login, 'pendingQr', pendingQr);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groupId) {
|
||||||
|
await markGroupQrGenerated(login, groupId);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(200).json({ success: true, count: recipients.length });
|
res.status(200).json({ success: true, count: recipients.length });
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import express from "express";
|
||||||
|
import { getStores, addStore, removeStore } from "../stores";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get("/", async (_req, res, next) => {
|
||||||
|
try {
|
||||||
|
const stores = await getStores();
|
||||||
|
res.status(200).json(stores);
|
||||||
|
} catch (e: any) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/add", async (req, res, next) => {
|
||||||
|
const { name, heslo } = req.body ?? {};
|
||||||
|
if (!name || typeof name !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'Nebyl předán název obchodu' });
|
||||||
|
}
|
||||||
|
if (!heslo || typeof heslo !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'Nebylo předáno heslo' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const stores = await addStore(name, heslo);
|
||||||
|
res.status(200).json(stores);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.message === 'UNAUTHORIZED') {
|
||||||
|
return res.status(403).json({ error: 'Nesprávné heslo' });
|
||||||
|
}
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/delete", async (req, res, next) => {
|
||||||
|
const { name, heslo } = req.body ?? {};
|
||||||
|
if (!name || typeof name !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'Nebyl předán název obchodu' });
|
||||||
|
}
|
||||||
|
if (!heslo || typeof heslo !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'Nebylo předáno heslo' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const stores = await removeStore(name, heslo);
|
||||||
|
res.status(200).json(stores);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.message === 'UNAUTHORIZED') {
|
||||||
|
return res.status(403).json({ error: 'Nesprávné heslo' });
|
||||||
|
}
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import express, { Request } from "express";
|
||||||
|
import { getLogin } from "../auth";
|
||||||
|
import { parseToken } from "../utils";
|
||||||
|
import { addSuggestion, deleteSuggestion, listSuggestions, voteSuggestion } from "../suggestions";
|
||||||
|
import { AddSuggestionData, VoteSuggestionData, DeleteSuggestionData } from "../../../types";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get("/list", async (req: Request, res, next) => {
|
||||||
|
try {
|
||||||
|
const login = getLogin(parseToken(req));
|
||||||
|
const data = await listSuggestions(login);
|
||||||
|
res.status(200).json(data);
|
||||||
|
} catch (e: any) { next(e) }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/add", async (req: Request<{}, any, AddSuggestionData["body"]>, res, next) => {
|
||||||
|
try {
|
||||||
|
const login = getLogin(parseToken(req));
|
||||||
|
if (!req.body?.title || !req.body?.description) {
|
||||||
|
return res.status(400).json({ error: "Chybné parametry volání" });
|
||||||
|
}
|
||||||
|
const data = await addSuggestion(login, req.body.title, req.body.description);
|
||||||
|
res.status(200).json(data);
|
||||||
|
} catch (e: any) { next(e) }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/vote", async (req: Request<{}, any, VoteSuggestionData["body"]>, res, next) => {
|
||||||
|
try {
|
||||||
|
const login = getLogin(parseToken(req));
|
||||||
|
if (!req.body?.id || !req.body?.direction) {
|
||||||
|
return res.status(400).json({ error: "Chybné parametry volání" });
|
||||||
|
}
|
||||||
|
const data = await voteSuggestion(login, req.body.id, req.body.direction);
|
||||||
|
res.status(200).json(data);
|
||||||
|
} catch (e: any) { next(e) }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/delete", async (req: Request<{}, any, DeleteSuggestionData["body"]>, res, next) => {
|
||||||
|
try {
|
||||||
|
const login = getLogin(parseToken(req));
|
||||||
|
if (!req.body?.id) {
|
||||||
|
return res.status(400).json({ error: "Chybné parametry volání" });
|
||||||
|
}
|
||||||
|
const data = await deleteSuggestion(login, req.body.id);
|
||||||
|
res.status(200).json(data);
|
||||||
|
} catch (e: any) { next(e) }
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import express, { Request } from "express";
|
|
||||||
import { getLogin } from "../auth";
|
|
||||||
import { parseToken } from "../utils";
|
|
||||||
import { getUserVotes, updateFeatureVote, getVotingStats } from "../voting";
|
|
||||||
import { GetVotesData, UpdateVoteData } from "../../../types";
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
router.get("/getVotes", async (req: Request<{}, any, GetVotesData["body"]>, res) => {
|
|
||||||
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) => {
|
|
||||||
const login = getLogin(parseToken(req));
|
|
||||||
if (req.body?.option == null || req.body?.active == null) {
|
|
||||||
res.status(400).json({ error: "Chybné parametry volání" });
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const data = await updateFeatureVote(login, req.body.option, req.body.active);
|
|
||||||
res.status(200).json(data);
|
|
||||||
} 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;
|
|
||||||
+73
-60
@@ -3,11 +3,17 @@ import getStorage from "./storage";
|
|||||||
import { getMenuSladovnicka, getMenuTechTower, getMenuZastavkaUmichala, getMenuSenkSerikova, StaleWeekError } from "./restaurants";
|
import { getMenuSladovnicka, getMenuTechTower, getMenuZastavkaUmichala, getMenuSenkSerikova, StaleWeekError } from "./restaurants";
|
||||||
import { getTodayMock } from "./mock";
|
import { getTodayMock } from "./mock";
|
||||||
import { removeAllUserPizzas } from "./pizza";
|
import { removeAllUserPizzas } from "./pizza";
|
||||||
import { ClientData, DepartureTime, LunchChoice, PizzaDayState, Restaurant, RestaurantDayMenu, WeekMenu } from "../../types/gen/types.gen";
|
import { getStores } from "./stores";
|
||||||
|
import { ClientData, DepartureTime, LunchChoice, MealSlot, PizzaDayState, Restaurant, RestaurantDayMenu, WeekMenu } from "../../types/gen/types.gen";
|
||||||
|
|
||||||
const storage = getStorage();
|
const storage = getStorage();
|
||||||
const MENU_PREFIX = 'menu';
|
const MENU_PREFIX = 'menu';
|
||||||
|
|
||||||
|
function getDataKey(date: Date, slot?: MealSlot): string {
|
||||||
|
const base = formatDate(date);
|
||||||
|
return slot === MealSlot.EXTRA ? `${base}_extra` : base;
|
||||||
|
}
|
||||||
|
|
||||||
/** Vrátí dnešní datum, případně fiktivní datum pro účely vývoje a testování. */
|
/** Vrátí dnešní datum, případně fiktivní datum pro účely vývoje a testování. */
|
||||||
export function getToday(): Date {
|
export function getToday(): Date {
|
||||||
if (process.env.MOCK_DATA === 'true') {
|
if (process.env.MOCK_DATA === 'true') {
|
||||||
@@ -34,6 +40,7 @@ export function getEmptyData(date?: Date): ClientData {
|
|||||||
return {
|
return {
|
||||||
todayDayIndex: getDayOfWeekIndex(getToday()),
|
todayDayIndex: getDayOfWeekIndex(getToday()),
|
||||||
date: getHumanDate(usedDate),
|
date: getHumanDate(usedDate),
|
||||||
|
isoDate: formatDate(usedDate),
|
||||||
isWeekend: getIsWeekend(usedDate),
|
isWeekend: getIsWeekend(usedDate),
|
||||||
dayIndex: getDayOfWeekIndex(usedDate),
|
dayIndex: getDayOfWeekIndex(usedDate),
|
||||||
choices: {},
|
choices: {},
|
||||||
@@ -43,14 +50,18 @@ export function getEmptyData(date?: Date): ClientData {
|
|||||||
/**
|
/**
|
||||||
* Vrátí veškerá klientská data pro předaný den, nebo aktuální den, pokud není předán.
|
* 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> {
|
export async function getData(date?: Date, slot?: MealSlot): Promise<ClientData> {
|
||||||
const clientData = await getClientData(date);
|
const clientData = await getClientData(date, slot);
|
||||||
clientData.menus = {
|
if (slot === MealSlot.EXTRA) {
|
||||||
SLADOVNICKA: await getRestaurantMenu('SLADOVNICKA', date),
|
clientData.stores = await getStores();
|
||||||
// UMOTLIKU: await getRestaurantMenu('UMOTLIKU', date),
|
} else {
|
||||||
TECHTOWER: await getRestaurantMenu('TECHTOWER', date),
|
clientData.menus = {
|
||||||
ZASTAVKAUMICHALA: await getRestaurantMenu('ZASTAVKAUMICHALA', date),
|
SLADOVNICKA: await getRestaurantMenu('SLADOVNICKA', date),
|
||||||
SENKSERIKOVA: await getRestaurantMenu('SENKSERIKOVA', date),
|
// UMOTLIKU: await getRestaurantMenu('UMOTLIKU', date),
|
||||||
|
TECHTOWER: await getRestaurantMenu('TECHTOWER', date),
|
||||||
|
ZASTAVKAUMICHALA: await getRestaurantMenu('ZASTAVKAUMICHALA', date),
|
||||||
|
SENKSERIKOVA: await getRestaurantMenu('SENKSERIKOVA', date),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return clientData;
|
return clientData;
|
||||||
}
|
}
|
||||||
@@ -290,8 +301,8 @@ function generateMenuWarnings(menu: RestaurantDayMenu): string[] {
|
|||||||
*
|
*
|
||||||
* @param date datum
|
* @param date datum
|
||||||
*/
|
*/
|
||||||
export async function initIfNeeded(date?: Date) {
|
export async function initIfNeeded(date?: Date, slot?: MealSlot) {
|
||||||
const usedDate = formatDate(date ?? getToday());
|
const usedDate = getDataKey(date ?? getToday(), slot);
|
||||||
const hasData = await storage.hasData(usedDate);
|
const hasData = await storage.hasData(usedDate);
|
||||||
if (!hasData) {
|
if (!hasData) {
|
||||||
await storage.setData(usedDate, getEmptyData(date || getToday()));
|
await storage.setData(usedDate, getEmptyData(date || getToday()));
|
||||||
@@ -307,9 +318,9 @@ export async function initIfNeeded(date?: Date) {
|
|||||||
* @param date datum, ke kterému se volba vztahuje
|
* @param date datum, ke kterému se volba vztahuje
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
export async function removeChoices(login: string, trusted: boolean, locationKey: LunchChoice, date?: Date) {
|
export async function removeChoices(login: string, trusted: boolean, locationKey: LunchChoice, date?: Date, slot?: MealSlot) {
|
||||||
const selectedDay = formatDate(date ?? getToday());
|
const selectedDay = getDataKey(date ?? getToday(), slot);
|
||||||
let data = await getClientData(date);
|
let data = await getClientData(date, slot);
|
||||||
validateTrusted(data, login, trusted);
|
validateTrusted(data, login, trusted);
|
||||||
if (locationKey in data.choices) {
|
if (locationKey in data.choices) {
|
||||||
if (data.choices[locationKey] && login in data.choices[locationKey]) {
|
if (data.choices[locationKey] && login in data.choices[locationKey]) {
|
||||||
@@ -334,9 +345,9 @@ export async function removeChoices(login: string, trusted: boolean, locationKey
|
|||||||
* @param date datum, ke kterému se volba vztahuje
|
* @param date datum, ke kterému se volba vztahuje
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
export async function removeChoice(login: string, trusted: boolean, locationKey: LunchChoice, foodIndex: number, date?: Date) {
|
export async function removeChoice(login: string, trusted: boolean, locationKey: LunchChoice, foodIndex: number, date?: Date, slot?: MealSlot) {
|
||||||
const selectedDay = formatDate(date ?? getToday());
|
const selectedDay = getDataKey(date ?? getToday(), slot);
|
||||||
let data = await getClientData(date);
|
let data = await getClientData(date, slot);
|
||||||
validateTrusted(data, login, trusted);
|
validateTrusted(data, login, trusted);
|
||||||
if (locationKey in data.choices) {
|
if (locationKey in data.choices) {
|
||||||
if (data.choices[locationKey] && login in data.choices[locationKey]) {
|
if (data.choices[locationKey] && login in data.choices[locationKey]) {
|
||||||
@@ -357,9 +368,9 @@ export async function removeChoice(login: string, trusted: boolean, locationKey:
|
|||||||
* @param date datum, ke kterému se volby vztahují
|
* @param date datum, ke kterému se volby vztahují
|
||||||
* @param ignoredLocationKey volba, která nebude odstraněna, pokud existuje
|
* @param ignoredLocationKey volba, která nebude odstraněna, pokud existuje
|
||||||
*/
|
*/
|
||||||
async function removeChoiceIfPresent(login: string, date?: Date, ignoredLocationKey?: LunchChoice) {
|
async function removeChoiceIfPresent(login: string, date?: Date, ignoredLocationKey?: LunchChoice, slot?: MealSlot) {
|
||||||
const usedDate = date ?? getToday();
|
const usedDate = date ?? getToday();
|
||||||
let data = await getClientData(usedDate);
|
let data = await getClientData(usedDate, slot);
|
||||||
for (const key of Object.keys(data.choices)) {
|
for (const key of Object.keys(data.choices)) {
|
||||||
const locationKey = key as LunchChoice;
|
const locationKey = key as LunchChoice;
|
||||||
if (ignoredLocationKey != null && ignoredLocationKey == locationKey) {
|
if (ignoredLocationKey != null && ignoredLocationKey == locationKey) {
|
||||||
@@ -370,7 +381,7 @@ async function removeChoiceIfPresent(login: string, date?: Date, ignoredLocation
|
|||||||
if (Object.keys(data.choices[locationKey]).length === 0) {
|
if (Object.keys(data.choices[locationKey]).length === 0) {
|
||||||
delete data.choices[locationKey];
|
delete data.choices[locationKey];
|
||||||
}
|
}
|
||||||
await storage.setData(formatDate(usedDate), data);
|
await storage.setData(getDataKey(usedDate, slot), data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
@@ -409,41 +420,43 @@ function validateTrusted(data: ClientData, login: string, trusted: boolean) {
|
|||||||
* @param date datum, ke kterému se volba vztahuje
|
* @param date datum, ke kterému se volba vztahuje
|
||||||
* @returns aktuální data
|
* @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: LunchChoice, foodIndex?: number, date?: Date, slot?: MealSlot) {
|
||||||
const usedDate = date ?? getToday();
|
const usedDate = date ?? getToday();
|
||||||
await initIfNeeded(usedDate);
|
await initIfNeeded(usedDate, slot);
|
||||||
let data = await getClientData(usedDate);
|
let data = await getClientData(usedDate, slot);
|
||||||
validateTrusted(data, login, trusted);
|
validateTrusted(data, login, trusted);
|
||||||
await validateFoodIndex(locationKey, foodIndex, date);
|
await validateFoodIndex(locationKey, foodIndex, date);
|
||||||
|
|
||||||
// Pokud uživatel měl vybranou PIZZA a mění na něco jiného
|
if (!slot || slot === MealSlot.OBED) {
|
||||||
const hadPizzaChoice = data.choices.PIZZA && login in data.choices.PIZZA;
|
// Pokud uživatel měl vybranou PIZZA a mění na něco jiného
|
||||||
if (hadPizzaChoice && locationKey !== LunchChoice.PIZZA && foodIndex == null) {
|
const hadPizzaChoice = data.choices.PIZZA && login in data.choices.PIZZA;
|
||||||
// Kontrola, zda existuje Pizza day a uživatel je jeho zakladatel
|
if (hadPizzaChoice && locationKey !== LunchChoice.PIZZA && foodIndex == null) {
|
||||||
if (data.pizzaDay && data.pizzaDay.creator === login) {
|
// Kontrola, zda existuje Pizza day a uživatel je jeho zakladatel
|
||||||
// Pokud Pizza day není ve stavu CREATED, nelze změnit volbu
|
if (data.pizzaDay && data.pizzaDay.creator === login) {
|
||||||
if (data.pizzaDay.state !== PizzaDayState.CREATED) {
|
// Pokud Pizza day není ve stavu CREATED, nelze změnit volbu
|
||||||
throw new PizzaDayConflictError(
|
if (data.pizzaDay.state !== PizzaDayState.CREATED) {
|
||||||
`Nelze změnit volbu. Pizza day je ve stavu "${data.pizzaDay.state}" a musí být nejprve dokončen nebo smazán.`
|
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)
|
||||||
}
|
}
|
||||||
// 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,
|
// Smažeme pizzy uživatele (pokud Pizza day nebyl založen tímto uživatelem,
|
||||||
// nebo byl již smazán frontendem)
|
// nebo byl již smazán frontendem)
|
||||||
await removeAllUserPizzas(login, usedDate);
|
await removeAllUserPizzas(login, usedDate);
|
||||||
// Znovu načteme data, protože removeAllUserPizzas je upravila
|
// Znovu načteme data, protože removeAllUserPizzas je upravila
|
||||||
data = await getClientData(usedDate);
|
data = await getClientData(usedDate, slot);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pokud měníme pouze lokaci, mažeme případné předchozí
|
// Pokud měníme pouze lokaci, mažeme případné předchozí
|
||||||
if (foodIndex == null) {
|
if (foodIndex == null) {
|
||||||
data = await removeChoiceIfPresent(login, usedDate);
|
data = await removeChoiceIfPresent(login, usedDate, undefined, slot);
|
||||||
} else {
|
} else {
|
||||||
// Mažeme případné ostatní volby (měla by být maximálně jedna)
|
// Mažeme případné ostatní volby (měla by být maximálně jedna)
|
||||||
data = await removeChoiceIfPresent(login, usedDate, locationKey);
|
data = await removeChoiceIfPresent(login, usedDate, locationKey, slot);
|
||||||
}
|
}
|
||||||
// TODO vytáhnout inicializaci "prázdné struktury" do vlastní funkce
|
// TODO vytáhnout inicializaci "prázdné struktury" do vlastní funkce
|
||||||
data.choices[locationKey] ??= {};
|
data.choices[locationKey] ??= {};
|
||||||
@@ -459,8 +472,7 @@ export async function addChoice(login: string, trusted: boolean, locationKey: Lu
|
|||||||
if (foodIndex != null && !data.choices[locationKey][login].selectedFoods?.includes(foodIndex)) {
|
if (foodIndex != null && !data.choices[locationKey][login].selectedFoods?.includes(foodIndex)) {
|
||||||
data.choices[locationKey][login].selectedFoods?.push(foodIndex);
|
data.choices[locationKey][login].selectedFoods?.push(foodIndex);
|
||||||
}
|
}
|
||||||
const selectedDate = formatDate(usedDate);
|
await storage.setData(getDataKey(usedDate, slot), data);
|
||||||
await storage.setData(selectedDate, data);
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,13 +486,13 @@ export async function addChoice(login: string, trusted: boolean, locationKey: Lu
|
|||||||
async function validateFoodIndex(locationKey: LunchChoice, foodIndex?: number, date?: Date) {
|
async function validateFoodIndex(locationKey: LunchChoice, foodIndex?: number, date?: Date) {
|
||||||
if (foodIndex != null) {
|
if (foodIndex != null) {
|
||||||
if (typeof foodIndex !== 'number') {
|
if (typeof foodIndex !== 'number') {
|
||||||
throw Error(`Neplatný index ${foodIndex} typu ${typeof foodIndex}`);
|
throw new Error(`Neplatný index ${foodIndex} typu ${typeof foodIndex}`);
|
||||||
}
|
}
|
||||||
if (foodIndex < 0) {
|
if (foodIndex < 0) {
|
||||||
throw Error(`Neplatný index ${foodIndex}`);
|
throw new Error(`Neplatný index ${foodIndex}`);
|
||||||
}
|
}
|
||||||
if (!Object.keys(Restaurant).includes(locationKey)) {
|
if (!Object.keys(Restaurant).includes(locationKey)) {
|
||||||
throw Error(`Neplatný index ${foodIndex} pro lokalitu ${locationKey} nepodporující indexy`);
|
throw new Error(`Neplatný index ${foodIndex} pro lokalitu ${locationKey} nepodporující indexy`);
|
||||||
}
|
}
|
||||||
const usedDate = date ?? getToday();
|
const usedDate = date ?? getToday();
|
||||||
const menu = await getRestaurantMenu(locationKey as Restaurant, usedDate);
|
const menu = await getRestaurantMenu(locationKey as Restaurant, usedDate);
|
||||||
@@ -498,10 +510,10 @@ async function validateFoodIndex(locationKey: LunchChoice, foodIndex?: number, d
|
|||||||
* @param note poznámka
|
* @param note poznámka
|
||||||
* @param date datum, ke kterému se volba vztahuje
|
* @param date datum, ke kterému se volba vztahuje
|
||||||
*/
|
*/
|
||||||
export async function updateNote(login: string, trusted: boolean, note?: string, date?: Date) {
|
export async function updateNote(login: string, trusted: boolean, note?: string, date?: Date, slot?: MealSlot) {
|
||||||
const usedDate = date ?? getToday();
|
const usedDate = date ?? getToday();
|
||||||
await initIfNeeded(usedDate);
|
await initIfNeeded(usedDate, slot);
|
||||||
let data = await getClientData(usedDate);
|
let data = await getClientData(usedDate, slot);
|
||||||
validateTrusted(data, login, trusted);
|
validateTrusted(data, login, trusted);
|
||||||
const userEntry = data.choices != null && Object.entries(data.choices).find(entry => entry[1][login] != null);
|
const userEntry = data.choices != null && Object.entries(data.choices).find(entry => entry[1][login] != null);
|
||||||
if (userEntry) {
|
if (userEntry) {
|
||||||
@@ -510,8 +522,7 @@ export async function updateNote(login: string, trusted: boolean, note?: string,
|
|||||||
} else {
|
} else {
|
||||||
userEntry[1][login].note = note;
|
userEntry[1][login].note = note;
|
||||||
}
|
}
|
||||||
const selectedDate = formatDate(usedDate);
|
await storage.setData(getDataKey(usedDate, slot), data);
|
||||||
await storage.setData(selectedDate, data);
|
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
@@ -533,11 +544,11 @@ export async function updateDepartureTime(login: string, time?: string, date?: D
|
|||||||
delete found[login].departureTime;
|
delete found[login].departureTime;
|
||||||
} else {
|
} else {
|
||||||
if (!Object.values<string>(DepartureTime).includes(time)) {
|
if (!Object.values<string>(DepartureTime).includes(time)) {
|
||||||
throw Error(`Neplatný čas odchodu ${time}`);
|
throw new Error(`Neplatný čas odchodu ${time}`);
|
||||||
}
|
}
|
||||||
found[login].departureTime = time;
|
found[login].departureTime = time;
|
||||||
}
|
}
|
||||||
await storage.setData(formatDate(usedDate), clientData);
|
await storage.setData(getDataKey(usedDate), clientData);
|
||||||
}
|
}
|
||||||
return clientData;
|
return clientData;
|
||||||
}
|
}
|
||||||
@@ -548,15 +559,15 @@ export async function updateDepartureTime(login: string, time?: string, date?: D
|
|||||||
*
|
*
|
||||||
* @param login přihlašovací jméno uživatele
|
* @param login přihlašovací jméno uživatele
|
||||||
*/
|
*/
|
||||||
export async function updateBuyer(login: string) {
|
export async function updateBuyer(login: string, slot?: MealSlot) {
|
||||||
const usedDate = getToday();
|
const usedDate = getToday();
|
||||||
let clientData = await getClientData(usedDate);
|
let clientData = await getClientData(usedDate, slot);
|
||||||
const userEntry = clientData.choices?.['OBJEDNAVAM']?.[login];
|
const userEntry = clientData.choices?.['OBJEDNAVAM']?.[login];
|
||||||
if (!userEntry) {
|
if (!userEntry) {
|
||||||
throw new Error("Nelze nastavit objednatele pro uživatele s jinou volbou než \"Budu objednávat\"");
|
throw new Error("Nelze nastavit objednatele pro uživatele s jinou volbou než \"Budu objednávat\"");
|
||||||
}
|
}
|
||||||
userEntry.isBuyer = !(userEntry.isBuyer || false);
|
userEntry.isBuyer = !(userEntry.isBuyer || false);
|
||||||
await storage.setData(formatDate(usedDate), clientData);
|
await storage.setData(getDataKey(usedDate, slot), clientData);
|
||||||
return clientData;
|
return clientData;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -566,12 +577,14 @@ export async function updateBuyer(login: string) {
|
|||||||
* @param date datum pro který vrátit data, pokud není vyplněno, je použit dnešní den
|
* @param date datum pro který vrátit data, pokud není vyplněno, je použit dnešní den
|
||||||
* @returns data pro klienta
|
* @returns data pro klienta
|
||||||
*/
|
*/
|
||||||
export async function getClientData(date?: Date): Promise<ClientData> {
|
export async function getClientData(date?: Date, slot?: MealSlot): Promise<ClientData> {
|
||||||
const targetDate = date ?? getToday();
|
const targetDate = date ?? getToday();
|
||||||
const dateString = formatDate(targetDate);
|
const dateString = getDataKey(targetDate, slot);
|
||||||
const clientData = await storage.getData<ClientData>(dateString) || getEmptyData(date);
|
const clientData = await storage.getData<ClientData>(dateString) || getEmptyData(date);
|
||||||
return {
|
return {
|
||||||
...clientData,
|
...clientData,
|
||||||
todayDayIndex: getDayOfWeekIndex(getToday()),
|
todayDayIndex: getDayOfWeekIndex(getToday()),
|
||||||
|
isoDate: formatDate(targetDate),
|
||||||
|
...(slot === MealSlot.EXTRA ? { slot: MealSlot.EXTRA } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+2
-2
@@ -22,13 +22,13 @@ export async function getStats(startDate: string, endDate: string): Promise<Week
|
|||||||
// Dočasná validace, aby to někdo ručně neshodil
|
// Dočasná validace, aby to někdo ručně neshodil
|
||||||
const daysDiff = ((end as any) - (start as any)) / (1000 * 60 * 60 * 24);
|
const daysDiff = ((end as any) - (start as any)) / (1000 * 60 * 60 * 24);
|
||||||
if (daysDiff > 4) {
|
if (daysDiff > 4) {
|
||||||
throw Error('Neplatný rozsah');
|
throw new Error('Neplatný rozsah');
|
||||||
}
|
}
|
||||||
|
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
today.setHours(23, 59, 59, 999);
|
today.setHours(23, 59, 59, 999);
|
||||||
if (end > today) {
|
if (end > today) {
|
||||||
throw Error('Nelze načíst statistiky pro budoucí datum');
|
throw new Error('Nelze načíst statistiky pro budoucí datum');
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = [];
|
const result = [];
|
||||||
|
|||||||
@@ -29,4 +29,10 @@ export interface StorageInterface {
|
|||||||
* @param data data pro uložení
|
* @param data data pro uložení
|
||||||
*/
|
*/
|
||||||
setData<Type>(key: string, data: Type): Promise<void>;
|
setData<Type>(key: string, data: Type): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrátí seznam všech klíčů, případně jen těch obsahujících předaný podřetězec.
|
||||||
|
* @param contains volitelný podřetězec, který musí klíč obsahovat (např. '_extra')
|
||||||
|
*/
|
||||||
|
listKeys(contains?: string): Promise<string[]>;
|
||||||
}
|
}
|
||||||
@@ -20,7 +20,7 @@ if (!process.env.STORAGE || process.env.STORAGE?.toLowerCase() === JSON_KEY) {
|
|||||||
} else if (process.env.STORAGE?.toLowerCase() === MEMORY_KEY) {
|
} else if (process.env.STORAGE?.toLowerCase() === MEMORY_KEY) {
|
||||||
storage = new MemoryStorage();
|
storage = new MemoryStorage();
|
||||||
} else {
|
} else {
|
||||||
throw Error("Nepodporovaná hodnota proměnné STORAGE: " + process.env.STORAGE + ", podporované jsou 'json', 'redis' nebo 'memory'");
|
throw new Error("Nepodporovaná hodnota proměnné STORAGE: " + process.env.STORAGE + ", podporované jsou 'json', 'redis' nebo 'memory'");
|
||||||
}
|
}
|
||||||
|
|
||||||
export const storageReady: Promise<void> = storage.initialize
|
export const storageReady: Promise<void> = storage.initialize
|
||||||
|
|||||||
@@ -29,4 +29,9 @@ export default class JsonStorage implements StorageInterface {
|
|||||||
db.set(key, data);
|
db.set(key, data);
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
listKeys(contains?: string): Promise<string[]> {
|
||||||
|
const keys = Object.keys(db.JSON());
|
||||||
|
return Promise.resolve(contains ? keys.filter(k => k.includes(contains)) : keys);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -24,4 +24,9 @@ export default class MemoryStorage implements StorageInterface {
|
|||||||
store.set(key, data);
|
store.set(key, data);
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
listKeys(contains?: string): Promise<string[]> {
|
||||||
|
const keys = Array.from(store.keys());
|
||||||
|
return Promise.resolve(contains ? keys.filter(k => k.includes(contains)) : keys);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,4 +31,16 @@ export default class RedisStorage implements StorageInterface {
|
|||||||
await client.json.set(key, '.', data as any);
|
await client.json.set(key, '.', data as any);
|
||||||
await client.json.get(key);
|
await client.json.get(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listKeys(contains?: string): Promise<string[]> {
|
||||||
|
// SCAN je bezpečnější než KEYS na produkci (neblokuje server)
|
||||||
|
const match = contains ? `*${contains}*` : '*';
|
||||||
|
const keys: string[] = [];
|
||||||
|
for await (const key of client.scanIterator({ MATCH: match, COUNT: 100 })) {
|
||||||
|
// node-redis v4 vrací buď string, nebo (novější verze) pole stringů
|
||||||
|
if (Array.isArray(key)) keys.push(...key);
|
||||||
|
else keys.push(key);
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import getStorage from "./storage";
|
||||||
|
|
||||||
|
const storage = getStorage();
|
||||||
|
const STORES_KEY = 'stores';
|
||||||
|
|
||||||
|
export async function getStores(): Promise<string[]> {
|
||||||
|
return (await storage.getData<string[]>(STORES_KEY)) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addStore(name: string, heslo: string): Promise<string[]> {
|
||||||
|
const adminPassword = process.env.ADMIN_PASSWORD;
|
||||||
|
if (!adminPassword || heslo !== adminPassword) {
|
||||||
|
throw new Error('UNAUTHORIZED');
|
||||||
|
}
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
throw new Error('Název obchodu nesmí být prázdný');
|
||||||
|
}
|
||||||
|
const stores = await getStores();
|
||||||
|
if (stores.some(s => s.toLowerCase() === trimmed.toLowerCase())) {
|
||||||
|
throw new Error('Obchod s tímto názvem již existuje');
|
||||||
|
}
|
||||||
|
const updated = [...stores, trimmed];
|
||||||
|
await storage.setData(STORES_KEY, updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeStore(name: string, heslo: string): Promise<string[]> {
|
||||||
|
const adminPassword = process.env.ADMIN_PASSWORD;
|
||||||
|
if (!adminPassword || heslo !== adminPassword) {
|
||||||
|
throw new Error('UNAUTHORIZED');
|
||||||
|
}
|
||||||
|
const stores = await getStores();
|
||||||
|
const updated = stores.filter(s => s.toLowerCase() !== name.trim().toLowerCase());
|
||||||
|
await storage.setData(STORES_KEY, updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import crypto from "crypto";
|
||||||
|
import { Suggestion, VoteDirection } from "../../types/gen/types.gen";
|
||||||
|
import getStorage from "./storage";
|
||||||
|
|
||||||
|
/** Interní reprezentace návrhu uložená ve storage (včetně seznamů hlasujících). */
|
||||||
|
interface StoredSuggestion {
|
||||||
|
id: string;
|
||||||
|
author: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
createdAt: string;
|
||||||
|
/** Loginy uživatelů hlasujících PRO návrh */
|
||||||
|
upvoters: string[];
|
||||||
|
/** Loginy uživatelů hlasujících PROTI návrhu */
|
||||||
|
downvoters: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = getStorage();
|
||||||
|
const STORAGE_KEY = 'suggestions';
|
||||||
|
|
||||||
|
/** Načte interní seznam návrhů ze storage. */
|
||||||
|
async function loadSuggestions(): Promise<StoredSuggestion[]> {
|
||||||
|
return (await storage.getData<StoredSuggestion[]>(STORAGE_KEY)) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Převede interní návrh na DTO pro daného uživatele - skryje seznamy hlasujících
|
||||||
|
* a doplní hlas přihlášeného uživatele a příznak vlastnictví.
|
||||||
|
*/
|
||||||
|
function toDto(suggestion: StoredSuggestion, login: string): Suggestion {
|
||||||
|
let myVote: VoteDirection | undefined;
|
||||||
|
if (suggestion.upvoters.includes(login)) {
|
||||||
|
myVote = 'up';
|
||||||
|
} else if (suggestion.downvoters.includes(login)) {
|
||||||
|
myVote = 'down';
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: suggestion.id,
|
||||||
|
author: suggestion.author,
|
||||||
|
title: suggestion.title,
|
||||||
|
description: suggestion.description,
|
||||||
|
voteScore: suggestion.upvoters.length - suggestion.downvoters.length,
|
||||||
|
myVote,
|
||||||
|
isMine: suggestion.author === login,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vrátí seznam návrhů jako DTO pro daného uživatele, seřazený sestupně dle skóre
|
||||||
|
* (při shodě skóre stabilně dle data vytvoření vzestupně).
|
||||||
|
*
|
||||||
|
* @param login login přihlášeného uživatele
|
||||||
|
*/
|
||||||
|
export async function listSuggestions(login: string): Promise<Suggestion[]> {
|
||||||
|
const suggestions = await loadSuggestions();
|
||||||
|
return suggestions
|
||||||
|
.map(s => toDto(s, login))
|
||||||
|
.sort((a, b) => b.voteScore - a.voteScore);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Přidá nový návrh. Autorovi se automaticky nastaví hlas pro.
|
||||||
|
*
|
||||||
|
* @param login login autora
|
||||||
|
* @param title název návrhu
|
||||||
|
* @param description detailní popis návrhu
|
||||||
|
* @returns aktualizovaný seznam návrhů jako DTO
|
||||||
|
*/
|
||||||
|
export async function addSuggestion(login: string, title: string, description: string): Promise<Suggestion[]> {
|
||||||
|
const trimmedTitle = title?.trim();
|
||||||
|
const trimmedDescription = description?.trim();
|
||||||
|
if (!trimmedTitle) {
|
||||||
|
throw new Error('Název návrhu nesmí být prázdný');
|
||||||
|
}
|
||||||
|
if (!trimmedDescription) {
|
||||||
|
throw new Error('Popis návrhu nesmí být prázdný');
|
||||||
|
}
|
||||||
|
const suggestions = await loadSuggestions();
|
||||||
|
suggestions.push({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
author: login,
|
||||||
|
title: trimmedTitle,
|
||||||
|
description: trimmedDescription,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
// Autor automaticky hlasuje pro svůj návrh
|
||||||
|
upvoters: [login],
|
||||||
|
downvoters: [],
|
||||||
|
});
|
||||||
|
await storage.setData(STORAGE_KEY, suggestions);
|
||||||
|
return listSuggestions(login);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Přepne hlas uživatele u návrhu. Klik na již aktivní směr hlas zruší,
|
||||||
|
* opačný směr stávající hlas přepíše.
|
||||||
|
*
|
||||||
|
* @param login login hlasujícího uživatele
|
||||||
|
* @param id identifikátor návrhu
|
||||||
|
* @param direction směr hlasu, na který uživatel klikl
|
||||||
|
* @returns aktualizovaný seznam návrhů jako DTO
|
||||||
|
*/
|
||||||
|
export async function voteSuggestion(login: string, id: string, direction: VoteDirection): Promise<Suggestion[]> {
|
||||||
|
const suggestions = await loadSuggestions();
|
||||||
|
const suggestion = suggestions.find(s => s.id === id);
|
||||||
|
if (!suggestion) {
|
||||||
|
throw new Error('Návrh nebyl nalezen');
|
||||||
|
}
|
||||||
|
const hadUp = suggestion.upvoters.includes(login);
|
||||||
|
const hadDown = suggestion.downvoters.includes(login);
|
||||||
|
// Nejprve odebereme případný stávající hlas uživatele
|
||||||
|
suggestion.upvoters = suggestion.upvoters.filter(l => l !== login);
|
||||||
|
suggestion.downvoters = suggestion.downvoters.filter(l => l !== login);
|
||||||
|
// Hlas přidáme pouze pokud uživatel neklikl na již aktivní směr (jinak ho jen zrušíme)
|
||||||
|
if (direction === 'up' && !hadUp) {
|
||||||
|
suggestion.upvoters.push(login);
|
||||||
|
} else if (direction === 'down' && !hadDown) {
|
||||||
|
suggestion.downvoters.push(login);
|
||||||
|
}
|
||||||
|
await storage.setData(STORAGE_KEY, suggestions);
|
||||||
|
return listSuggestions(login);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smaže návrh včetně všech jeho hlasů. Smazat lze pouze vlastní návrh.
|
||||||
|
*
|
||||||
|
* @param login login uživatele požadujícího smazání
|
||||||
|
* @param id identifikátor návrhu ke smazání
|
||||||
|
* @returns aktualizovaný seznam návrhů jako DTO
|
||||||
|
*/
|
||||||
|
export async function deleteSuggestion(login: string, id: string): Promise<Suggestion[]> {
|
||||||
|
const suggestions = await loadSuggestions();
|
||||||
|
const suggestion = suggestions.find(s => s.id === id);
|
||||||
|
if (!suggestion) {
|
||||||
|
throw new Error('Návrh nebyl nalezen');
|
||||||
|
}
|
||||||
|
if (suggestion.author !== login) {
|
||||||
|
throw new Error('Smazat lze pouze vlastní návrh');
|
||||||
|
}
|
||||||
|
const filtered = suggestions.filter(s => s.id !== id);
|
||||||
|
await storage.setData(STORAGE_KEY, filtered);
|
||||||
|
return listSuggestions(login);
|
||||||
|
}
|
||||||
@@ -31,8 +31,8 @@ test('saláty mají name a ingredients', async () => {
|
|||||||
|
|
||||||
test('cena salátu zahrnuje pevný příplatek 13 Kč za obal', async () => {
|
test('cena salátu zahrnuje pevný příplatek 13 Kč za obal', async () => {
|
||||||
const salaty = await downloadSalaty(false);
|
const salaty = await downloadSalaty(false);
|
||||||
// Caesar sticker price = 129, box = 13
|
// Caesar sticker price = 129, box = 13 → (129 + 13) * 100 haléřů
|
||||||
expect(salaty[0].price).toBe(129 + 13);
|
expect(salaty[0].price).toBe((129 + 13) * 100);
|
||||||
// Řecký sticker price = 119, box = 13
|
// Řecký sticker price = 119, box = 13 → (119 + 13) * 100 haléřů
|
||||||
expect(salaty[1].price).toBe(119 + 13);
|
expect(salaty[1].price).toBe((119 + 13) * 100);
|
||||||
});
|
});
|
||||||
|
|||||||
+8
-22
@@ -1,29 +1,15 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<body>
|
<body>
|
||||||
<div class="outer-container">
|
<div class="b b-text cf">
|
||||||
<div class="header-section"><!-- font.parent().parent() -->
|
<div class="b-c b-text-c cf">
|
||||||
<p><!-- font.parent() -->
|
<p class="wnd-align-center"><font class="wsw-41 wnd-font-size-120"><strong>Jídelní lístek 12.5.-16.5.2025</strong></font></p>
|
||||||
<font class="wsw-41">Obědy 12.5.-16.5.2025</font>
|
<p class="wnd-align-center"><strong>Pondělí</strong><br>Polévka dne 1<br>Svíčková na smetaně s knedlíkem 1,3,7 — 149 Kč<br>Smažený sýr s bramborami 1,3 — 139 Kč</p>
|
||||||
</p>
|
<p class="wnd-align-center"><strong>Úterý</strong><br>Česnečka 1<br>Vepřový guláš s houskovým knedlíkem 1,3 — 145 Kč</p>
|
||||||
|
<p class="wnd-align-center"><strong>Středa</strong><br>Hovězí vývar s nudlemi 1<br>Kuřecí řízek s bramborami 1 — 139 Kč</p>
|
||||||
|
<p class="wnd-align-center"><strong>Čtvrtek</strong><br>Dršťková polévka 1<br>Segedínský guláš s knedlíkem 1,3 — 145 Kč</p>
|
||||||
|
<p class="wnd-align-center"><strong>Pátek</strong><br>Rajská polévka s rýží 1<br>Rizoto s kuřecím masem a zeleninou 1 — 139 Kč</p>
|
||||||
</div>
|
</div>
|
||||||
<!-- níže jsou sourozenci .header-section = výsledek $(font).parent().parent().siblings() -->
|
|
||||||
<p>Pondělí</p>
|
|
||||||
<p>• Polévka dne 1</p>
|
|
||||||
<p>• Svíčková na smetaně s knedlíkem 1, 3, 7 149 Kč</p>
|
|
||||||
<p>• Smažený sýr s bramborami 1, 3 139 Kč</p>
|
|
||||||
<p>Úterý</p>
|
|
||||||
<p>• Česnečka 1</p>
|
|
||||||
<p>• Vepřový guláš s houskovým knedlíkem 1, 3 145 Kč</p>
|
|
||||||
<p>Středa</p>
|
|
||||||
<p>• Hovězí vývar s nudlemi 1</p>
|
|
||||||
<p>• Kuřecí řízek s bramborami 1 139 Kč</p>
|
|
||||||
<p>Čtvrtek</p>
|
|
||||||
<p>• Dršťková polévka 1</p>
|
|
||||||
<p>• Segedínský guláš s knedlíkem 1, 3 145 Kč</p>
|
|
||||||
<p>Pátek</p>
|
|
||||||
<p>• Rajská polévka s rýží 1</p>
|
|
||||||
<p>• Rizoto s kuřecím masem a zeleninou 1 139 Kč</p>
|
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { resetMemoryStorage } from '../storage/memory';
|
||||||
|
import { getStores, addStore } from '../stores';
|
||||||
|
import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState } from '../groups';
|
||||||
|
import { GroupState } from '../../../types/gen/types.gen';
|
||||||
|
|
||||||
|
const CREATOR = 'tomas';
|
||||||
|
const USER = 'petr';
|
||||||
|
const ADMIN_PW = 'testadmin';
|
||||||
|
const STORE = 'McDonald\'s';
|
||||||
|
const TODAY = new Date('2025-01-10');
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
resetMemoryStorage();
|
||||||
|
process.env.ADMIN_PASSWORD = ADMIN_PW;
|
||||||
|
await addStore(STORE, ADMIN_PW);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.ADMIN_PASSWORD;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createGroup', () => {
|
||||||
|
test('vytvoří skupinu, creator je člen', async () => {
|
||||||
|
const data = await createGroup(CREATOR, STORE, TODAY);
|
||||||
|
expect(data.groups).toHaveLength(1);
|
||||||
|
const group = data.groups![0];
|
||||||
|
expect(group.name).toBe(STORE);
|
||||||
|
expect(group.creatorLogin).toBe(CREATOR);
|
||||||
|
expect(group.state).toBe(GroupState.OPEN);
|
||||||
|
expect(group.members[CREATOR]).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('odmítne název mimo seznam obchodů', async () => {
|
||||||
|
await expect(createGroup(CREATOR, 'Neznámý obchod', TODAY)).rejects.toThrow('seznam');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('vygeneruje unikátní ID', async () => {
|
||||||
|
const d1 = await createGroup(CREATOR, STORE, TODAY);
|
||||||
|
const d2 = await createGroup(USER, STORE, TODAY);
|
||||||
|
expect(d2.groups).toHaveLength(2);
|
||||||
|
expect(d2.groups![1].id).not.toBe(d2.groups![0].id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteGroup', () => {
|
||||||
|
test('creator může smazat skupinu', async () => {
|
||||||
|
const d = await createGroup(CREATOR, STORE, TODAY);
|
||||||
|
const groupId = d.groups![0].id;
|
||||||
|
const result = await deleteGroup(CREATOR, groupId, TODAY);
|
||||||
|
expect(result.groups).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nečlen nemůže smazat skupinu', async () => {
|
||||||
|
const d = await createGroup(CREATOR, STORE, TODAY);
|
||||||
|
const groupId = d.groups![0].id;
|
||||||
|
await expect(deleteGroup(USER, groupId, TODAY)).rejects.toThrow('zakladatel');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('smazání neexistující skupiny vyhodí chybu', async () => {
|
||||||
|
await expect(deleteGroup(CREATOR, 'nonexistent', TODAY)).rejects.toThrow('nalezena');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addGroupMember', () => {
|
||||||
|
let groupId: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const d = await createGroup(CREATOR, STORE, TODAY);
|
||||||
|
groupId = d.groups![0].id;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uživatel se může přidat sám (open)', async () => {
|
||||||
|
const d = await addGroupMember(USER, groupId, USER, TODAY);
|
||||||
|
expect(d.groups![0].members[USER]).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creator může přidat jiného uživatele', async () => {
|
||||||
|
const d = await addGroupMember(CREATOR, groupId, USER, TODAY);
|
||||||
|
expect(d.groups![0].members[USER]).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nečlen nemůže přidat jiného uživatele', async () => {
|
||||||
|
await expect(addGroupMember(USER, groupId, 'dalsi', TODAY)).rejects.toThrow('zakladatel');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nelze přidat do skupiny ve stavu ordered', async () => {
|
||||||
|
await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY);
|
||||||
|
await setGroupState(CREATOR, groupId, GroupState.ORDERED, TODAY);
|
||||||
|
await expect(addGroupMember(USER, groupId, USER, TODAY)).rejects.toThrow('objednáno');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nelze přidat existujícího člena', async () => {
|
||||||
|
await expect(addGroupMember(CREATOR, groupId, CREATOR, TODAY)).rejects.toThrow('již');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removeGroupMember', () => {
|
||||||
|
let groupId: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const d = await createGroup(CREATOR, STORE, TODAY);
|
||||||
|
groupId = d.groups![0].id;
|
||||||
|
await addGroupMember(CREATOR, groupId, USER, TODAY);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('člen se může odhlásit sám', async () => {
|
||||||
|
const d = await removeGroupMember(USER, groupId, USER, TODAY);
|
||||||
|
expect(d.groups![0].members[USER]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creator může odebrat jiného člena', async () => {
|
||||||
|
const d = await removeGroupMember(CREATOR, groupId, USER, TODAY);
|
||||||
|
expect(d.groups![0].members[USER]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nelze odebrat zakladatele', async () => {
|
||||||
|
await expect(removeGroupMember(CREATOR, groupId, CREATOR, TODAY)).rejects.toThrow('Zakladatel');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nečlen nemůže odebrat jiného', async () => {
|
||||||
|
await expect(removeGroupMember(USER, groupId, CREATOR, TODAY)).rejects.toThrow('zakladatel');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateGroupMember', () => {
|
||||||
|
let groupId: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const d = await createGroup(CREATOR, STORE, TODAY);
|
||||||
|
groupId = d.groups![0].id;
|
||||||
|
await addGroupMember(CREATOR, groupId, USER, TODAY);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('člen může upravit svá data (open)', async () => {
|
||||||
|
const d = await updateGroupMember(USER, groupId, USER, { amount: 150, note: 'Big Mac' }, TODAY);
|
||||||
|
expect(d.groups![0].members[USER].amount).toBe(150);
|
||||||
|
expect(d.groups![0].members[USER].note).toBe('Big Mac');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creator může upravit data jiného člena', async () => {
|
||||||
|
const d = await updateGroupMember(CREATOR, groupId, USER, { amount: 200 }, TODAY);
|
||||||
|
expect(d.groups![0].members[USER].amount).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('člen nemůže upravit data jiného (locked)', async () => {
|
||||||
|
await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY);
|
||||||
|
await expect(updateGroupMember(USER, groupId, USER, { amount: 100 }, TODAY)).rejects.toThrow('uzamčeno');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nikdo nemůže upravit při stavu ordered', async () => {
|
||||||
|
await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY);
|
||||||
|
await setGroupState(CREATOR, groupId, GroupState.ORDERED, TODAY);
|
||||||
|
await expect(updateGroupMember(CREATOR, groupId, USER, { amount: 100 }, TODAY)).rejects.toThrow('objednáno');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setGroupState', () => {
|
||||||
|
let groupId: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const d = await createGroup(CREATOR, STORE, TODAY);
|
||||||
|
groupId = d.groups![0].id;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('open → locked', async () => {
|
||||||
|
const d = await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY);
|
||||||
|
expect(d.groups![0].state).toBe(GroupState.LOCKED);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('locked → open (odemčení)', async () => {
|
||||||
|
await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY);
|
||||||
|
const d = await setGroupState(CREATOR, groupId, GroupState.OPEN, TODAY);
|
||||||
|
expect(d.groups![0].state).toBe(GroupState.OPEN);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('locked → ordered', async () => {
|
||||||
|
await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY);
|
||||||
|
const d = await setGroupState(CREATOR, groupId, GroupState.ORDERED, TODAY);
|
||||||
|
expect(d.groups![0].state).toBe(GroupState.ORDERED);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('open → ordered není povoleno', async () => {
|
||||||
|
await expect(setGroupState(CREATOR, groupId, GroupState.ORDERED, TODAY)).rejects.toThrow('Nelze přejít');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ordered je terminální stav', async () => {
|
||||||
|
await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY);
|
||||||
|
await setGroupState(CREATOR, groupId, GroupState.ORDERED, TODAY);
|
||||||
|
await expect(setGroupState(CREATOR, groupId, GroupState.OPEN, TODAY)).rejects.toThrow('Nelze přejít');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nečlen nemůže měnit stav', async () => {
|
||||||
|
await expect(setGroupState(USER, groupId, GroupState.LOCKED, TODAY)).rejects.toThrow('zakladatel');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { convertBbanToIban } from '../qr';
|
import { convertBbanToIban, sanitizeQrMessage } from '../qr';
|
||||||
|
|
||||||
test('konverze BBAN s prefixem na IBAN', () => {
|
test('konverze BBAN s prefixem na IBAN', () => {
|
||||||
// Číslo účtu 19-2000145399/0800 (Česká spořitelna) → CZ6508000000192000145399
|
// Číslo účtu 19-2000145399/0800 (Česká spořitelna) → CZ6508000000192000145399
|
||||||
@@ -34,3 +34,26 @@ test('výsledek vždy začíná CZ', () => {
|
|||||||
expect(convertBbanToIban('100-2000145399/0300')).toMatch(/^CZ/);
|
expect(convertBbanToIban('100-2000145399/0300')).toMatch(/^CZ/);
|
||||||
expect(convertBbanToIban('2000145399/0100')).toMatch(/^CZ/);
|
expect(convertBbanToIban('2000145399/0100')).toMatch(/^CZ/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('sanitizace zprávy – diakritika se transliteruje na základní písmena', () => {
|
||||||
|
expect(sanitizeQrMessage('Pizza Šunková')).toBe('Pizza Sunkova');
|
||||||
|
expect(sanitizeQrMessage('čaj a káva')).toBe('caj a kava');
|
||||||
|
expect(sanitizeQrMessage('Žížala, řeřicha, ďábel')).toBe('Zizala, rericha, dabel');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sanitizace zprávy – hvězdička se odstraní', () => {
|
||||||
|
expect(sanitizeQrMessage('Pizza *akce* 1+1')).toBe('Pizza akce 1+1');
|
||||||
|
expect(sanitizeQrMessage('***')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sanitizace zprávy – znaky mimo ISO 8859-1 se odstraní', () => {
|
||||||
|
// Emoji a CJK znaky nemají ASCII ekvivalent → zmizí, zbytek zůstane
|
||||||
|
expect(sanitizeQrMessage('Oběd 🍕 hotovo')).toBe('Obed hotovo');
|
||||||
|
// Znaky v rozsahu ISO 8859-1 (např. § ° é) zůstanou zachovány
|
||||||
|
expect(sanitizeQrMessage('Cena 100°C § café')).toBe('Cena 100°C § cafe');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sanitizace zprávy – ořez na 60 znaků', () => {
|
||||||
|
const long = 'a'.repeat(70);
|
||||||
|
expect(sanitizeQrMessage(long)).toHaveLength(60);
|
||||||
|
});
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ beforeEach(() => {
|
|||||||
|
|
||||||
const VALID_BODY = {
|
const VALID_BODY = {
|
||||||
recipients: [
|
recipients: [
|
||||||
{ login: 'uzivatel1', purpose: 'Pizza Margherita', amount: 149 },
|
{ login: 'uzivatel1', purpose: 'Pizza Margherita', amount: 14900 },
|
||||||
{ login: 'uzivatel2', purpose: 'Pizza Diavola', amount: 179 },
|
{ login: 'uzivatel2', purpose: 'Pizza Diavola', amount: 17900 },
|
||||||
],
|
],
|
||||||
bankAccount: '19-2000145399/0800',
|
bankAccount: '19-2000145399/0800',
|
||||||
bankAccountHolder: 'Jan Novák',
|
bankAccountHolder: 'Jan Novák',
|
||||||
@@ -76,17 +76,17 @@ test('POST /generate vrátí 400 pro zápornou částku', async () => {
|
|||||||
expect(res.body.error).toContain('částku');
|
expect(res.body.error).toContain('částku');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('POST /generate vrátí 400 pro částku s více než 2 desetinnými místy', async () => {
|
test('POST /generate vrátí 400 pro necelou částku', async () => {
|
||||||
const body = {
|
const body = {
|
||||||
...VALID_BODY,
|
...VALID_BODY,
|
||||||
recipients: [{ login: 'uzivatel1', purpose: 'Pizza', amount: 149.999 }],
|
recipients: [{ login: 'uzivatel1', purpose: 'Pizza', amount: 149.5 }],
|
||||||
};
|
};
|
||||||
const res = await request(buildApp())
|
const res = await request(buildApp())
|
||||||
.post('/api/qr/generate')
|
.post('/api/qr/generate')
|
||||||
.set('Authorization', TOKEN)
|
.set('Authorization', TOKEN)
|
||||||
.send(body);
|
.send(body);
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
expect(res.body.error).toContain('desetinná');
|
expect(res.body.error).toContain('částku');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('POST /generate vrátí 400 pro příjemce bez login', async () => {
|
test('POST /generate vrátí 400 pro příjemce bez login', async () => {
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
const mockStorageData = new Map<string, any>();
|
||||||
|
jest.mock('../storage', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: () => ({
|
||||||
|
hasData: async (key: string) => mockStorageData.has(key),
|
||||||
|
getData: async <T>(key: string) => mockStorageData.get(key) as T,
|
||||||
|
setData: async <T>(key: string, val: T) => void mockStorageData.set(key, val),
|
||||||
|
}),
|
||||||
|
storageReady: Promise.resolve(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { addChoice, getData } from '../service';
|
||||||
|
import { LunchChoice, MealSlot } from '../../../types/gen/types.gen';
|
||||||
|
|
||||||
|
const TODAY = new Date('2025-01-10');
|
||||||
|
const TODAY_STR = '2025-01-10';
|
||||||
|
const TODAY_EXTRA_STR = '2025-01-10_extra';
|
||||||
|
|
||||||
|
describe('MealSlot storage isolation', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockStorageData.clear();
|
||||||
|
jest.useFakeTimers();
|
||||||
|
jest.setSystemTime(TODAY);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addChoice slot=extra writes only to _extra key, not to obed key, and returns slot=EXTRA', async () => {
|
||||||
|
const result = await addChoice('user1', false, LunchChoice.OBJEDNAVAM, undefined, TODAY, MealSlot.EXTRA);
|
||||||
|
expect(result.slot).toBe(MealSlot.EXTRA);
|
||||||
|
expect(mockStorageData.has(TODAY_EXTRA_STR)).toBe(true);
|
||||||
|
expect(mockStorageData.has(TODAY_STR)).toBe(false);
|
||||||
|
const extraData = mockStorageData.get(TODAY_EXTRA_STR);
|
||||||
|
expect(extraData.choices.OBJEDNAVAM?.['user1']).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getData slot=extra returns slot===MealSlot.EXTRA and no menus', async () => {
|
||||||
|
await addChoice('user1', false, LunchChoice.OBJEDNAVAM, undefined, TODAY, MealSlot.EXTRA);
|
||||||
|
const result = await getData(TODAY, MealSlot.EXTRA);
|
||||||
|
expect(result.slot).toBe(MealSlot.EXTRA);
|
||||||
|
expect(result.menus).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addChoice slot=extra does not modify obed data even when obed has PIZZA choice', async () => {
|
||||||
|
mockStorageData.set(TODAY_STR, {
|
||||||
|
choices: { PIZZA: { user1: { selectedFoods: [0], trusted: false } } },
|
||||||
|
todayDayIndex: 4,
|
||||||
|
date: '10. 1. 2025',
|
||||||
|
isWeekend: false,
|
||||||
|
dayIndex: 4,
|
||||||
|
});
|
||||||
|
|
||||||
|
await addChoice('user1', false, LunchChoice.OBJEDNAVAM, undefined, TODAY, MealSlot.EXTRA);
|
||||||
|
|
||||||
|
const obed = mockStorageData.get(TODAY_STR);
|
||||||
|
expect(obed.choices.PIZZA?.['user1']).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,6 +26,10 @@ const implementations: [string, () => StorageInterface, () => void][] = [
|
|||||||
inst.hasData = async (key: string) => Promise.resolve((inst as any).db.has(key));
|
inst.hasData = async (key: string) => Promise.resolve((inst as any).db.has(key));
|
||||||
inst.getData = async (key: string) => (inst as any).db.get(key);
|
inst.getData = async (key: string) => (inst as any).db.get(key);
|
||||||
inst.setData = async (key: string, data: any) => { (inst as any).db.set(key, data); return Promise.resolve(); };
|
inst.setData = async (key: string, data: any) => { (inst as any).db.set(key, data); return Promise.resolve(); };
|
||||||
|
inst.listKeys = async (contains?: string) => {
|
||||||
|
const keys = Object.keys((inst as any).db.JSON());
|
||||||
|
return contains ? keys.filter((k: string) => k.includes(contains)) : keys;
|
||||||
|
};
|
||||||
return inst;
|
return inst;
|
||||||
}, () => {
|
}, () => {
|
||||||
if (fs.existsSync(tempDbPath)) {
|
if (fs.existsSync(tempDbPath)) {
|
||||||
@@ -76,6 +80,22 @@ describe.each(implementations)('%s splňuje StorageInterface kontrakt', (name, f
|
|||||||
expect((await storage.getData<{ val: string }>('a'))?.val).toBe('A');
|
expect((await storage.getData<{ val: string }>('a'))?.val).toBe('A');
|
||||||
expect((await storage.getData<{ val: string }>('b'))?.val).toBe('B');
|
expect((await storage.getData<{ val: string }>('b'))?.val).toBe('B');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('listKeys vrátí všechny uložené klíče', async () => {
|
||||||
|
await storage.setData('2024-01-01_extra', {});
|
||||||
|
await storage.setData('2024-01-02', {});
|
||||||
|
const keys = await storage.listKeys();
|
||||||
|
expect(keys).toContain('2024-01-01_extra');
|
||||||
|
expect(keys).toContain('2024-01-02');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('listKeys filtruje podle podřetězce', async () => {
|
||||||
|
await storage.setData('2024-01-01_extra', {});
|
||||||
|
await storage.setData('2024-01-02_extra', {});
|
||||||
|
await storage.setData('2024-01-02', {});
|
||||||
|
const keys = await storage.listKeys('_extra');
|
||||||
|
expect(keys.sort()).toEqual(['2024-01-01_extra', '2024-01-02_extra']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { resetMemoryStorage } from '../storage/memory';
|
||||||
|
import { getStores, addStore, removeStore } from '../stores';
|
||||||
|
|
||||||
|
const ADMIN_PW = 'testadmin';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetMemoryStorage();
|
||||||
|
process.env.ADMIN_PASSWORD = ADMIN_PW;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.ADMIN_PASSWORD;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getStores', () => {
|
||||||
|
test('vrátí prázdné pole, pokud seznam neexistuje', async () => {
|
||||||
|
const stores = await getStores();
|
||||||
|
expect(stores).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addStore', () => {
|
||||||
|
test('přidá obchod se správným heslem', async () => {
|
||||||
|
const stores = await addStore('McDonald\'s', ADMIN_PW);
|
||||||
|
expect(stores).toContain('McDonald\'s');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('vyhodí UNAUTHORIZED s nesprávným heslem', async () => {
|
||||||
|
await expect(addStore('KFC', 'spatne')).rejects.toThrow('UNAUTHORIZED');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('vyhodí UNAUTHORIZED pokud ADMIN_PASSWORD není nastaven', async () => {
|
||||||
|
delete process.env.ADMIN_PASSWORD;
|
||||||
|
await expect(addStore('KFC', '')).rejects.toThrow('UNAUTHORIZED');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('odmítne prázdný název', async () => {
|
||||||
|
await expect(addStore(' ', ADMIN_PW)).rejects.toThrow('prázdný');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('odmítne duplikát (case-insensitive)', async () => {
|
||||||
|
await addStore('McDonald\'s', ADMIN_PW);
|
||||||
|
await expect(addStore('mcdonald\'s', ADMIN_PW)).rejects.toThrow('existuje');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('vrátí aktualizovaný seznam', async () => {
|
||||||
|
await addStore('McDonald\'s', ADMIN_PW);
|
||||||
|
const stores = await addStore('KFC', ADMIN_PW);
|
||||||
|
expect(stores).toHaveLength(2);
|
||||||
|
expect(stores).toContain('McDonald\'s');
|
||||||
|
expect(stores).toContain('KFC');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removeStore', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await addStore('McDonald\'s', ADMIN_PW);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('odebere obchod se správným heslem', async () => {
|
||||||
|
const stores = await removeStore('McDonald\'s', ADMIN_PW);
|
||||||
|
expect(stores).not.toContain('McDonald\'s');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('case-insensitive odebrání', async () => {
|
||||||
|
const stores = await removeStore('MCDONALD\'S', ADMIN_PW);
|
||||||
|
expect(stores).not.toContain('McDonald\'s');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('vyhodí UNAUTHORIZED s nesprávným heslem', async () => {
|
||||||
|
await expect(removeStore('McDonald\'s', 'spatne')).rejects.toThrow('UNAUTHORIZED');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('odebrání neexistujícího obchodu nic nerozbije', async () => {
|
||||||
|
const stores = await removeStore('Neexistuje', ADMIN_PW);
|
||||||
|
expect(stores).toContain('McDonald\'s');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { resetMemoryStorage } from '../storage/memory';
|
||||||
|
import { addSuggestion, deleteSuggestion, listSuggestions, voteSuggestion } from '../suggestions';
|
||||||
|
|
||||||
|
const AUTHOR = 'tomas';
|
||||||
|
const VOTER = 'petr';
|
||||||
|
const OTHER = 'jana';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetMemoryStorage();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Pomocná funkce: vytvoří návrh a vrátí jeho id (z pohledu autora). */
|
||||||
|
async function createSuggestion(author = AUTHOR, title = 'Tmavý režim', description = 'Přidat tmavý režim aplikace') {
|
||||||
|
const list = await addSuggestion(author, title, description);
|
||||||
|
return list.find(s => s.title === title)!.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('addSuggestion', () => {
|
||||||
|
test('přidá návrh a autorovi nastaví hlas pro', async () => {
|
||||||
|
const list = await addSuggestion(AUTHOR, 'Tmavý režim', 'Popis');
|
||||||
|
expect(list).toHaveLength(1);
|
||||||
|
const s = list[0];
|
||||||
|
expect(s.author).toBe(AUTHOR);
|
||||||
|
expect(s.title).toBe('Tmavý režim');
|
||||||
|
expect(s.description).toBe('Popis');
|
||||||
|
expect(s.voteScore).toBe(1);
|
||||||
|
expect(s.myVote).toBe('up');
|
||||||
|
expect(s.isMine).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ořízne mezery a odmítne prázdný název i popis', async () => {
|
||||||
|
await expect(addSuggestion(AUTHOR, ' ', 'popis')).rejects.toThrow();
|
||||||
|
await expect(addSuggestion(AUTHOR, 'název', ' ')).rejects.toThrow();
|
||||||
|
const list = await addSuggestion(AUTHOR, ' Název ', ' Popis ');
|
||||||
|
expect(list[0].title).toBe('Název');
|
||||||
|
expect(list[0].description).toBe('Popis');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('jeden uživatel může přidat více návrhů', async () => {
|
||||||
|
await addSuggestion(AUTHOR, 'První', 'popis');
|
||||||
|
const list = await addSuggestion(AUTHOR, 'Druhý', 'popis');
|
||||||
|
expect(list).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('listSuggestions', () => {
|
||||||
|
test('řadí sestupně dle skóre', async () => {
|
||||||
|
const lowId = await createSuggestion(AUTHOR, 'Nízké', 'popis');
|
||||||
|
const highId = await createSuggestion(OTHER, 'Vysoké', 'popis');
|
||||||
|
// "Vysoké" dostane další hlas pro
|
||||||
|
await voteSuggestion(VOTER, highId, 'up');
|
||||||
|
|
||||||
|
const list = await listSuggestions(VOTER);
|
||||||
|
expect(list[0].id).toBe(highId);
|
||||||
|
expect(list[0].voteScore).toBe(2);
|
||||||
|
expect(list[1].id).toBe(lowId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('myVote a isMine jsou relativní k uživateli', async () => {
|
||||||
|
const id = await createSuggestion(AUTHOR);
|
||||||
|
const asAuthor = (await listSuggestions(AUTHOR))[0];
|
||||||
|
expect(asAuthor.isMine).toBe(true);
|
||||||
|
expect(asAuthor.myVote).toBe('up');
|
||||||
|
|
||||||
|
const asOther = (await listSuggestions(VOTER))[0];
|
||||||
|
expect(asOther.isMine).toBe(false);
|
||||||
|
expect(asOther.myVote).toBeUndefined();
|
||||||
|
// Seznamy hlasujících se klientovi neposílají
|
||||||
|
expect((asOther as any).upvoters).toBeUndefined();
|
||||||
|
expect(id).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('voteSuggestion', () => {
|
||||||
|
test('hlas pro a proti od jiného uživatele', async () => {
|
||||||
|
const id = await createSuggestion();
|
||||||
|
let list = await voteSuggestion(VOTER, id, 'up');
|
||||||
|
expect(list[0].voteScore).toBe(2);
|
||||||
|
expect(list.find(s => s.id === id)!.voteScore).toBe(2);
|
||||||
|
|
||||||
|
// přehlasování z pro na proti
|
||||||
|
list = await voteSuggestion(VOTER, id, 'down');
|
||||||
|
// autor +1, voter -1 => 0
|
||||||
|
expect(list[0].voteScore).toBe(0);
|
||||||
|
expect((await listSuggestions(VOTER))[0].myVote).toBe('down');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('klik na aktivní směr hlas zruší', async () => {
|
||||||
|
const id = await createSuggestion();
|
||||||
|
await voteSuggestion(VOTER, id, 'up');
|
||||||
|
const list = await voteSuggestion(VOTER, id, 'up');
|
||||||
|
// zůstává jen autorův hlas
|
||||||
|
expect(list[0].voteScore).toBe(1);
|
||||||
|
expect((await listSuggestions(VOTER))[0].myVote).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('autor může svůj automatický hlas odebrat', async () => {
|
||||||
|
const id = await createSuggestion();
|
||||||
|
const list = await voteSuggestion(AUTHOR, id, 'up');
|
||||||
|
expect(list[0].voteScore).toBe(0);
|
||||||
|
expect((await listSuggestions(AUTHOR))[0].myVote).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hlasování pro neexistující návrh vyhodí chybu', async () => {
|
||||||
|
await expect(voteSuggestion(VOTER, 'neexistuje', 'up')).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteSuggestion', () => {
|
||||||
|
test('autor smaže svůj návrh včetně hlasů', async () => {
|
||||||
|
const id = await createSuggestion();
|
||||||
|
await voteSuggestion(VOTER, id, 'up');
|
||||||
|
const list = await deleteSuggestion(AUTHOR, id);
|
||||||
|
expect(list).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cizí uživatel nemůže smazat návrh', async () => {
|
||||||
|
const id = await createSuggestion();
|
||||||
|
await expect(deleteSuggestion(VOTER, id)).rejects.toThrow();
|
||||||
|
expect(await listSuggestions(AUTHOR)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('smazání neexistujícího návrhu vyhodí chybu', async () => {
|
||||||
|
await expect(deleteSuggestion(AUTHOR, 'neexistuje')).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import { getUserVotes, updateFeatureVote, getVotingStats } from '../voting';
|
|
||||||
import { resetMemoryStorage } from '../storage/memory';
|
|
||||||
import { FeatureRequest } from '../../../types/gen/types.gen';
|
|
||||||
|
|
||||||
const OPT_A = FeatureRequest.STATISTICS;
|
|
||||||
const OPT_B = FeatureRequest.UI;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
resetMemoryStorage();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('updateFeatureVote', () => {
|
|
||||||
test('přidá hlas pro nového uživatele', async () => {
|
|
||||||
const result = await updateFeatureVote('alice', OPT_A, true);
|
|
||||||
expect(result['alice']).toContain(OPT_A);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('vyhodí chybu při duplicitním hlasování', async () => {
|
|
||||||
await updateFeatureVote('alice', OPT_A, true);
|
|
||||||
await expect(updateFeatureVote('alice', OPT_A, true)).rejects.toThrow('hlasovali');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('odebere hlas', async () => {
|
|
||||||
await updateFeatureVote('alice', OPT_A, true);
|
|
||||||
await updateFeatureVote('alice', OPT_A, false);
|
|
||||||
const stats = await getVotingStats();
|
|
||||||
expect(stats[OPT_A] ?? 0).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('odebrání neexistujícího hlasu je no-op', async () => {
|
|
||||||
await expect(updateFeatureVote('alice', OPT_A, false)).resolves.not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('odebrání posledního hlasu odstraní login ze storage', async () => {
|
|
||||||
await updateFeatureVote('alice', OPT_A, true);
|
|
||||||
const data = await updateFeatureVote('alice', OPT_A, false);
|
|
||||||
expect('alice' in data).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('vyhodí chybu po 4 hlasech', async () => {
|
|
||||||
const options = Object.values(FeatureRequest);
|
|
||||||
for (let i = 0; i < 4; i++) {
|
|
||||||
await updateFeatureVote('alice', options[i], true);
|
|
||||||
}
|
|
||||||
await expect(updateFeatureVote('alice', options[4], true)).rejects.toThrow('4');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getUserVotes', () => {
|
|
||||||
test('vrátí hlasy uživatele', async () => {
|
|
||||||
await updateFeatureVote('alice', OPT_A, true);
|
|
||||||
const votes = await getUserVotes('alice');
|
|
||||||
expect(votes).toContain(OPT_A);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('vrátí prázdné pole pro uživatele bez hlasů', async () => {
|
|
||||||
const votes = await getUserVotes('neexistujici');
|
|
||||||
expect(votes).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getVotingStats', () => {
|
|
||||||
test('vrátí agregované počty hlasů', async () => {
|
|
||||||
await updateFeatureVote('alice', OPT_A, true);
|
|
||||||
await updateFeatureVote('bob', OPT_A, true);
|
|
||||||
await updateFeatureVote('bob', OPT_B, true);
|
|
||||||
|
|
||||||
const stats = await getVotingStats();
|
|
||||||
expect(stats[OPT_A]).toBe(2);
|
|
||||||
expect(stats[OPT_B]).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('vrátí prázdný objekt bez hlasů', async () => {
|
|
||||||
const stats = await getVotingStats();
|
|
||||||
expect(stats).toEqual({});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import express from 'express';
|
|
||||||
import request from 'supertest';
|
|
||||||
import bodyParser from 'body-parser';
|
|
||||||
import { generateToken } from '../auth';
|
|
||||||
import { resetMemoryStorage } from '../storage/memory';
|
|
||||||
import { FeatureRequest } from '../../../types/gen/types.gen';
|
|
||||||
import votingRouter from '../routes/votingRoutes';
|
|
||||||
|
|
||||||
const VALID_OPTION = FeatureRequest.STATISTICS;
|
|
||||||
|
|
||||||
function buildApp() {
|
|
||||||
const app = express();
|
|
||||||
app.use(bodyParser.json());
|
|
||||||
app.use('/api/voting', votingRouter);
|
|
||||||
app.use((err: any, _req: any, res: any, _next: any) => {
|
|
||||||
res.status(400).json({ error: err.message });
|
|
||||||
});
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TOKEN = `Bearer ${generateToken('testuser')}`;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
resetMemoryStorage();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('GET /getVotes vrátí 200 s prázdným polem pro nového uživatele', async () => {
|
|
||||||
const res = await request(buildApp())
|
|
||||||
.get('/api/voting/getVotes')
|
|
||||||
.set('Authorization', TOKEN);
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('GET /getVotes vrátí 401 bez tokenu', async () => {
|
|
||||||
const res = await request(buildApp()).get('/api/voting/getVotes');
|
|
||||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('POST /updateVote přidá hlas a vrátí 200', async () => {
|
|
||||||
const res = await request(buildApp())
|
|
||||||
.post('/api/voting/updateVote')
|
|
||||||
.set('Authorization', TOKEN)
|
|
||||||
.send({ option: VALID_OPTION, active: true });
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('POST /updateVote vrátí 400 pro chybějící parametry', async () => {
|
|
||||||
const res = await request(buildApp())
|
|
||||||
.post('/api/voting/updateVote')
|
|
||||||
.set('Authorization', TOKEN)
|
|
||||||
.send({});
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('POST /updateVote vrátí 400 při duplicitním hlasu', async () => {
|
|
||||||
const app = buildApp();
|
|
||||||
await request(app)
|
|
||||||
.post('/api/voting/updateVote')
|
|
||||||
.set('Authorization', TOKEN)
|
|
||||||
.send({ option: VALID_OPTION, active: true });
|
|
||||||
const res = await request(app)
|
|
||||||
.post('/api/voting/updateVote')
|
|
||||||
.set('Authorization', TOKEN)
|
|
||||||
.send({ option: VALID_OPTION, active: true });
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
expect(res.body.error).toContain('hlasovali');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('GET /stats vrátí 200 s objektem', async () => {
|
|
||||||
const res = await request(buildApp())
|
|
||||||
.get('/api/voting/stats')
|
|
||||||
.set('Authorization', TOKEN);
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(typeof res.body).toBe('object');
|
|
||||||
});
|
|
||||||
+2
-2
@@ -90,7 +90,7 @@ export const parseToken = (req: any) => {
|
|||||||
export const checkQueryParams = (req: any, paramNames: string[]) => {
|
export const checkQueryParams = (req: any, paramNames: string[]) => {
|
||||||
for (const name of paramNames) {
|
for (const name of paramNames) {
|
||||||
if (req.query[name] == null) {
|
if (req.query[name] == null) {
|
||||||
throw Error(`Nebyl předán parametr '${name}' v query požadavku`);
|
throw new Error(`Nebyl předán parametr '${name}' v query požadavku`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,7 +105,7 @@ export const checkQueryParams = (req: any, paramNames: string[]) => {
|
|||||||
export const checkBodyParams = (req: any, paramNames: string[]) => {
|
export const checkBodyParams = (req: any, paramNames: string[]) => {
|
||||||
for (const name of paramNames) {
|
for (const name of paramNames) {
|
||||||
if (req.body[name] == null) {
|
if (req.body[name] == null) {
|
||||||
throw Error(`Nebyl předán parametr '${name}' v těle požadavku`);
|
throw new Error(`Nebyl předán parametr '${name}' v těle požadavku`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
import { FeatureRequest, VotingStats } from "../../types/gen/types.gen";
|
|
||||||
import getStorage from "./storage";
|
|
||||||
|
|
||||||
interface VotingData {
|
|
||||||
[login: string]: FeatureRequest[],
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VotingStatsResult {
|
|
||||||
[feature: string]: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const storage = getStorage();
|
|
||||||
const STORAGE_KEY = 'voting';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Vrátí pole voleb, pro které uživatel aktuálně hlasuje.
|
|
||||||
*
|
|
||||||
* @param login login uživatele
|
|
||||||
* @returns pole voleb
|
|
||||||
*/
|
|
||||||
export async function getUserVotes(login: string) {
|
|
||||||
const data = await storage.getData<VotingData>(STORAGE_KEY);
|
|
||||||
return data?.[login] || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Aktualizuje hlas uživatele pro konkrétní volbu.
|
|
||||||
*
|
|
||||||
* @param login login uživatele
|
|
||||||
* @param option volba
|
|
||||||
* @param active příznak, zda volbu přidat nebo odebrat
|
|
||||||
* @returns aktuální data
|
|
||||||
*/
|
|
||||||
export async function updateFeatureVote(login: string, option: FeatureRequest, active: boolean): Promise<VotingData> {
|
|
||||||
let data = await storage.getData<VotingData>(STORAGE_KEY);
|
|
||||||
data ??= {};
|
|
||||||
if (!(login in data)) {
|
|
||||||
data[login] = [];
|
|
||||||
}
|
|
||||||
const index = data[login].indexOf(option);
|
|
||||||
if (index > -1) {
|
|
||||||
if (active) {
|
|
||||||
throw Error('Pro tuto možnost jste již hlasovali');
|
|
||||||
} else {
|
|
||||||
data[login].splice(index, 1);
|
|
||||||
if (data[login].length === 0) {
|
|
||||||
delete data[login];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (active) {
|
|
||||||
if (data[login].length == 4) {
|
|
||||||
throw Error('Je možné hlasovat pro maximálně 4 možnosti');
|
|
||||||
}
|
|
||||||
data[login].push(option);
|
|
||||||
}
|
|
||||||
await storage.setData(STORAGE_KEY, data);
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Vrátí agregované statistiky hlasování - počet hlasů pro každou funkci.
|
|
||||||
*
|
|
||||||
* @returns objekt, kde klíčem je název funkce a hodnotou počet hlasů
|
|
||||||
*/
|
|
||||||
export async function getVotingStats(): Promise<VotingStatsResult> {
|
|
||||||
const data = await storage.getData<VotingData>(STORAGE_KEY);
|
|
||||||
const stats: VotingStatsResult = {};
|
|
||||||
if (data) {
|
|
||||||
for (const votes of Object.values(data)) {
|
|
||||||
for (const feature of votes) {
|
|
||||||
stats[feature] = (stats[feature] || 0) + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return stats;
|
|
||||||
}
|
|
||||||
+12
-2
@@ -11,6 +11,12 @@ export const initWebsocket = (server: any) => {
|
|||||||
io.on("connection", (socket) => {
|
io.on("connection", (socket) => {
|
||||||
console.log(`New client connected: ${socket.id}`);
|
console.log(`New client connected: ${socket.id}`);
|
||||||
|
|
||||||
|
socket.on("join", (login: string) => {
|
||||||
|
if (login && typeof login === "string") {
|
||||||
|
socket.join(`user:${login}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
socket.on("message", (message) => {
|
socket.on("message", (message) => {
|
||||||
io.emit("message", message);
|
io.emit("message", message);
|
||||||
});
|
});
|
||||||
@@ -22,6 +28,10 @@ export const initWebsocket = (server: any) => {
|
|||||||
return io;
|
return io;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getWebsocket = () => {
|
export const getWebsocket = () => io;
|
||||||
return io;
|
|
||||||
|
/** Pošle event konkrétnímu přihlášenému uživateli (pokud je připojen). */
|
||||||
|
export const emitToUser = (login: string, event: string, data: unknown) => {
|
||||||
|
if (!io) return;
|
||||||
|
io.to(`user:${login}`).emit(event, data);
|
||||||
}
|
}
|
||||||
+37
-7
@@ -69,18 +69,48 @@ paths:
|
|||||||
/stats:
|
/stats:
|
||||||
$ref: "./paths/stats/stats.yml"
|
$ref: "./paths/stats/stats.yml"
|
||||||
|
|
||||||
# Hlasování (/api/voting)
|
# Návrhy na vylepšení (/api/suggestions)
|
||||||
/voting/getVotes:
|
/suggestions/list:
|
||||||
$ref: "./paths/voting/getVotes.yml"
|
$ref: "./paths/suggestions/list.yml"
|
||||||
/voting/updateVote:
|
/suggestions/add:
|
||||||
$ref: "./paths/voting/updateVote.yml"
|
$ref: "./paths/suggestions/add.yml"
|
||||||
/voting/stats:
|
/suggestions/vote:
|
||||||
$ref: "./paths/voting/getVotingStats.yml"
|
$ref: "./paths/suggestions/vote.yml"
|
||||||
|
/suggestions/delete:
|
||||||
|
$ref: "./paths/suggestions/delete.yml"
|
||||||
|
|
||||||
# Changelog (/api/changelogs)
|
# Changelog (/api/changelogs)
|
||||||
/changelogs:
|
/changelogs:
|
||||||
$ref: "./paths/changelogs/getChangelogs.yml"
|
$ref: "./paths/changelogs/getChangelogs.yml"
|
||||||
|
|
||||||
|
# Skupiny objednávek (/api/groups)
|
||||||
|
/groups/dates:
|
||||||
|
$ref: "./paths/groups/getOrderDates.yml"
|
||||||
|
/groups/create:
|
||||||
|
$ref: "./paths/groups/createGroup.yml"
|
||||||
|
/groups/delete:
|
||||||
|
$ref: "./paths/groups/deleteGroup.yml"
|
||||||
|
/groups/addMember:
|
||||||
|
$ref: "./paths/groups/addMember.yml"
|
||||||
|
/groups/removeMember:
|
||||||
|
$ref: "./paths/groups/removeMember.yml"
|
||||||
|
/groups/updateMember:
|
||||||
|
$ref: "./paths/groups/updateMember.yml"
|
||||||
|
/groups/setState:
|
||||||
|
$ref: "./paths/groups/setState.yml"
|
||||||
|
/groups/updateTimes:
|
||||||
|
$ref: "./paths/groups/updateTimes.yml"
|
||||||
|
/groups/updateFees:
|
||||||
|
$ref: "./paths/groups/updateFees.yml"
|
||||||
|
|
||||||
|
# Správa obchodů (/api/stores)
|
||||||
|
/stores:
|
||||||
|
$ref: "./paths/stores/listStores.yml"
|
||||||
|
/stores/add:
|
||||||
|
$ref: "./paths/stores/addStore.yml"
|
||||||
|
/stores/delete:
|
||||||
|
$ref: "./paths/stores/deleteStore.yml"
|
||||||
|
|
||||||
# DEV endpointy (/api/dev)
|
# DEV endpointy (/api/dev)
|
||||||
/dev/generate:
|
/dev/generate:
|
||||||
$ref: "./paths/dev/generate.yml"
|
$ref: "./paths/dev/generate.yml"
|
||||||
|
|||||||
Generated
+594
@@ -0,0 +1,594 @@
|
|||||||
|
{
|
||||||
|
"name": "@luncher/types",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "@luncher/types",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"@hey-api/client-fetch": "^0.8.2",
|
||||||
|
"@hey-api/openapi-ts": "^0.64.7",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@hey-api/client-fetch": {
|
||||||
|
"version": "0.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@hey-api/client-fetch/-/client-fetch-0.8.2.tgz",
|
||||||
|
"integrity": "sha512-61T4UGfAzY5345vMxWDX8qnSTNRJcOpWuZyvNu3vNebCTLPwMQAM85mhEuBoACdWeRtLhNoUjU0UR5liRyD1bA==",
|
||||||
|
"deprecated": "Starting with v0.73.0, this package is bundled directly inside @hey-api/openapi-ts.",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/hey-api"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@hey-api/json-schema-ref-parser": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-F6LSkttZcT/XiX3ydeDqTY3uRN3BLJMwyMTk4kg/ichZlKUp3+3Odv0WokSmXGSoZGTW/N66FROMYAm5NPdJlA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jsdevtools/ono": "^7.1.3",
|
||||||
|
"@types/json-schema": "^7.0.15",
|
||||||
|
"js-yaml": "^4.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/hey-api"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@hey-api/openapi-ts": {
|
||||||
|
"version": "0.64.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.64.7.tgz",
|
||||||
|
"integrity": "sha512-xpaBzdGAKz7cPuGah1GZWl3zTZquOXRnwmpVCQKEUpvDtSYWBLjSxtAYIqDBjwJkuNHcakZBIWLcappx7slc2g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@hey-api/json-schema-ref-parser": "1.0.2",
|
||||||
|
"c12": "2.0.1",
|
||||||
|
"commander": "13.0.0",
|
||||||
|
"handlebars": "4.7.8"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"openapi-ts": "bin/index.cjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.18.0 || ^20.9.0 || >=22.11.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/hey-api"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5.5.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@jsdevtools/ono": {
|
||||||
|
"version": "7.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz",
|
||||||
|
"integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/json-schema": {
|
||||||
|
"version": "7.0.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||||
|
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/acorn": {
|
||||||
|
"version": "8.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
|
||||||
|
"integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"acorn": "bin/acorn"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/argparse": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Python-2.0"
|
||||||
|
},
|
||||||
|
"node_modules/c12": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/c12/-/c12-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-Z4JgsKXHG37C6PYUtIxCfLJZvo6FyhHJoClwwb9ftUkLpPSkuYqn6Tr+vnaN8hymm0kIbcg6Ey3kv/Q71k5w/A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"chokidar": "^4.0.1",
|
||||||
|
"confbox": "^0.1.7",
|
||||||
|
"defu": "^6.1.4",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"giget": "^1.2.3",
|
||||||
|
"jiti": "^2.3.0",
|
||||||
|
"mlly": "^1.7.1",
|
||||||
|
"ohash": "^1.1.4",
|
||||||
|
"pathe": "^1.1.2",
|
||||||
|
"perfect-debounce": "^1.0.0",
|
||||||
|
"pkg-types": "^1.2.0",
|
||||||
|
"rc9": "^2.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"magicast": "^0.3.5"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"magicast": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/chokidar": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"readdirp": "^4.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.16.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/chownr": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/citty": {
|
||||||
|
"version": "0.1.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
|
||||||
|
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"consola": "^3.2.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/commander": {
|
||||||
|
"version": "13.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/commander/-/commander-13.0.0.tgz",
|
||||||
|
"integrity": "sha512-oPYleIY8wmTVzkvQq10AEok6YcTC4sRUBl8F9gVuwchGVUCTbl/vhLTaQqutuuySYOsu8YTgV+OxKc/8Yvx+mQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/confbox": {
|
||||||
|
"version": "0.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz",
|
||||||
|
"integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/consola": {
|
||||||
|
"version": "3.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.0.tgz",
|
||||||
|
"integrity": "sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^14.18.0 || >=16.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/defu": {
|
||||||
|
"version": "6.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
|
||||||
|
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/destr": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-2N3BOUU4gYMpTP24s5rF5iP7BDr7uNTCs4ozw3kf/eKfvWSIu93GEBi5m427YoyJoeOzQ5smuu4nNAPGb8idSQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/dotenv": {
|
||||||
|
"version": "16.4.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
|
||||||
|
"integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://dotenvx.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fs-minipass": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"minipass": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fs-minipass/node_modules/minipass": {
|
||||||
|
"version": "3.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
|
||||||
|
"integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"yallist": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/giget": {
|
||||||
|
"version": "1.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/giget/-/giget-1.2.5.tgz",
|
||||||
|
"integrity": "sha512-r1ekGw/Bgpi3HLV3h1MRBIlSAdHoIMklpaQ3OQLFcRw9PwAj2rqigvIbg+dBUI51OxVI2jsEtDywDBjSiuf7Ug==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"citty": "^0.1.6",
|
||||||
|
"consola": "^3.4.0",
|
||||||
|
"defu": "^6.1.4",
|
||||||
|
"node-fetch-native": "^1.6.6",
|
||||||
|
"nypm": "^0.5.4",
|
||||||
|
"pathe": "^2.0.3",
|
||||||
|
"tar": "^6.2.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"giget": "dist/cli.mjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/giget/node_modules/pathe": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/handlebars": {
|
||||||
|
"version": "4.7.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz",
|
||||||
|
"integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"minimist": "^1.2.5",
|
||||||
|
"neo-async": "^2.6.2",
|
||||||
|
"source-map": "^0.6.1",
|
||||||
|
"wordwrap": "^1.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"handlebars": "bin/handlebars"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.7"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"uglify-js": "^3.1.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jiti": {
|
||||||
|
"version": "2.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
|
||||||
|
"integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/js-yaml": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"argparse": "^2.0.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"js-yaml": "bin/js-yaml.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/minimist": {
|
||||||
|
"version": "1.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||||
|
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/minipass": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/minizlib": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"minipass": "^3.0.0",
|
||||||
|
"yallist": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/minizlib/node_modules/minipass": {
|
||||||
|
"version": "3.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
|
||||||
|
"integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"yallist": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mkdirp": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"mkdirp": "bin/cmd.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mlly": {
|
||||||
|
"version": "1.7.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.4.tgz",
|
||||||
|
"integrity": "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"acorn": "^8.14.0",
|
||||||
|
"pathe": "^2.0.1",
|
||||||
|
"pkg-types": "^1.3.0",
|
||||||
|
"ufo": "^1.5.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mlly/node_modules/pathe": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/neo-async": {
|
||||||
|
"version": "2.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
|
||||||
|
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/node-fetch-native": {
|
||||||
|
"version": "1.6.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.6.tgz",
|
||||||
|
"integrity": "sha512-8Mc2HhqPdlIfedsuZoc3yioPuzp6b+L5jRCRY1QzuWZh2EGJVQrGppC6V6cF0bLdbW0+O2YpqCA25aF/1lvipQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/nypm": {
|
||||||
|
"version": "0.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.5.4.tgz",
|
||||||
|
"integrity": "sha512-X0SNNrZiGU8/e/zAB7sCTtdxWTMSIO73q+xuKgglm2Yvzwlo8UoC5FNySQFCvl84uPaeADkqHUZUkWy4aH4xOA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"citty": "^0.1.6",
|
||||||
|
"consola": "^3.4.0",
|
||||||
|
"pathe": "^2.0.3",
|
||||||
|
"pkg-types": "^1.3.1",
|
||||||
|
"tinyexec": "^0.3.2",
|
||||||
|
"ufo": "^1.5.4"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"nypm": "dist/cli.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^14.16.0 || >=16.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nypm/node_modules/pathe": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/ohash": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/ohash/-/ohash-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-FlDryZAahJmEF3VR3w1KogSEdWX3WhA5GPakFx4J81kEAiHyLMpdLLElS8n8dfNadMgAne/MywcvmogzscVt4g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pathe": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/perfect-debounce": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pkg-types": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"confbox": "^0.1.8",
|
||||||
|
"mlly": "^1.7.4",
|
||||||
|
"pathe": "^2.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pkg-types/node_modules/pathe": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/rc9": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"defu": "^6.1.4",
|
||||||
|
"destr": "^2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/readdirp": {
|
||||||
|
"version": "4.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||||
|
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.18.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/source-map": {
|
||||||
|
"version": "0.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||||
|
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tar": {
|
||||||
|
"version": "6.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
|
||||||
|
"integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==",
|
||||||
|
"deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"chownr": "^2.0.0",
|
||||||
|
"fs-minipass": "^2.0.0",
|
||||||
|
"minipass": "^5.0.0",
|
||||||
|
"minizlib": "^2.1.1",
|
||||||
|
"mkdirp": "^1.0.3",
|
||||||
|
"yallist": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tinyexec": {
|
||||||
|
"version": "0.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
|
||||||
|
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ufo": {
|
||||||
|
"version": "1.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.4.tgz",
|
||||||
|
"integrity": "sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/uglify-js": {
|
||||||
|
"version": "3.19.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
|
||||||
|
"integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"optional": true,
|
||||||
|
"bin": {
|
||||||
|
"uglifyjs": "bin/uglifyjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wordwrap": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/yallist": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,8 @@ post:
|
|||||||
$ref: "../../schemas/_index.yml#/DayIndex"
|
$ref: "../../schemas/_index.yml#/DayIndex"
|
||||||
foodIndex:
|
foodIndex:
|
||||||
$ref: "../../schemas/_index.yml#/FoodIndex"
|
$ref: "../../schemas/_index.yml#/FoodIndex"
|
||||||
|
slot:
|
||||||
|
$ref: "../../schemas/_index.yml#/MealSlot"
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
$ref: "../../api.yml#/components/responses/ClientDataResponse"
|
$ref: "../../api.yml#/components/responses/ClientDataResponse"
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ post:
|
|||||||
$ref: "../../schemas/_index.yml#/LunchChoice"
|
$ref: "../../schemas/_index.yml#/LunchChoice"
|
||||||
dayIndex:
|
dayIndex:
|
||||||
$ref: "../../schemas/_index.yml#/DayIndex"
|
$ref: "../../schemas/_index.yml#/DayIndex"
|
||||||
|
slot:
|
||||||
|
$ref: "../../schemas/_index.yml#/MealSlot"
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
$ref: "../../api.yml#/components/responses/ClientDataResponse"
|
$ref: "../../api.yml#/components/responses/ClientDataResponse"
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ post:
|
|||||||
$ref: "../../schemas/_index.yml#/LunchChoice"
|
$ref: "../../schemas/_index.yml#/LunchChoice"
|
||||||
dayIndex:
|
dayIndex:
|
||||||
$ref: "../../schemas/_index.yml#/DayIndex"
|
$ref: "../../schemas/_index.yml#/DayIndex"
|
||||||
|
slot:
|
||||||
|
$ref: "../../schemas/_index.yml#/MealSlot"
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
$ref: "../../api.yml#/components/responses/ClientDataResponse"
|
$ref: "../../api.yml#/components/responses/ClientDataResponse"
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
post:
|
post:
|
||||||
operationId: setBuyer
|
operationId: setBuyer
|
||||||
summary: Nastavení/odnastavení aktuálně přihlášeného uživatele jako objednatele pro stav "Budu objednávat" pro aktuální den.
|
summary: Nastavení/odnastavení aktuálně přihlášeného uživatele jako objednatele pro stav "Budu objednávat" pro aktuální den.
|
||||||
|
requestBody:
|
||||||
|
required: false
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
properties:
|
||||||
|
slot:
|
||||||
|
$ref: "../../schemas/_index.yml#/MealSlot"
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Stav byl úspěšně změněn.
|
description: Stav byl úspěšně změněn.
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ post:
|
|||||||
$ref: "../../schemas/_index.yml#/DayIndex"
|
$ref: "../../schemas/_index.yml#/DayIndex"
|
||||||
note:
|
note:
|
||||||
type: string
|
type: string
|
||||||
|
slot:
|
||||||
|
$ref: "../../schemas/_index.yml#/MealSlot"
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
$ref: "../../api.yml#/components/responses/ClientDataResponse"
|
$ref: "../../api.yml#/components/responses/ClientDataResponse"
|
||||||
|
|||||||
@@ -9,6 +9,20 @@ get:
|
|||||||
type: integer
|
type: integer
|
||||||
minimum: 0
|
minimum: 0
|
||||||
maximum: 4
|
maximum: 4
|
||||||
|
- in: query
|
||||||
|
name: date
|
||||||
|
description: >-
|
||||||
|
Konkrétní datum (YYYY-MM-DD), pro které se mají vrátit data. Má přednost
|
||||||
|
před dayIndex a umožňuje načtení historických dat i mimo aktuální týden.
|
||||||
|
Datum v budoucnosti je oříznuto na dnešek.
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: date
|
||||||
|
- in: query
|
||||||
|
name: slot
|
||||||
|
description: Slot jídla. Pokud není předán, je použit výchozí slot (oběd).
|
||||||
|
schema:
|
||||||
|
$ref: "../schemas/_index.yml#/MealSlot"
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
$ref: "../api.yml#/components/responses/ClientDataResponse"
|
$ref: "../api.yml#/components/responses/ClientDataResponse"
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
post:
|
||||||
|
operationId: addGroupMember
|
||||||
|
summary: Přidá uživatele do skupiny (sebe, nebo jiného jako zakladatel).
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
description: ID skupiny
|
||||||
|
type: string
|
||||||
|
login:
|
||||||
|
description: Login uživatele (volitelné — pokud není zadán, přidá přihlášeného uživatele)
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
$ref: "../../api.yml#/components/responses/ClientDataResponse"
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
post:
|
||||||
|
operationId: createGroup
|
||||||
|
summary: Vytvoří novou skupinu objednávky pro aktuální den.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
description: Název obchodu/restaurace (musí být v seznamu povolených obchodů)
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
$ref: "../../api.yml#/components/responses/ClientDataResponse"
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
post:
|
||||||
|
operationId: deleteGroup
|
||||||
|
summary: Smaže skupinu objednávky (pouze zakladatel).
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
description: ID skupiny
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
$ref: "../../api.yml#/components/responses/ClientDataResponse"
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
get:
|
||||||
|
operationId: getOrderDates
|
||||||
|
summary: Vrátí seznam dnů, pro které existuje alespoň jedna objednávková skupina.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Seznam dnů s objednávkou
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- dates
|
||||||
|
properties:
|
||||||
|
dates:
|
||||||
|
description: Pole ISO dat (YYYY-MM-DD) s alespoň jednou skupinou
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
format: date
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
post:
|
||||||
|
operationId: removeGroupMember
|
||||||
|
summary: Odebere uživatele ze skupiny (sebe, nebo jiného jako zakladatel).
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- login
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
description: ID skupiny
|
||||||
|
type: string
|
||||||
|
login:
|
||||||
|
description: Login uživatele k odebrání
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
$ref: "../../api.yml#/components/responses/ClientDataResponse"
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
post:
|
||||||
|
operationId: setGroupState
|
||||||
|
summary: Změní stav skupiny (pouze zakladatel).
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- state
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
description: ID skupiny
|
||||||
|
type: string
|
||||||
|
state:
|
||||||
|
$ref: "../../schemas/_index.yml#/GroupState"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
$ref: "../../api.yml#/components/responses/ClientDataResponse"
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
post:
|
||||||
|
operationId: updateGroupFees
|
||||||
|
summary: Aktualizuje skupinové poplatky a slevu (pouze zakladatel, pouze otevřená skupina).
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
description: ID skupiny
|
||||||
|
type: string
|
||||||
|
fees:
|
||||||
|
description: Poplatky (haléře)
|
||||||
|
type: integer
|
||||||
|
shipping:
|
||||||
|
description: Doprava (haléře)
|
||||||
|
type: integer
|
||||||
|
tip:
|
||||||
|
description: Spropitné (haléře)
|
||||||
|
type: integer
|
||||||
|
discountType:
|
||||||
|
description: Typ slevy
|
||||||
|
type: string
|
||||||
|
enum: [percent, fixed]
|
||||||
|
discountValue:
|
||||||
|
description: Hodnota slevy (procenta nebo haléře pro fixed)
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
$ref: "../../api.yml#/components/responses/ClientDataResponse"
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
post:
|
||||||
|
operationId: updateGroupMember
|
||||||
|
summary: Aktualizuje data člena skupiny (částka, poznámka, příplatek).
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- login
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
description: ID skupiny
|
||||||
|
type: string
|
||||||
|
login:
|
||||||
|
description: Login člena ke změně
|
||||||
|
type: string
|
||||||
|
amount:
|
||||||
|
description: Částka k úhradě v haléřích
|
||||||
|
type: integer
|
||||||
|
note:
|
||||||
|
description: Poznámka
|
||||||
|
type: string
|
||||||
|
surchargeText:
|
||||||
|
description: Popis příplatku
|
||||||
|
type: string
|
||||||
|
surchargeAmount:
|
||||||
|
description: Výše příplatku v haléřích
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
$ref: "../../api.yml#/components/responses/ClientDataResponse"
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
post:
|
||||||
|
operationId: updateGroupTimes
|
||||||
|
summary: Aktualizuje časy objednání a doručení skupiny (pouze zakladatel).
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
description: ID skupiny
|
||||||
|
type: string
|
||||||
|
orderedAt:
|
||||||
|
description: Čas objednání ve formátu HH:MM
|
||||||
|
type: string
|
||||||
|
deliveryAt:
|
||||||
|
description: Očekávaný čas doručení ve formátu HH:MM
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
$ref: "../../api.yml#/components/responses/ClientDataResponse"
|
||||||
@@ -16,8 +16,8 @@ post:
|
|||||||
type: string
|
type: string
|
||||||
description: Textový popis přirážky/slevy
|
description: Textový popis přirážky/slevy
|
||||||
price:
|
price:
|
||||||
type: number
|
type: integer
|
||||||
description: Částka přirážky/slevy v Kč
|
description: Částka přirážky/slevy v haléřích
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Nastavení přirážky/slevy proběhlo úspěšně.
|
description: Nastavení přirážky/slevy proběhlo úspěšně.
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
post:
|
||||||
|
operationId: addStore
|
||||||
|
summary: Přidá obchod do seznamu povolených (vyžaduje admin heslo).
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
- heslo
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
description: Název obchodu/restaurace
|
||||||
|
type: string
|
||||||
|
heslo:
|
||||||
|
description: Admin heslo (ADMIN_PASSWORD)
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Obchod byl přidán
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user