QR kódy pro platbu za pizza day jsou nyní zobrazeny persistentně i po následující dny, dokud uživatel nepotvrdí platbu tlačítkem "Zaplatil jsem". Nevyřízené QR kódy jsou uloženy per-user v storage a zobrazeny v sekci "Nevyřízené platby".
771 lines
34 KiB
TypeScript
771 lines
34 KiB
TypeScript
import React, { useContext, useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
|
import 'bootstrap/dist/css/bootstrap.min.css';
|
|
import { EVENT_DISCONNECT, EVENT_MESSAGE, SocketContext } from './context/socket';
|
|
import { useAuth } from './context/auth';
|
|
import Login from './Login';
|
|
import { Alert, Button, Col, Form, Row, Table } from 'react-bootstrap';
|
|
import Header from './components/Header';
|
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
|
import PizzaOrderList from './components/PizzaOrderList';
|
|
import SelectSearch, { SelectedOptionValue, SelectSearchOption } from 'react-select-search';
|
|
import 'react-select-search/style.css';
|
|
import './App.scss';
|
|
import { faCircleCheck, faNoteSticky, faTrashCan, faComment } from '@fortawesome/free-regular-svg-icons';
|
|
import { useSettings } from './context/settings';
|
|
import Footer from './components/Footer';
|
|
import { faBasketShopping, faChainBroken, faChevronLeft, faChevronRight, faGear, faSatelliteDish, faSearch, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons';
|
|
import Loader from './components/Loader';
|
|
import { getHumanDateTime, isInTheFuture } from './Utils';
|
|
import NoteModal from './components/modals/NoteModal';
|
|
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, setBuyer, dismissQr } from '../../types';
|
|
import { getLunchChoiceName } from './enums';
|
|
// import FallingLeaves, { LEAF_PRESETS, LEAF_COLOR_THEMES } from './FallingLeaves';
|
|
// import './FallingLeaves.scss';
|
|
|
|
const EVENT_CONNECT = "connect"
|
|
|
|
// Fixní styl pro všechny easter egg obrázky
|
|
const EASTER_EGG_STYLE = {
|
|
zIndex: 1,
|
|
animationName: "bounce-in",
|
|
animationTimingFunction: "ease"
|
|
}
|
|
|
|
// Mapování čísel alergenů na jejich názvy
|
|
const ALLERGENS: { [key: number]: string } = {
|
|
1: "Obiloviny obsahující lepek",
|
|
2: "Korýši a výrobky z nich",
|
|
3: "Vejce a výrobky z nich",
|
|
4: "Ryby a výrobky z nich",
|
|
5: "Arašidy a výrobky z nich",
|
|
6: "Sója a výrobky z nich",
|
|
7: "Mléko a výrobky z nich (včetně laktózy)",
|
|
8: "Skořápkové plody",
|
|
9: "Celer a výrobky z něj",
|
|
10: "Hořčice a výrobky z ní",
|
|
11: "Sezamová semena a výrobky z nich",
|
|
12: "Oxid siřičitý a siřičitany",
|
|
13: "Vlčí bob (Lupina) a výrobky z něj",
|
|
14: "Měkkýši a výrobky z nich"
|
|
}
|
|
|
|
const LINK_ALLERGENS = 'https://www.strava.cz/Strava/Napoveda/cz/Prilohy/alergeny.pdf';
|
|
|
|
// Výchozí doba trvání animace v sekundách, pokud není přetíženo v konfiguračním JSONu
|
|
const EASTER_EGG_DEFAULT_DURATION = 0.75;
|
|
|
|
function App() {
|
|
const auth = useAuth();
|
|
const settings = useSettings();
|
|
const [easterEgg, _] = useEasterEgg(auth);
|
|
const [isConnected, setIsConnected] = useState<boolean>(false);
|
|
const [data, setData] = useState<ClientData>();
|
|
const [food, setFood] = useState<RestaurantDayMenuMap>();
|
|
const [myOrder, setMyOrder] = useState<PizzaOrder>();
|
|
const [foodChoiceList, setFoodChoiceList] = useState<Food[]>();
|
|
const [closed, setClosed] = useState<boolean>(false);
|
|
const socket = useContext(SocketContext);
|
|
const choiceRef = useRef<HTMLSelectElement>(null);
|
|
const foodChoiceRef = useRef<HTMLSelectElement>(null);
|
|
const departureChoiceRef = useRef<HTMLSelectElement>(null);
|
|
const pizzaPoznamkaRef = useRef<HTMLInputElement>(null);
|
|
const [failure, setFailure] = useState<boolean>(false);
|
|
const [dayIndex, setDayIndex] = useState<number>();
|
|
const [loadingPizzaDay, setLoadingPizzaDay] = useState<boolean>(false);
|
|
const [noteModalOpen, setNoteModalOpen] = useState<boolean>(false);
|
|
const [eggImage, setEggImage] = useState<Blob>();
|
|
const eggRef = useRef<HTMLImageElement>(null);
|
|
// Prazvláštní workaround, aby socket.io listener viděl aktuální hodnotu
|
|
// https://medium.com/@kishorkrishna/cant-access-latest-state-inside-socket-io-listener-heres-how-to-fix-it-1522a5abebdb
|
|
const dayIndexRef = useRef<number | undefined>(dayIndex);
|
|
|
|
// Načtení dat po přihlášení
|
|
useEffect(() => {
|
|
if (!auth?.login) {
|
|
return
|
|
}
|
|
getData().then(response => {
|
|
const data = response.data
|
|
if (data) {
|
|
setData(data);
|
|
setDayIndex(data.dayIndex);
|
|
dayIndexRef.current = data.dayIndex;
|
|
setFood(data.menus);
|
|
}
|
|
}).catch(e => {
|
|
setFailure(true);
|
|
})
|
|
}, [auth, auth?.login]);
|
|
|
|
// Přenačtení pro zvolený den
|
|
useEffect(() => {
|
|
if (!auth?.login) {
|
|
return
|
|
}
|
|
getData({ query: { dayIndex: dayIndex } }).then(response => {
|
|
const data = response.data;
|
|
setData(data);
|
|
if (data) {
|
|
setFood(data.menus);
|
|
}
|
|
}).catch(e => {
|
|
setFailure(true);
|
|
})
|
|
}, [dayIndex, auth]);
|
|
|
|
// Registrace socket eventů
|
|
useEffect(() => {
|
|
socket.on(EVENT_CONNECT, () => {
|
|
setIsConnected(true);
|
|
});
|
|
socket.on(EVENT_DISCONNECT, () => {
|
|
setIsConnected(false);
|
|
});
|
|
socket.on(EVENT_MESSAGE, (newData: ClientData) => {
|
|
// console.log("Přijata nová data ze socketu", newData);
|
|
// Aktualizujeme pouze, pokud jsme dostali data pro den, který máme aktuálně zobrazený
|
|
if (dayIndexRef.current == null || newData.dayIndex === dayIndexRef.current) {
|
|
setData(newData);
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
socket.off(EVENT_CONNECT);
|
|
socket.off(EVENT_DISCONNECT);
|
|
socket.off(EVENT_MESSAGE);
|
|
}
|
|
}, [socket]);
|
|
|
|
useEffect(() => {
|
|
if (!auth?.login || !data?.choices) {
|
|
return
|
|
}
|
|
// Pre-fill form refs from existing choices
|
|
let foundKey: LunchChoice | undefined;
|
|
let foundChoice: UserLunchChoice | undefined;
|
|
for (const key of Object.keys(data.choices)) {
|
|
const locationKey = key as LunchChoice;
|
|
const locationChoices = data.choices[locationKey];
|
|
if (locationChoices && auth.login in locationChoices) {
|
|
foundKey = locationKey;
|
|
foundChoice = locationChoices[auth.login];
|
|
break;
|
|
}
|
|
}
|
|
if (foundKey && choiceRef.current) {
|
|
choiceRef.current.value = foundKey;
|
|
const restaurantKey = Object.keys(Restaurant).indexOf(foundKey);
|
|
if (restaurantKey > -1 && food) {
|
|
const restaurant = Object.keys(Restaurant)[restaurantKey] as Restaurant;
|
|
setFoodChoiceList(food[restaurant]?.food);
|
|
setClosed(food[restaurant]?.closed ?? false);
|
|
}
|
|
}
|
|
if (foundChoice?.departureTime && departureChoiceRef.current) {
|
|
departureChoiceRef.current.value = foundChoice.departureTime;
|
|
}
|
|
}, [auth, auth?.login, data?.choices])
|
|
|
|
// Reference na mojí objednávku
|
|
useEffect(() => {
|
|
if (data?.pizzaDay?.orders) {
|
|
const myOrder = data.pizzaDay.orders.find(o => o.customer === auth?.login);
|
|
setMyOrder(myOrder);
|
|
}
|
|
}, [auth?.login, data?.pizzaDay?.orders])
|
|
|
|
useEffect(() => {
|
|
if (choiceRef?.current?.value && choiceRef.current.value !== "") {
|
|
const locationKey = choiceRef.current.value as LunchChoice;
|
|
const restaurantKey = Object.keys(Restaurant).indexOf(locationKey);
|
|
if (restaurantKey > -1 && food) {
|
|
const restaurant = Object.keys(Restaurant)[restaurantKey] as Restaurant;
|
|
setFoodChoiceList(food[restaurant]?.food);
|
|
setClosed(food[restaurant]?.closed ?? false);
|
|
} else {
|
|
setFoodChoiceList(undefined);
|
|
setClosed(false);
|
|
}
|
|
} else {
|
|
setFoodChoiceList(undefined);
|
|
setClosed(false);
|
|
}
|
|
}, [choiceRef.current?.value, food])
|
|
|
|
// Navigace mezi dny pomocí klávesových šípek
|
|
const handleKeyDown = useCallback((e: any) => {
|
|
if (e.keyCode === 37 && dayIndex != null && dayIndex > 0) {
|
|
handleDayChange(dayIndex - 1);
|
|
} else if (e.keyCode === 39 && dayIndex != null && dayIndex < 4) {
|
|
handleDayChange(dayIndex + 1);
|
|
}
|
|
}, [dayIndex]);
|
|
|
|
useEffect(() => {
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
return () => {
|
|
document.removeEventListener('keydown', handleKeyDown);
|
|
}
|
|
}, [handleKeyDown]);
|
|
|
|
// Stažení a nastavení easter egg obrázku
|
|
useEffect(() => {
|
|
if (auth?.login && easterEgg?.url && !eggImage) {
|
|
getEasterEggImage({ path: { url: easterEgg.url } }).then(response => {
|
|
if (response.data) {
|
|
setEggImage(response.data);
|
|
// Smazání obrázku z DOMu po animaci
|
|
setTimeout(() => {
|
|
if (eggRef?.current) {
|
|
eggRef.current.remove();
|
|
}
|
|
}, (easterEgg.duration || EASTER_EGG_DEFAULT_DURATION) * 1000);
|
|
}
|
|
});
|
|
}
|
|
}, [auth?.login, easterEgg?.duration, easterEgg?.url, eggImage]);
|
|
|
|
const doAddClickFoodChoice = async (location: LunchChoice, foodIndex?: number) => {
|
|
if (document.getSelection()?.type !== 'Range') { // pouze pokud se nejedná o výběr textu
|
|
if (canChangeChoice && auth?.login) {
|
|
await addChoice({ body: { locationKey: location, foodIndex, dayIndex } });
|
|
}
|
|
}
|
|
}
|
|
|
|
const doAddChoice = async (event: React.ChangeEvent<HTMLSelectElement>) => {
|
|
const locationKey = event.target.value as LunchChoice;
|
|
if (canChangeChoice && auth?.login) {
|
|
await addChoice({ body: { locationKey, dayIndex } });
|
|
if (foodChoiceRef.current?.value) {
|
|
foodChoiceRef.current.value = "";
|
|
}
|
|
choiceRef.current?.blur();
|
|
}
|
|
}
|
|
|
|
const doJdemeObed = async () => {
|
|
if (auth?.login) {
|
|
await jdemeObed();
|
|
}
|
|
}
|
|
|
|
const doAddFoodChoice = async (event: React.ChangeEvent<HTMLSelectElement>) => {
|
|
if (event.target.value && foodChoiceList?.length && choiceRef.current?.value) {
|
|
const locationKey = choiceRef.current.value as LunchChoice;
|
|
if (auth?.login) {
|
|
await addChoice({ body: { locationKey, foodIndex: Number(event.target.value), dayIndex } });
|
|
}
|
|
}
|
|
}
|
|
|
|
const doRemoveChoices = async (locationKey: LunchChoice) => {
|
|
if (auth?.login) {
|
|
await removeChoices({ body: { locationKey, dayIndex } });
|
|
// Vyresetujeme výběr, aby bylo jasné pro který případně vybíráme jídlo
|
|
if (choiceRef?.current?.value) {
|
|
choiceRef.current.value = "";
|
|
}
|
|
if (foodChoiceRef?.current?.value) {
|
|
foodChoiceRef.current.value = "";
|
|
}
|
|
}
|
|
}
|
|
|
|
const doRemoveFoodChoice = async (locationKey: LunchChoice, foodIndex: number) => {
|
|
if (auth?.login) {
|
|
await removeChoice({ body: { locationKey, foodIndex, dayIndex } });
|
|
if (choiceRef?.current?.value) {
|
|
choiceRef.current.value = "";
|
|
}
|
|
if (foodChoiceRef?.current?.value) {
|
|
foodChoiceRef.current.value = "";
|
|
}
|
|
}
|
|
}
|
|
|
|
const saveNote = async (note?: string) => {
|
|
if (auth?.login) {
|
|
await updateNote({ body: { note, dayIndex } });
|
|
setNoteModalOpen(false);
|
|
}
|
|
}
|
|
|
|
const copyNote = async (note: string) => {
|
|
if (auth?.login && note) {
|
|
await updateNote({ body: { note, dayIndex } });
|
|
}
|
|
}
|
|
|
|
const markAsBuyer = async () => {
|
|
if (auth?.login) {
|
|
await setBuyer();
|
|
}
|
|
}
|
|
|
|
const pizzaSuggestions = useMemo(() => {
|
|
if (!data?.pizzaList) {
|
|
return [];
|
|
}
|
|
const suggestions: SelectSearchOption[] = [];
|
|
data.pizzaList.forEach((pizza, index) => {
|
|
const group: SelectSearchOption = { name: pizza.name, type: "group", items: [] }
|
|
pizza.sizes.forEach((size, sizeIndex) => {
|
|
const name = `${size.size} (${size.price} Kč)`;
|
|
const value = `${index}|${sizeIndex}`;
|
|
group.items?.push({ name, value });
|
|
})
|
|
suggestions.push(group);
|
|
})
|
|
return suggestions;
|
|
}, [data?.pizzaList]);
|
|
|
|
const handlePizzaChange = async (value: SelectedOptionValue | SelectedOptionValue[]) => {
|
|
if (auth?.login && data?.pizzaList) {
|
|
if (typeof value !== 'string') {
|
|
throw new TypeError('Nepodporovaný typ hodnoty: ' + typeof value);
|
|
}
|
|
const s = value.split('|');
|
|
const pizzaIndex = Number.parseInt(s[0]);
|
|
const pizzaSizeIndex = Number.parseInt(s[1]);
|
|
await addPizza({ body: { pizzaIndex, pizzaSizeIndex } });
|
|
}
|
|
}
|
|
|
|
const handlePizzaDelete = async (pizzaOrder: PizzaVariant) => {
|
|
await removePizza({ body: { pizzaOrder } });
|
|
}
|
|
|
|
const handlePizzaPoznamkaChange = async () => {
|
|
if (pizzaPoznamkaRef.current?.value && pizzaPoznamkaRef.current.value.length > 70) {
|
|
alert("Poznámka může mít maximálně 70 znaků");
|
|
return;
|
|
}
|
|
updatePizzaDayNote({ body: { note: pizzaPoznamkaRef.current?.value } });
|
|
}
|
|
|
|
const handleChangeDepartureTime = async (event: React.ChangeEvent<HTMLSelectElement>) => {
|
|
if (foodChoiceList?.length && choiceRef.current?.value) {
|
|
await changeDepartureTime({ body: { time: event.target.value as DepartureTime, dayIndex } });
|
|
}
|
|
}
|
|
|
|
const handleDayChange = async (dayIndex: number) => {
|
|
setDayIndex(dayIndex);
|
|
dayIndexRef.current = dayIndex;
|
|
if (choiceRef?.current?.value) {
|
|
choiceRef.current.value = "";
|
|
}
|
|
if (foodChoiceRef?.current?.value) {
|
|
foodChoiceRef.current.value = "";
|
|
}
|
|
if (departureChoiceRef?.current?.value) {
|
|
departureChoiceRef.current.value = "";
|
|
}
|
|
}
|
|
|
|
const renderFoodTable = (location: Restaurant, menu: RestaurantDayMenu) => {
|
|
let content;
|
|
if (menu?.closed) {
|
|
content = <div className="restaurant-closed">Zavřeno</div>
|
|
} else if (menu?.food?.length && menu.food.length > 0) {
|
|
const hideSoups = settings?.hideSoups;
|
|
content = <Table className="food-table">
|
|
<tbody style={{ cursor: canChangeChoice ? 'pointer' : 'default' }}>
|
|
{menu.food.map((f: Food, index: number) =>
|
|
(!hideSoups || !f.isSoup) &&
|
|
<tr key={f.name} onClick={() => doAddClickFoodChoice(location, index)}>
|
|
<td>
|
|
<div className="food-name">
|
|
{f.name}
|
|
{f.allergens && f.allergens.length > 0 && (
|
|
<span className="food-allergens">
|
|
{' '}({f.allergens.map((a, idx) => (
|
|
<span key={a}>
|
|
<span className="allergen-link" title={ALLERGENS[a]} onClick={e => {
|
|
e.stopPropagation();
|
|
window.open(LINK_ALLERGENS, '_blank');
|
|
}}>{a}</span>
|
|
{idx < f.allergens!.length - 1 && ', '}
|
|
</span>
|
|
))})
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="food-meta">
|
|
{f.amount && f.amount !== '-' && <span className="food-amount">{f.amount}</span>}
|
|
<span className="food-price">{f.price}</span>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</Table>
|
|
} else {
|
|
content = <div className="restaurant-error">Chyba načtení dat</div>
|
|
}
|
|
return <Col md={6} lg={3} className='mt-3'>
|
|
<div className="restaurant-card">
|
|
<div className="restaurant-header" style={{ cursor: canChangeChoice ? 'pointer' : 'default' }} onClick={() => doAddClickFoodChoice(location)}>
|
|
<h3>
|
|
{getLunchChoiceName(location)}
|
|
</h3>
|
|
{menu?.lastUpdate && <small>Aktualizace: {getHumanDateTime(new Date(menu.lastUpdate))}</small>}
|
|
{menu?.warnings && menu.warnings.length > 0 && (
|
|
<span className="restaurant-warning" title={menu.warnings.join('\n')}>
|
|
<FontAwesomeIcon icon={faTriangleExclamation} />
|
|
</span>
|
|
)}
|
|
</div>
|
|
{content}
|
|
</div>
|
|
</Col>
|
|
}
|
|
|
|
if (!auth?.login) {
|
|
return <Login />;
|
|
}
|
|
|
|
if (!isConnected) {
|
|
return <Loader
|
|
icon={faSatelliteDish}
|
|
description={'Zdá se, že máme problémy se spojením se serverem. Pokud problém přetrvává, kontaktujte správce systému.'}
|
|
animation={'fa-beat-fade'}
|
|
/>
|
|
}
|
|
|
|
if (failure) {
|
|
return <Loader
|
|
title="Něco se nám nepovedlo :("
|
|
icon={faChainBroken}
|
|
description={'Ale to nevadí. To se stává, takový je život. Kontaktujte správce systému, který zajistí nápravu.'}
|
|
/>
|
|
}
|
|
|
|
if (!data || !food) {
|
|
return <Loader
|
|
icon={faSearch}
|
|
description={'Hledáme, co dobrého je aktuálně v nabídce'}
|
|
animation={'fa-bounce'}
|
|
/>
|
|
}
|
|
|
|
const noOrders = data?.pizzaDay?.orders?.length === 0;
|
|
const canChangeChoice = dayIndex == null || data.todayDayIndex == null || dayIndex >= data.todayDayIndex;
|
|
|
|
const { path, url, startOffset, endOffset, duration, ...style } = easterEgg || {};
|
|
|
|
return (
|
|
<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` }} />}
|
|
<Header />
|
|
<div className='wrapper'>
|
|
{data.todayDayIndex != null && data.todayDayIndex > 4 &&
|
|
<Alert variant="info" className="mb-3">
|
|
Zobrazujete uplynulý týden
|
|
</Alert>
|
|
}
|
|
<>
|
|
{dayIndex != null &&
|
|
<div className='day-navigator'>
|
|
<span title='Předchozí den'>
|
|
<FontAwesomeIcon icon={faChevronLeft} style={{ cursor: "pointer", visibility: dayIndex > 0 ? "initial" : "hidden" }} onClick={() => handleDayChange(dayIndex - 1)} />
|
|
</span>
|
|
<h1 className={`title ${dayIndex !== data.todayDayIndex ? 'text-muted' : ''}`}>{data.date}</h1>
|
|
<span title="Následující den">
|
|
<FontAwesomeIcon icon={faChevronRight} style={{ cursor: "pointer", visibility: dayIndex < 4 ? "initial" : "hidden" }} onClick={() => handleDayChange(dayIndex + 1)} />
|
|
</span>
|
|
</div>
|
|
}
|
|
<Row className='food-tables'>
|
|
{Object.keys(Restaurant).map(key => {
|
|
const locationKey = key as Restaurant;
|
|
return food[locationKey] && renderFoodTable(locationKey, food[locationKey]);
|
|
})}
|
|
</Row>
|
|
<div className='content-wrapper'>
|
|
<div className='content'>
|
|
{canChangeChoice && <div className="choice-section fade-in">
|
|
<p>{`Jak to ${dayIndex == null || dayIndex === data.todayDayIndex ? 'dnes' : 'tento den'} vidíš s obědem?`}</p>
|
|
<Form.Select ref={choiceRef} onChange={doAddChoice}>
|
|
<option value="">Vyber možnost...</option>
|
|
{Object.entries(LunchChoice)
|
|
.filter(entry => {
|
|
const locationKey = entry[0] as Restaurant;
|
|
return !food[locationKey]?.closed;
|
|
})
|
|
.map(entry => <option key={entry[0]} value={entry[0]}>{getLunchChoiceName(entry[1])}</option>)}
|
|
</Form.Select>
|
|
<small>Je možné vybrat jen jednu možnost. Výběr jiné odstraní předchozí.</small>
|
|
{foodChoiceList && !closed && <>
|
|
<p className="mt-3">Na co dobrého? <small style={{ color: 'var(--luncher-text-muted)' }}>(nepovinné)</small></p>
|
|
<Form.Select ref={foodChoiceRef} onChange={doAddFoodChoice}>
|
|
<option value="">Vyber jídlo...</option>
|
|
{foodChoiceList.map((food, index) => <option key={food.name} value={index}>{food.name}</option>)}
|
|
</Form.Select>
|
|
</>}
|
|
{foodChoiceList && !closed && <>
|
|
<p className="mt-3">V kolik hodin preferuješ odchod?</p>
|
|
<Form.Select ref={departureChoiceRef} onChange={handleChangeDepartureTime}>
|
|
<option value="">Vyber čas...</option>
|
|
{Object.values(DepartureTime)
|
|
.filter(time => isInTheFuture(time))
|
|
.map(time => <option key={time} value={time}>{time}</option>)}
|
|
</Form.Select>
|
|
</>}
|
|
</div>}
|
|
{Object.keys(data.choices).length > 0 ?
|
|
<Table className='choices-table mt-4 fade-in'>
|
|
<tbody>
|
|
{Object.keys(data.choices).map(key => {
|
|
const locationKey = key as LunchChoice;
|
|
const locationName = getLunchChoiceName(locationKey);
|
|
const loginObject = data.choices[locationKey];
|
|
if (!loginObject) {
|
|
return null;
|
|
}
|
|
const locationLoginList = Object.entries(loginObject);
|
|
const locationPickCount = locationLoginList.length
|
|
return (
|
|
<tr key={key}>
|
|
<td>
|
|
{locationName}
|
|
{(locationPickCount ?? 0) > 1 && <span className="ms-1">({locationPickCount})</span>}
|
|
</td>
|
|
<td className='p-0'>
|
|
<Table className="nested-table">
|
|
<tbody>
|
|
{locationLoginList.map((entry: [string, UserLunchChoice]) => {
|
|
const login = entry[0];
|
|
const userPayload = entry[1];
|
|
const userChoices = userPayload?.selectedFoods;
|
|
const trusted = userPayload?.trusted || false;
|
|
const isBuyer = userPayload?.isBuyer || false;
|
|
return <tr key={entry[0]}>
|
|
<td>
|
|
{trusted && <span className='trusted-icon' title='Uživatel ověřený doménovým přihlášením'>
|
|
<FontAwesomeIcon icon={faCircleCheck} style={{ cursor: "help" }} />
|
|
</span>}
|
|
<strong>{login}</strong>
|
|
{userPayload.departureTime && <small className="ms-2" style={{ color: 'var(--luncher-text-muted)' }}>({userPayload.departureTime})</small>}
|
|
{userPayload.note && <span className="ms-2" style={{ fontSize: 'small', color: 'var(--luncher-text-secondary)' }}>({userPayload.note})</span>}
|
|
{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'>
|
|
<FontAwesomeIcon onClick={() => {
|
|
copyNote(userPayload.note!);
|
|
}} className='action-icon' icon={faComment} />
|
|
</span>}
|
|
{login === auth.login && canChangeChoice && <span title='Upravit poznámku'>
|
|
<FontAwesomeIcon onClick={() => {
|
|
setNoteModalOpen(true);
|
|
}} className='action-icon' icon={faNoteSticky} />
|
|
</span>}
|
|
{login === auth.login && canChangeChoice && <span title={`Odstranit volbu ${locationName}, včetně případných zvolených jídel`}>
|
|
<FontAwesomeIcon onClick={() => {
|
|
doRemoveChoices(key as LunchChoice);
|
|
}} className='action-icon' icon={faTrashCan} />
|
|
</span>}
|
|
</td>
|
|
{userChoices?.length && food ? <td>
|
|
<div className="food-choices">
|
|
{userChoices?.map(foodIndex => {
|
|
const restaurantKey = key as Restaurant;
|
|
const foodName = food[restaurantKey]?.food?.[foodIndex].name;
|
|
return <div key={foodIndex} className="food-choice-item">
|
|
<span className="food-choice-name">{foodName}</span>
|
|
{login === auth.login && canChangeChoice &&
|
|
<span title={`Odstranit ${foodName}`}>
|
|
<FontAwesomeIcon onClick={() => {
|
|
doRemoveFoodChoice(restaurantKey, foodIndex);
|
|
}} className='action-icon' icon={faTrashCan} />
|
|
</span>}
|
|
</div>
|
|
})}
|
|
</div>
|
|
</td> : null}
|
|
</tr>
|
|
}
|
|
)}
|
|
</tbody>
|
|
</Table>
|
|
</td>
|
|
</tr>)
|
|
}
|
|
)}
|
|
</tbody>
|
|
</Table>
|
|
: <div className='no-votes mt-4'>Zatím nikdo nehlasoval...</div>
|
|
}
|
|
</div>
|
|
{dayIndex === data.todayDayIndex &&
|
|
<div className='pizza-section fade-in'>
|
|
{!data.pizzaDay &&
|
|
<>
|
|
<h3>Pizza Day</h3>
|
|
<p>Pro dnešní den není aktuálně založen Pizza day.</p>
|
|
{loadingPizzaDay ?
|
|
<span style={{ color: 'var(--luncher-primary)' }}>
|
|
<FontAwesomeIcon icon={faGear} className='fa-spin me-2' /> Zjišťujeme dostupné pizzy
|
|
</span>
|
|
:
|
|
<div>
|
|
<Button onClick={async () => {
|
|
setLoadingPizzaDay(true);
|
|
await createPizzaDay().then(() => setLoadingPizzaDay(false));
|
|
}}>Založit Pizza day</Button>
|
|
<Button variant="outline-primary" onClick={doJdemeObed}>Jdeme na oběd!</Button>
|
|
</div>
|
|
}
|
|
</>
|
|
}
|
|
{data.pizzaDay &&
|
|
<>
|
|
<h3>Pizza Day</h3>
|
|
{
|
|
data.pizzaDay.state === PizzaDayState.CREATED &&
|
|
<>
|
|
<p>
|
|
Pizza Day je založen a spravován uživatelem <strong>{data.pizzaDay.creator}</strong>.<br />
|
|
Můžete upravovat své objednávky.
|
|
</p>
|
|
{
|
|
data.pizzaDay.creator === auth.login &&
|
|
<div className="mb-4">
|
|
<Button variant="danger" title="Smaže kompletně pizza day, včetně dosud zadaných objednávek." onClick={async () => {
|
|
await deletePizzaDay();
|
|
}}>Smazat Pizza day</Button>
|
|
<Button title={noOrders ? "Nelze uzamknout - neexistuje žádná objednávka" : "Zamezí přidávat/odebírat objednávky. Použij před samotným objednáním, aby již nemohlo docházet ke změnám."} disabled={noOrders} onClick={async () => {
|
|
await lockPizzaDay();
|
|
}}>Uzamknout</Button>
|
|
</div>
|
|
}
|
|
</>
|
|
}
|
|
{
|
|
data.pizzaDay.state === PizzaDayState.LOCKED &&
|
|
<>
|
|
<p>Objednávky jsou uzamčeny uživatelem <strong>{data.pizzaDay.creator}</strong></p>
|
|
{data.pizzaDay.creator === auth.login &&
|
|
<div className="mb-4">
|
|
<Button variant="secondary" title="Umožní znovu editovat objednávky." onClick={async () => {
|
|
await unlockPizzaDay();
|
|
}}>Odemknout</Button>
|
|
<Button title={noOrders ? "Nelze objednat - neexistuje žádná objednávka" : "Použij po objednání. Objednávky zůstanou zamčeny."} disabled={noOrders} onClick={async () => {
|
|
await finishOrder();
|
|
}}>Objednáno</Button>
|
|
</div>
|
|
}
|
|
</>
|
|
}
|
|
{
|
|
data.pizzaDay.state === PizzaDayState.ORDERED &&
|
|
<>
|
|
<p>Pizzy byly objednány uživatelem <strong>{data.pizzaDay.creator}</strong></p>
|
|
{data.pizzaDay.creator === auth.login &&
|
|
<div className="mb-4">
|
|
<Button variant="secondary" title="Vrátí stav do předchozího kroku (před objednáním)." onClick={async () => {
|
|
await lockPizzaDay();
|
|
}}>Vrátit do "uzamčeno"</Button>
|
|
<Button title="Nastaví stav na 'Doručeno' - koncový stav." onClick={async () => {
|
|
await finishDelivery({ body: { bankAccount: settings?.bankAccount, bankAccountHolder: settings?.holderName } });
|
|
}}>Doručeno</Button>
|
|
</div>
|
|
}
|
|
</>
|
|
}
|
|
{
|
|
data.pizzaDay.state === PizzaDayState.DELIVERED &&
|
|
<p>
|
|
Pizzy byly doručeny.
|
|
{myOrder?.hasQr ? ` Objednávku můžete uživateli ${data.pizzaDay.creator} uhradit pomocí QR kódu níže.` : ''}
|
|
</p>
|
|
}
|
|
{data.pizzaDay.state === PizzaDayState.CREATED &&
|
|
<div className="pizza-order-form">
|
|
<SelectSearch
|
|
search={true}
|
|
options={pizzaSuggestions}
|
|
placeholder='Vyhledat pizzu...'
|
|
onChange={handlePizzaChange}
|
|
onBlur={_ => { }}
|
|
onFocus={_ => { }}
|
|
/>
|
|
<div className="d-flex align-items-center gap-2">
|
|
<label style={{ color: 'var(--luncher-text-secondary)' }}>Poznámka:</label>
|
|
<input ref={pizzaPoznamkaRef} type="text" placeholder="Např. bez cibule" onKeyDown={event => {
|
|
if (event.key === 'Enter') {
|
|
handlePizzaPoznamkaChange();
|
|
}
|
|
event.stopPropagation();
|
|
}} />
|
|
<Button
|
|
disabled={!myOrder?.pizzaList?.length}
|
|
onClick={handlePizzaPoznamkaChange}>
|
|
Uložit
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
}
|
|
<PizzaOrderList state={data.pizzaDay.state!} orders={data.pizzaDay.orders!} onDelete={handlePizzaDelete} creator={data.pizzaDay.creator!} />
|
|
{
|
|
data.pizzaDay.state === PizzaDayState.DELIVERED && myOrder?.hasQr &&
|
|
<div className='qr-code'>
|
|
<h3>QR platba</h3>
|
|
<img src={`/api/qr?login=${auth.login}`} alt='QR kód' />
|
|
</div>
|
|
}
|
|
</>
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
{data.pendingQrs && data.pendingQrs.length > 0 &&
|
|
<div className='pizza-section fade-in mt-4'>
|
|
<h3>Nevyřízené platby</h3>
|
|
<p>Máte neuhrazené QR kódy z předchozích Pizza day.</p>
|
|
{data.pendingQrs.map(qr => (
|
|
<div key={qr.date} className='qr-code mb-3'>
|
|
<p>
|
|
<strong>{qr.date}</strong> — {qr.creator} ({qr.totalPrice} Kč)
|
|
</p>
|
|
<img src={`/api/qr?login=${auth.login}`} alt='QR kód' />
|
|
<div className='mt-2'>
|
|
<Button variant="success" onClick={async () => {
|
|
await dismissQr({ body: { date: qr.date } });
|
|
// Přenačteme data pro aktualizaci
|
|
const response = await getData({ query: { dayIndex } });
|
|
if (response.data) {
|
|
setData(response.data);
|
|
}
|
|
}}>
|
|
Zaplatil jsem
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
}
|
|
</>
|
|
</div>
|
|
{/* <FallingLeaves
|
|
numLeaves={LEAF_PRESETS.NORMAL}
|
|
leafVariants={LEAF_COLOR_THEMES.AUTUMN}
|
|
/> */}
|
|
<Footer />
|
|
<NoteModal isOpen={noteModalOpen} onClose={() => setNoteModalOpen(false)} onSave={saveNote} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default App;
|