feat: zadávání cen
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 28s
CI / Build server (push) Successful in 37s
CI / Build client (push) Successful in 45s
CI / Playwright E2E tests (push) Successful in 2m15s
CI / Build and push Docker image (push) Successful in 45s
CI / Notify (push) Successful in 2s
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 28s
CI / Build server (push) Successful in 37s
CI / Build client (push) Successful in 45s
CI / Playwright E2E tests (push) Successful in 2m15s
CI / Build and push Docker image (push) Successful in 45s
CI / Notify (push) Successful in 2s
This commit is contained in:
1 parent
96dc3f0053
commit
9a3987a2fa
13 files changed
+244
-11
No files matched your search
+33
-3
@@ -18,18 +18,20 @@ import './themes/bocchi.scss';
|
|||||||
import { faCircleCheck, faNoteSticky, faTrashCan, faComment } from '@fortawesome/free-regular-svg-icons';
|
import { faCircleCheck, faNoteSticky, faTrashCan, faComment } from '@fortawesome/free-regular-svg-icons';
|
||||||
import { useSettings } from './context/settings';
|
import { useSettings } from './context/settings';
|
||||||
import Footer from './components/Footer';
|
import Footer from './components/Footer';
|
||||||
import { faArrowUpRightFromSquare, faBasketShopping, faChainBroken, faChevronLeft, faChevronRight, faDharmachakra, faGear, faMoneyBillTransfer, faSatelliteDish, faSearch, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons';
|
import { faArrowUpRightFromSquare, faBasketShopping, faTag, faChainBroken, faChevronLeft, faChevronRight, faDharmachakra, faGear, faMoneyBillTransfer, faSatelliteDish, faSearch, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import Loader from './components/Loader';
|
import Loader from './components/Loader';
|
||||||
import { getHumanDateTime, isInTheFuture } from './Utils';
|
import { getHumanDateTime, isInTheFuture } from './Utils';
|
||||||
import NoteModal from './components/modals/NoteModal';
|
import NoteModal from './components/modals/NoteModal';
|
||||||
|
import PriceModal from './components/modals/PriceModal';
|
||||||
|
import { parsePriceCzk } from './utils/parsePrice';
|
||||||
import PayForAllModal from './components/modals/PayForAllModal';
|
import PayForAllModal from './components/modals/PayForAllModal';
|
||||||
import SpinWheelModal, { WheelPick } from './components/modals/SpinWheelModal';
|
import SpinWheelModal, { WheelPick } from './components/modals/SpinWheelModal';
|
||||||
import EaterBadge from './components/EaterBadge';
|
import EaterBadge from './components/EaterBadge';
|
||||||
import PendingPayments from './components/PendingPayments';
|
import PendingPayments from './components/PendingPayments';
|
||||||
import { useEasterEgg } from './context/eggs';
|
import { useEasterEgg } from './context/eggs';
|
||||||
import { ClientData, Food, MealSlot, PendingQr, PizzaOrder, DepartureTime, PizzaDayState, Restaurant, RestaurantDayMenu, RestaurantDayMenuMap, LunchChoice, LocationLunchChoicesMap, UserLunchChoice, PizzaVariant, getData, getEasterEggImage, addPizza, removePizza, updatePizzaDayNote, createPizzaDay, deletePizzaDay, lockPizzaDay, unlockPizzaDay, finishOrder, finishDelivery, addChoice, jdemeObed, removeChoices, removeChoice, updateNote, changeDepartureTime, setBuyer, generateQr } from '../../types';
|
import { ClientData, Food, 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, updatePrice, changeDepartureTime, setBuyer, generateQr } from '../../types';
|
||||||
import { getLunchChoiceName } from './enums';
|
import { CHOICES_WITHOUT_PRICE, getLunchChoiceName } from './enums';
|
||||||
import { isArrowNavigationBlocked } from './utils/arrowNavigation';
|
import { isArrowNavigationBlocked } from './utils/arrowNavigation';
|
||||||
// import FallingLeaves, { LEAF_PRESETS, LEAF_COLOR_THEMES } from './FallingLeaves';
|
// import FallingLeaves, { LEAF_PRESETS, LEAF_COLOR_THEMES } from './FallingLeaves';
|
||||||
// import './FallingLeaves.scss';
|
// import './FallingLeaves.scss';
|
||||||
@@ -88,6 +90,7 @@ function App() {
|
|||||||
const [dayIndex, setDayIndex] = useState<number>();
|
const [dayIndex, setDayIndex] = useState<number>();
|
||||||
const [loadingPizzaDay, setLoadingPizzaDay] = useState<boolean>(false);
|
const [loadingPizzaDay, setLoadingPizzaDay] = useState<boolean>(false);
|
||||||
const [noteModalOpen, setNoteModalOpen] = useState<boolean>(false);
|
const [noteModalOpen, setNoteModalOpen] = useState<boolean>(false);
|
||||||
|
const [priceModalOpen, setPriceModalOpen] = useState<boolean>(false);
|
||||||
const [payForAllLocationKey, setPayForAllLocationKey] = useState<LunchChoice | null>(null);
|
const [payForAllLocationKey, setPayForAllLocationKey] = useState<LunchChoice | null>(null);
|
||||||
const [spinWheelOpen, setSpinWheelOpen] = useState<boolean>(false);
|
const [spinWheelOpen, setSpinWheelOpen] = useState<boolean>(false);
|
||||||
const [eggImage, setEggImage] = useState<Blob>();
|
const [eggImage, setEggImage] = useState<Blob>();
|
||||||
@@ -443,6 +446,13 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const savePrice = async (price?: number) => {
|
||||||
|
if (auth?.login) {
|
||||||
|
await updatePrice({ body: { price, dayIndex } });
|
||||||
|
setPriceModalOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const copyNote = async (note: string) => {
|
const copyNote = async (note: string) => {
|
||||||
if (auth?.login && note) {
|
if (auth?.login && note) {
|
||||||
await updateNote({ body: { note, dayIndex } });
|
await updateNote({ body: { note, dayIndex } });
|
||||||
@@ -666,6 +676,15 @@ function App() {
|
|||||||
const noOrders = data?.pizzaDay?.orders?.length === 0;
|
const noOrders = data?.pizzaDay?.orders?.length === 0;
|
||||||
const canChangeChoice = dayIndex == null || data.todayDayIndex == null || dayIndex >= data.todayDayIndex;
|
const canChangeChoice = dayIndex == null || data.todayDayIndex == null || dayIndex >= data.todayDayIndex;
|
||||||
|
|
||||||
|
const ownChoice = auth?.login ? Object.entries(data.choices)
|
||||||
|
.map(([locationKey, loginMap]) => ({ locationKey: locationKey as LunchChoice, choice: loginMap?.[auth.login!] }))
|
||||||
|
.find(entry => entry.choice != null) as { locationKey: LunchChoice, choice: UserLunchChoice } | undefined : undefined;
|
||||||
|
// Součet cen vybraných jídel z menu jako návrh ceny; pokud některou cenu nelze přečíst, nenavrhujeme nic
|
||||||
|
const ownFoodPrices = ownChoice?.choice.selectedFoods?.map(index => parsePriceCzk(food[ownChoice.locationKey as Restaurant]?.food?.[index]?.price)) ?? [];
|
||||||
|
const ownSuggestedPrice = ownFoodPrices.length && !ownFoodPrices.includes(null)
|
||||||
|
? Math.round((ownFoodPrices as number[]).reduce((sum, price) => sum + price, 0) * 100)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const { path, url, startOffset, endOffset, duration, ...style } = easterEgg || {};
|
const { path, url, startOffset, endOffset, duration, ...style } = easterEgg || {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -781,6 +800,7 @@ function App() {
|
|||||||
<strong>{login}</strong>
|
<strong>{login}</strong>
|
||||||
{userPayload.departureTime && <small className="ms-2" style={{ color: 'var(--luncher-text-muted)' }}>({userPayload.departureTime})</small>}
|
{userPayload.departureTime && <small className="ms-2" style={{ color: 'var(--luncher-text-muted)' }}>({userPayload.departureTime})</small>}
|
||||||
{userPayload.note && <span className="ms-2" style={{ fontSize: 'small', color: 'var(--luncher-text-secondary)' }}>({userPayload.note})</span>}
|
{userPayload.note && <span className="ms-2" style={{ fontSize: 'small', color: 'var(--luncher-text-secondary)' }}>({userPayload.note})</span>}
|
||||||
|
{login === auth.login && userPayload.price != null && <small className="ms-2" style={{ color: 'var(--luncher-text-muted)' }}>{userPayload.price / 100} Kč</small>}
|
||||||
</div>
|
</div>
|
||||||
<div className="user-actions">
|
<div className="user-actions">
|
||||||
{login === auth.login && canChangeChoice && locationKey === LunchChoice.OBJEDNAVAM && <span title='Označit/odznačit se jako objednávající'>
|
{login === auth.login && canChangeChoice && locationKey === LunchChoice.OBJEDNAVAM && <span title='Označit/odznačit se jako objednávající'>
|
||||||
@@ -804,6 +824,9 @@ function App() {
|
|||||||
{login === auth.login && canChangeChoice && locationKey === LunchChoice.ROZHODUJI && <span title='Roztočit kolo štěstí'>
|
{login === auth.login && canChangeChoice && locationKey === LunchChoice.ROZHODUJI && <span title='Roztočit kolo štěstí'>
|
||||||
<FontAwesomeIcon onClick={() => setSpinWheelOpen(true)} className='action-icon spin-wheel-icon' icon={faDharmachakra} />
|
<FontAwesomeIcon onClick={() => setSpinWheelOpen(true)} className='action-icon spin-wheel-icon' icon={faDharmachakra} />
|
||||||
</span>}
|
</span>}
|
||||||
|
{login === auth.login && canChangeChoice && !CHOICES_WITHOUT_PRICE.includes(locationKey) && <span title='Zadat cenu oběda do přehledu stravování'>
|
||||||
|
<FontAwesomeIcon onClick={() => setPriceModalOpen(true)} className='action-icon' icon={faTag} />
|
||||||
|
</span>}
|
||||||
{login === auth.login && canChangeChoice && <span title='Upravit poznámku'>
|
{login === auth.login && canChangeChoice && <span title='Upravit poznámku'>
|
||||||
<FontAwesomeIcon onClick={() => {
|
<FontAwesomeIcon onClick={() => {
|
||||||
setNoteModalOpen(true);
|
setNoteModalOpen(true);
|
||||||
@@ -969,6 +992,13 @@ function App() {
|
|||||||
<Footer />
|
<Footer />
|
||||||
{spinWheelOpen && <SpinWheelModal isOpen onClose={() => setSpinWheelOpen(false)} menus={food} onPick={handleWheelPick} />}
|
{spinWheelOpen && <SpinWheelModal isOpen onClose={() => setSpinWheelOpen(false)} menus={food} onPick={handleWheelPick} />}
|
||||||
<NoteModal isOpen={noteModalOpen} onClose={() => setNoteModalOpen(false)} onSave={saveNote} />
|
<NoteModal isOpen={noteModalOpen} onClose={() => setNoteModalOpen(false)} onSave={saveNote} />
|
||||||
|
<PriceModal
|
||||||
|
isOpen={priceModalOpen}
|
||||||
|
onClose={() => setPriceModalOpen(false)}
|
||||||
|
price={ownChoice?.choice.price}
|
||||||
|
suggestedPrice={ownSuggestedPrice}
|
||||||
|
onSave={savePrice}
|
||||||
|
/>
|
||||||
{payForAllLocationKey && data && (
|
{payForAllLocationKey && data && (
|
||||||
<PayForAllModal
|
<PayForAllModal
|
||||||
isOpen
|
isOpen
|
||||||
|
|||||||
@@ -49,9 +49,10 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
|
|||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
const entries: DinerEntry[] = Object.entries(locationChoices).map(([login, choice]) => {
|
const entries: DinerEntry[] = Object.entries(locationChoices).map(([login, choice]) => {
|
||||||
const selectedFoods = choice.selectedFoods ?? [];
|
const selectedFoods = choice.selectedFoods ?? [];
|
||||||
let baseAmount = 0;
|
// Cena, kterou si strávník zadal sám, má přednost před cenami z menu
|
||||||
|
let baseAmount = choice.price ?? 0;
|
||||||
let baseAmountParseFailed = false;
|
let baseAmountParseFailed = false;
|
||||||
if (menu) {
|
if (menu && choice.price == null) {
|
||||||
for (const idx of selectedFoods) {
|
for (const idx of selectedFoods) {
|
||||||
const priceKc = parsePriceCzk(menu.food?.[idx]?.price);
|
const priceKc = parsePriceCzk(menu.food?.[idx]?.price);
|
||||||
if (priceKc === null) {
|
if (priceKc === null) {
|
||||||
@@ -168,7 +169,7 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
|
|||||||
<>
|
<>
|
||||||
<p>Zaplatili jste za skupinu v restauraci. Nastavte příplatky a společné poplatky, poté vygenerujte QR kódy pro ostatní.</p>
|
<p>Zaplatili jste za skupinu v restauraci. Nastavte příplatky a společné poplatky, poté vygenerujte QR kódy pro ostatní.</p>
|
||||||
|
|
||||||
{!hasMenu && (
|
{!hasMenu && !diners.some(d => d.baseAmount > 0) && (
|
||||||
<Alert variant="info">
|
<Alert variant="info">
|
||||||
Pro tuto skupinu nejsou k dispozici ceny jídel — vyplňte příplatky ručně.
|
Pro tuto skupinu nejsou k dispozici ceny jídel — vyplňte příplatky ručně.
|
||||||
</Alert>
|
</Alert>
|
||||||
@@ -219,7 +220,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 / 100} Kč)</span>}
|
{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>
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Modal, Button, Form } from "react-bootstrap";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean,
|
||||||
|
onClose: () => void,
|
||||||
|
/** Uložená cena v haléřích */
|
||||||
|
price?: number,
|
||||||
|
/** Cena v haléřích spočtená z vybraných jídel v menu, nabídne se, pokud cena ještě není uložená */
|
||||||
|
suggestedPrice?: number,
|
||||||
|
/** Cena v haléřích, undefined cenu odstraní */
|
||||||
|
onSave: (price?: number) => void,
|
||||||
|
}
|
||||||
|
|
||||||
|
const toCrownsText = (amount?: number) => amount ? String(amount / 100).replace('.', ',') : '';
|
||||||
|
|
||||||
|
/** Modální dialog pro zadání ceny oběda, která se promítne do přehledu stravování. */
|
||||||
|
const PriceModal = ({ isOpen, onClose, price, suggestedPrice, onSave }: Readonly<Props>) => {
|
||||||
|
const [value, setValue] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setValue(toCrownsText(price ?? suggestedPrice));
|
||||||
|
}
|
||||||
|
}, [isOpen, price, suggestedPrice]);
|
||||||
|
|
||||||
|
const parsed = value.trim().length ? Number.parseFloat(value.replace(',', '.')) : undefined;
|
||||||
|
const invalid = parsed != null && (Number.isNaN(parsed) || parsed < 0);
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
if (invalid) return;
|
||||||
|
onSave(parsed == null ? undefined : Math.round(parsed * 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Modal show={isOpen} onHide={onClose}>
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title>Cena oběda</Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
<p className="text-muted small">Cena se promítne do vašeho měsíčního přehledu stravování (export).</p>
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
<Form.Control
|
||||||
|
autoFocus
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
placeholder="např. 145"
|
||||||
|
value={value}
|
||||||
|
isInvalid={invalid}
|
||||||
|
onChange={e => setValue(e.target.value.replace(/[^0-9.,]/g, ''))}
|
||||||
|
onKeyDown={event => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
save();
|
||||||
|
}
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>Kč</span>
|
||||||
|
</div>
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
{price != null && <Button variant="outline-danger" className="me-auto" onClick={() => onSave(undefined)}>
|
||||||
|
Odstranit cenu
|
||||||
|
</Button>}
|
||||||
|
<Button variant="secondary" onClick={() => onClose()}>
|
||||||
|
Storno
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={() => save()} disabled={invalid}>
|
||||||
|
Uložit
|
||||||
|
</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PriceModal;
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import { LunchChoice, Restaurant } from "../../types";
|
import { LunchChoice, Restaurant } from "../../types";
|
||||||
|
|
||||||
|
/** Volby, u kterých se nezadává cena oběda — cena je dána objednávkou, Pizza day, nebo se neobědvá. */
|
||||||
|
export const CHOICES_WITHOUT_PRICE: LunchChoice[] = [LunchChoice.OBJEDNAVAM, LunchChoice.PIZZA, LunchChoice.NEOBEDVAM, LunchChoice.ROZHODUJI];
|
||||||
|
|
||||||
export function getRestaurantName(restaurant: Restaurant) {
|
export function getRestaurantName(restaurant: Restaurant) {
|
||||||
switch (restaurant) {
|
switch (restaurant) {
|
||||||
case Restaurant.SLADOVNICKA:
|
case Restaurant.SLADOVNICKA:
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[
|
||||||
|
"U oběda v restauraci si můžete zadat cenu (ikonka cenovky u vaší volby) — promítne se do měsíčního přehledu stravování a předvyplní se i v „Zaplatit za všechny“"
|
||||||
|
]
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import express, { Request, Response } from "express";
|
import express, { Request, Response } from "express";
|
||||||
import { getLogin, getTrusted } from "../auth";
|
import { getLogin, getTrusted } from "../auth";
|
||||||
import { addChoice, getActiveRestaurants, getDateForWeekIndex, getToday, removeChoice, removeChoices, updateDepartureTime, updateNote, fetchRestaurantWeekMenuData, saveRestaurantWeekMenu, updateBuyer } from "../service";
|
import { addChoice, getActiveRestaurants, getDateForWeekIndex, getToday, removeChoice, removeChoices, updateDepartureTime, updateNote, updatePrice, fetchRestaurantWeekMenuData, saveRestaurantWeekMenu, updateBuyer } from "../service";
|
||||||
import { getDayOfWeekIndex, parseToken, getFirstWorkDayOfWeek } from "../utils";
|
import { getDayOfWeekIndex, parseToken, getFirstWorkDayOfWeek } from "../utils";
|
||||||
import { getWebsocket } from "../websocket";
|
import { getWebsocket } from "../websocket";
|
||||||
import { callNotifikace } from "../notifikace";
|
import { callNotifikace } from "../notifikace";
|
||||||
import { AddChoiceData, ChangeDepartureTimeData, MealSlot, RemoveChoiceData, RemoveChoicesData, UdalostEnum, UpdateNoteData } from "../../../types/gen/types.gen";
|
import { AddChoiceData, ChangeDepartureTimeData, MealSlot, RemoveChoiceData, RemoveChoicesData, UdalostEnum, UpdateNoteData, UpdatePriceData } from "../../../types/gen/types.gen";
|
||||||
|
|
||||||
|
|
||||||
// RateLimit na refresh endpoint
|
// RateLimit na refresh endpoint
|
||||||
@@ -54,7 +54,7 @@ function checkRateLimit(key: string, limit: number = RATE_LIMIT): boolean {
|
|||||||
* @param req request
|
* @param req request
|
||||||
* @returns index dne v týdnu
|
* @returns index dne v týdnu
|
||||||
*/
|
*/
|
||||||
const parseValidateFutureDayIndex = (req: Request<{}, any, AddChoiceData["body"] | UpdateNoteData["body"]>) => {
|
const parseValidateFutureDayIndex = (req: Request<{}, any, AddChoiceData["body"] | UpdateNoteData["body"] | UpdatePriceData["body"]>) => {
|
||||||
if (req.body.dayIndex == null) {
|
if (req.body.dayIndex == null) {
|
||||||
throw new Error(`Nebyl předán index dne v týdnu.`);
|
throw new Error(`Nebyl předán index dne v týdnu.`);
|
||||||
}
|
}
|
||||||
@@ -171,6 +171,24 @@ router.post("/updateNote", async (req: Request<{}, any, UpdateNoteData["body"]>,
|
|||||||
} catch (e: any) { next(e) }
|
} catch (e: any) { next(e) }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post("/updatePrice", async (req: Request<{}, any, UpdatePriceData["body"]>, res, next) => {
|
||||||
|
const login = getLogin(parseToken(req));
|
||||||
|
const trusted = getTrusted(parseToken(req));
|
||||||
|
let date = undefined;
|
||||||
|
if (req.body.dayIndex != null) {
|
||||||
|
try {
|
||||||
|
date = getDateForWeekIndex(parseValidateFutureDayIndex(req));
|
||||||
|
} catch (e: any) {
|
||||||
|
return res.status(400).json({ error: e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await updatePrice(login, trusted, req.body.price ?? undefined, date);
|
||||||
|
getWebsocket().emit("message", data);
|
||||||
|
res.status(200).json(data);
|
||||||
|
} catch (e: any) { next(e) }
|
||||||
|
});
|
||||||
|
|
||||||
router.post("/changeDepartureTime", async (req: Request<{}, any, ChangeDepartureTimeData["body"]>, res, next) => {
|
router.post("/changeDepartureTime", async (req: Request<{}, any, ChangeDepartureTimeData["body"]>, res, next) => {
|
||||||
const login = getLogin(parseToken(req));
|
const login = getLogin(parseToken(req));
|
||||||
let date = undefined;
|
let date = undefined;
|
||||||
|
|||||||
@@ -590,6 +590,37 @@ export async function updateNote(login: string, trusted: boolean, note?: string,
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Volby, u kterých nemá smysl zadávat cenu ručně — cena je dána objednávkou nebo se neobědvá. */
|
||||||
|
export const CHOICES_WITHOUT_PRICE: LunchChoice[] = [LunchChoice.OBJEDNAVAM, LunchChoice.PIZZA, LunchChoice.NEOBEDVAM, LunchChoice.ROZHODUJI];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nastaví nebo odstraní cenu oběda u aktuálně vybrané možnosti uživatele.
|
||||||
|
*
|
||||||
|
* @param login login uživatele
|
||||||
|
* @param trusted příznak, zda se jedná o ověřeného uživatele
|
||||||
|
* @param price cena v haléřích, při nevyplnění se cena odstraní
|
||||||
|
* @param date datum, ke kterému se volba vztahuje
|
||||||
|
*/
|
||||||
|
export async function updatePrice(login: string, trusted: boolean, price?: number, date?: Date) {
|
||||||
|
if (price != null && (!Number.isInteger(price) || price < 0)) {
|
||||||
|
throw new Error(`Neplatná cena: ${price}`);
|
||||||
|
}
|
||||||
|
const usedDate = date ?? getToday();
|
||||||
|
const snapshot = await getClientData(usedDate);
|
||||||
|
validateTrusted(snapshot, login, trusted);
|
||||||
|
return storage.updateData<ClientData>(getDataKey(usedDate), (current) => {
|
||||||
|
const data = current ?? getEmptyData(date);
|
||||||
|
const userEntry = Object.entries(data.choices ?? {}).find(entry => entry[1][login] != null);
|
||||||
|
if (!userEntry) throw new Error("Cenu lze zadat jen k vybrané volbě");
|
||||||
|
if (CHOICES_WITHOUT_PRICE.includes(userEntry[0] as LunchChoice)) {
|
||||||
|
throw new Error("K této volbě nelze zadat cenu");
|
||||||
|
}
|
||||||
|
if (price == null) delete userEntry[1][login].price;
|
||||||
|
else userEntry[1][login].price = price;
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Aktualizuje preferovaný čas odchodu strávníka.
|
* Aktualizuje preferovaný čas odchodu strávníka.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import getStorage from '../storage';
|
||||||
|
import { resetMemoryStorage } from '../storage/memory';
|
||||||
|
import { updatePrice } from '../service';
|
||||||
|
import { ClientData } from '../../../types/gen/types.gen';
|
||||||
|
|
||||||
|
const storage = getStorage();
|
||||||
|
const USER = 'petr';
|
||||||
|
const DATE = new Date('2025-03-04');
|
||||||
|
const ISO_DATE = '2025-03-04';
|
||||||
|
|
||||||
|
/** Uloží volby uživatelů pro testovací den. */
|
||||||
|
async function saveChoices(choices: ClientData["choices"]) {
|
||||||
|
await storage.setData<Partial<ClientData>>(ISO_DATE, { choices });
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetMemoryStorage();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updatePrice', () => {
|
||||||
|
test('nastaví a odstraní cenu u volby podniku', async () => {
|
||||||
|
await saveChoices({ SPSE: { [USER]: { note: 'polévka' } } });
|
||||||
|
|
||||||
|
let data = await updatePrice(USER, false, 9500, DATE);
|
||||||
|
expect(data.choices.SPSE?.[USER]).toMatchObject({ price: 9500, note: 'polévka' });
|
||||||
|
|
||||||
|
data = await updatePrice(USER, false, undefined, DATE);
|
||||||
|
expect(data.choices.SPSE?.[USER]?.price).toBeUndefined();
|
||||||
|
expect(data.choices.SPSE?.[USER]?.note).toBe('polévka');
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(['OBJEDNAVAM', 'PIZZA', 'NEOBEDVAM', 'ROZHODUJI'])('odmítne cenu u volby %s', async (choice) => {
|
||||||
|
await saveChoices({ [choice]: { [USER]: {} } });
|
||||||
|
await expect(updatePrice(USER, false, 10000, DATE)).rejects.toThrow('K této volbě nelze zadat cenu');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('odmítne cenu bez vybrané volby i zápornou cenu', async () => {
|
||||||
|
await saveChoices({ SPSE: { tomas: {} } });
|
||||||
|
await expect(updatePrice(USER, false, 10000, DATE)).rejects.toThrow('Cenu lze zadat jen k vybrané volbě');
|
||||||
|
await expect(updatePrice(USER, false, -1, DATE)).rejects.toThrow('Neplatná cena');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -67,6 +67,14 @@ describe('getUserExportRows', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('u volby podniku vyplní uživatelem zadanou cenu', async () => {
|
||||||
|
await saveLunch('2025-03-04', { SPSE: { [USER]: { price: 9500 } } });
|
||||||
|
|
||||||
|
const rows = await getUserExportRows(USER, YEAR, MONTH);
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0]).toMatchObject({ type: 'SPŠE', amount: 9500 });
|
||||||
|
});
|
||||||
|
|
||||||
test('u volby "budu objednávat" sloučí záznam s objednávkovou skupinou včetně ceny po slevě', async () => {
|
test('u volby "budu objednávat" sloučí záznam s objednávkovou skupinou včetně ceny po slevě', async () => {
|
||||||
await saveLunch('2025-03-06', { OBJEDNAVAM: { [USER]: { note: 'platím kartou' } } });
|
await saveLunch('2025-03-06', { OBJEDNAVAM: { [USER]: { note: 'platím kartou' } } });
|
||||||
await saveExtra('2025-03-06', [{
|
await saveExtra('2025-03-06', [{
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ export async function getUserExportRows(login: string, year: number, month: numb
|
|||||||
|
|
||||||
let food: string | undefined;
|
let food: string | undefined;
|
||||||
let note = userChoice.note;
|
let note = userChoice.note;
|
||||||
let amount: number | undefined;
|
let amount = userChoice.price;
|
||||||
if (locationKey === LunchChoice.PIZZA) {
|
if (locationKey === LunchChoice.PIZZA) {
|
||||||
const pizza = getPizzaText(lunchData, login);
|
const pizza = getPizzaText(lunchData, login);
|
||||||
food = pizza.food;
|
food = pizza.food;
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ paths:
|
|||||||
$ref: "./paths/food/removeChoice.yml"
|
$ref: "./paths/food/removeChoice.yml"
|
||||||
/food/updateNote:
|
/food/updateNote:
|
||||||
$ref: "./paths/food/updateNote.yml"
|
$ref: "./paths/food/updateNote.yml"
|
||||||
|
/food/updatePrice:
|
||||||
|
$ref: "./paths/food/updatePrice.yml"
|
||||||
/food/removeChoices:
|
/food/removeChoices:
|
||||||
$ref: "./paths/food/removeChoices.yml"
|
$ref: "./paths/food/removeChoices.yml"
|
||||||
/food/changeDepartureTime:
|
/food/changeDepartureTime:
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
post:
|
||||||
|
operationId: updatePrice
|
||||||
|
summary: Nastavení ceny oběda k volbě uživatele (pro přehled stravování). Lze jen u voleb podniků, ne u objednávání, Pizza day, "neobědvám" a "rozhoduji se".
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
properties:
|
||||||
|
dayIndex:
|
||||||
|
$ref: "../../schemas/_index.yml#/DayIndex"
|
||||||
|
price:
|
||||||
|
description: Cena v haléřích. Pokud není předána, cena se odstraní.
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
$ref: "../../api.yml#/components/responses/ClientDataResponse"
|
||||||
@@ -101,6 +101,9 @@ UserLunchChoice:
|
|||||||
note:
|
note:
|
||||||
description: Volitelná, veřejně viditelná uživatelská poznámka k vybrané volbě
|
description: Volitelná, veřejně viditelná uživatelská poznámka k vybrané volbě
|
||||||
type: string
|
type: string
|
||||||
|
price:
|
||||||
|
description: Uživatelem zadaná cena oběda v haléřích, promítá se do přehledu stravování
|
||||||
|
type: integer
|
||||||
isBuyer:
|
isBuyer:
|
||||||
description: Příznak, zda je tento uživatel objednatelem pro stav "Budu objednávat"
|
description: Příznak, zda je tento uživatel objednatelem pro stav "Budu objednávat"
|
||||||
type: boolean
|
type: boolean
|
||||||
|
|||||||
Reference in new issue
Block a user