8 Commits

24 changed files with 1414 additions and 1577 deletions

View File

@@ -10,6 +10,17 @@
<link rel="apple-touch-icon" href="/logo192.png" /> <link rel="apple-touch-icon" href="/logo192.png" />
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<title>Luncher</title> <title>Luncher</title>
<script>
(function() {
var saved = localStorage.getItem('theme_preference');
var theme = 'light';
if (saved === 'dark') theme = 'dark';
else if (saved === 'system' || !saved) {
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
document.documentElement.setAttribute('data-bs-theme', theme);
})();
</script>
</head> </head>
<body> <body>

View File

@@ -24,6 +24,7 @@
"react-router": "^7.9.5", "react-router": "^7.9.5",
"react-router-dom": "^7.9.5", "react-router-dom": "^7.9.5",
"react-select-search": "^4.1.6", "react-select-search": "^4.1.6",
"react-snow-overlay": "^1.0.14",
"react-snowfall": "^2.3.0", "react-snowfall": "^2.3.0",
"react-toastify": "^11.0.5", "react-toastify": "^11.0.5",
"recharts": "^3.4.1", "recharts": "^3.4.1",

View File

@@ -1,3 +1,17 @@
:root, [data-bs-theme="light"] {
--luncher-navbar-bg: #3c3c3c;
--luncher-action-icon: rgb(0, 89, 255);
--luncher-buyer-icon: #dbba00;
--luncher-text-muted: gray;
}
[data-bs-theme="dark"] {
--luncher-navbar-bg: #1a1d21;
--luncher-action-icon: #5c9aff;
--luncher-buyer-icon: #ffd700;
--luncher-text-muted: #9ca3af;
}
.App { .App {
text-align: center; text-align: center;
} }
@@ -87,7 +101,7 @@ body,
} }
.navbar { .navbar {
background-color: #3c3c3c; background-color: var(--luncher-navbar-bg);
padding-left: 20px; padding-left: 20px;
padding-right: 20px; padding-right: 20px;
} }
@@ -100,11 +114,19 @@ body,
margin-bottom: 0; margin-bottom: 0;
} }
.table> :not(caption) .action-icon { .table> :not(caption) {
color: rgb(0, 89, 255); .action-icon {
color: var(--luncher-action-icon);
cursor: pointer; cursor: pointer;
margin-left: 10px; margin-left: 10px;
padding: 0; padding: 0;
}
.buyer-icon {
color: var(--luncher-buyer-icon);
margin-left: 10px;
padding: 0;
}
} }
.table ul { .table ul {
@@ -131,7 +153,7 @@ body,
} }
.trusted-icon { .trusted-icon {
color: rgb(0, 89, 255); color: var(--luncher-action-icon);
margin-right: 10px; margin-right: 10px;
} }

View File

@@ -13,15 +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 { faChainBroken, faChevronLeft, faChevronRight, faGear, faSatelliteDish, faSearch } from '@fortawesome/free-solid-svg-icons'; import { faBasketShopping, faChainBroken, faChevronLeft, faChevronRight, faGear, faSatelliteDish, faSearch } from '@fortawesome/free-solid-svg-icons';
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 { useEasterEgg } from './context/eggs'; import { useEasterEgg } from './context/eggs';
import { ClientData, Food, PizzaOrder, DepartureTime, PizzaDayState, Restaurant, RestaurantDayMenu, RestaurantDayMenuMap, LunchChoice, UserLunchChoice, PizzaVariant, getData, getEasterEggImage, addPizza, removePizza, updatePizzaDayNote, createPizzaDay, deletePizzaDay, lockPizzaDay, unlockPizzaDay, finishOrder, finishDelivery, addChoice, jdemeObed, removeChoices, removeChoice, updateNote, changeDepartureTime } from '../../types'; import { ClientData, Food, PizzaOrder, DepartureTime, PizzaDayState, Restaurant, RestaurantDayMenu, RestaurantDayMenuMap, LunchChoice, UserLunchChoice, PizzaVariant, getData, getEasterEggImage, addPizza, removePizza, updatePizzaDayNote, createPizzaDay, deletePizzaDay, lockPizzaDay, unlockPizzaDay, finishOrder, finishDelivery, addChoice, jdemeObed, removeChoices, removeChoice, updateNote, changeDepartureTime, setBuyer } 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';
const EVENT_CONNECT = "connect" const EVENT_CONNECT = "connect"
@@ -141,16 +141,6 @@ function App() {
if (!auth?.login) { if (!auth?.login) {
return return
} }
// TODO tohle občas náhodně nezafunguje, nutno přepsat, viz https://medium.com/@teh_builder/ref-objects-inside-useeffect-hooks-eb7c15198780
// TODO nutno opravit
// if (data?.choices && choiceRef.current) {
// for (let entry of Object.entries(data.choices)) {
// if (entry[1].includes(auth.login)) {
// const value = entry[0] as any as number; // TODO tohle je absurdní
// choiceRef.current.value = Object.values(Locations)[value];
// }
// }
// }
}, [auth, auth?.login, data?.choices]) }, [auth, auth?.login, data?.choices])
// Reference na mojí objednávku // Reference na mojí objednávku
@@ -214,7 +204,7 @@ function App() {
const doAddClickFoodChoice = async (location: LunchChoice, foodIndex?: number) => { const doAddClickFoodChoice = async (location: LunchChoice, foodIndex?: number) => {
if (document.getSelection()?.type !== 'Range') { // pouze pokud se nejedná o výběr textu if (document.getSelection()?.type !== 'Range') { // pouze pokud se nejedná o výběr textu
if (auth?.login) { if (canChangeChoice && auth?.login) {
await addChoice({ body: { locationKey: location, foodIndex, dayIndex } }); await addChoice({ body: { locationKey: location, foodIndex, dayIndex } });
} }
} }
@@ -222,7 +212,7 @@ function App() {
const doAddChoice = async (event: React.ChangeEvent<HTMLSelectElement>) => { const doAddChoice = async (event: React.ChangeEvent<HTMLSelectElement>) => {
const locationKey = event.target.value as LunchChoice; const locationKey = event.target.value as LunchChoice;
if (auth?.login) { if (canChangeChoice && auth?.login) {
await addChoice({ body: { locationKey, dayIndex } }); await addChoice({ body: { locationKey, dayIndex } });
if (foodChoiceRef.current?.value) { if (foodChoiceRef.current?.value) {
foodChoiceRef.current.value = ""; foodChoiceRef.current.value = "";
@@ -284,6 +274,12 @@ function App() {
} }
} }
const markAsBuyer = async () => {
if (auth?.login) {
await setBuyer();
}
}
const pizzaSuggestions = useMemo(() => { const pizzaSuggestions = useMemo(() => {
if (!data?.pizzaList) { if (!data?.pizzaList) {
return []; return [];
@@ -304,7 +300,7 @@ function App() {
const handlePizzaChange = async (value: SelectedOptionValue | SelectedOptionValue[]) => { const handlePizzaChange = async (value: SelectedOptionValue | SelectedOptionValue[]) => {
if (auth?.login && data?.pizzaList) { if (auth?.login && data?.pizzaList) {
if (typeof value !== 'string') { if (typeof value !== 'string') {
throw Error('Nepodporovaný typ hodnoty'); throw new TypeError('Nepodporovaný typ hodnoty: ' + typeof value);
} }
const s = value.split('|'); const s = value.split('|');
const pizzaIndex = Number.parseInt(s[0]); const pizzaIndex = Number.parseInt(s[0]);
@@ -325,30 +321,6 @@ function App() {
updatePizzaDayNote({ body: { note: pizzaPoznamkaRef.current?.value } }); updatePizzaDayNote({ body: { note: pizzaPoznamkaRef.current?.value } });
} }
// const addToCart = async () => {
// TODO aktuálně nefunkční - nedokážeme poslat PHPSESSIONID cookie
// if (data?.pizzaDay?.orders) {
// for (const order of data?.pizzaDay?.orders) {
// for (const pizzaOrder of order.pizzaList) {
// const url = 'https://www.pizzachefie.cz/pridat.html';
// const payload = new URLSearchParams();
// payload.append('varId', pizzaOrder.varId.toString());
// await fetch(url, {
// method: "POST",
// mode: "no-cors",
// cache: "no-cache",
// credentials: "same-origin",
// headers: {
// 'Content-Type': 'application/x-www-form-urlencoded',
// },
// body: payload,
// })
// }
// }
// // TODO otevřít košík v nové záložce
// }
// }
const handleChangeDepartureTime = async (event: React.ChangeEvent<HTMLSelectElement>) => { const handleChangeDepartureTime = async (event: React.ChangeEvent<HTMLSelectElement>) => {
if (foodChoiceList?.length && choiceRef.current?.value) { if (foodChoiceList?.length && choiceRef.current?.value) {
await changeDepartureTime({ body: { time: event.target.value as DepartureTime, dayIndex } }); await changeDepartureTime({ body: { time: event.target.value as DepartureTime, dayIndex } });
@@ -376,7 +348,7 @@ function App() {
} else if (menu?.food?.length && menu.food.length > 0) { } else if (menu?.food?.length && menu.food.length > 0) {
const hideSoups = settings?.hideSoups; const hideSoups = settings?.hideSoups;
content = <Table striped bordered hover> content = <Table striped bordered hover>
<tbody style={{ cursor: 'pointer' }}> <tbody style={{ cursor: canChangeChoice ? 'pointer' : 'default' }}>
{menu.food.map((f: Food, index: number) => {menu.food.map((f: Food, index: number) =>
(!hideSoups || !f.isSoup) && (!hideSoups || !f.isSoup) &&
<tr key={f.name} onClick={() => doAddClickFoodChoice(location, index)}> <tr key={f.name} onClick={() => doAddClickFoodChoice(location, index)}>
@@ -404,7 +376,7 @@ function App() {
content = <h3>Chyba načtení dat</h3> content = <h3>Chyba načtení dat</h3>
} }
return <Col md={12} lg={3} className='mt-3'> return <Col md={12} lg={3} className='mt-3'>
<h3 style={{ cursor: 'pointer' }} onClick={() => doAddClickFoodChoice(location)}>{getLunchChoiceName(location)}</h3> <h3 style={{ cursor: canChangeChoice ? 'pointer' : 'default' }} onClick={() => doAddClickFoodChoice(location)}>{getLunchChoiceName(location)}</h3>
{menu?.lastUpdate && <small>Poslední aktualizace: {getHumanDateTime(new Date(menu.lastUpdate))}</small>} {menu?.lastUpdate && <small>Poslední aktualizace: {getHumanDateTime(new Date(menu.lastUpdate))}</small>}
{content} {content}
</Col> </Col>
@@ -450,13 +422,13 @@ function App() {
<div className='wrapper'> <div className='wrapper'>
{data.isWeekend ? <h4>Užívejte víkend :)</h4> : <> {data.isWeekend ? <h4>Užívejte víkend :)</h4> : <>
<Alert variant={'primary'}> <Alert variant={'primary'}>
{/* <img alt="" src='hat.png' style={{ position: "absolute", width: "70px", rotate: "-45deg", left: -40, top: -58 }} /> <img alt="" src='hat.png' style={{ position: "absolute", width: "70px", rotate: "-45deg", left: -40, top: -58 }} />
<img alt="" src='snowman.png' style={{ position: "absolute", height: "110px", right: 10, top: 5 }} /> */} <img alt="" src='snowman.png' style={{ position: "absolute", height: "110px", right: 10, top: 5 }} />
Poslední změny: Poslední změny:
<ul> <ul>
<li>Zobrazení alergenu při najetí myší a proklik na seznam alergenů</li> <li>Oprava parsování Sladovnické a TechTower</li>
<li>Přesun přenačtení menu do samostatného dialogu</li> <li>Zimní atmosféra</li>
<li>Podzimní atmosféra</li> <li>Možnost označit se jako objednávající u volby "budu objednávat"</li>
</ul> </ul>
</Alert> </Alert>
{dayIndex != null && {dayIndex != null &&
@@ -464,18 +436,17 @@ function App() {
<span title='Předchozí den'> <span title='Předchozí den'>
<FontAwesomeIcon icon={faChevronLeft} style={{ cursor: "pointer", visibility: dayIndex > 0 ? "initial" : "hidden" }} onClick={() => handleDayChange(dayIndex - 1)} /> <FontAwesomeIcon icon={faChevronLeft} style={{ cursor: "pointer", visibility: dayIndex > 0 ? "initial" : "hidden" }} onClick={() => handleDayChange(dayIndex - 1)} />
</span> </span>
<h1 className='title' style={{ color: dayIndex === data.todayDayIndex ? 'black' : 'gray' }}>{data.date}</h1> <h1 className={`title ${dayIndex !== data.todayDayIndex ? 'text-muted' : ''}`}>{data.date}</h1>
<span title="Následující den"> <span title="Následující den">
<FontAwesomeIcon icon={faChevronRight} style={{ cursor: "pointer", visibility: dayIndex < 4 ? "initial" : "hidden" }} onClick={() => handleDayChange(dayIndex + 1)} /> <FontAwesomeIcon icon={faChevronRight} style={{ cursor: "pointer", visibility: dayIndex < 4 ? "initial" : "hidden" }} onClick={() => handleDayChange(dayIndex + 1)} />
</span> </span>
</div> </div>
} }
<Row className='food-tables'> <Row className='food-tables'>
{/* TODO zjednodušit, stačí iterovat klíče typu Restaurant */} {Object.keys(Restaurant).map(key => {
{food['SLADOVNICKA'] && renderFoodTable('SLADOVNICKA', food['SLADOVNICKA'])} const locationKey = key as Restaurant;
{food['TECHTOWER'] && renderFoodTable('TECHTOWER', food['TECHTOWER'])} return food[locationKey] && renderFoodTable(locationKey, food[locationKey]);
{food['ZASTAVKAUMICHALA'] && renderFoodTable('ZASTAVKAUMICHALA', food['ZASTAVKAUMICHALA'])} })}
{food['SENKSERIKOVA'] && renderFoodTable('SENKSERIKOVA', food['SENKSERIKOVA'])}
</Row> </Row>
<div className='content-wrapper'> <div className='content-wrapper'>
<div className='content'> <div className='content'>
@@ -534,14 +505,26 @@ function App() {
const userPayload = entry[1]; const userPayload = entry[1];
const userChoices = userPayload?.selectedFoods; const userChoices = userPayload?.selectedFoods;
const trusted = userPayload?.trusted || false; const trusted = userPayload?.trusted || false;
const isBuyer = userPayload?.isBuyer || false;
return <tr key={entry[0]}> return <tr key={entry[0]}>
<td> <td>
{/* TODO zrefaktorovat, oddělit řádek do samostatné komponenty (a akce možná zvlášť) */}
{trusted && <span className='trusted-icon' title='Uživatel ověřený doménovým přihlášením'> {trusted && <span className='trusted-icon' title='Uživatel ověřený doménovým přihlášením'>
<FontAwesomeIcon icon={faCircleCheck} style={{ cursor: "help" }} /> <FontAwesomeIcon icon={faCircleCheck} style={{ cursor: "help" }} />
</span>} </span>}
{login} {login}
{userPayload.departureTime && <small> ({userPayload.departureTime})</small>} {userPayload.departureTime && <small> ({userPayload.departureTime})</small>}
{userPayload.note && <span style={{ fontSize: 'small' }}> ({userPayload.note})</span>} {userPayload.note && <span style={{ fontSize: 'small' }}> ({userPayload.note})</span>}
{login === auth.login && canChangeChoice && locationKey === LunchChoice.OBJEDNAVAM && <span title='Označit/odznačit se jako objednávající'>
<FontAwesomeIcon onClick={() => {
markAsBuyer();
}} icon={faBasketShopping} className={isBuyer ? 'buyer-icon' : 'action-icon'} style={{cursor: 'pointer'}} />
</span>}
{login !== auth.login && locationKey === LunchChoice.OBJEDNAVAM && isBuyer && <span title='Objednávající'>
<FontAwesomeIcon onClick={() => {
copyNote(userPayload.note!);
}} icon={faBasketShopping} className='buyer-icon' />
</span>}
{login !== auth.login && canChangeChoice && userPayload?.note?.length && <span title='Převzít poznámku'> {login !== auth.login && canChangeChoice && userPayload?.note?.length && <span title='Převzít poznámku'>
<FontAwesomeIcon onClick={() => { <FontAwesomeIcon onClick={() => {
copyNote(userPayload.note!); copyNote(userPayload.note!);
@@ -668,7 +651,10 @@ function App() {
{ {
data.pizzaDay.state === PizzaDayState.DELIVERED && data.pizzaDay.state === PizzaDayState.DELIVERED &&
<div> <div>
<p>{`Pizzy byly doručeny.${myOrder?.hasQr ? ` Objednávku můžete uživateli ${data.pizzaDay.creator} uhradit pomocí QR kódu níže.` : ''}`}</p> <p>
Pizzy byly doručeny.
{myOrder?.hasQr ? ` Objednávku můžete uživateli ${data.pizzaDay.creator} uhradit pomocí QR kódu níže.` : ''}
</p>
</div> </div>
} }
</div> </div>
@@ -711,10 +697,10 @@ function App() {
</div> </div>
</> || "Jejda, něco se nám nepovedlo :("} </> || "Jejda, něco se nám nepovedlo :("}
</div> </div>
<FallingLeaves {/* <FallingLeaves
numLeaves={LEAF_PRESETS.NORMAL} numLeaves={LEAF_PRESETS.NORMAL}
leafVariants={LEAF_COLOR_THEMES.AUTUMN} leafVariants={LEAF_COLOR_THEMES.AUTUMN}
/> /> */}
<Footer /> <Footer />
<NoteModal isOpen={noteModalOpen} onClose={() => setNoteModalOpen(false)} onSave={saveNote} /> <NoteModal isOpen={noteModalOpen} onClose={() => setNoteModalOpen(false)} onSave={saveNote} />
</div> </div>

View File

@@ -1,6 +1,7 @@
import { Routes, Route } from "react-router-dom"; import { Routes, Route } from "react-router-dom";
import { ProvideSettings } from "./context/settings"; import { ProvideSettings } from "./context/settings";
// import Snowfall from "react-snowfall"; // import Snowfall from "react-snowfall";
import { SnowOverlay } from 'react-snow-overlay';
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
import { SocketContext, socket } from "./context/socket"; import { SocketContext, socket } from "./context/socket";
import StatsPage from "./pages/StatsPage"; import StatsPage from "./pages/StatsPage";
@@ -22,6 +23,7 @@ export default function AppRoutes() {
width: '100vw', width: '100vw',
height: '100vh' height: '100vh'
}} /> */} }} /> */}
<SnowOverlay color={'rgba(240, 240, 240, 0.9)'} disabledOnSingleCpuDevices={true} />
<App /> <App />
</> </>
<ToastContainer /> <ToastContainer />

View File

@@ -26,7 +26,7 @@ export default function Login() {
}, [auth]); }, [auth]);
const doLogin = useCallback(async () => { const doLogin = useCallback(async () => {
const length = loginRef?.current?.value.length && loginRef.current.value.replace(/\s/g, '').length const length = loginRef?.current?.value.length && loginRef.current.value.replaceAll(/\s/g, '').length
if (length) { if (length) {
const response = await login({ body: { login: loginRef.current?.value } }); const response = await login({ body: { login: loginRef.current?.value } });
if (response.data) { if (response.data) {

View File

@@ -75,14 +75,14 @@ export const getDayOfWeekIndex = (date: Date) => {
/** Vrátí první pracovní den v týdnu předaného data. */ /** Vrátí první pracovní den v týdnu předaného data. */
export function getFirstWorkDayOfWeek(date: Date) { export function getFirstWorkDayOfWeek(date: Date) {
const firstDay = new Date(date.getTime()); const firstDay = new Date(date);
firstDay.setDate(date.getDate() - getDayOfWeekIndex(date)); firstDay.setDate(date.getDate() - getDayOfWeekIndex(date));
return firstDay; return firstDay;
} }
/** Vrátí poslední pracovní den v týdnu předaného data. */ /** Vrátí poslední pracovní den v týdnu předaného data. */
export function getLastWorkDayOfWeek(date: Date) { export function getLastWorkDayOfWeek(date: Date) {
const lastDay = new Date(date.getTime()); const lastDay = new Date(date);
lastDay.setDate(date.getDate() + (4 - getDayOfWeekIndex(date))); lastDay.setDate(date.getDate() + (4 - getDayOfWeekIndex(date)));
return lastDay; return lastDay;
} }

View File

@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import { Navbar, Nav, NavDropdown } from "react-bootstrap"; import { Navbar, Nav, NavDropdown } 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 } from "../context/settings"; import { useSettings, ThemePreference } from "../context/settings";
import FeaturesVotingModal from "./modals/FeaturesVotingModal"; import FeaturesVotingModal from "./modals/FeaturesVotingModal";
import PizzaCalculatorModal from "./modals/PizzaCalculatorModal"; import PizzaCalculatorModal from "./modals/PizzaCalculatorModal";
import RefreshMenuModal from "./modals/RefreshMenuModal"; import RefreshMenuModal from "./modals/RefreshMenuModal";
@@ -54,19 +54,19 @@ export default function Header() {
return n !== Infinity && String(n) === str && n >= 0; return n !== Infinity && String(n) === str && n >= 0;
} }
const saveSettings = (bankAccountNumber?: string, bankAccountHolderName?: string, hideSoupsOption?: boolean) => { const saveSettings = (bankAccountNumber?: string, bankAccountHolderName?: string, hideSoupsOption?: boolean, themePreference?: ThemePreference) => {
if (bankAccountNumber) { if (bankAccountNumber) {
try { try {
// Validace kódu banky // Validace kódu banky
if (bankAccountNumber.indexOf('/') < 0) { if (!bankAccountNumber.includes('/')) {
throw Error("Číslo účtu neobsahuje lomítko/kód banky") throw new Error("Číslo účtu neobsahuje lomítko/kód banky")
} }
const split = bankAccountNumber.split("/"); const split = bankAccountNumber.split("/");
if (split[1].length !== 4) { if (split[1].length !== 4) {
throw Error("Kód banky musí být 4 číslice") throw new Error("Kód banky musí být 4 číslice")
} }
if (!isValidInteger(split[1])) { if (!isValidInteger(split[1])) {
throw Error("Kód banky není číslo") throw new Error("Kód banky není číslo")
} }
// Validace čísla a předčíslí // Validace čísla a předčíslí
@@ -76,7 +76,7 @@ export default function Header() {
cislo = cislo.replace('-', ''); cislo = cislo.replace('-', '');
} }
if (!isValidInteger(cislo)) { if (!isValidInteger(cislo)) {
throw Error("Předčíslí nebo číslo účtu neobsahuje pouze číslice") throw new Error("Předčíslí nebo číslo účtu neobsahuje pouze číslice")
} }
if (cislo.length < 16) { if (cislo.length < 16) {
cislo = cislo.padStart(16, '0'); cislo = cislo.padStart(16, '0');
@@ -89,7 +89,7 @@ export default function Header() {
sum += Number.parseInt(char) * weight sum += Number.parseInt(char) * weight
} }
if (sum % 11 !== 0) { if (sum % 11 !== 0) {
throw Error("Číslo účtu je neplatné") throw new Error("Číslo účtu je neplatné")
} }
} catch (e: any) { } catch (e: any) {
alert(e.message) alert(e.message)
@@ -99,6 +99,9 @@ export default function Header() {
settings?.setBankAccountNumber(bankAccountNumber); settings?.setBankAccountNumber(bankAccountNumber);
settings?.setBankAccountHolderName(bankAccountHolderName); settings?.setBankAccountHolderName(bankAccountHolderName);
settings?.setHideSoupsOption(hideSoupsOption); settings?.setHideSoupsOption(hideSoupsOption);
if (themePreference) {
settings?.setThemePreference(themePreference);
}
closeSettingsModal(); closeSettingsModal();
} }

View File

@@ -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, parseInt(priceRef.current?.value ?? "0")); onSave(customerName, textRef.current?.value, Number.parseInt(priceRef.current?.value ?? "0"));
} }
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
onSave(customerName, textRef.current?.value, parseInt(priceRef.current?.value ?? "0")); onSave(customerName, textRef.current?.value, Number.parseInt(priceRef.current?.value ?? "0"));
} }
} }

View File

@@ -36,13 +36,13 @@ export default function PizzaCalculatorModal({ isOpen, onClose }: Readonly<Props
// 1. pizza // 1. pizza
if (diameter1Ref.current?.value) { if (diameter1Ref.current?.value) {
const diameter1 = parseInt(diameter1Ref.current?.value); const diameter1 = Number.parseInt(diameter1Ref.current?.value);
r.pizza1 ??= {}; r.pizza1 ??= {};
if (diameter1 && diameter1 > 0) { if (diameter1 && diameter1 > 0) {
r.pizza1.diameter = diameter1; r.pizza1.diameter = diameter1;
r.pizza1.area = Math.PI * Math.pow(diameter1 / 2, 2); r.pizza1.area = Math.PI * Math.pow(diameter1 / 2, 2);
if (price1Ref.current?.value) { if (price1Ref.current?.value) {
const price1 = parseInt(price1Ref.current?.value); const price1 = Number.parseInt(price1Ref.current?.value);
if (price1) { if (price1) {
r.pizza1.pricePerM = price1 / r.pizza1.area; r.pizza1.pricePerM = price1 / r.pizza1.area;
} else { } else {
@@ -56,13 +56,13 @@ export default function PizzaCalculatorModal({ isOpen, onClose }: Readonly<Props
// 2. pizza // 2. pizza
if (diameter2Ref.current?.value) { if (diameter2Ref.current?.value) {
const diameter2 = parseInt(diameter2Ref.current?.value); const diameter2 = Number.parseInt(diameter2Ref.current?.value);
r.pizza2 ??= {}; r.pizza2 ??= {};
if (diameter2 && diameter2 > 0) { if (diameter2 && diameter2 > 0) {
r.pizza2.diameter = diameter2; r.pizza2.diameter = diameter2;
r.pizza2.area = Math.PI * Math.pow(diameter2 / 2, 2); r.pizza2.area = Math.PI * Math.pow(diameter2 / 2, 2);
if (price2Ref.current?.value) { if (price2Ref.current?.value) {
const price2 = parseInt(price2Ref.current?.value); const price2 = Number.parseInt(price2Ref.current?.value);
if (price2) { if (price2) {
r.pizza2.pricePerM = price2 / r.pizza2.area; r.pizza2.pricePerM = price2 / r.pizza2.area;
} else { } else {
@@ -77,8 +77,8 @@ export default function PizzaCalculatorModal({ isOpen, onClose }: Readonly<Props
// Srovnání // Srovnání
if (r.pizza1?.pricePerM && r.pizza2?.pricePerM && r.pizza1.diameter && r.pizza2.diameter) { if (r.pizza1?.pricePerM && r.pizza2?.pricePerM && r.pizza1.diameter && r.pizza2.diameter) {
r.choice = r.pizza1.pricePerM < r.pizza2.pricePerM ? 1 : 2; r.choice = r.pizza1.pricePerM < r.pizza2.pricePerM ? 1 : 2;
const bigger = r.pizza1.pricePerM > r.pizza2.pricePerM ? r.pizza1.pricePerM : r.pizza2.pricePerM; const bigger = Math.max(r.pizza1.pricePerM, r.pizza2.pricePerM);
const smaller = r.pizza1.pricePerM < r.pizza2.pricePerM ? r.pizza1.pricePerM : r.pizza2.pricePerM; const smaller = Math.min(r.pizza1.pricePerM, r.pizza2.pricePerM);
r.ratio = (bigger / smaller) - 1; r.ratio = (bigger / smaller) - 1;
r.diameterDiff = Math.abs(r.pizza1.diameter - r.pizza2.diameter); r.diameterDiff = Math.abs(r.pizza1.diameter - r.pizza2.diameter);
} else { } else {

View File

@@ -1,11 +1,11 @@
import { useRef } from "react"; import { useRef } from "react";
import { Modal, Button } from "react-bootstrap" import { Modal, Button, Form } from "react-bootstrap"
import { useSettings } from "../../context/settings"; import { useSettings, ThemePreference } from "../../context/settings";
type Props = { type Props = {
isOpen: boolean, isOpen: boolean,
onClose: () => void, onClose: () => void,
onSave: (bankAccountNumber?: string, bankAccountHolderName?: string, hideSoupsOption?: boolean) => void, onSave: (bankAccountNumber?: string, bankAccountHolderName?: string, hideSoupsOption?: boolean, themePreference?: ThemePreference) => void,
} }
/** Modální dialog pro uživatelská nastavení. */ /** Modální dialog pro uživatelská nastavení. */
@@ -14,12 +14,23 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
const bankAccountRef = useRef<HTMLInputElement>(null); const bankAccountRef = useRef<HTMLInputElement>(null);
const nameRef = useRef<HTMLInputElement>(null); const nameRef = useRef<HTMLInputElement>(null);
const hideSoupsRef = useRef<HTMLInputElement>(null); const hideSoupsRef = useRef<HTMLInputElement>(null);
const themeRef = useRef<HTMLSelectElement>(null);
return <Modal show={isOpen} onHide={onClose} size="lg"> return <Modal show={isOpen} onHide={onClose} size="lg">
<Modal.Header closeButton> <Modal.Header closeButton>
<Modal.Title><h2>Nastavení</h2></Modal.Title> <Modal.Title><h2>Nastavení</h2></Modal.Title>
</Modal.Header> </Modal.Header>
<Modal.Body> <Modal.Body>
<h4>Vzhled</h4>
<Form.Group className="mb-3">
<Form.Label>Barevný motiv</Form.Label>
<Form.Select ref={themeRef} defaultValue={settings?.themePreference}>
<option value="system">Podle systému</option>
<option value="light">Světlý</option>
<option value="dark">Tmavý</option>
</Form.Select>
</Form.Group>
<hr />
<h4>Obecné</h4> <h4>Obecné</h4>
<span title="V nabídkách nebudou zobrazovány polévky. Tato funkce je experimentální, a zejména u TechTower bývá často problém polévky spolehlivě rozeznat. V případě využití této funkce průběžně nahlašujte stále se zobrazující polévky." style={{ "cursor": "help" }}> <span title="V nabídkách nebudou zobrazovány polévky. Tato funkce je experimentální, a zejména u TechTower bývá často problém polévky spolehlivě rozeznat. V případě využití této funkce průběžně nahlašujte stále se zobrazující polévky." style={{ "cursor": "help" }}>
<input ref={hideSoupsRef} type="checkbox" defaultChecked={settings?.hideSoups} /> Skrýt polévky <input ref={hideSoupsRef} type="checkbox" defaultChecked={settings?.hideSoups} /> Skrýt polévky
@@ -34,7 +45,7 @@ export default function SettingsModal({ isOpen, onClose, onSave }: Readonly<Prop
<Button variant="secondary" onClick={onClose}> <Button variant="secondary" onClick={onClose}>
Storno Storno
</Button> </Button>
<Button variant="primary" onClick={() => onSave(bankAccountRef.current?.value, nameRef.current?.value, hideSoupsRef.current?.checked)}> <Button variant="primary" onClick={() => onSave(bankAccountRef.current?.value, nameRef.current?.value, hideSoupsRef.current?.checked, themeRef.current?.value as ThemePreference)}>
Uložit Uložit
</Button> </Button>
</Modal.Footer> </Modal.Footer>

View File

@@ -55,7 +55,7 @@ function useProvideAuth(): AuthContextProps {
setLoginName(undefined); setLoginName(undefined);
setTrusted(undefined); setTrusted(undefined);
if (trusted && logoutUrl?.length) { if (trusted && logoutUrl?.length) {
window.location.replace(logoutUrl); globalThis.location.replace(logoutUrl);
} }
} }

View File

@@ -3,14 +3,19 @@ import React, { ReactNode, useContext, useEffect, useState } from "react"
const BANK_ACCOUNT_NUMBER_KEY = 'bank_account_number'; 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';
export type ThemePreference = 'system' | 'light' | 'dark';
export type SettingsContextProps = { export type SettingsContextProps = {
bankAccount?: string, bankAccount?: string,
holderName?: string, holderName?: string,
hideSoups?: boolean, hideSoups?: boolean,
themePreference: ThemePreference,
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,
} }
type ContextProps = { type ContextProps = {
@@ -32,6 +37,7 @@ 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>('system');
useEffect(() => { useEffect(() => {
const accountNumber = localStorage.getItem(BANK_ACCOUNT_NUMBER_KEY); const accountNumber = localStorage.getItem(BANK_ACCOUNT_NUMBER_KEY);
@@ -46,6 +52,10 @@ function useProvideSettings(): SettingsContextProps {
if (hideSoups !== null) { if (hideSoups !== null) {
setHideSoups(hideSoups === 'true'); setHideSoups(hideSoups === 'true');
} }
const savedTheme = localStorage.getItem(THEME_KEY) as ThemePreference | null;
if (savedTheme && ['system', 'light', 'dark'].includes(savedTheme)) {
setTheme(savedTheme);
}
}, []) }, [])
useEffect(() => { useEffect(() => {
@@ -72,6 +82,29 @@ function useProvideSettings(): SettingsContextProps {
} }
}, [hideSoups]); }, [hideSoups]);
useEffect(() => {
localStorage.setItem(THEME_KEY, themePreference);
}, [themePreference]);
useEffect(() => {
const applyTheme = (theme: 'light' | 'dark') => {
document.documentElement.setAttribute('data-bs-theme', theme);
};
if (themePreference === 'system') {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
applyTheme(mediaQuery.matches ? 'dark' : 'light');
const handler = (e: MediaQueryListEvent) => {
applyTheme(e.matches ? 'dark' : 'light');
};
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
} else {
applyTheme(themePreference);
}
}, [themePreference]);
function setBankAccountNumber(bankAccount?: string) { function setBankAccountNumber(bankAccount?: string) {
setBankAccount(bankAccount); setBankAccount(bankAccount);
} }
@@ -84,12 +117,18 @@ function useProvideSettings(): SettingsContextProps {
setHideSoups(hideSoups); setHideSoups(hideSoups);
} }
function setThemePreference(theme: ThemePreference) {
setTheme(theme);
}
return { return {
bankAccount, bankAccount,
holderName, holderName,
hideSoups, hideSoups,
themePreference,
setBankAccountNumber, setBankAccountNumber,
setBankAccountHolderName, setBankAccountHolderName,
setHideSoupsOption, setHideSoupsOption,
setThemePreference,
} }
} }

View File

@@ -7,8 +7,8 @@ if (process.env.NODE_ENV === 'development') {
socketUrl = `http://localhost:3001`; socketUrl = `http://localhost:3001`;
socketPath = undefined; socketPath = undefined;
} else { } else {
socketUrl = `${window.location.host}`; socketUrl = `${globalThis.location.host}`;
socketPath = `${window.location.pathname}socket.io`; socketPath = `${globalThis.location.pathname}socket.io`;
} }
export const socket = socketio.connect(socketUrl, { path: socketPath, transports: ["websocket"] }); export const socket = socketio.connect(socketUrl, { path: socketPath, transports: ["websocket"] });

View File

@@ -17,7 +17,7 @@ client.setConfig({
// Interceptor na vyhození toasteru při chybě // Interceptor na vyhození toasteru při chybě
client.interceptors.response.use(async response => { client.interceptors.response.use(async response => {
// TODO opravit - login je zatím výjimka, voláme ho "naprázdno" abychom zjistili, zda nás nepřihlásily trusted headers // TODO opravit - login je zatím výjimka, voláme ho "naprázdno" abychom zjistili, zda nás nepřihlásily trusted headers
if (!response.ok && response.url.indexOf("/login") == -1) { if (!response.ok && !response.url.includes("/login")) {
const json = await response.json(); const json = await response.json();
toast.error(json.error, { theme: "colored" }); toast.error(json.error, { theme: "colored" });
} }

View File

@@ -17,16 +17,15 @@ const CHART_HEIGHT = 700;
const STROKE_WIDTH = 2.5; const STROKE_WIDTH = 2.5;
const COLORS = [ const COLORS = [
// Komentáře jsou kvůli vizualizaci barev ve VS Code '#ff1493',
'#ff1493', // #ff1493 '#1e90ff',
'#1e90ff', // #1e90ff '#c5a700',
'#c5a700', // #c5a700 '#006400',
'#006400', // #006400 '#b300ff',
'#b300ff', // #b300ff '#ff4500',
'#ff4500', // #ff4500 '#bc8f8f',
'#bc8f8f', // #bc8f8f '#00ff00',
'#00ff00', // #00ff00 '#7c7c7c',
'#7c7c7c', // #7c7c7c
] ]
export default function StatsPage() { export default function StatsPage() {

File diff suppressed because it is too large Load Diff

View File

@@ -100,7 +100,7 @@ export const getMenuSladovnicka = async (firstDayOfWeek: Date, mock: boolean = f
const html = await getHtml(SLADOVNICKA_URL); const html = await getHtml(SLADOVNICKA_URL);
const $ = load(html); const $ = load(html);
// Nejdříve zjistíme, které dny jsou k dispozici z tab elementů // Zjistíme, které dny jsou k dispozici z tab elementů
const tabElements = $('#daily-menu-tab-list').children('button[id^="daily-menu-tab-"]'); const tabElements = $('#daily-menu-tab-list').children('button[id^="daily-menu-tab-"]');
const availableDays: { [dayIndex: number]: number } = {}; // mapování dayIndex -> contentIndex const availableDays: { [dayIndex: number]: number } = {}; // mapování dayIndex -> contentIndex
@@ -112,7 +112,7 @@ export const getMenuSladovnicka = async (firstDayOfWeek: Date, mock: boolean = f
} }
}); });
const menuContentElements = $('#daily-menu-content-list').children('[id^="daily-menu-content-"]'); const menuContentElements = $('#daily-menu-content-list').children('.daily-menu-content__content').not('.daily-menu-content__content--static');
const result: Food[][] = []; const result: Food[][] = [];
@@ -130,59 +130,32 @@ export const getMenuSladovnicka = async (firstDayOfWeek: Date, mock: boolean = f
continue; // Přeskočíme, pokud content element neexistuje continue; // Přeskočíme, pokud content element neexistuje
} }
const dayChildren = $(menuContentElements[contentIndexNum]).children(); const contentElement = $(menuContentElements[contentIndexNum]);
const itemElement = contentElement.find('.daily-menu-content__item');
// Ověříme, že má element očekávanou strukturu const table = itemElement.find('table.daily-menu-content__table tbody');
if (dayChildren.length < 2) { const rows = table.children('tr');
console.warn(`Neočekávaný počet children v menu Sladovnické pro den ${dayIndexNum}: ${dayChildren.length}, očekávány alespoň 2 (polévka a hlavní jídlo)`);
continue;
}
// Parsování polévky
const soupElement = dayChildren.get(0);
const soupTable = $(soupElement).find('table tbody tr');
const soupCells = soupTable.children('td');
if (soupCells.length !== 3) {
console.warn(`Neočekávaný počet buněk v tabulce polévky pro den ${dayIndexNum}: ${soupCells.length}, ale očekávány byly 3`);
continue;
}
const soupAmount = sanitizeText($(soupCells.get(0)).text());
const soupNameRaw = sanitizeText($(soupCells.get(1)).text());
const soupPrice = sanitizeText($(soupCells.get(2)).text().replace(' ', '\xA0'));
const soupParsed = parseAllergens(soupNameRaw);
// Parsování hlavních jídel
const mainCourseElement = dayChildren.get(1);
const mainCourseTable = $(mainCourseElement).find('table tbody');
const mainCourseRows = mainCourseTable.children('tr');
const currentDayFood: Food[] = []; const currentDayFood: Food[] = [];
// Přidáme polévku do seznamu jídel // Projdeme všechny řádky - první je polévka, zbytek jsou hlavní jídla
currentDayFood.push({ rows.each((i, row) => {
amount: soupAmount,
name: soupParsed.cleanName,
price: soupPrice,
isSoup: true,
allergens: soupParsed.allergens.length > 0 ? soupParsed.allergens : undefined,
});
// Projdeme všechny řádky hlavních jídel
mainCourseRows.each((i, row) => {
const cells = $(row).children('td'); const cells = $(row).children('td');
if (cells.length !== 3) {
return; // Přeskočíme řádky s nesprávnou strukturou
}
const amount = sanitizeText($(cells.get(0)).text()); const amount = sanitizeText($(cells.get(0)).text());
const nameRaw = sanitizeText($(cells.get(1)).text()); const nameRaw = sanitizeText($(cells.get(1)).text());
const price = sanitizeText($(cells.get(2)).text().replace(' ', '\xA0')); const price = sanitizeText($(cells.get(2)).text().replace(' ', '\xA0'));
const parsed = parseAllergens(nameRaw); const parsed = parseAllergens(nameRaw);
// Přeskočíme prázdné řádky (první řádek může být prázdný) // Přeskočíme prázdné řádky
if (parsed.cleanName.trim().length > 0) { if (parsed.cleanName.trim().length > 0) {
currentDayFood.push({ currentDayFood.push({
amount, amount,
name: parsed.cleanName, name: parsed.cleanName,
price, price,
isSoup: false, isSoup: i === 0, // První řádek je polévka
allergens: parsed.allergens.length > 0 ? parsed.allergens : undefined, allergens: parsed.allergens.length > 0 ? parsed.allergens : undefined,
}); });
} }
@@ -351,8 +324,13 @@ export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = fal
const split = [tmp.slice(0, -2).join(' ')].concat(tmp.slice(-2)); const split = [tmp.slice(0, -2).join(' ')].concat(tmp.slice(-2));
price = `${split.slice(1)[0]}\xA0Kč` price = `${split.slice(1)[0]}\xA0Kč`
nameRaw = split[0].replace('•', ''); 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('')) { if (nameRaw.endsWith('')|| nameRaw.endsWith('—')) {
nameRaw = nameRaw.slice(0, -1).trim(); nameRaw = nameRaw.slice(0, -1).trim();
} }

View File

@@ -1,6 +1,6 @@
import express, { Request, Response } from "express"; import express, { Request, Response } from "express";
import { getLogin, getTrusted } from "../auth"; import { getLogin, getTrusted } from "../auth";
import { addChoice, getDateForWeekIndex, getToday, removeChoice, removeChoices, updateDepartureTime, updateNote, fetchRestaurantWeekMenuData, saveRestaurantWeekMenu } from "../service"; import { addChoice, getDateForWeekIndex, getToday, removeChoice, removeChoices, updateDepartureTime, updateNote, 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";
@@ -182,6 +182,15 @@ router.post("/jdemeObed", async (req, res, next) => {
} catch (e: any) { next(e) } } catch (e: any) { next(e) }
}); });
router.post("/updateBuyer", async (req, res, next) => {
const login = getLogin(parseToken(req));
try {
const data = await updateBuyer(login);
getWebsocket().emit("message", data);
res.status(200).json({});
} catch (e: any) { next(e) }
});
// /api/food/refresh?type=week&heslo=docasnyheslo // /api/food/refresh?type=week&heslo=docasnyheslo
export const refreshMetoda = async (req: Request, res: Response) => { export const refreshMetoda = async (req: Request, res: Response) => {
const { type, heslo } = req.query as { type?: string; heslo?: string }; const { type, heslo } = req.query as { type?: string; heslo?: string };

View File

@@ -477,6 +477,24 @@ export async function updateDepartureTime(login: string, time?: string, date?: D
return clientData; return clientData;
} }
/**
* Nastaví/odnastaví uživatele jako objednatele pro dnešní den.
* Objednatelů může být více.
*
* @param login přihlašovací jméno uživatele
*/
export async function updateBuyer(login: string) {
const usedDate = getToday();
let clientData = await getClientData(usedDate);
const userEntry = clientData.choices?.['OBJEDNAVAM']?.[login];
if (!userEntry) {
throw new Error("Nelze nastavit objednatele pro uživatele s jinou volbou než \"Budu objednávat\"");
}
userEntry.isBuyer = !(userEntry.isBuyer || false);
await storage.setData(formatDate(usedDate), clientData);
return clientData;
}
/** /**
* Vrátí data pro klienta pro předaný nebo aktuální den. * Vrátí data pro klienta pro předaný nebo aktuální den.
* *

File diff suppressed because it is too large Load Diff

View File

@@ -26,6 +26,8 @@ paths:
$ref: "./paths/food/changeDepartureTime.yml" $ref: "./paths/food/changeDepartureTime.yml"
/food/jdemeObed: /food/jdemeObed:
$ref: "./paths/food/jdemeObed.yml" $ref: "./paths/food/jdemeObed.yml"
/food/updateBuyer:
$ref: "./paths/food/updateBuyer.yml"
# Pizza day (/api/pizzaDay) # Pizza day (/api/pizzaDay)
/pizzaDay/create: /pizzaDay/create:

View File

@@ -0,0 +1,6 @@
post:
operationId: setBuyer
summary: Nastavení/odnastavení aktuálně přihlášeného uživatele jako objednatele pro stav "Budu objednávat" pro aktuální den.
responses:
"200":
description: Stav byl úspěšně změněn.

View File

@@ -74,6 +74,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
isBuyer:
description: Příznak, zda je tento uživatel objednatelem pro stav "Budu objednávat"
type: boolean
LocationLunchChoicesMap: LocationLunchChoicesMap:
description: Objekt, kde klíčem je možnost stravování ((#LunchChoice)) a hodnotou množina uživatelů s touto volbou ((#LunchChoices)). description: Objekt, kde klíčem je možnost stravování ((#LunchChoice)) a hodnotou množina uživatelů s touto volbou ((#LunchChoices)).
type: object type: object