Compare commits
23 Commits
c7f78cf2c9
..
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 |
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
+30
-51
@@ -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, 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, 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>();
|
||||||
@@ -449,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 });
|
||||||
})
|
})
|
||||||
@@ -458,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);
|
||||||
}
|
}
|
||||||
@@ -696,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">
|
||||||
@@ -732,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!);
|
||||||
@@ -881,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
|
||||||
|
|||||||
@@ -6,15 +6,22 @@ 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 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 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={
|
<Route path={OBJEDNANI_URL} element={
|
||||||
<ProvideSettings>
|
<ProvideSettings>
|
||||||
<SocketContext.Provider value={socket}>
|
<SocketContext.Provider value={socket}>
|
||||||
|
|||||||
@@ -110,3 +110,16 @@ 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, OBJEDNANI_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,15 +151,20 @@ 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>
|
||||||
@@ -233,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Modal, Button, Form, Table, Alert } from "react-bootstrap";
|
import { Modal, Button, Form, Table, Alert } from "react-bootstrap";
|
||||||
import { updateGroupFees, OrderGroup, OrderGroupMember } from "../../../../types";
|
import { updateGroupFees, OrderGroup, OrderGroupMember } from "../../../../types";
|
||||||
|
import { computeFeeShare, computeMemberTotal, countActiveMembers, isActiveMember } from "../../utils/groupFees";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -9,18 +10,14 @@ type Props = {
|
|||||||
onSaved: (data: any) => void;
|
onSaved: (data: any) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function parseNum(s: string): number {
|
function parseHal(s: string): number {
|
||||||
const n = parseFloat(s.replace(',', '.'));
|
const n = parseFloat(s.replace(',', '.'));
|
||||||
return isNaN(n) || n < 0 ? 0 : Math.round(n * 100) / 100;
|
return isNaN(n) || n < 0 ? 0 : Math.round(n * 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeMemberTotal(member: OrderGroupMember, feeShare: number, discountType: string, discountValue: number, memberCount: number): number {
|
function parsePercent(s: string): number {
|
||||||
const base = member.amount ?? 0;
|
const n = parseFloat(s.replace(',', '.'));
|
||||||
const surcharge = member.surchargeAmount ?? 0;
|
return isNaN(n) || n < 0 ? 0 : Math.round(n);
|
||||||
const discount = discountType === 'percent'
|
|
||||||
? Math.round((base + surcharge) * discountValue / 100 * 100) / 100
|
|
||||||
: Math.round(discountValue / memberCount * 100) / 100;
|
|
||||||
return Math.round((base + surcharge + feeShare - discount) * 100) / 100;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EditGroupFeesModal({ isOpen, onClose, group, onSaved }: Readonly<Props>) {
|
export default function EditGroupFeesModal({ isOpen, onClose, group, onSaved }: Readonly<Props>) {
|
||||||
@@ -34,40 +31,42 @@ export default function EditGroupFeesModal({ isOpen, onClose, group, onSaved }:
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
setFees(group.fees ? String(group.fees) : '');
|
setFees(group.fees ? String(group.fees / 100) : '');
|
||||||
setShipping(group.shipping ? String(group.shipping) : '');
|
setShipping(group.shipping ? String(group.shipping / 100) : '');
|
||||||
setTip(group.tip ? String(group.tip) : '');
|
setTip(group.tip ? String(group.tip / 100) : '');
|
||||||
setDiscountType((group.discountType as 'percent' | 'fixed') ?? 'percent');
|
setDiscountType((group.discountType as 'percent' | 'fixed') ?? 'percent');
|
||||||
setDiscountValue(group.discountValue ? String(group.discountValue) : '');
|
setDiscountValue(group.discountValue
|
||||||
|
? ((group.discountType as string) === 'fixed' ? String(group.discountValue / 100) : String(group.discountValue))
|
||||||
|
: '');
|
||||||
setError(null);
|
setError(null);
|
||||||
}, [isOpen, group]);
|
}, [isOpen, group]);
|
||||||
|
|
||||||
const memberEntries = Object.entries(group.members) as [string, OrderGroupMember][];
|
const memberEntries = Object.entries(group.members) as [string, OrderGroupMember][];
|
||||||
const memberCount = memberEntries.length;
|
// Poplatky se dělí jen mezi aktivní strávníky (kdo si reálně něco objednal).
|
||||||
|
const activeCount = countActiveMembers(group.members);
|
||||||
|
|
||||||
const feesNum = parseNum(fees);
|
const feesNum = parseHal(fees);
|
||||||
const shippingNum = parseNum(shipping);
|
const shippingNum = parseHal(shipping);
|
||||||
const tipNum = parseNum(tip);
|
const tipNum = parseHal(tip);
|
||||||
const discountNum = parseNum(discountValue);
|
const discountNum = discountType === 'percent' ? parsePercent(discountValue) : parseHal(discountValue);
|
||||||
const totalFees = feesNum + shippingNum + tipNum;
|
const totalFees = feesNum + shippingNum + tipNum;
|
||||||
const feeShare = memberCount > 0 ? Math.round(totalFees / memberCount * 100) / 100 : 0;
|
const feeShare = computeFeeShare(totalFees, activeCount);
|
||||||
|
const feeParams = { totalFees, discountType, discountValue: discountNum };
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const body: Record<string, any> = { id: group.id };
|
const res = await updateGroupFees({
|
||||||
body.fees = feesNum;
|
body: {
|
||||||
body.shipping = shippingNum;
|
id: group.id,
|
||||||
body.tip = tipNum;
|
fees: feesNum,
|
||||||
if (discountNum > 0) {
|
shipping: shippingNum,
|
||||||
body.discountType = discountType;
|
tip: tipNum,
|
||||||
body.discountValue = discountNum;
|
discountType: discountNum > 0 ? discountType : undefined,
|
||||||
} else {
|
discountValue: discountNum > 0 ? discountNum : undefined,
|
||||||
body.discountType = '';
|
}
|
||||||
body.discountValue = 0;
|
});
|
||||||
}
|
|
||||||
const res = await updateGroupFees({ body });
|
|
||||||
if (res.error) {
|
if (res.error) {
|
||||||
setError((res.error as any).error || 'Nastala chyba');
|
setError((res.error as any).error || 'Nastala chyba');
|
||||||
} else {
|
} else {
|
||||||
@@ -145,7 +144,7 @@ export default function EditGroupFeesModal({ isOpen, onClose, group, onSaved }:
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
<h6>Náhled celkových částek ({memberCount} členů, {feeShare > 0 ? `poplatek ${feeShare} Kč/os.` : 'bez poplatku'})</h6>
|
<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>
|
<Table size="sm" bordered>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -161,20 +160,22 @@ export default function EditGroupFeesModal({ isOpen, onClose, group, onSaved }:
|
|||||||
{memberEntries.map(([login, member]) => {
|
{memberEntries.map(([login, member]) => {
|
||||||
const base = member.amount ?? 0;
|
const base = member.amount ?? 0;
|
||||||
const surcharge = member.surchargeAmount ?? 0;
|
const surcharge = member.surchargeAmount ?? 0;
|
||||||
const discount = discountNum > 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'
|
? (discountType === 'percent'
|
||||||
? Math.round((base + surcharge) * discountNum / 100 * 100) / 100
|
? Math.round((base + surcharge) * discountNum / 100)
|
||||||
: Math.round(discountNum / memberCount * 100) / 100)
|
: Math.round(discountNum / activeCount))
|
||||||
: 0;
|
: 0;
|
||||||
const total = computeMemberTotal(member, feeShare, discountType, discountNum, memberCount);
|
|
||||||
return (
|
return (
|
||||||
<tr key={login}>
|
<tr key={login} className={active ? '' : 'text-muted'}>
|
||||||
<td><strong>{login}</strong></td>
|
<td><strong>{login}</strong>{!active && <small className="ms-1">(jen objednává)</small>}</td>
|
||||||
<td className="text-end">{base > 0 ? `${base} Kč` : '—'}</td>
|
<td className="text-end">{base > 0 ? `${base / 100} Kč` : '—'}</td>
|
||||||
<td className="text-end">{surcharge > 0 ? `${surcharge} Kč` : '—'}</td>
|
<td className="text-end">{surcharge > 0 ? `${surcharge / 100} Kč` : '—'}</td>
|
||||||
<td className="text-end">{feeShare > 0 ? `${feeShare} Kč` : '—'}</td>
|
<td className="text-end">{active && feeShare > 0 ? `${feeShare / 100} Kč` : '—'}</td>
|
||||||
<td className="text-end text-danger">{discount > 0 ? `-${discount} Kč` : '—'}</td>
|
<td className="text-end text-danger">{discount > 0 ? `-${discount / 100} Kč` : '—'}</td>
|
||||||
<td className="text-end fw-bold">{total > 0 ? `${total} Kč` : '—'}</td>
|
<td className="text-end fw-bold">{total > 0 ? `${total / 100} Kč` : '—'}</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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,19 +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;
|
||||||
const totalPeople = includedDiners.length + 1; // +1 for payer
|
const totalPeople = includedDiners.length + 1;
|
||||||
return Math.round((tip / totalPeople) * 100) / 100;
|
return Math.round(tip / totalPeople);
|
||||||
})();
|
})();
|
||||||
const payerTipShare = (() => {
|
const payerTipShare = (() => {
|
||||||
const tip = parseAmount(tipTotal);
|
const tip = parseAmount(tipTotal);
|
||||||
if (!tip) return 0;
|
if (!tip) return 0;
|
||||||
return Math.round((tip - tipPerPerson * includedDiners.length) * 100) / 100;
|
return tip - tipPerPerson * includedDiners.length;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const getTotal = (d: DinerEntry): number => {
|
const getTotal = (d: DinerEntry): number => {
|
||||||
const surcharge = parseAmount(d.surchargeAmount) ?? 0;
|
const surcharge = parseAmount(d.surchargeAmount) ?? 0;
|
||||||
const tip = d.login === payerLogin ? payerTipShare : tipPerPerson;
|
const tip = d.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) => {
|
||||||
@@ -122,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({
|
||||||
@@ -226,7 +219,7 @@ 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>
|
||||||
@@ -254,10 +247,10 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="text-end">
|
<td className="text-end">
|
||||||
{(() => { const s = isPayer ? payerTipShare : tipPerPerson; return s > 0 ? `${s} 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">
|
||||||
{`${total} Kč`}
|
{`${total / 100} Kč`}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
@@ -278,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>
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Modal, Button, Form, Table, Alert } from "react-bootstrap";
|
import { Modal, Button, Form, Table, Alert } from "react-bootstrap";
|
||||||
import { generateQr, OrderGroup, OrderGroupMember, QrRecipient } from "../../../../types";
|
import { generateQr, OrderGroup, OrderGroupMember, QrRecipient } from "../../../../types";
|
||||||
|
import { sanitizeQrMessage } from "../../Utils";
|
||||||
|
import { computeFeeShare, computeMemberTotal, countActiveMembers, isActiveMember } from "../../utils/groupFees";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onSuccess?: () => void;
|
||||||
group: OrderGroup;
|
group: OrderGroup;
|
||||||
payerLogin: string;
|
payerLogin: string;
|
||||||
bankAccount: string;
|
bankAccount: string;
|
||||||
@@ -18,7 +21,7 @@ type DinerEntry = {
|
|||||||
included: boolean;
|
included: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PayForGroupModal({ isOpen, onClose, group, payerLogin, bankAccount, bankAccountHolder, groupId }: Readonly<Props>) {
|
export default function PayForGroupModal({ isOpen, onClose, onSuccess, group, payerLogin, bankAccount, bankAccountHolder, groupId }: Readonly<Props>) {
|
||||||
const [diners, setDiners] = useState<DinerEntry[]>([]);
|
const [diners, setDiners] = useState<DinerEntry[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -29,32 +32,25 @@ export default function PayForGroupModal({ isOpen, onClose, group, payerLogin, b
|
|||||||
const entries: DinerEntry[] = (Object.entries(group.members) as [string, OrderGroupMember][]).map(([login, member]) => ({
|
const entries: DinerEntry[] = (Object.entries(group.members) as [string, OrderGroupMember][]).map(([login, member]) => ({
|
||||||
login,
|
login,
|
||||||
member,
|
member,
|
||||||
included: login !== payerLogin,
|
// Standardně zahrnout všechny, kdo nejsou plátce a něco si objednali.
|
||||||
|
included: login !== payerLogin && isActiveMember(member),
|
||||||
}));
|
}));
|
||||||
setDiners(entries);
|
setDiners(entries);
|
||||||
setError(null);
|
setError(null);
|
||||||
setSuccess(false);
|
setSuccess(false);
|
||||||
}, [isOpen, group, payerLogin]);
|
}, [isOpen, group, payerLogin]);
|
||||||
|
|
||||||
const memberCount = diners.length;
|
|
||||||
const fees = group.fees ?? 0;
|
const fees = group.fees ?? 0;
|
||||||
const shipping = group.shipping ?? 0;
|
const shipping = group.shipping ?? 0;
|
||||||
const tip = group.tip ?? 0;
|
const tip = group.tip ?? 0;
|
||||||
const totalFees = fees + shipping + tip;
|
const totalFees = fees + shipping + tip;
|
||||||
const feeShare = memberCount > 0 ? Math.round(totalFees / memberCount * 100) / 100 : 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 = (entry: DinerEntry): number => {
|
const getMemberTotal = (entry: DinerEntry): number =>
|
||||||
const base = entry.member.amount ?? 0;
|
computeMemberTotal(entry.member, feeParams, feeShare, activeCount);
|
||||||
const surcharge = entry.member.surchargeAmount ?? 0;
|
|
||||||
const discountType = group.discountType;
|
|
||||||
const discountValue = group.discountValue ?? 0;
|
|
||||||
const discount = discountValue > 0
|
|
||||||
? (discountType === 'percent'
|
|
||||||
? Math.round((base + surcharge) * discountValue / 100 * 100) / 100
|
|
||||||
: Math.round(discountValue / memberCount * 100) / 100)
|
|
||||||
: 0;
|
|
||||||
return Math.round((base + surcharge + feeShare - discount) * 100) / 100;
|
|
||||||
};
|
|
||||||
|
|
||||||
const includedNonPayers = diners.filter(d => d.included && d.login !== payerLogin);
|
const includedNonPayers = diners.filter(d => d.included && d.login !== payerLogin);
|
||||||
|
|
||||||
@@ -73,14 +69,10 @@ export default function PayForGroupModal({ isOpen, onClose, group, payerLogin, b
|
|||||||
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();
|
const note = d.member.note?.trim();
|
||||||
if (amountStr.includes('.') && amountStr.split('.')[1].length > 2) {
|
|
||||||
setError(`Částka pro ${d.login} má více než 2 desetinná místa`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
recipients.push({
|
recipients.push({
|
||||||
login: d.login,
|
login: d.login,
|
||||||
purpose: `Objednávka ${group.name}`.substring(0, 60),
|
purpose: sanitizeQrMessage(note || `Objednávka ${group.name}`),
|
||||||
amount: total,
|
amount: total,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -99,6 +91,7 @@ export default function PayForGroupModal({ isOpen, onClose, group, payerLogin, b
|
|||||||
setError((response.error as any).error || 'Nastala chyba při generování QR kódů');
|
setError((response.error as any).error || 'Nastala chyba při generování QR kódů');
|
||||||
} else {
|
} else {
|
||||||
setSuccess(true);
|
setSuccess(true);
|
||||||
|
onSuccess?.();
|
||||||
setTimeout(() => onClose(), 2000);
|
setTimeout(() => onClose(), 2000);
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -132,15 +125,15 @@ export default function PayForGroupModal({ isOpen, onClose, group, payerLogin, b
|
|||||||
|
|
||||||
{hasFees && (
|
{hasFees && (
|
||||||
<div className="d-flex gap-3 mb-2 text-muted" style={{ fontSize: '0.9em' }}>
|
<div className="d-flex gap-3 mb-2 text-muted" style={{ fontSize: '0.9em' }}>
|
||||||
{fees > 0 && <span>Poplatky: <strong>{fees} Kč</strong></span>}
|
{fees > 0 && <span>Poplatky: <strong>{fees / 100} Kč</strong></span>}
|
||||||
{shipping > 0 && <span>Doprava: <strong>{shipping} Kč</strong></span>}
|
{shipping > 0 && <span>Doprava: <strong>{shipping / 100} Kč</strong></span>}
|
||||||
{tip > 0 && <span>Spropitné: <strong>{tip} Kč</strong></span>}
|
{tip > 0 && <span>Spropitné: <strong>{tip / 100} Kč</strong></span>}
|
||||||
<span>→ {feeShare} Kč/os.</span>
|
<span>→ {feeShare / 100} Kč/os.</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{group.discountValue != null && group.discountValue > 0 && (
|
{group.discountValue != null && group.discountValue > 0 && (
|
||||||
<div className="mb-2 text-success" style={{ fontSize: '0.9em' }}>
|
<div className="mb-2 text-success" style={{ fontSize: '0.9em' }}>
|
||||||
Sleva: {group.discountType === 'percent' ? `${group.discountValue}%` : `${group.discountValue} Kč`}
|
Sleva: {group.discountType === 'percent' ? `${group.discountValue}%` : `${group.discountValue / 100} Kč`}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -158,13 +151,16 @@ export default function PayForGroupModal({ isOpen, onClose, group, payerLogin, b
|
|||||||
<tbody>
|
<tbody>
|
||||||
{diners.map(d => {
|
{diners.map(d => {
|
||||||
const isPayer = d.login === payerLogin;
|
const isPayer = d.login === payerLogin;
|
||||||
|
const active = isActiveMember(d.member);
|
||||||
const total = getMemberTotal(d);
|
const total = getMemberTotal(d);
|
||||||
const surcharge = d.member.surchargeAmount ?? 0;
|
const surcharge = d.member.surchargeAmount ?? 0;
|
||||||
return (
|
return (
|
||||||
<tr key={d.login} className={!d.included && !isPayer ? 'text-muted' : ''}>
|
<tr key={d.login} className={(!d.included && !isPayer) || !active ? 'text-muted' : ''}>
|
||||||
<td className="text-center">
|
<td className="text-center">
|
||||||
{isPayer ? (
|
{isPayer ? (
|
||||||
<small className="text-muted">plátce</small>
|
<small className="text-muted">plátce</small>
|
||||||
|
) : !active ? (
|
||||||
|
<small className="text-muted">jen objednává</small>
|
||||||
) : (
|
) : (
|
||||||
<Form.Check
|
<Form.Check
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -180,18 +176,18 @@ export default function PayForGroupModal({ isOpen, onClose, group, payerLogin, b
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-end">
|
<td className="text-end">
|
||||||
{(d.member.amount ?? 0) > 0 ? `${d.member.amount} Kč` : <span className="text-muted">—</span>}
|
{(d.member.amount ?? 0) > 0 ? `${d.member.amount! / 100} Kč` : <span className="text-muted">—</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-end">
|
<td className="text-end">
|
||||||
{surcharge > 0 ? `${surcharge} Kč` : <span className="text-muted">—</span>}
|
{surcharge > 0 ? `${surcharge / 100} Kč` : <span className="text-muted">—</span>}
|
||||||
</td>
|
</td>
|
||||||
{hasFees && (
|
{hasFees && (
|
||||||
<td className="text-end">
|
<td className="text-end">
|
||||||
{feeShare > 0 ? `${feeShare} Kč` : '—'}
|
{active && feeShare > 0 ? `${feeShare / 100} Kč` : '—'}
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
<td className="text-end fw-bold">
|
<td className="text-end fw-bold">
|
||||||
{total > 0 ? `${total} Kč` : <span className="text-muted">—</span>}
|
{total > 0 ? `${total / 100} Kč` : <span className="text-muted">—</span>}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
import { useContext, useEffect, useRef, useState } from 'react';
|
import { useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||||
import { Alert, Badge, Button, Card, Form, Modal, OverlayTrigger, Table, Tooltip } from 'react-bootstrap';
|
import { Alert, Badge, Button, Card, Form, Modal, OverlayTrigger, Table, Tooltip } from 'react-bootstrap';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faTrashCan } from '@fortawesome/free-regular-svg-icons';
|
import { faTrashCan } from '@fortawesome/free-regular-svg-icons';
|
||||||
import { faBasketShopping, faCircleCheck, faGear, faLock, faLockOpen, faSearch, faUserPlus } from '@fortawesome/free-solid-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 {
|
import {
|
||||||
ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember,
|
ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember, PendingQr,
|
||||||
getData, createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes,
|
getData, createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, getOrderDates,
|
||||||
} from '../../../types';
|
} from '../../../types';
|
||||||
import { EVENT_MESSAGE, SocketContext } from '../context/socket';
|
import { computeFeeShare, computeMemberTotal, countActiveMembers } from '../utils/groupFees';
|
||||||
|
import { EVENT_MESSAGE, EVENT_PENDING_QR, SocketContext } from '../context/socket';
|
||||||
import { useAuth } from '../context/auth';
|
import { useAuth } from '../context/auth';
|
||||||
import { useSettings } from '../context/settings';
|
import { useSettings } from '../context/settings';
|
||||||
|
import { formatDate, formatDateString } from '../Utils';
|
||||||
import Login from '../Login';
|
import Login from '../Login';
|
||||||
import Header from '../components/Header';
|
import Header from '../components/Header';
|
||||||
import Footer from '../components/Footer';
|
import Footer from '../components/Footer';
|
||||||
@@ -17,10 +22,26 @@ import Loader from '../components/Loader';
|
|||||||
import StoreAdminModal from '../components/modals/StoreAdminModal';
|
import StoreAdminModal from '../components/modals/StoreAdminModal';
|
||||||
import PayForGroupModal from '../components/modals/PayForGroupModal';
|
import PayForGroupModal from '../components/modals/PayForGroupModal';
|
||||||
import EditGroupFeesModal from '../components/modals/EditGroupFeesModal';
|
import EditGroupFeesModal from '../components/modals/EditGroupFeesModal';
|
||||||
|
import PendingPayments from '../components/PendingPayments';
|
||||||
|
|
||||||
const SLOT = MealSlot.EXTRA;
|
const SLOT = MealSlot.EXTRA;
|
||||||
const TIME_REGEX = /^([01]\d|2[0-3]):[0-5]\d$/;
|
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) {
|
function stateBadge(state: GroupState) {
|
||||||
const map: Record<GroupState, { bg: string; label: string }> = {
|
const map: Record<GroupState, { bg: string; label: string }> = {
|
||||||
[GroupState.OPEN]: { bg: 'success', label: 'Otevřeno' },
|
[GroupState.OPEN]: { bg: 'success', label: 'Otevřeno' },
|
||||||
@@ -37,6 +58,14 @@ export default function OrderGroupsPage() {
|
|||||||
const socket = useContext(SocketContext);
|
const socket = useContext(SocketContext);
|
||||||
const [data, setData] = useState<ClientData | undefined>();
|
const [data, setData] = useState<ClientData | undefined>();
|
||||||
const [failure, setFailure] = useState(false);
|
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 [newGroupName, setNewGroupName] = useState('');
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [adminModalOpen, setAdminModalOpen] = useState(false);
|
const [adminModalOpen, setAdminModalOpen] = useState(false);
|
||||||
@@ -48,32 +77,79 @@ export default function OrderGroupsPage() {
|
|||||||
const [feesModal, setFeesModal] = useState<OrderGroup | null>(null);
|
const [feesModal, setFeesModal] = useState<OrderGroup | null>(null);
|
||||||
const [confirmOrderGroup, setConfirmOrderGroup] = useState<OrderGroup | null>(null);
|
const [confirmOrderGroup, setConfirmOrderGroup] = useState<OrderGroup | null>(null);
|
||||||
const [pageError, setPageError] = useState<string | null>(null);
|
const [pageError, setPageError] = useState<string | null>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async (date?: string) => {
|
||||||
try {
|
try {
|
||||||
const r = await getData({ query: { slot: SLOT } });
|
const r = await getData({ query: { slot: SLOT, date } });
|
||||||
if (r.data) setData(r.data);
|
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 {
|
} catch {
|
||||||
setFailure(true);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!auth?.login) return;
|
if (!auth?.login) return;
|
||||||
fetchData();
|
fetchData(selectedDate);
|
||||||
|
}, [auth?.login, selectedDate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!auth?.login) return;
|
||||||
|
fetchOrderDates();
|
||||||
}, [auth?.login]);
|
}, [auth?.login]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
socket.on(EVENT_MESSAGE, (newData: ClientData) => {
|
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 => ({
|
if (newData.slot === SLOT) setData(prev => ({
|
||||||
...newData,
|
...newData,
|
||||||
stores: newData.stores ?? prev?.stores,
|
stores: newData.stores ?? prev?.stores,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
return () => { socket.off(EVENT_MESSAGE); };
|
// 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]);
|
}, [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> => {
|
const refresh = async (fn: () => Promise<any>): Promise<boolean> => {
|
||||||
setPageError(null);
|
setPageError(null);
|
||||||
const result = await fn();
|
const result = await fn();
|
||||||
@@ -87,6 +163,8 @@ export default function OrderGroupsPage() {
|
|||||||
socket.emit?.('message', result.data as ClientData);
|
socket.emit?.('message', result.data as ClientData);
|
||||||
}
|
}
|
||||||
await fetchData();
|
await fetchData();
|
||||||
|
// Sada dnů s objednávkou se mohla změnit (vytvoření/smazání skupiny)
|
||||||
|
fetchOrderDates();
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -125,7 +203,7 @@ export default function OrderGroupsPage() {
|
|||||||
setPageError('Zadejte platnou kladnou částku');
|
setPageError('Zadejte platnou kladnou částku');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ok = await refresh(() => updateGroupMember({ body: { id: groupId, login, amount: n } }));
|
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; });
|
if (ok) setEditAmounts(prev => { const next = { ...prev }; delete next[key]; return next; });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -145,7 +223,7 @@ export default function OrderGroupsPage() {
|
|||||||
setPageError('Zadejte platnou výši příplatku');
|
setPageError('Zadejte platnou výši příplatku');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ok = await refresh(() => updateGroupMember({ body: { id: groupId, login, surchargeText, surchargeAmount: rawAmount === '' ? 0 : surchargeAmount } }));
|
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; });
|
if (ok) setEditSurcharges(prev => { const next = { ...prev }; delete next[key]; return next; });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -165,7 +243,10 @@ export default function OrderGroupsPage() {
|
|||||||
if (ok) setEditTimes(prev => { const next = { ...prev }; delete next[group.id]; return next; });
|
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) => {
|
const canEditMember = (group: OrderGroup, targetLogin: string) => {
|
||||||
|
if (selectedDate) return false;
|
||||||
if (group.state === GroupState.ORDERED) return false;
|
if (group.state === GroupState.ORDERED) return false;
|
||||||
if (auth?.login === group.creatorLogin) return true;
|
if (auth?.login === group.creatorLogin) return true;
|
||||||
if (auth?.login === targetLogin && group.state === GroupState.OPEN) return true;
|
if (auth?.login === targetLogin && group.state === GroupState.OPEN) return true;
|
||||||
@@ -173,6 +254,7 @@ export default function OrderGroupsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const canManageMembers = (group: OrderGroup) => {
|
const canManageMembers = (group: OrderGroup) => {
|
||||||
|
if (selectedDate) return false;
|
||||||
if (group.state === GroupState.ORDERED) return false;
|
if (group.state === GroupState.ORDERED) return false;
|
||||||
if (auth?.login === group.creatorLogin) return true;
|
if (auth?.login === group.creatorLogin) return true;
|
||||||
return group.state === GroupState.OPEN;
|
return group.state === GroupState.OPEN;
|
||||||
@@ -191,6 +273,29 @@ export default function OrderGroupsPage() {
|
|||||||
const stores = data.stores ?? [];
|
const stores = data.stores ?? [];
|
||||||
const groups = data.groups ?? [];
|
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 (
|
return (
|
||||||
<div className="app-container">
|
<div className="app-container">
|
||||||
<Header choices={data.choices} />
|
<Header choices={data.choices} />
|
||||||
@@ -203,6 +308,43 @@ export default function OrderGroupsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<p style={{ color: 'var(--luncher-text-muted)' }}>Skupinové objednávky z obchodů a restaurací</p>
|
<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 && (
|
{pageError && (
|
||||||
<Alert variant="danger" dismissible onClose={() => setPageError(null)} className="mt-2">
|
<Alert variant="danger" dismissible onClose={() => setPageError(null)} className="mt-2">
|
||||||
{pageError}
|
{pageError}
|
||||||
@@ -210,8 +352,9 @@ export default function OrderGroupsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="content-wrapper">
|
<div className="content-wrapper">
|
||||||
<div className="content">
|
<div className="content" style={{ maxWidth: 1200 }}>
|
||||||
{/* Vytvoření nové skupiny */}
|
{/* Vytvoření nové skupiny – pouze pro aktuální den */}
|
||||||
|
{!isReadOnly && (
|
||||||
<div className="choice-section fade-in mb-4">
|
<div className="choice-section fade-in mb-4">
|
||||||
<h5>Vytvořit skupinu</h5>
|
<h5>Vytvořit skupinu</h5>
|
||||||
{stores.length === 0 ? (
|
{stores.length === 0 ? (
|
||||||
@@ -237,10 +380,13 @@ export default function OrderGroupsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Seznam skupin */}
|
{/* Seznam skupin */}
|
||||||
{groups.length === 0 && (
|
{groups.length === 0 && (
|
||||||
<p className="text-muted fade-in">Zatím žádné skupiny pro dnešní den.</p>
|
<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 => {
|
{groups.map(group => {
|
||||||
@@ -250,22 +396,15 @@ export default function OrderGroupsPage() {
|
|||||||
const isOrdered = group.state === GroupState.ORDERED;
|
const isOrdered = group.state === GroupState.ORDERED;
|
||||||
const isLocked = group.state === GroupState.LOCKED;
|
const isLocked = group.state === GroupState.LOCKED;
|
||||||
const memberEntries = Object.entries(group.members) as [string, OrderGroupMember][];
|
const memberEntries = Object.entries(group.members) as [string, OrderGroupMember][];
|
||||||
const memberCount = memberEntries.length;
|
|
||||||
const editingTimes = group.id in editTimes;
|
const editingTimes = group.id in editTimes;
|
||||||
|
|
||||||
const totalFees = (group.fees ?? 0) + (group.shipping ?? 0) + (group.tip ?? 0);
|
const totalFees = (group.fees ?? 0) + (group.shipping ?? 0) + (group.tip ?? 0);
|
||||||
const feeShare = memberCount > 0 ? Math.round(totalFees / memberCount * 100) / 100 : 0;
|
// Poplatky se dělí jen mezi aktivní strávníky (kdo si reálně něco objednal).
|
||||||
const getMemberTotal = (m: OrderGroupMember) => {
|
const activeCount = countActiveMembers(group.members);
|
||||||
const base = m.amount ?? 0;
|
const feeShare = computeFeeShare(totalFees, activeCount);
|
||||||
const surcharge = m.surchargeAmount ?? 0;
|
const feeParams = { totalFees, discountType: group.discountType, discountValue: group.discountValue ?? 0 };
|
||||||
const dv = group.discountValue ?? 0;
|
const getMemberTotal = (m: OrderGroupMember) =>
|
||||||
const discount = dv > 0
|
computeMemberTotal(m, feeParams, feeShare, activeCount);
|
||||||
? (group.discountType === 'percent'
|
|
||||||
? Math.round((base + surcharge) * dv / 100 * 100) / 100
|
|
||||||
: Math.round(dv / memberCount * 100) / 100)
|
|
||||||
: 0;
|
|
||||||
return Math.round((base + surcharge + feeShare - discount) * 100) / 100;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={group.id} className="mb-3 fade-in">
|
<Card key={group.id} className="mb-3 fade-in">
|
||||||
@@ -276,7 +415,7 @@ export default function OrderGroupsPage() {
|
|||||||
<small className="text-muted">zakladatel: {group.creatorLogin}</small>
|
<small className="text-muted">zakladatel: {group.creatorLogin}</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="d-flex gap-2">
|
<div className="d-flex gap-2">
|
||||||
{isCreator && !isOrdered && (
|
{!isReadOnly && isCreator && !isOrdered && (
|
||||||
<>
|
<>
|
||||||
<Button variant="outline-info" size="sm" onClick={() => setFeesModal(group)} title="Upravit poplatky a slevu">
|
<Button variant="outline-info" size="sm" onClick={() => setFeesModal(group)} title="Upravit poplatky a slevu">
|
||||||
Poplatky
|
Poplatky
|
||||||
@@ -294,9 +433,9 @@ export default function OrderGroupsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{isCreator && isOrdered && (
|
{!isReadOnly && isCreator && isOrdered && (
|
||||||
<>
|
<>
|
||||||
{settings?.bankAccount && settings?.holderName && (
|
{settings?.bankAccount && settings?.holderName && !group.qrGenerated && (
|
||||||
<Button variant="primary" size="sm" onClick={() => setPayModal(group)}>
|
<Button variant="primary" size="sm" onClick={() => setPayModal(group)}>
|
||||||
<FontAwesomeIcon icon={faBasketShopping} className="me-1" />
|
<FontAwesomeIcon icon={faBasketShopping} className="me-1" />
|
||||||
Generovat QR
|
Generovat QR
|
||||||
@@ -307,7 +446,7 @@ export default function OrderGroupsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!isMember && !isOrdered && !isLocked && (
|
{!isReadOnly && !isMember && !isOrdered && !isLocked && (
|
||||||
<Button variant="outline-success" size="sm" onClick={() => handleJoin(group.id)}>
|
<Button variant="outline-success" size="sm" onClick={() => handleJoin(group.id)}>
|
||||||
<FontAwesomeIcon icon={faUserPlus} className="me-1" />
|
<FontAwesomeIcon icon={faUserPlus} className="me-1" />
|
||||||
Přidat se
|
Přidat se
|
||||||
@@ -320,10 +459,10 @@ export default function OrderGroupsPage() {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Člen</th>
|
<th>Člen</th>
|
||||||
<th style={{ width: 120 }}>Částka (Kč)</th>
|
<th style={{ width: 180 }}>Částka (bez slev)</th>
|
||||||
<th style={{ width: 180 }}>Příplatek</th>
|
<th style={{ width: 220 }}>Příplatek</th>
|
||||||
<th>Poznámka</th>
|
<th>Poznámka</th>
|
||||||
<th style={{ width: 90 }}>Celkem</th>
|
<th style={{ width: 160 }}>Celkem (s poplatky)</th>
|
||||||
<th style={{ width: 40 }}></th>
|
<th style={{ width: 40 }}></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -356,24 +495,23 @@ export default function OrderGroupsPage() {
|
|||||||
{canEdit && editingAmount ? (
|
{canEdit && editingAmount ? (
|
||||||
<div className="d-flex gap-1">
|
<div className="d-flex gap-1">
|
||||||
<Form.Control
|
<Form.Control
|
||||||
ref={memberLogin === login ? inputRef : undefined}
|
|
||||||
type="number"
|
type="number"
|
||||||
size="sm"
|
size="sm"
|
||||||
value={editAmounts[key]}
|
value={editAmounts[key]}
|
||||||
onChange={e => setEditAmounts(prev => ({ ...prev, [key]: e.target.value }))}
|
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; }); }}
|
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: 75 }}
|
style={{ width: 95 }}
|
||||||
autoFocus={memberLogin === login}
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<Button size="sm" variant="outline-success" onClick={() => handleSaveAmount(group.id, memberLogin)}>✓</Button>
|
<Button size="sm" variant="outline-success" onClick={() => handleSaveAmount(group.id, memberLogin)}>✓</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span
|
<span
|
||||||
style={{ cursor: canEdit ? 'pointer' : undefined }}
|
style={{ cursor: canEdit ? 'pointer' : undefined }}
|
||||||
onClick={() => canEdit && setEditAmounts(prev => ({ ...prev, [key]: String(member.amount ?? '') }))}
|
onClick={() => canEdit && setEditAmounts(prev => ({ ...prev, [key]: member.amount != null ? String(member.amount / 100) : '' }))}
|
||||||
title={canEdit ? 'Klikněte pro úpravu' : undefined}
|
title={canEdit ? 'Klikněte pro úpravu' : undefined}
|
||||||
>
|
>
|
||||||
{member.amount != null ? `${member.amount} Kč` : <span className="text-muted">—</span>}
|
{member.amount != null ? `${member.amount / 100} Kč` : <span className="text-muted">—</span>}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
@@ -404,11 +542,11 @@ export default function OrderGroupsPage() {
|
|||||||
) : (
|
) : (
|
||||||
<span
|
<span
|
||||||
style={{ cursor: canEdit ? 'pointer' : undefined }}
|
style={{ cursor: canEdit ? 'pointer' : undefined }}
|
||||||
onClick={() => canEdit && setEditSurcharges(prev => ({ ...prev, [key]: { text: member.surchargeText ?? '', amount: member.surchargeAmount != null ? String(member.surchargeAmount) : '' } }))}
|
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}
|
title={canEdit ? 'Klikněte pro úpravu příplatku' : undefined}
|
||||||
>
|
>
|
||||||
{member.surchargeAmount != null && member.surchargeAmount > 0 ? (
|
{member.surchargeAmount != null && member.surchargeAmount > 0 ? (
|
||||||
<small>{member.surchargeText ? `${member.surchargeText}: ` : ''}<strong>{member.surchargeAmount} Kč</strong></small>
|
<small>{member.surchargeText ? `${member.surchargeText}: ` : ''}<strong>{member.surchargeAmount / 100} Kč</strong></small>
|
||||||
) : (
|
) : (
|
||||||
<small className="text-muted">—</small>
|
<small className="text-muted">—</small>
|
||||||
)}
|
)}
|
||||||
@@ -440,7 +578,7 @@ export default function OrderGroupsPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="text-end">
|
<td className="text-end">
|
||||||
<small className={memberTotal > 0 ? 'fw-bold' : 'text-muted'}>
|
<small className={memberTotal > 0 ? 'fw-bold' : 'text-muted'}>
|
||||||
{memberTotal > 0 ? `${memberTotal} Kč` : '—'}
|
{memberTotal > 0 ? `${memberTotal / 100} Kč` : '—'}
|
||||||
</small>
|
</small>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -459,18 +597,35 @@ export default function OrderGroupsPage() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</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>
|
</Table>
|
||||||
|
|
||||||
{/* Souhrn poplatků a slevy */}
|
{/* Souhrn poplatků a slevy */}
|
||||||
{(totalFees > 0 || (group.discountValue != null && group.discountValue > 0)) && (
|
{(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)' }}>
|
<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} Kč</strong></span>}
|
{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} 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} Kč</strong></span>}
|
{group.tip != null && group.tip > 0 && <span>Spropitné: <strong>{group.tip / 100} Kč</strong></span>}
|
||||||
{feeShare > 0 && <span>→ <strong>{feeShare} Kč</strong>/os.</span>}
|
{feeShare > 0 && <span>→ <strong>{feeShare / 100} Kč</strong>/os.</span>}
|
||||||
{group.discountValue != null && group.discountValue > 0 && (
|
{group.discountValue != null && group.discountValue > 0 && (
|
||||||
<span className="text-success">
|
<span className="text-success">
|
||||||
Sleva: <strong>{group.discountType === 'percent' ? `${group.discountValue}%` : `${group.discountValue} Kč`}</strong>
|
Sleva: <strong>{group.discountType === 'percent' ? `${group.discountValue}%` : `${group.discountValue / 100} Kč`}</strong>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -479,7 +634,7 @@ export default function OrderGroupsPage() {
|
|||||||
{/* Časy objednání a doručení */}
|
{/* Časy objednání a doručení */}
|
||||||
{isOrdered && (
|
{isOrdered && (
|
||||||
<div className="px-3 py-2 border-top">
|
<div className="px-3 py-2 border-top">
|
||||||
{isCreator && editingTimes ? (
|
{!isReadOnly && isCreator && editingTimes ? (
|
||||||
<div className="d-flex align-items-center gap-3 flex-wrap">
|
<div className="d-flex align-items-center gap-3 flex-wrap">
|
||||||
<div className="d-flex align-items-center gap-1">
|
<div className="d-flex align-items-center gap-1">
|
||||||
<small className="text-muted text-nowrap">Objednáno v:</small>
|
<small className="text-muted text-nowrap">Objednáno v:</small>
|
||||||
@@ -512,9 +667,9 @@ export default function OrderGroupsPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="d-flex align-items-center gap-3 flex-wrap"
|
className="d-flex align-items-center gap-3 flex-wrap"
|
||||||
style={{ cursor: isCreator ? 'pointer' : undefined }}
|
style={{ cursor: !isReadOnly && isCreator ? 'pointer' : undefined }}
|
||||||
onClick={() => isCreator && setEditTimes(prev => ({ ...prev, [group.id]: { orderedAt: group.orderedAt ?? '', deliveryAt: group.deliveryAt ?? '' } }))}
|
onClick={() => !isReadOnly && isCreator && setEditTimes(prev => ({ ...prev, [group.id]: { orderedAt: group.orderedAt ?? '', deliveryAt: group.deliveryAt ?? '' } }))}
|
||||||
title={isCreator ? 'Klikněte pro úpravu časů' : undefined}
|
title={!isReadOnly && isCreator ? 'Klikněte pro úpravu časů' : undefined}
|
||||||
>
|
>
|
||||||
<small className="text-muted">
|
<small className="text-muted">
|
||||||
Objednáno v: <strong>{group.orderedAt ?? '—'}</strong>
|
Objednáno v: <strong>{group.orderedAt ?? '—'}</strong>
|
||||||
@@ -522,7 +677,6 @@ export default function OrderGroupsPage() {
|
|||||||
<small className="text-muted">
|
<small className="text-muted">
|
||||||
Doručení v: <strong>{group.deliveryAt ?? '—'}</strong>
|
Doručení v: <strong>{group.deliveryAt ?? '—'}</strong>
|
||||||
</small>
|
</small>
|
||||||
{isCreator && <small className="text-muted fst-italic">(upravit)</small>}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -531,6 +685,15 @@ export default function OrderGroupsPage() {
|
|||||||
</Card>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -564,6 +727,7 @@ export default function OrderGroupsPage() {
|
|||||||
<PayForGroupModal
|
<PayForGroupModal
|
||||||
isOpen={!!payModal}
|
isOpen={!!payModal}
|
||||||
onClose={() => setPayModal(null)}
|
onClose={() => setPayModal(null)}
|
||||||
|
onSuccess={() => fetchData()}
|
||||||
group={payModal}
|
group={payModal}
|
||||||
groupId={payModal.id}
|
groupId={payModal.id}
|
||||||
payerLogin={auth.login}
|
payerLogin={auth.login}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -28,6 +28,24 @@ function findGroup(data: ClientData, id: string): OrderGroup | undefined {
|
|||||||
return data.groups?.find(g => g.id === id);
|
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> {
|
export async function createGroup(creatorLogin: string, name: string, date?: Date): Promise<ClientData> {
|
||||||
const stores = await getStores();
|
const stores = await getStores();
|
||||||
if (!stores.some(s => s.toLowerCase() === name.trim().toLowerCase())) {
|
if (!stores.some(s => s.toLowerCase() === name.trim().toLowerCase())) {
|
||||||
@@ -131,6 +149,7 @@ export async function setGroupState(login: string, groupId: string, newState: Gr
|
|||||||
await removePendingQrsByGroupId(memberLogins, groupId);
|
await removePendingQrsByGroupId(memberLogins, groupId);
|
||||||
group.orderedAt = undefined;
|
group.orderedAt = undefined;
|
||||||
group.deliveryAt = undefined;
|
group.deliveryAt = undefined;
|
||||||
|
group.qrGenerated = undefined;
|
||||||
for (const ml of memberLogins) {
|
for (const ml of memberLogins) {
|
||||||
group.members[ml] = { ...group.members[ml], paid: undefined };
|
group.members[ml] = { ...group.members[ml], paid: undefined };
|
||||||
}
|
}
|
||||||
@@ -139,6 +158,16 @@ export async function setGroupState(login: string, groupId: string, newState: Gr
|
|||||||
return saveExtraData(data, date);
|
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> {
|
export async function markGroupMemberPaid(login: string, groupId: string, date?: Date): Promise<ClientData | null> {
|
||||||
const data = await getExtraData(date);
|
const data = await getExtraData(date);
|
||||||
const group = findGroup(data, groupId);
|
const group = findGroup(data, groupId);
|
||||||
|
|||||||
+18
-8
@@ -14,7 +14,7 @@ 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";
|
||||||
@@ -29,7 +29,7 @@ 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();
|
||||||
@@ -83,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));
|
||||||
@@ -161,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));
|
||||||
@@ -191,7 +199,7 @@ 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);
|
||||||
@@ -201,8 +209,10 @@ app.use("/api/changelogs", changelogRoutes);
|
|||||||
app.use("/api/groups", groupRoutes);
|
app.use("/api/groups", groupRoutes);
|
||||||
app.use("/api/stores", storeRoutes);
|
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,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+30
-30
@@ -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;
|
||||||
|
|||||||
+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,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,15 +56,15 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -72,7 +72,7 @@ const parseValidateFutureDayIndex = (req: Request<{}, any, AddChoiceData["body"]
|
|||||||
const parseSlot = (body: Record<string, any>): MealSlot | undefined => {
|
const parseSlot = (body: Record<string, any>): MealSlot | undefined => {
|
||||||
const slot = body?.slot;
|
const slot = body?.slot;
|
||||||
if (slot != null && slot !== MealSlot.OBED) {
|
if (slot != null && slot !== MealSlot.OBED) {
|
||||||
throw Error(`Neplatný slot: ${slot}`);
|
throw new Error(`Neplatný slot: ${slot}`);
|
||||||
}
|
}
|
||||||
return slot ?? undefined;
|
return slot ?? undefined;
|
||||||
};
|
};
|
||||||
@@ -153,7 +153,7 @@ router.post("/updateNote", async (req: Request<{}, any, UpdateNoteData["body"]>,
|
|||||||
try { slot = parseSlot(req.body); } catch (e: any) { return res.status(400).json({ error: e.message }); }
|
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) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import express, { Request } from "express";
|
|||||||
import { getLogin } from "../auth";
|
import { getLogin } from "../auth";
|
||||||
import { parseToken } from "../utils";
|
import { parseToken } from "../utils";
|
||||||
import { getWebsocket } from "../websocket";
|
import { getWebsocket } from "../websocket";
|
||||||
import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, updateGroupFees } from "../groups";
|
import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, updateGroupFees, getOrderDates } from "../groups";
|
||||||
import { GroupState } from "../../../types/gen/types.gen";
|
import { GroupState } from "../../../types/gen/types.gen";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -11,6 +11,13 @@ function broadcastExtra(data: any) {
|
|||||||
getWebsocket().emit("message", data);
|
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) => {
|
router.post("/create", async (req: Request, res, next) => {
|
||||||
const login = getLogin(parseToken(req));
|
const login = getLogin(parseToken(req));
|
||||||
const { name } = req.body ?? {};
|
const { name } = req.body ?? {};
|
||||||
@@ -69,7 +76,7 @@ router.post("/updateMember", async (req: Request, res, next) => {
|
|||||||
if (!targetLogin) return res.status(400).json({ error: 'Nebyl předán login uživatele' });
|
if (!targetLogin) return res.status(400).json({ error: 'Nebyl předán login uživatele' });
|
||||||
const patch: Record<string, any> = {};
|
const patch: Record<string, any> = {};
|
||||||
if (amount !== undefined) {
|
if (amount !== undefined) {
|
||||||
if (typeof amount !== 'number' || !Number.isFinite(amount) || amount < 0) {
|
if (!Number.isInteger(amount) || amount < 0) {
|
||||||
return res.status(400).json({ error: 'Neplatná částka' });
|
return res.status(400).json({ error: 'Neplatná částka' });
|
||||||
}
|
}
|
||||||
patch.amount = amount;
|
patch.amount = amount;
|
||||||
@@ -83,7 +90,7 @@ router.post("/updateMember", async (req: Request, res, next) => {
|
|||||||
patch.surchargeText = surchargeText;
|
patch.surchargeText = surchargeText;
|
||||||
}
|
}
|
||||||
if (surchargeAmount !== undefined) {
|
if (surchargeAmount !== undefined) {
|
||||||
if (typeof surchargeAmount !== 'number' || !Number.isFinite(surchargeAmount) || surchargeAmount < 0) {
|
if (!Number.isInteger(surchargeAmount) || surchargeAmount < 0) {
|
||||||
return res.status(400).json({ error: 'Neplatná výše příplatku' });
|
return res.status(400).json({ error: 'Neplatná výše příplatku' });
|
||||||
}
|
}
|
||||||
patch.surchargeAmount = surchargeAmount;
|
patch.surchargeAmount = surchargeAmount;
|
||||||
@@ -113,19 +120,19 @@ router.post("/updateFees", async (req: Request, res, next) => {
|
|||||||
const login = getLogin(parseToken(req));
|
const login = getLogin(parseToken(req));
|
||||||
const { id, fees, shipping, tip, discountType, discountValue } = req.body ?? {};
|
const { id, fees, shipping, tip, discountType, discountValue } = req.body ?? {};
|
||||||
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
|
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
|
||||||
if (fees !== undefined && (typeof fees !== 'number' || !Number.isFinite(fees) || fees < 0)) {
|
if (fees !== undefined && (!Number.isInteger(fees) || fees < 0)) {
|
||||||
return res.status(400).json({ error: 'Neplatná výše poplatků' });
|
return res.status(400).json({ error: 'Neplatná výše poplatků' });
|
||||||
}
|
}
|
||||||
if (shipping !== undefined && (typeof shipping !== 'number' || !Number.isFinite(shipping) || shipping < 0)) {
|
if (shipping !== undefined && (!Number.isInteger(shipping) || shipping < 0)) {
|
||||||
return res.status(400).json({ error: 'Neplatná výše dopravy' });
|
return res.status(400).json({ error: 'Neplatná výše dopravy' });
|
||||||
}
|
}
|
||||||
if (tip !== undefined && (typeof tip !== 'number' || !Number.isFinite(tip) || tip < 0)) {
|
if (tip !== undefined && (!Number.isInteger(tip) || tip < 0)) {
|
||||||
return res.status(400).json({ error: 'Neplatná výše spropitného' });
|
return res.status(400).json({ error: 'Neplatná výše spropitného' });
|
||||||
}
|
}
|
||||||
if (discountType !== undefined && discountType !== '' && !['percent', 'fixed'].includes(discountType)) {
|
if (discountType !== undefined && discountType !== '' && !['percent', 'fixed'].includes(discountType)) {
|
||||||
return res.status(400).json({ error: 'Neplatný typ slevy' });
|
return res.status(400).json({ error: 'Neplatný typ slevy' });
|
||||||
}
|
}
|
||||||
if (discountValue !== undefined && (typeof discountValue !== 'number' || !Number.isFinite(discountValue) || discountValue < 0)) {
|
if (discountValue !== undefined && (!Number.isInteger(discountValue) || discountValue < 0)) {
|
||||||
return res.status(400).json({ error: 'Neplatná výše slevy' });
|
return res.status(400).json({ error: 'Neplatná výše slevy' });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -30,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);
|
||||||
@@ -41,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);
|
||||||
@@ -67,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);
|
||||||
@@ -106,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);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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 { emitToUser } from "../websocket";
|
||||||
import { GenerateQrData } from "../../../types";
|
import { GenerateQrData } from "../../../types";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
@@ -36,18 +37,13 @@ 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 a okamžitě doručit příjemci
|
// Uložit jako nevyřízený QR kód a okamžitě doručit příjemci
|
||||||
const pendingQr = {
|
const pendingQr = {
|
||||||
@@ -62,6 +58,10 @@ router.post("/generate", async (req: Request<{}, any, GenerateQrData["body"]>, r
|
|||||||
emitToUser(recipient.login, 'pendingQr', 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 });
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
next(e);
|
next(e);
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -40,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: {},
|
||||||
@@ -485,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);
|
||||||
@@ -543,7 +544,7 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -583,6 +584,7 @@ export async function getClientData(date?: Date, slot?: MealSlot): Promise<Clien
|
|||||||
return {
|
return {
|
||||||
...clientData,
|
...clientData,
|
||||||
todayDayIndex: getDayOfWeekIndex(getToday()),
|
todayDayIndex: getDayOfWeekIndex(getToday()),
|
||||||
|
isoDate: formatDate(targetDate),
|
||||||
...(slot === MealSlot.EXTRA ? { slot: MealSlot.EXTRA } : {}),
|
...(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,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>
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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,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;
|
|
||||||
}
|
|
||||||
@@ -32,5 +32,6 @@ export const getWebsocket = () => io;
|
|||||||
|
|
||||||
/** Pošle event konkrétnímu přihlášenému uživateli (pokud je připojen). */
|
/** 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) => {
|
export const emitToUser = (login: string, event: string, data: unknown) => {
|
||||||
|
if (!io) return;
|
||||||
io.to(`user:${login}`).emit(event, data);
|
io.to(`user:${login}`).emit(event, data);
|
||||||
}
|
}
|
||||||
+11
-7
@@ -69,19 +69,23 @@ 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)
|
# Skupiny objednávek (/api/groups)
|
||||||
|
/groups/dates:
|
||||||
|
$ref: "./paths/groups/getOrderDates.yml"
|
||||||
/groups/create:
|
/groups/create:
|
||||||
$ref: "./paths/groups/createGroup.yml"
|
$ref: "./paths/groups/createGroup.yml"
|
||||||
/groups/delete:
|
/groups/delete:
|
||||||
|
|||||||
@@ -9,6 +9,15 @@ 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
|
- in: query
|
||||||
name: slot
|
name: slot
|
||||||
description: Slot jídla. Pokud není předán, je použit výchozí slot (oběd).
|
description: Slot jídla. Pokud není předán, je použit výchozí slot (oběd).
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -14,21 +14,21 @@ post:
|
|||||||
description: ID skupiny
|
description: ID skupiny
|
||||||
type: string
|
type: string
|
||||||
fees:
|
fees:
|
||||||
description: Poplatky (Kč)
|
description: Poplatky (haléře)
|
||||||
type: number
|
type: integer
|
||||||
shipping:
|
shipping:
|
||||||
description: Doprava (Kč)
|
description: Doprava (haléře)
|
||||||
type: number
|
type: integer
|
||||||
tip:
|
tip:
|
||||||
description: Spropitné (Kč)
|
description: Spropitné (haléře)
|
||||||
type: number
|
type: integer
|
||||||
discountType:
|
discountType:
|
||||||
description: Typ slevy
|
description: Typ slevy
|
||||||
type: string
|
type: string
|
||||||
enum: [percent, fixed]
|
enum: [percent, fixed]
|
||||||
discountValue:
|
discountValue:
|
||||||
description: Hodnota slevy
|
description: Hodnota slevy (procenta nebo haléře pro fixed)
|
||||||
type: number
|
type: integer
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
$ref: "../../api.yml#/components/responses/ClientDataResponse"
|
$ref: "../../api.yml#/components/responses/ClientDataResponse"
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ post:
|
|||||||
description: Login člena ke změně
|
description: Login člena ke změně
|
||||||
type: string
|
type: string
|
||||||
amount:
|
amount:
|
||||||
description: Částka k úhradě v Kč
|
description: Částka k úhradě v haléřích
|
||||||
type: number
|
type: integer
|
||||||
note:
|
note:
|
||||||
description: Poznámka
|
description: Poznámka
|
||||||
type: string
|
type: string
|
||||||
@@ -27,8 +27,8 @@ post:
|
|||||||
description: Popis příplatku
|
description: Popis příplatku
|
||||||
type: string
|
type: string
|
||||||
surchargeAmount:
|
surchargeAmount:
|
||||||
description: Výše příplatku v Kč
|
description: Výše příplatku v haléřích
|
||||||
type: number
|
type: integer
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
$ref: "../../api.yml#/components/responses/ClientDataResponse"
|
$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: addSuggestion
|
||||||
|
summary: Přidá nový návrh na vylepšení. Autorovi se automaticky nastaví hlas pro.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- title
|
||||||
|
- description
|
||||||
|
properties:
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
description: Stručný jednořádkový název návrhu
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
description: Detailní popis navrhované úpravy
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Aktualizovaný seznam návrhů.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "../../schemas/_index.yml#/Suggestion"
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
post:
|
||||||
|
operationId: deleteSuggestion
|
||||||
|
summary: >-
|
||||||
|
Smaže návrh včetně všech jeho hlasů. Smazat lze pouze vlastní návrh
|
||||||
|
(validováno na serveru).
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
description: Identifikátor návrhu ke smazání
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Aktualizovaný seznam návrhů.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "../../schemas/_index.yml#/Suggestion"
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
get:
|
||||||
|
operationId: listSuggestions
|
||||||
|
summary: Vrátí seznam návrhů na vylepšení seřazený sestupně dle počtu hlasů.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "../../schemas/_index.yml#/Suggestion"
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
post:
|
||||||
|
operationId: voteSuggestion
|
||||||
|
summary: >-
|
||||||
|
Přepne hlas přihlášeného uživatele u návrhu. Klik na již aktivní směr hlas
|
||||||
|
zruší, opačný směr stávající hlas přepíše.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- direction
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
description: Identifikátor návrhu
|
||||||
|
direction:
|
||||||
|
description: Směr hlasu, na který uživatel klikl
|
||||||
|
$ref: "../../schemas/_index.yml#/VoteDirection"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Aktualizovaný seznam návrhů.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "../../schemas/_index.yml#/Suggestion"
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
get:
|
|
||||||
operationId: getVotes
|
|
||||||
summary: Vrátí statistiky hlasování o nových funkcích.
|
|
||||||
responses:
|
|
||||||
"200":
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: "../../schemas/_index.yml#/FeatureRequest"
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
get:
|
|
||||||
operationId: getVotingStats
|
|
||||||
summary: Vrátí agregované statistiky hlasování o nových funkcích.
|
|
||||||
responses:
|
|
||||||
"200":
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: "../../schemas/_index.yml#/VotingStats"
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
post:
|
|
||||||
operationId: updateVote
|
|
||||||
summary: Aktualizuje hlasování uživatele o dané funkcionalitě.
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- option
|
|
||||||
- active
|
|
||||||
properties:
|
|
||||||
option:
|
|
||||||
description: Hlasovací možnost, kterou uživatel zvolil.
|
|
||||||
$ref: "../../schemas/_index.yml#/FeatureRequest"
|
|
||||||
active:
|
|
||||||
type: boolean
|
|
||||||
description: True, pokud uživatel hlasoval pro, jinak false.
|
|
||||||
responses:
|
|
||||||
"200":
|
|
||||||
description: Hlasování bylo úspěšně aktualizováno.
|
|
||||||
+77
-59
@@ -32,6 +32,10 @@ ClientData:
|
|||||||
date:
|
date:
|
||||||
description: Human-readable datum dne
|
description: Human-readable datum dne
|
||||||
type: string
|
type: string
|
||||||
|
isoDate:
|
||||||
|
description: Datum zobrazeného dne ve formátu YYYY-MM-DD (pro navigaci mezi dny)
|
||||||
|
type: string
|
||||||
|
format: date
|
||||||
isWeekend:
|
isWeekend:
|
||||||
description: Příznak, zda je tento den víkend
|
description: Příznak, zda je tento den víkend
|
||||||
type: boolean
|
type: boolean
|
||||||
@@ -268,39 +272,50 @@ DepartureTime:
|
|||||||
- T12_45
|
- T12_45
|
||||||
- T13_00
|
- T13_00
|
||||||
|
|
||||||
# --- HLASOVÁNÍ ---
|
# --- NÁVRHY NA VYLEPŠENÍ ---
|
||||||
FeatureRequest:
|
VoteDirection:
|
||||||
|
description: Směr hlasu uživatele u návrhu
|
||||||
type: string
|
type: string
|
||||||
enum:
|
enum:
|
||||||
- Možnost označovat si jídla jako oblíbená (taková jídla by se uživateli následně zvýrazňovala)
|
- up
|
||||||
- Možnost úhrady v podniku za všechny jednou osobou a následné generování QR ostatním
|
- down
|
||||||
- Zrušení \"užívejte víkend\", místo toho umožnit zpětně náhled na uplynulý týden
|
|
||||||
- Umožnění zobrazení vygenerovaného QR kódu i po následující dny (dokud ho uživatel ručně \"nezavře\", např. tlačítkem \"Zaplatil jsem\")
|
|
||||||
- Zobrazování náhledů (fotografií) pizz v rámci Pizza day
|
|
||||||
- Statistiky (nejoblíbenější podnik, nejpopulárnější jídla, nejobjednávanější pizzy, nejčastější uživatelé, ...)
|
|
||||||
- Vylepšení responzivního designu
|
|
||||||
- Zvýšení zabezpečení aplikace
|
|
||||||
- Zvýšená ochrana proti chybám uživatele (potvrzovací dialogy, překliky, ...)
|
|
||||||
- Celkové vylepšení UI/UX
|
|
||||||
- Zlepšení dokumentace/postupů pro ostatní vývojáře
|
|
||||||
x-enum-varnames:
|
x-enum-varnames:
|
||||||
- FAVORITES
|
- UP
|
||||||
- SINGLE_PAYMENT
|
- DOWN
|
||||||
- NO_WEEKENDS
|
|
||||||
- QR_FOREVER
|
|
||||||
- PIZZA_PICTURES
|
|
||||||
- STATISTICS
|
|
||||||
- RESPONSIVITY
|
|
||||||
- SECURITY
|
|
||||||
- SAFETY
|
|
||||||
- UI
|
|
||||||
- DEVELOPMENT
|
|
||||||
|
|
||||||
VotingStats:
|
Suggestion:
|
||||||
description: Statistiky hlasování - klíčem je název funkce, hodnotou počet hlasů
|
description: Návrh na vylepšení aplikace tak, jak je posílán klientovi.
|
||||||
type: object
|
type: object
|
||||||
additionalProperties:
|
additionalProperties: false
|
||||||
type: integer
|
required:
|
||||||
|
- id
|
||||||
|
- author
|
||||||
|
- title
|
||||||
|
- description
|
||||||
|
- voteScore
|
||||||
|
- isMine
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
description: Jednoznačný identifikátor návrhu
|
||||||
|
author:
|
||||||
|
type: string
|
||||||
|
description: Login uživatele, který návrh vytvořil
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
description: Stručný jednořádkový název návrhu
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
description: Detailní popis navrhované úpravy
|
||||||
|
voteScore:
|
||||||
|
type: integer
|
||||||
|
description: Skóre návrhu = počet hlasů pro mínus počet hlasů proti
|
||||||
|
myVote:
|
||||||
|
description: Hlas přihlášeného uživatele (chybí, pokud nehlasoval)
|
||||||
|
$ref: "#/VoteDirection"
|
||||||
|
isMine:
|
||||||
|
type: boolean
|
||||||
|
description: True, pokud návrh vytvořil přihlášený uživatel
|
||||||
|
|
||||||
# --- EASTER EGGS ---
|
# --- EASTER EGGS ---
|
||||||
EasterEgg:
|
EasterEgg:
|
||||||
@@ -420,14 +435,14 @@ PizzaSize:
|
|||||||
description: Velikost pizzy, např. "30cm"
|
description: Velikost pizzy, např. "30cm"
|
||||||
type: string
|
type: string
|
||||||
pizzaPrice:
|
pizzaPrice:
|
||||||
description: Cena samotné pizzy v Kč
|
description: Cena samotné pizzy v haléřích
|
||||||
type: number
|
type: integer
|
||||||
boxPrice:
|
boxPrice:
|
||||||
description: Cena krabice pizzy v Kč
|
description: Cena krabice pizzy v haléřích
|
||||||
type: number
|
type: integer
|
||||||
price:
|
price:
|
||||||
description: Celková cena (pizza + krabice)
|
description: Celková cena (pizza + krabice) v haléřích
|
||||||
type: number
|
type: integer
|
||||||
Pizza:
|
Pizza:
|
||||||
description: Údaje o konkrétní pizze.
|
description: Údaje o konkrétní pizze.
|
||||||
type: object
|
type: object
|
||||||
@@ -470,8 +485,8 @@ PizzaVariant:
|
|||||||
description: Velikost pizzy (např. "30cm"), nebo "1 porce" pro salát
|
description: Velikost pizzy (např. "30cm"), nebo "1 porce" pro salát
|
||||||
type: string
|
type: string
|
||||||
price:
|
price:
|
||||||
description: Cena v Kč, včetně krabice/obalu
|
description: Cena v haléřích, včetně krabice/obalu
|
||||||
type: number
|
type: integer
|
||||||
category:
|
category:
|
||||||
description: Kategorie položky (pizza nebo salat)
|
description: Kategorie položky (pizza nebo salat)
|
||||||
type: string
|
type: string
|
||||||
@@ -494,8 +509,8 @@ Salat:
|
|||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
price:
|
price:
|
||||||
description: Cena salátu v Kč (bez obalu)
|
description: Cena salátu v haléřích (bez obalu)
|
||||||
type: number
|
type: integer
|
||||||
PizzaOrder:
|
PizzaOrder:
|
||||||
description: Údaje o objednávce pizzy jednoho uživatele.
|
description: Údaje o objednávce pizzy jednoho uživatele.
|
||||||
type: object
|
type: object
|
||||||
@@ -521,11 +536,11 @@ PizzaOrder:
|
|||||||
description: Popis příplatku (např. "kuřecí maso navíc")
|
description: Popis příplatku (např. "kuřecí maso navíc")
|
||||||
type: string
|
type: string
|
||||||
price:
|
price:
|
||||||
description: Cena příplatku v Kč
|
description: Cena příplatku v haléřích
|
||||||
type: number
|
type: integer
|
||||||
totalPrice:
|
totalPrice:
|
||||||
description: Celková cena všech objednaných pizz daného uživatele, včetně krabic a příplatků
|
description: Celková cena všech objednaných pizz daného uživatele v haléřích, včetně krabic a příplatků
|
||||||
type: number
|
type: integer
|
||||||
hasQr:
|
hasQr:
|
||||||
description: |
|
description: |
|
||||||
Příznak, pokud je k této objednávce vygenerován QR kód pro platbu. To je typicky pravda, pokud:
|
Příznak, pokud je k této objednávce vygenerován QR kód pro platbu. To je typicky pravda, pokud:
|
||||||
@@ -635,9 +650,9 @@ QrRecipient:
|
|||||||
description: Účel platby (např. "Pizza prosciutto")
|
description: Účel platby (např. "Pizza prosciutto")
|
||||||
type: string
|
type: string
|
||||||
amount:
|
amount:
|
||||||
description: Částka v Kč (kladné číslo, max 2 desetinná místa)
|
description: Částka v haléřích (kladné celé číslo)
|
||||||
type: number
|
type: integer
|
||||||
minimum: 0.01
|
minimum: 1
|
||||||
GenerateQrRequest:
|
GenerateQrRequest:
|
||||||
description: Request pro generování QR kódů
|
description: Request pro generování QR kódů
|
||||||
type: object
|
type: object
|
||||||
@@ -704,8 +719,8 @@ OrderGroupMember:
|
|||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
properties:
|
properties:
|
||||||
amount:
|
amount:
|
||||||
description: Částka k úhradě v Kč
|
description: Částka k úhradě v haléřích
|
||||||
type: number
|
type: integer
|
||||||
note:
|
note:
|
||||||
description: Volitelná poznámka (např. co si objednává)
|
description: Volitelná poznámka (např. co si objednává)
|
||||||
type: string
|
type: string
|
||||||
@@ -713,8 +728,8 @@ OrderGroupMember:
|
|||||||
description: Popis příplatku
|
description: Popis příplatku
|
||||||
type: string
|
type: string
|
||||||
surchargeAmount:
|
surchargeAmount:
|
||||||
description: Výše příplatku v Kč
|
description: Výše příplatku v haléřích
|
||||||
type: number
|
type: integer
|
||||||
paid:
|
paid:
|
||||||
description: Příznak, zda člen uhradil svůj podíl objednávky
|
description: Příznak, zda člen uhradil svůj podíl objednávky
|
||||||
type: boolean
|
type: boolean
|
||||||
@@ -753,21 +768,24 @@ OrderGroup:
|
|||||||
description: Očekávaný čas doručení ve formátu HH:MM
|
description: Očekávaný čas doručení ve formátu HH:MM
|
||||||
type: string
|
type: string
|
||||||
fees:
|
fees:
|
||||||
description: Poplatky (balení apod.) celkem v Kč
|
description: Poplatky (balení apod.) celkem v haléřích
|
||||||
type: number
|
type: integer
|
||||||
shipping:
|
shipping:
|
||||||
description: Doprava v Kč
|
description: Doprava v haléřích
|
||||||
type: number
|
type: integer
|
||||||
tip:
|
tip:
|
||||||
description: Spropitné v Kč
|
description: Spropitné v haléřích
|
||||||
type: number
|
type: integer
|
||||||
discountType:
|
discountType:
|
||||||
description: Typ slevy aplikované na objednávku
|
description: Typ slevy aplikované na objednávku
|
||||||
type: string
|
type: string
|
||||||
enum: [percent, fixed]
|
enum: [percent, fixed]
|
||||||
discountValue:
|
discountValue:
|
||||||
description: Hodnota slevy (procenta nebo Kč)
|
description: Hodnota slevy (procenta pro typ 'percent', haléře pro typ 'fixed')
|
||||||
type: number
|
type: integer
|
||||||
|
qrGenerated:
|
||||||
|
description: Příznak, zda byly pro skupinu vygenerovány QR kódy (blokuje opakované generování)
|
||||||
|
type: boolean
|
||||||
|
|
||||||
# --- NEVYŘÍZENÉ QR KÓDY ---
|
# --- NEVYŘÍZENÉ QR KÓDY ---
|
||||||
PendingQr:
|
PendingQr:
|
||||||
@@ -790,8 +808,8 @@ PendingQr:
|
|||||||
description: Jméno uživatele, který QR vygeneroval (příjemce platby)
|
description: Jméno uživatele, který QR vygeneroval (příjemce platby)
|
||||||
type: string
|
type: string
|
||||||
totalPrice:
|
totalPrice:
|
||||||
description: Celková cena objednávky v Kč
|
description: Celková cena objednávky v haléřích
|
||||||
type: number
|
type: integer
|
||||||
purpose:
|
purpose:
|
||||||
description: Účel platby (např. "Pizza prosciutto")
|
description: Účel platby (např. "Pizza prosciutto")
|
||||||
type: string
|
type: string
|
||||||
|
|||||||
Reference in New Issue
Block a user