8 Commits

30 changed files with 1449 additions and 2033 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 {
cursor: pointer; color: var(--luncher-action-icon);
margin-left: 10px; cursor: pointer;
padding: 0; margin-left: 10px;
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 { getDayOfWeekIndex, getHumanDate, getHumanDateTime, getIsWeekend, 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"
@@ -71,10 +71,7 @@ function App() {
const departureChoiceRef = useRef<HTMLSelectElement>(null); const departureChoiceRef = useRef<HTMLSelectElement>(null);
const pizzaPoznamkaRef = useRef<HTMLInputElement>(null); const pizzaPoznamkaRef = useRef<HTMLInputElement>(null);
const [failure, setFailure] = useState<boolean>(false); const [failure, setFailure] = useState<boolean>(false);
const [dayIndex, setDayIndex] = useState<number>(); // Index zobrazovaného dne const [dayIndex, setDayIndex] = useState<number>();
// TODO berka zde je nutné dořešit mocking pro testování
const [todayDayIndex, setTodayDayIndex] = useState<number>(getDayOfWeekIndex(new Date())); // Index dnešního dne
const [isTodayWeekend, setIsTodayWeekend] = useState<boolean>(getIsWeekend(new Date()));
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 [eggImage, setEggImage] = useState<Blob>(); const [eggImage, setEggImage] = useState<Blob>();
@@ -92,9 +89,8 @@ function App() {
const data = response.data const data = response.data
if (data) { if (data) {
setData(data); setData(data);
const dayIndex = getDayOfWeekIndex(new Date(data.date)); setDayIndex(data.dayIndex);
setDayIndex(dayIndex); dayIndexRef.current = data.dayIndex;
dayIndexRef.current = dayIndex;
setFood(data.menus); setFood(data.menus);
} }
}).catch(e => { }).catch(e => {
@@ -107,8 +103,6 @@ function App() {
if (!auth?.login) { if (!auth?.login) {
return return
} }
setTodayDayIndex(getDayOfWeekIndex(new Date()));
setIsTodayWeekend(getIsWeekend(new Date()));
getData({ query: { dayIndex: dayIndex } }).then(response => { getData({ query: { dayIndex: dayIndex } }).then(response => {
const data = response.data; const data = response.data;
setData(data); setData(data);
@@ -131,7 +125,7 @@ function App() {
socket.on(EVENT_MESSAGE, (newData: ClientData) => { socket.on(EVENT_MESSAGE, (newData: ClientData) => {
// console.log("Přijata nová data ze socketu", newData); // console.log("Přijata nová data ze socketu", newData);
// Aktualizujeme pouze, pokud jsme dostali data pro den, který máme aktuálně zobrazený // Aktualizujeme pouze, pokud jsme dostali data pro den, který máme aktuálně zobrazený
if (dayIndexRef.current == null || getDayOfWeekIndex(new Date(newData.date)) === dayIndexRef.current) { if (dayIndexRef.current == null || newData.dayIndex === dayIndexRef.current) {
setData(newData); setData(newData);
} }
}); });
@@ -147,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
@@ -220,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 } });
} }
} }
@@ -228,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 = "";
@@ -290,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 [];
@@ -310,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]);
@@ -331,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 } });
@@ -382,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)}>
@@ -410,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>
@@ -445,24 +411,24 @@ function App() {
} }
const noOrders = data?.pizzaDay?.orders?.length === 0; const noOrders = data?.pizzaDay?.orders?.length === 0;
const canChangeChoice = dayIndex == null || dayIndex >= todayDayIndex; const canChangeChoice = dayIndex == null || data.todayDayIndex == null || dayIndex >= data.todayDayIndex;
const { path, url, startOffset, endOffset, duration, ...style } = easterEgg || {}; const { path, url, startOffset, endOffset, duration, ...style } = easterEgg || {};
return ( return (
<div className="app-container"> <div className="app-container">
{easterEgg && eggImage && <img ref={eggRef} alt='' src={URL.createObjectURL(eggImage)} style={{ position: 'absolute', ...EASTER_EGG_STYLE, ...style, animationDuration: `${duration ?? EASTER_EGG_DEFAULT_DURATION}s` }} />} {easterEgg && eggImage && <img ref={eggRef} alt='' src={URL.createObjectURL(eggImage)} style={{ position: 'absolute', ...EASTER_EGG_STYLE, ...style, animationDuration: `${duration ?? EASTER_EGG_DEFAULT_DURATION}s` }} />}
<Header dayIndex={dayIndex} /> <Header />
<div className='wrapper'> <div className='wrapper'>
{isTodayWeekend ? <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 &&
@@ -470,23 +436,22 @@ 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 === todayDayIndex ? 'black' : 'gray' }}>{getHumanDate(new Date(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'>
{canChangeChoice && <> {canChangeChoice && <>
<p>{`Jak to ${dayIndex == null || dayIndex === todayDayIndex ? 'dnes' : 'tento den'} vidíš s obědem?`}</p> <p>{`Jak to ${dayIndex == null || dayIndex === data.todayDayIndex ? 'dnes' : 'tento den'} vidíš s obědem?`}</p>
<Form.Select ref={choiceRef} onChange={doAddChoice}> <Form.Select ref={choiceRef} onChange={doAddChoice}>
<option></option> <option></option>
{Object.entries(LunchChoice) {Object.entries(LunchChoice)
@@ -540,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!);
@@ -595,7 +572,7 @@ function App() {
: <div className='mt-5'><i>Zatím nikdo nehlasoval...</i></div> : <div className='mt-5'><i>Zatím nikdo nehlasoval...</i></div>
} }
</div> </div>
{dayIndex === todayDayIndex && {dayIndex === data.todayDayIndex &&
<div className='mt-5'> <div className='mt-5'>
{!data.pizzaDay && {!data.pizzaDay &&
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
@@ -674,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>
@@ -717,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

@@ -73,22 +73,16 @@ export const getDayOfWeekIndex = (date: Date) => {
return (((date.getDay() - 1) % 7) + 7) % 7; return (((date.getDay() - 1) % 7) + 7) % 7;
} }
/** Vrátí true, pokud je předané datum o víkendu. */
export function getIsWeekend(date: Date) {
const index = getDayOfWeekIndex(date);
return index == 5 || index == 6;
}
/** 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,20 +2,15 @@ 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";
import GenerateQRModal from "./modals/GenerateQRModal";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { STATS_URL } from "../AppRoutes"; import { STATS_URL } from "../AppRoutes";
import { FeatureRequest, getVotes, updateVote } from "../../../types"; import { FeatureRequest, getVotes, updateVote } from "../../../types";
type Props = { export default function Header() {
dayIndex?: number;
}
export default function Header({ dayIndex }: Readonly<Props>) {
const auth = useAuth(); const auth = useAuth();
const settings = useSettings(); const settings = useSettings();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -23,7 +18,6 @@ export default function Header({ dayIndex }: Readonly<Props>) {
const [votingModalOpen, setVotingModalOpen] = 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 [generateQRModalOpen, setGenerateQRModalOpen] = useState<boolean>(false);
const [featureVotes, setFeatureVotes] = useState<FeatureRequest[] | undefined>([]); const [featureVotes, setFeatureVotes] = useState<FeatureRequest[] | undefined>([]);
useEffect(() => { useEffect(() => {
@@ -50,10 +44,6 @@ export default function Header({ dayIndex }: Readonly<Props>) {
setRefreshMenuModalOpen(false); setRefreshMenuModalOpen(false);
} }
const closeGenerateQRModal = () => {
setGenerateQRModalOpen(false);
}
const isValidInteger = (str: string) => { const isValidInteger = (str: string) => {
str = str.trim(); str = str.trim();
if (!str) { if (!str) {
@@ -64,19 +54,19 @@ export default function Header({ dayIndex }: Readonly<Props>) {
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í
@@ -86,7 +76,7 @@ export default function Header({ dayIndex }: Readonly<Props>) {
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');
@@ -99,7 +89,7 @@ export default function Header({ dayIndex }: Readonly<Props>) {
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)
@@ -109,6 +99,9 @@ export default function Header({ dayIndex }: Readonly<Props>) {
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();
} }
@@ -131,7 +124,6 @@ export default function Header({ dayIndex }: Readonly<Props>) {
<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={() => setGenerateQRModalOpen(true)}>Generování QR</NavDropdown.Item>
<NavDropdown.Item onClick={() => setVotingModalOpen(true)}>Hlasovat o nových funkcích</NavDropdown.Item> <NavDropdown.Item onClick={() => setVotingModalOpen(true)}>Hlasovat o nových funkcích</NavDropdown.Item>
<NavDropdown.Item onClick={() => setPizzaModalOpen(true)}>Pizza kalkulačka</NavDropdown.Item> <NavDropdown.Item onClick={() => setPizzaModalOpen(true)}>Pizza kalkulačka</NavDropdown.Item>
<NavDropdown.Item onClick={() => navigate(STATS_URL)}>Statistiky</NavDropdown.Item> <NavDropdown.Item onClick={() => navigate(STATS_URL)}>Statistiky</NavDropdown.Item>
@@ -142,7 +134,6 @@ export default function Header({ dayIndex }: Readonly<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} />
<GenerateQRModal isOpen={generateQRModalOpen} onClose={closeGenerateQRModal} dayIndex={dayIndex} bankAccount={settings?.bankAccount} bankAccountHolder={settings?.holderName} />
<FeaturesVotingModal isOpen={votingModalOpen} onClose={closeVotingModal} onChange={saveFeatureVote} initialValues={featureVotes} /> <FeaturesVotingModal isOpen={votingModalOpen} onClose={closeVotingModal} onChange={saveFeatureVote} initialValues={featureVotes} />
<PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} /> <PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} />
</Navbar> </Navbar>

View File

@@ -1,176 +0,0 @@
import { useEffect, useState } from "react";
import { Modal, Button, Table, Form, Alert } from "react-bootstrap";
import { ClientData, generateQr, getData } from "../../../../types";
type Props = {
isOpen: boolean,
onClose: () => void,
dayIndex?: number,
bankAccount?: string,
bankAccountHolder?: string,
}
type UserQRData = {
login: string;
selected: boolean;
note: string;
amount: string;
}
/** Modální dialog pro generování QR kódů. */
export default function GenerateQRModal({ isOpen, onClose, dayIndex, bankAccount, bankAccountHolder }: Readonly<Props>) {
const [users, setUsers] = useState<UserQRData[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const isBankDataValid = bankAccount && bankAccountHolder;
useEffect(() => {
if (isOpen) {
setLoading(true);
getData({ query: { dayIndex } }).then(response => {
const data: ClientData = response.data;
const userList: UserQRData[] = [];
// Projdeme všechny volby stravování a získáme uživatele
if (data.choices) {
Object.entries(data.choices).forEach(([locationKey, locationUsers]) => {
Object.keys(locationUsers).forEach(login => {
// Přidáme uživatele pouze pokud tam ještě není
if (!userList.find(u => u.login === login)) {
userList.push({
login,
selected: false,
note: '',
amount: ''
});
}
});
});
}
setUsers(userList);
setLoading(false);
}).catch(() => {
setLoading(false);
});
}
}, [isOpen, dayIndex]);
const handleCheckboxChange = (login: string) => {
setUsers(users.map(u =>
u.login === login ? { ...u, selected: !u.selected } : u
));
};
const handleNoteChange = (login: string, note: string) => {
setUsers(users.map(u =>
u.login === login ? { ...u, note } : u
));
};
const handleAmountChange = (login: string, amount: string) => {
setUsers(users.map(u =>
u.login === login ? { ...u, amount } : u
));
};
const handleGenerate = async () => {
const selectedUsers = users.filter(u => u.selected);
// TODO: Implementovat generování QR kódů
console.log('Generování QR pro:', selectedUsers);
alert('Funkce generování QR bude implementována');
await generateQr({
body: {
bankAccount: bankAccount!,
bankAccountHolder: bankAccountHolder!,
qrCodes: selectedUsers.map(u => ({
login: u.login,
des: u.note,
amount: Number.parseFloat(u.amount)
}))
},
})
};
const handleClose = () => {
setUsers([]);
onClose();
};
return (
<Modal show={isOpen} onHide={handleClose} size="lg">
<Modal.Header closeButton>
<Modal.Title><h2>Generování QR kódů</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
{!isBankDataValid && (
<Alert variant="warning">
<strong>Upozornění:</strong> Pro generování QR kódů je nutné mít v nastavení vyplněné číslo bankovního účtu a jméno majitele účtu.
</Alert>
)}
{loading ? (
<p>Načítání uživatelů...</p>
) : users.length === 0 ? (
<p>Pro aktuální den nemá žádný uživatel vybranou volbu stravování.</p>
) : (
<Table striped bordered hover>
<thead>
<tr>
<th style={{ width: '50px' }}></th>
<th>Uživatel</th>
<th>Poznámka</th>
<th style={{ width: '120px' }}>Částka ()</th>
</tr>
</thead>
<tbody>
{users.map(user => (
<tr key={user.login}>
<td className="text-center">
<Form.Check
type="checkbox"
checked={user.selected}
onChange={() => handleCheckboxChange(user.login)}
/>
</td>
<td>{user.login}</td>
<td>
<Form.Control
type="text"
value={user.note}
onChange={(e) => handleNoteChange(user.login, e.target.value)}
placeholder="Poznámka"
disabled={!user.selected}
/>
</td>
<td>
<Form.Control
type="number"
step="0.01"
min="0"
value={user.amount}
onChange={(e) => handleAmountChange(user.login, e.target.value)}
placeholder="0.00"
disabled={!user.selected}
/>
</td>
</tr>
))}
</tbody>
</Table>
)}
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={handleClose}>
Zavřít
</Button>
<Button
variant="primary"
onClick={handleGenerate}
disabled={users.filter(u => u.selected).length === 0 || !isBankDataValid}
>
Generovat
</Button>
</Modal.Footer>
</Modal>
);
}

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() {
@@ -103,7 +102,7 @@ export default function StatsPage() {
return ( return (
<> <>
<Header dayIndex={undefined} /> <Header />
<div className="stats-page"> <div className="stats-page">
<h1>Statistiky</h1> <h1>Statistiky</h1>
<div className="week-navigator"> <div className="week-navigator">

File diff suppressed because it is too large Load Diff

View File

@@ -13,8 +13,6 @@ import foodRoutes, { refreshMetoda } from "./routes/foodRoutes";
import votingRoutes from "./routes/votingRoutes"; import votingRoutes from "./routes/votingRoutes";
import easterEggRoutes from "./routes/easterEggRoutes"; import easterEggRoutes from "./routes/easterEggRoutes";
import statsRoutes from "./routes/statsRoutes"; import statsRoutes from "./routes/statsRoutes";
import debugRoutes from "./routes/debugRoutes";
import qrRoutes from "./routes/qrRoutes";
const ENVIRONMENT = process.env.NODE_ENV ?? 'production'; const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) }); dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
@@ -101,8 +99,6 @@ app.get("/api/qr", (req, res) => {
// Přeskočení auth pro refresh dat xd // Přeskočení auth pro refresh dat xd
app.use("/api/food/refresh", refreshMetoda); app.use("/api/food/refresh", refreshMetoda);
app.use("/api/debug", debugRoutes);
/** Middleware ověřující JWT token */ /** Middleware ověřující JWT token */
app.use("/api/", (req, res, next) => { app.use("/api/", (req, res, next) => {
if (HTTP_REMOTE_USER_ENABLED) { if (HTTP_REMOTE_USER_ENABLED) {
@@ -147,7 +143,6 @@ app.use("/api/food", foodRoutes);
app.use("/api/voting", votingRoutes); app.use("/api/voting", votingRoutes);
app.use("/api/easterEggs", easterEggRoutes); app.use("/api/easterEggs", easterEggRoutes);
app.use("/api/stats", statsRoutes); app.use("/api/stats", statsRoutes);
app.use("/api/qr", qrRoutes);
app.use('/stats', express.static('public')); app.use('/stats', express.static('public'));
app.use(express.static('public')); app.use(express.static('public'));

View File

@@ -517,24 +517,6 @@ const MOCK_DATA = {
name: "Pečené vepřové koleno, křen, hořčice, chléb", name: "Pečené vepřové koleno, křen, hořčice, chléb",
price: "320\xA0Kč", price: "320\xA0Kč",
isSoup: false, isSoup: false,
},
{
amount: "-",
name: "Slovácké strapačky s uzenou slaninou, zelím, mletým pepřem & sekanou petrželkou",
price: "140\xA0Kč",
isSoup: false,
},
{
amount: "-",
name: "Hovězí guláš s vejcem, zeleninovou garniturkou & žemlovými knedlíky",
price: "145\xA0Kč",
isSoup: false,
},
{
amount: "-",
name: "Kuřecí roláda s kaštanovou nádivkou, demi-glace & smetanovou bramborovou kaší",
price: "150\xA0Kč",
isSoup: false,
} }
], ],
[ [
@@ -549,24 +531,6 @@ const MOCK_DATA = {
name: "Poutine (trhané vepřové, hranolky, sýr, čalamáda, pikantní omáčka)", name: "Poutine (trhané vepřové, hranolky, sýr, čalamáda, pikantní omáčka)",
price: "190\xA0Kč", price: "190\xA0Kč",
isSoup: false, isSoup: false,
},
{
amount: "-",
name: "Slovácké strapačky s uzenou slaninou, zelím, mletým pepřem & sekanou petrželkou",
price: "140\xA0Kč",
isSoup: false,
},
{
amount: "-",
name: "Hovězí guláš s vejcem, zeleninovou garniturkou & žemlovými knedlíky",
price: "145\xA0Kč",
isSoup: false,
},
{
amount: "-",
name: "Kuřecí roláda s kaštanovou nádivkou, demi-glace & smetanovou bramborovou kaší",
price: "150\xA0Kč",
isSoup: false,
} }
], ],
[ [
@@ -581,24 +545,6 @@ const MOCK_DATA = {
name: "Vepřový řízek z kotlety, domácí bramborový salát", name: "Vepřový řízek z kotlety, domácí bramborový salát",
price: "170\xA0Kč", price: "170\xA0Kč",
isSoup: false, isSoup: false,
},
{
amount: "-",
name: "Slovácké strapačky s uzenou slaninou, zelím, mletým pepřem & sekanou petrželkou",
price: "140\xA0Kč",
isSoup: false,
},
{
amount: "-",
name: "Hovězí guláš s vejcem, zeleninovou garniturkou & žemlovými knedlíky",
price: "145\xA0Kč",
isSoup: false,
},
{
amount: "-",
name: "Kuřecí roláda s kaštanovou nádivkou, demi-glace & smetanovou bramborovou kaší",
price: "150\xA0Kč",
isSoup: false,
} }
], ],
[ [
@@ -613,24 +559,6 @@ const MOCK_DATA = {
name: "Burger z Chuck rollu, hranolky, tatarská omáčka", name: "Burger z Chuck rollu, hranolky, tatarská omáčka",
price: "200\xA0Kč", price: "200\xA0Kč",
isSoup: false, isSoup: false,
},
{
amount: "-",
name: "Slovácké strapačky s uzenou slaninou, zelím, mletým pepřem & sekanou petrželkou",
price: "140\xA0Kč",
isSoup: false,
},
{
amount: "-",
name: "Hovězí guláš s vejcem, zeleninovou garniturkou & žemlovými knedlíky",
price: "145\xA0Kč",
isSoup: false,
},
{
amount: "-",
name: "Kuřecí roláda s kaštanovou nádivkou, demi-glace & smetanovou bramborovou kaší",
price: "150\xA0Kč",
isSoup: false,
} }
], ],
], ],
@@ -673,18 +601,6 @@ const MOCK_DATA = {
name: "Hovězí po Burgundsku, bramborová kaše", name: "Hovězí po Burgundsku, bramborová kaše",
price: "155\xA0Kč", price: "155\xA0Kč",
isSoup: false, isSoup: false,
},
{
amount: "-",
name: "Špagety s kuřecím masem, špenátem a smetanou",
price: "145\xA0Kč",
isSoup: false,
},
{
amount: "-",
name: "Medailonky z vepřové panenky s fazolkami se slaninou, šťouchané brambory",
price: "185\xA0Kč",
isSoup: false,
} }
], ],
[ [
@@ -699,18 +615,6 @@ const MOCK_DATA = {
name: "Kuřecí plátky na sušených rajčatech, bylinkách a česneku, bramborová kaše", name: "Kuřecí plátky na sušených rajčatech, bylinkách a česneku, bramborová kaše",
price: "155\xA0Kč", price: "155\xA0Kč",
isSoup: false, isSoup: false,
},
{
amount: "-",
name: "Špagety s kuřecím masem, špenátem a smetanou",
price: "145\xA0Kč",
isSoup: false,
},
{
amount: "-",
name: "Medailonky z vepřové panenky s fazolkami se slaninou, šťouchané brambory",
price: "185\xA0Kč",
isSoup: false,
} }
], ],
[ [
@@ -725,18 +629,6 @@ const MOCK_DATA = {
name: "Rajská s plněnou paprikou, knedlík", name: "Rajská s plněnou paprikou, knedlík",
price: "170\xA0Kč", price: "170\xA0Kč",
isSoup: false, isSoup: false,
},
{
amount: "-",
name: "Špagety s kuřecím masem, špenátem a smetanou",
price: "145\xA0Kč",
isSoup: false,
},
{
amount: "-",
name: "Medailonky z vepřové panenky s fazolkami se slaninou, šťouchané brambory",
price: "185\xA0Kč",
isSoup: false,
} }
], ],
[ [
@@ -751,18 +643,6 @@ const MOCK_DATA = {
name: "Ragú z trhané kachny, onsen vejce, soté ze špenátu a ředkvičky, bramborové pyré, lanýžová sůl, zelený olej", name: "Ragú z trhané kachny, onsen vejce, soté ze špenátu a ředkvičky, bramborové pyré, lanýžová sůl, zelený olej",
price: "189\xA0Kč", price: "189\xA0Kč",
isSoup: false, isSoup: false,
},
{
amount: "-",
name: "Špagety s kuřecím masem, špenátem a smetanou",
price: "145\xA0Kč",
isSoup: false,
},
{
amount: "-",
name: "Medailonky z vepřové panenky s fazolkami se slaninou, šťouchané brambory",
price: "185\xA0Kč",
isSoup: false,
} }
], ],
], ],
@@ -1522,7 +1402,7 @@ const MOCK_PIZZA_LIST = [
* Funkce vrací mock datu ve formátu YYYY-MM-DD * Funkce vrací mock datu ve formátu YYYY-MM-DD
*/ */
export const getTodayMock = (): Date => { export const getTodayMock = (): Date => {
return new Date('2025-01-08'); // středa return new Date('2025-01-10'); // pátek
} }
export const getMenuSladovnickaMock = () => { export const getMenuSladovnickaMock = () => {

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,45 +0,0 @@
import express, { Request } from "express";
import { addChoice, getData, removeChoices } from "../service";
import { ClientData, LunchChoice } from "../../../types";
const NAMES = ["alice", "bob", "carol", "dave", "eve", "frank", "grace", "heidi", "ivan", "judy"];
const DATES = ["2025-01-06", "2025-01-07", "2025-01-08", "2025-01-09", "2025-01-10"];
const router = express.Router();
router.get("/createUsers", async (req: Request<{}, any, any>, res) => {
for (const element of NAMES) {
for (const dateStr of DATES) {
// Se šancí 50 % přidat pro tohoto uživatele tento den náhodnou volbu
if (Math.random() > 0.5) {
const foodIndex = Math.floor(Math.random() * 3); // Předpokládáme, že jsou 3 možnosti jídla
const date = new Date(dateStr);
// Náhodná volba z LunchChoice
const lunchChoices = [
"SLADOVNICKA",
"TECHTOWER",
"ZASTAVKAUMICHALA",
"SENKSERIKOVA",
];
const randomLunchChoice = lunchChoices[Math.floor(Math.random() * lunchChoices.length)];
await addChoice(element, true, randomLunchChoice as LunchChoice, foodIndex, date);
}
}
}
res.status(200).json({});
});
router.get("/clearUsers", async (req: Request<{}, any, any>, res) => {
for (const dateStr of DATES) {
const date = new Date(dateStr);
const data: ClientData = await getData(date);
for (const user of NAMES) {
for (const locationKey in data.choices) {
await removeChoices(user, true, locationKey as keyof ClientData["choices"], date);
}
}
}
res.status(200).json({});
});
export default router;

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

@@ -1,15 +0,0 @@
import express, { Request, Response } from "express";
import { getLogin } from "../auth";
import { parseToken } from "../utils";
import { GenerateQrData } from "../../../types";
const router = express.Router();
router.post("/generate", async (req: Request<{}, any, GenerateQrData["body"]>, res: Response<any>) => {
getLogin(parseToken(req));
console.log("Bank account for QR codes:", req.body.bankAccount);
console.log("Bank account holder for QR codes:", req.body.bankAccountHolder);
console.log("Requested QR codes for users:", req.body.qrCodes);
});
export default router;

View File

@@ -1,4 +1,4 @@
import { InsufficientPermissions, formatDate, getDayOfWeekIndex, getFirstWorkDayOfWeek, getIsWeekend, getWeekNumber } from "./utils"; import { InsufficientPermissions, formatDate, getDayOfWeekIndex, getFirstWorkDayOfWeek, getHumanDate, getIsWeekend, getWeekNumber } from "./utils";
import getStorage from "./storage"; import getStorage from "./storage";
import { getMenuSladovnicka, getMenuTechTower, getMenuZastavkaUmichala, getMenuSenkSerikova } from "./restaurants"; import { getMenuSladovnicka, getMenuTechTower, getMenuZastavkaUmichala, getMenuSenkSerikova } from "./restaurants";
import { getTodayMock } from "./mock"; import { getTodayMock } from "./mock";
@@ -31,7 +31,10 @@ export const getDateForWeekIndex = (index: number) => {
function getEmptyData(date?: Date): ClientData { function getEmptyData(date?: Date): ClientData {
const usedDate = date || getToday(); const usedDate = date || getToday();
return { return {
date: usedDate.toISOString().split('T')[0], todayDayIndex: getDayOfWeekIndex(getToday()),
date: getHumanDate(usedDate),
isWeekend: getIsWeekend(usedDate),
dayIndex: getDayOfWeekIndex(usedDate),
choices: {}, choices: {},
}; };
} }
@@ -474,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.
* *
@@ -483,5 +504,9 @@ export async function updateDepartureTime(login: string, time?: string, date?: D
export async function getClientData(date?: Date): Promise<ClientData> { export async function getClientData(date?: Date): Promise<ClientData> {
const targetDate = date ?? getToday(); const targetDate = date ?? getToday();
const dateString = formatDate(targetDate); const dateString = formatDate(targetDate);
return await storage.getData<ClientData>(dateString) || getEmptyData(date); const clientData = await storage.getData<ClientData>(dateString) || getEmptyData(date);
return {
...clientData,
todayDayIndex: getDayOfWeekIndex(getToday()),
}
} }

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:
@@ -65,10 +67,6 @@ paths:
/voting/updateVote: /voting/updateVote:
$ref: "./paths/voting/updateVote.yml" $ref: "./paths/voting/updateVote.yml"
# QR kódy (/api/qr)
/qr/generate:
$ref: "./paths/qr/generateQr.yml"
components: components:
schemas: schemas:
$ref: "./schemas/_index.yml" $ref: "./schemas/_index.yml"

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

@@ -1,12 +0,0 @@
post:
operationId: generateQr
summary: Generování QR kódů.
requestBody:
required: true
content:
application/json:
schema:
$ref: "../../schemas/_index.yml#/GenerateQrCodesRequest"
responses:
"200":
description: QR kódy byly úspěšně vygenerovány.

View File

@@ -21,13 +21,23 @@ ClientData:
type: object type: object
additionalProperties: false additionalProperties: false
required: required:
- todayDayIndex
- date - date
- isWeekend
- choices - choices
properties: properties:
todayDayIndex:
description: Index dnešního dne v týdnu
$ref: "#/DayIndex"
date: date:
description: Datum konkrétního dne description: Human-readable datum dne
type: string type: string
format: date isWeekend:
description: Příznak, zda je tento den víkend
type: boolean
dayIndex:
description: Index dne v týdnu, ke kterému se vztahují tato data
$ref: "#/DayIndex"
choices: choices:
$ref: "#/LunchChoices" $ref: "#/LunchChoices"
menus: menus:
@@ -64,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
@@ -469,43 +482,6 @@ PizzaDay:
items: items:
$ref: "#/PizzaOrder" $ref: "#/PizzaOrder"
# --- QR KÓDY ---
QrCodeRequest:
description: Data potřebná pro vygenerování jednoho QR kódu pro platbu
type: object
required:
- login
- note
- amount
properties:
login:
description: Přihlašovací jméno uživatele, pro kterého bude QR kód vygenerován
type: string
note:
description: Popis platby
type: string
amount:
description: Částka platby v Kč
type: number
GenerateQrCodesRequest:
description: Data potřebná pro vygenerování QR kódů pro platbu
type: object
required:
- bankAccount
- bankAccountHolder
properties:
bankAccount:
description: Číslo bankovního účtu objednávajícího
type: string
bankAccountHolder:
description: Jméno majitele bankovního účtu
type: string
qrCodes:
description: Pole požadavků na vygenerování QR kódů
type: array
items:
$ref: "#/QrCodeRequest"
# --- NOTIFIKACE --- # --- NOTIFIKACE ---
UdalostEnum: UdalostEnum:
type: string type: string