8 Commits

Author SHA1 Message Date
batmanisko 1e1e23df80 feat: úhrada za všechny jednou osobou (issue #29, SINGLE_PAYMENT)
ci/woodpecker/push/workflow Pipeline was canceled
Přidává možnost, aby jeden strávník zaplatil celý účet v restauraci a ostatní
obdrželi QR kód pro refundaci.

Prerekvizita — podpora více QR kódů na (příjemce, den):
- PendingQr.id (UUID) nahrazuje deduplikaci podle data; každý QR má vlastní klíč
- QR obrázky uloženy do Redis/storage (base64) místo tmpdir — přežijí redeploy
- GET /api/qr vyžaduje ?id= parametr; dismissQr přijímá {id} místo {date}

Feature:
- Ikona 'Zaplatit za všechny' v choices-table pro každou LunchChoice (kromě
  PIZZA/NEOBEDVAM/ROZHODUJI); viditelná jen při ≥2 strávnících a vyplněném účtu
- PayForAllModal: tabulka strávníků s prefillovanými cenami z menu, příplatky
  per-diner, celkové dýško rozpočtené rovnoměrně, generování QR přes POST /api/qr/generate
- parsePriceCzk() helper pro parsing 'N Kč' → number

Co se nemění: POST /api/qr/generate API kontrakt, PizzaOrder.hasQr boolean

Co se mění v OpenAPI: PendingQr.id (required), getPizzaQr ?id param, dismissQr body

Co-Authored-By: opmrdkazkrtkaus <opmrdkazkrtkaus@melancholik.eu>
2026-04-28 22:44:32 +02:00
mates e5999852b7 docs: aktualizace CLAUDE.md
ci/woodpecker/push/workflow Pipeline was canceled
2026-04-28 13:40:32 +02:00
mates 4e7b83b667 fix: oprava parsování pro aktuální podobu TechTower
ci/woodpecker/push/workflow Pipeline failed
2026-04-28 12:50:19 +02:00
mates d6729388ab feat: podpora salátů z Pizza Chefie
ci/woodpecker/push/workflow Pipeline failed
2026-04-02 10:51:46 +02:00
mates e9696f722c feat: automatický výběr výchozího času
ci/woodpecker/push/workflow Pipeline failed
2026-03-09 11:50:24 +01:00
mates fdeb2636c2 fix: potvrzovací dialog pro Pizza day akce (#44)
ci/woodpecker/push/workflow Pipeline failed
2026-03-09 07:55:42 +01:00
mates 82ed16715f fix: odstranění textu "nepovinné"
ci/woodpecker/push/workflow Pipeline failed
2026-03-09 07:40:38 +01:00
mates 44cf749bc9 feat: nový způsob zobrazování novinek
ci/woodpecker/push/workflow Pipeline is pending
fix: oprava kopírování changelogů do Docker image

fix: oprava kopírování changelogů do Docker image

fix: oprava
2026-03-08 10:55:50 +01:00
44 changed files with 960 additions and 175 deletions
+6 -15
View File
@@ -45,17 +45,18 @@ cd client && yarn build # tsc --noEmit + vite build → client/dist
### Tests ### Tests
```bash ```bash
cd server && yarn test # Jest (tests in server/src/tests/) cd server && yarn test # Jest (tests in server/src/tests/)
cd server && yarn test dates # Run one test file
cd server && yarn test -t "name" # Run by test name pattern
``` ```
### Formatting ### Formatting
```bash Prettier is installed in `client/` (devDependency only, no script or config) — invoke via `yarn prettier <path>` with defaults.
# Prettier available in client (no config file — uses defaults)
```
## Architecture ## Architecture
### API Types (types/) ### API Types (types/)
- OpenAPI 3.0 spec in `types/api.yml` — all API endpoints and DTOs defined here - OpenAPI 3.0 spec in `types/api.yml` — all API endpoints and DTOs defined here
- `api.yml` is a thin aggregator — actual endpoint specs live in `types/paths/<domain>/*.yml`, shared schemas in `types/schemas/_index.yml`
- `yarn openapi-ts` generates `types/gen/` (client.gen.ts, sdk.gen.ts, types.gen.ts) - `yarn openapi-ts` generates `types/gen/` (client.gen.ts, sdk.gen.ts, types.gen.ts)
- Both server and client import from these generated types - Both server and client import from these generated types
- **When changing API contracts: update api.yml first, then regenerate** - **When changing API contracts: update api.yml first, then regenerate**
@@ -67,6 +68,7 @@ cd server && yarn test # Jest (tests in server/src/tests/)
- **Auth:** `auth.ts` — JWT + optional trusted-header authentication - **Auth:** `auth.ts` — JWT + optional trusted-header authentication
- **Storage:** `storage/StorageInterface.ts` defines the interface; implementations in `storage/json.ts` (file-based, dev) and `storage/redis.ts` (production). Data keyed by date (YYYY-MM-DD). - **Storage:** `storage/StorageInterface.ts` defines the interface; implementations in `storage/json.ts` (file-based, dev) and `storage/redis.ts` (production). Data keyed by date (YYYY-MM-DD).
- **Restaurant scrapers:** Cheerio-based HTML parsing for daily menus from multiple restaurants - **Restaurant scrapers:** Cheerio-based HTML parsing for daily menus from multiple restaurants
- **Pizza Chefie integration:** `chefie.ts` scrapes pizzachefie.cz for Pizza day menus and salads (only active when a Pizza day is open)
- **WebSocket:** `websocket.ts` — Socket.io for real-time client updates - **WebSocket:** `websocket.ts` — Socket.io for real-time client updates
- **Mock mode:** `MOCK_DATA=true` env var returns fake menus (useful for weekend/holiday dev) - **Mock mode:** `MOCK_DATA=true` env var returns fake menus (useful for weekend/holiday dev)
- **Config:** `.env.development` / `.env.production` (see `.env.template` for all options) - **Config:** `.env.development` / `.env.production` (see `.env.template` for all options)
@@ -97,15 +99,4 @@ cd server && yarn test # Jest (tests in server/src/tests/)
- Czech naming for domain variables and UI strings; English for infrastructure code - Czech naming for domain variables and UI strings; English for infrastructure code
- TypeScript strict mode in both client and server - TypeScript strict mode in both client and server
- Server module resolution: Node16; Client: ESNext/bundler - Server module resolution: Node16; Client: ESNext/bundler
- `TODO.md` tracks open bugs and roadmap items — worth scanning before starting non-trivial work
## Code Search Strategy
When searching through the project for information, use the Task tool to spawn
subagents. Each subagent should read the relevant files and return a brief
summary of what it found (not the full file contents). This keeps the main
context window small and saves tokens. Only pull in full file contents once
you've identified the specific files that matter.
When using subagents to search, each subagent should return:
- File path
- Whether it's relevant (yes/no)
- 1-3 sentence summary of what's in the file
Do NOT return full file contents in subagent responses.
+5 -2
View File
@@ -82,8 +82,11 @@ COPY --from=builder /build/client/dist ./public
# Zkopírování produkčních .env serveru # Zkopírování produkčních .env serveru
COPY /server/.env.production ./server COPY /server/.env.production ./server
# Zkopírování konfigurace easter eggů # Zkopírování changelogů (seznamu novinek)
RUN if [ -f /server/.easter-eggs.json ]; then cp /server/.easter-eggs.json ./server/; fi COPY /server/changelogs ./server/changelogs
# Zkopírování konfigurace easter eggů a changelogů
RUN if [ -f ./server/.easter-eggs.json ]; then cp ./server/.easter-eggs.json ./server/; fi
# Export /data/db.json do složky /data # Export /data/db.json do složky /data
VOLUME ["/data"] VOLUME ["/data"]
+6 -2
View File
@@ -18,8 +18,12 @@ COPY ./server/dist ./
# Vykopírování sestaveného klienta # Vykopírování sestaveného klienta
COPY ./client/dist ./public COPY ./client/dist ./public
# Zkopírování konfigurace easter eggů # Zkopírování changelogů (seznamu novinek)
RUN if [ -f ./server/.easter-eggs.json ]; then cp ./server/.easter-eggs.json ./server/; fi COPY ./server/changelogs ./server/changelogs
# Zkopírování konfigurace easter eggů a changelogů
RUN if [ -f ./server/.easter-eggs.json ]; then cp ./server/.easter-eggs.json ./server/; fi \
&& if [ -d ./server/changelogs ]; then cp -r ./server/changelogs ./server/changelogs; fi
EXPOSE 3000 EXPOSE 3000
+125 -46
View File
@@ -13,12 +13,13 @@ import './App.scss';
import { faCircleCheck, faNoteSticky, faTrashCan, faComment } from '@fortawesome/free-regular-svg-icons'; import { faCircleCheck, faNoteSticky, faTrashCan, faComment } from '@fortawesome/free-regular-svg-icons';
import { useSettings } from './context/settings'; import { useSettings } from './context/settings';
import Footer from './components/Footer'; import Footer from './components/Footer';
import { faBasketShopping, faChainBroken, faChevronLeft, faChevronRight, faGear, faSatelliteDish, faSearch, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'; import { faBasketShopping, faChainBroken, faChevronLeft, faChevronRight, faGear, faMoneyBillTransfer, faSatelliteDish, faSearch, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons';
import Loader from './components/Loader'; import Loader from './components/Loader';
import { getHumanDateTime, isInTheFuture, formatDateString } from './Utils'; import { getHumanDateTime, isInTheFuture, formatDateString } from './Utils';
import NoteModal from './components/modals/NoteModal'; import NoteModal from './components/modals/NoteModal';
import PayForAllModal from './components/modals/PayForAllModal';
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, setBuyer, dismissQr } from '../../types'; import { ClientData, Food, PizzaOrder, DepartureTime, PizzaDayState, Restaurant, RestaurantDayMenu, RestaurantDayMenuMap, LunchChoice, LocationLunchChoicesMap, UserLunchChoice, PizzaVariant, getData, getEasterEggImage, addPizza, removePizza, updatePizzaDayNote, createPizzaDay, deletePizzaDay, lockPizzaDay, unlockPizzaDay, finishOrder, finishDelivery, addChoice, jdemeObed, removeChoices, removeChoice, updateNote, changeDepartureTime, setBuyer, dismissQr, generateQr } from '../../types';
import { 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';
@@ -74,6 +75,7 @@ function App() {
const [dayIndex, setDayIndex] = useState<number>(); const [dayIndex, setDayIndex] = useState<number>();
const [loadingPizzaDay, setLoadingPizzaDay] = useState<boolean>(false); const [loadingPizzaDay, setLoadingPizzaDay] = useState<boolean>(false);
const [noteModalOpen, setNoteModalOpen] = useState<boolean>(false); const [noteModalOpen, setNoteModalOpen] = useState<boolean>(false);
const [payForAllLocationKey, setPayForAllLocationKey] = useState<LunchChoice | null>(null);
const [eggImage, setEggImage] = useState<Blob>(); const [eggImage, setEggImage] = useState<Blob>();
const eggRef = useRef<HTMLImageElement>(null); const eggRef = useRef<HTMLImageElement>(null);
// Prazvláštní workaround, aby socket.io listener viděl aktuální hodnotu // Prazvláštní workaround, aby socket.io listener viděl aktuální hodnotu
@@ -287,6 +289,7 @@ function App() {
try { try {
await addChoice({ body: { locationKey: location, foodIndex, dayIndex } }); await addChoice({ body: { locationKey: location, foodIndex, dayIndex } });
await tryAutoSelectDepartureTime();
} catch (error: any) { } catch (error: any) {
alert(`Chyba při změně volby: ${error.message || error}`); alert(`Chyba při změně volby: ${error.message || error}`);
} }
@@ -313,6 +316,10 @@ function App() {
foodChoiceRef.current.value = ""; foodChoiceRef.current.value = "";
} }
choiceRef.current?.blur(); choiceRef.current?.blur();
// Automatický výběr času odchodu pouze pro restaurace s menu
if (Object.keys(Restaurant).includes(locationKey)) {
await tryAutoSelectDepartureTime();
}
} catch (error: any) { } catch (error: any) {
alert(`Chyba při změně volby: ${error.message || error}`); alert(`Chyba při změně volby: ${error.message || error}`);
// Reset výběru zpět // Reset výběru zpět
@@ -337,6 +344,7 @@ function App() {
const locationKey = choiceRef.current.value as LunchChoice; const locationKey = choiceRef.current.value as LunchChoice;
if (auth?.login) { if (auth?.login) {
await addChoice({ body: { locationKey, foodIndex: Number(event.target.value), dayIndex } }); await addChoice({ body: { locationKey, foodIndex: Number(event.target.value), dayIndex } });
await tryAutoSelectDepartureTime();
} }
} }
} }
@@ -385,32 +393,80 @@ function App() {
} }
} }
const handleCreatePizzaDay = async () => {
if (!window.confirm('Opravdu chcete založit Pizza day?')) return;
setLoadingPizzaDay(true);
await createPizzaDay().then(() => setLoadingPizzaDay(false));
}
const handleDeletePizzaDay = async () => {
if (!window.confirm('Opravdu chcete smazat Pizza day? Budou smazány i všechny dosud zadané objednávky.')) return;
await deletePizzaDay();
}
const handleLockPizzaDay = async () => {
if (!window.confirm('Opravdu chcete uzamknout objednávky? Po uzamčení nebude možné přidávat ani odebírat objednávky.')) return;
await lockPizzaDay();
}
const handleUnlockPizzaDay = async () => {
if (!window.confirm('Opravdu chcete odemknout objednávky? Uživatelé budou moci opět upravovat své objednávky.')) return;
await unlockPizzaDay();
}
const handleFinishOrder = async () => {
if (!window.confirm('Opravdu chcete označit objednávky jako objednané? Objednávky zůstanou zamčeny.')) return;
await finishOrder();
}
const handleReturnToLocked = async () => {
if (!window.confirm('Opravdu chcete vrátit stav zpět do "uzamčeno" (před objednáním)?')) return;
await lockPizzaDay();
}
const handleFinishDelivery = async () => {
if (!window.confirm(`Opravdu chcete označit pizzy jako doručené?${settings?.bankAccount && settings?.holderName ? ' Uživatelům bude vygenerován QR kód pro platbu.' : ''}`)) return;
await finishDelivery({ body: { bankAccount: settings?.bankAccount, bankAccountHolder: settings?.holderName } });
}
const pizzaSuggestions = useMemo(() => { const pizzaSuggestions = useMemo(() => {
if (!data?.pizzaList) { if (!data?.pizzaList && !data?.salatList) {
return []; return [];
} }
const suggestions: SelectSearchOption[] = []; const suggestions: SelectSearchOption[] = [];
data.pizzaList.forEach((pizza, index) => { data.pizzaList?.forEach((pizza, index) => {
const group: SelectSearchOption = { name: pizza.name, type: "group", items: [] } const group: SelectSearchOption = { name: pizza.name, type: "group", items: [] }
pizza.sizes.forEach((size, sizeIndex) => { pizza.sizes.forEach((size, sizeIndex) => {
const name = `${size.size} (${size.price} Kč)`; const name = `${size.size} (${size.price} Kč)`;
const value = `${index}|${sizeIndex}`; const value = `pizza|${index}|${sizeIndex}`;
group.items?.push({ name, value }); group.items?.push({ name, value });
}) })
suggestions.push(group); suggestions.push(group);
}) });
if (data.salatList?.length) {
const salatGroup: SelectSearchOption = { name: "Saláty", type: "group", items: [] }
data.salatList.forEach((salat, index) => {
salatGroup.items?.push({ name: `${salat.name} (${salat.price} Kč)`, value: `salat|${index}` });
});
suggestions.push(salatGroup);
}
return suggestions; return suggestions;
}, [data?.pizzaList]); }, [data?.pizzaList, data?.salatList]);
const handlePizzaChange = async (value: SelectedOptionValue | SelectedOptionValue[]) => { const handlePizzaChange = async (value: SelectedOptionValue | SelectedOptionValue[]) => {
if (auth?.login && data?.pizzaList) { if (auth?.login) {
if (typeof value !== 'string') { if (typeof value !== 'string') {
throw new TypeError('Nepodporovaný typ hodnoty: ' + typeof value); throw new TypeError('Nepodporovaný typ hodnoty: ' + typeof value);
} }
const s = value.split('|'); const s = value.split('|');
const pizzaIndex = Number.parseInt(s[0]); if (s[0] === 'salat') {
const pizzaSizeIndex = Number.parseInt(s[1]); const salatIndex = Number.parseInt(s[1]);
await addPizza({ body: { pizzaIndex, pizzaSizeIndex } }); await addPizza({ body: { salatIndex } });
} else {
const pizzaIndex = Number.parseInt(s[1]);
const pizzaSizeIndex = Number.parseInt(s[2]);
await addPizza({ body: { pizzaIndex, pizzaSizeIndex } });
}
} }
} }
@@ -432,6 +488,16 @@ function App() {
} }
} }
// Automaticky nastaví preferovaný čas odchodu 10:45, pokud tento čas ještě nenastal (nebo jde o budoucí den)
const tryAutoSelectDepartureTime = async () => {
const preferredTime = "10:45" as DepartureTime;
const isToday = dayIndex === data?.todayDayIndex;
if ((!isToday || isInTheFuture(preferredTime)) && departureChoiceRef.current) {
departureChoiceRef.current.value = preferredTime;
await changeDepartureTime({ body: { time: preferredTime, dayIndex } });
}
}
const handleDayChange = async (dayIndex: number) => { const handleDayChange = async (dayIndex: number) => {
setDayIndex(dayIndex); setDayIndex(dayIndex);
dayIndexRef.current = dayIndex; dayIndexRef.current = dayIndex;
@@ -582,7 +648,7 @@ function App() {
</Form.Select> </Form.Select>
<small>Je možné vybrat jen jednu možnost. Výběr jiné odstraní předchozí.</small> <small>Je možné vybrat jen jednu možnost. Výběr jiné odstraní předchozí.</small>
{foodChoiceList && !closed && <> {foodChoiceList && !closed && <>
<p className="mt-3">Na co dobrého? <small style={{ color: 'var(--luncher-text-muted)' }}>(nepovinné)</small></p> <p className="mt-3">Na co dobrého?</p>
<Form.Select ref={foodChoiceRef} onChange={doAddFoodChoice}> <Form.Select ref={foodChoiceRef} onChange={doAddFoodChoice}>
<option value="">Vyber jídlo...</option> <option value="">Vyber jídlo...</option>
{foodChoiceList.map((food, index) => <option key={food.name} value={index}>{food.name}</option>)} {foodChoiceList.map((food, index) => <option key={food.name} value={index}>{food.name}</option>)}
@@ -593,7 +659,7 @@ function App() {
<Form.Select ref={departureChoiceRef} onChange={handleChangeDepartureTime}> <Form.Select ref={departureChoiceRef} onChange={handleChangeDepartureTime}>
<option value="">Vyber čas...</option> <option value="">Vyber čas...</option>
{Object.values(DepartureTime) {Object.values(DepartureTime)
.filter(time => isInTheFuture(time)) .filter(time => dayIndex !== data.todayDayIndex || isInTheFuture(time))
.map(time => <option key={time} value={time}>{time}</option>)} .map(time => <option key={time} value={time}>{time}</option>)}
</Form.Select> </Form.Select>
</>} </>}
@@ -615,6 +681,18 @@ function App() {
<td> <td>
{locationName} {locationName}
{(locationPickCount ?? 0) > 1 && <span className="ms-1">({locationPickCount})</span>} {(locationPickCount ?? 0) > 1 && <span className="ms-1">({locationPickCount})</span>}
{locationPickCount >= 2 && auth.login && loginObject[auth.login] !== undefined
&& locationKey !== LunchChoice.PIZZA && locationKey !== LunchChoice.NEOBEDVAM && locationKey !== LunchChoice.ROZHODUJI
&& settings?.bankAccount && settings?.holderName && (
<span title='Zaplatit za všechny a vygenerovat QR kódy ostatním' className="ms-2">
<FontAwesomeIcon
icon={faMoneyBillTransfer}
onClick={() => setPayForAllLocationKey(locationKey)}
className='action-icon'
style={{ cursor: 'pointer' }}
/>
</span>
)}
</td> </td>
<td className='p-0'> <td className='p-0'>
<Table className="nested-table"> <Table className="nested-table">
@@ -708,10 +786,7 @@ function App() {
</span> </span>
: :
<div> <div>
<Button onClick={async () => { <Button onClick={handleCreatePizzaDay}>Založit Pizza day</Button>
setLoadingPizzaDay(true);
await createPizzaDay().then(() => setLoadingPizzaDay(false));
}}>Založit Pizza day</Button>
<Button variant="outline-primary" onClick={doJdemeObed}>Jdeme na oběd!</Button> <Button variant="outline-primary" onClick={doJdemeObed}>Jdeme na oběd!</Button>
</div> </div>
} }
@@ -730,12 +805,8 @@ function App() {
{ {
data.pizzaDay.creator === auth.login && data.pizzaDay.creator === auth.login &&
<div className="mb-4"> <div className="mb-4">
<Button variant="danger" title="Smaže kompletně pizza day, včetně dosud zadaných objednávek." onClick={async () => { <Button variant="danger" title="Smaže kompletně pizza day, včetně dosud zadaných objednávek." onClick={handleDeletePizzaDay}>Smazat Pizza day</Button>
await deletePizzaDay(); <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={handleLockPizzaDay}>Uzamknout</Button>
}}>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> </div>
} }
</> </>
@@ -746,12 +817,8 @@ function App() {
<p>Objednávky jsou uzamčeny uživatelem <strong>{data.pizzaDay.creator}</strong></p> <p>Objednávky jsou uzamčeny uživatelem <strong>{data.pizzaDay.creator}</strong></p>
{data.pizzaDay.creator === auth.login && {data.pizzaDay.creator === auth.login &&
<div className="mb-4"> <div className="mb-4">
<Button variant="secondary" title="Umožní znovu editovat objednávky." onClick={async () => { <Button variant="secondary" title="Umožní znovu editovat objednávky." onClick={handleUnlockPizzaDay}>Odemknout</Button>
await unlockPizzaDay(); <Button title={noOrders ? "Nelze objednat - neexistuje žádná objednávka" : "Použij po objednání. Objednávky zůstanou zamčeny."} disabled={noOrders} onClick={handleFinishOrder}>Objednáno</Button>
}}>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> </div>
} }
</> </>
@@ -762,12 +829,8 @@ function App() {
<p>Pizzy byly objednány uživatelem <strong>{data.pizzaDay.creator}</strong></p> <p>Pizzy byly objednány uživatelem <strong>{data.pizzaDay.creator}</strong></p>
{data.pizzaDay.creator === auth.login && {data.pizzaDay.creator === auth.login &&
<div className="mb-4"> <div className="mb-4">
<Button variant="secondary" title="Vrátí stav do předchozího kroku (před objednáním)." onClick={async () => { <Button variant="secondary" title="Vrátí stav do předchozího kroku (před objednáním)." onClick={handleReturnToLocked}>Vrátit do "uzamčeno"</Button>
await lockPizzaDay(); <Button title="Nastaví stav na 'Doručeno' - koncový stav." onClick={handleFinishDelivery}>Doručeno</Button>
}}>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> </div>
} }
</> </>
@@ -784,7 +847,7 @@ function App() {
<SelectSearch <SelectSearch
search={true} search={true}
options={pizzaSuggestions} options={pizzaSuggestions}
placeholder='Vyhledat pizzu...' placeholder='Vyhledat pizzu nebo salát...'
onChange={handlePizzaChange} onChange={handlePizzaChange}
onBlur={_ => { }} onBlur={_ => { }}
onFocus={_ => { }} onFocus={_ => { }}
@@ -807,11 +870,15 @@ function App() {
} }
<PizzaOrderList state={data.pizzaDay.state!} orders={data.pizzaDay.orders!} onDelete={handlePizzaDelete} creator={data.pizzaDay.creator!} /> <PizzaOrderList state={data.pizzaDay.state!} orders={data.pizzaDay.orders!} onDelete={handlePizzaDelete} creator={data.pizzaDay.creator!} />
{ {
data.pizzaDay.state === PizzaDayState.DELIVERED && myOrder?.hasQr && data.pizzaDay.state === PizzaDayState.DELIVERED && myOrder?.hasQr && (() => {
<div className='qr-code'> const pizzaQr = data.pendingQrs?.find(qr => qr.creator === data.pizzaDay?.creator);
<h3>QR platba</h3> return pizzaQr ? (
<img src={`/api/qr?login=${auth.login}`} alt='QR kód' /> <div className='qr-code'>
</div> <h3>QR platba</h3>
<img src={`/api/qr?login=${auth.login}&id=${pizzaQr.id}`} alt='QR kód' />
</div>
) : null;
})()
} }
</> </>
} }
@@ -821,18 +888,17 @@ function App() {
{data.pendingQrs && data.pendingQrs.length > 0 && {data.pendingQrs && data.pendingQrs.length > 0 &&
<div className='pizza-section fade-in mt-4'> <div className='pizza-section fade-in mt-4'>
<h3>Nevyřízené platby</h3> <h3>Nevyřízené platby</h3>
<p>Máte neuhrazené platby z předchozích dní.</p> <p>Máte neuhrazené platby.</p>
{data.pendingQrs.map(qr => ( {data.pendingQrs.map(qr => (
<div key={qr.date} className='qr-code mb-3'> <div key={qr.id} className='qr-code mb-3'>
<p> <p>
<strong>{formatDateString(qr.date)}</strong> {qr.creator} ({qr.totalPrice} ) <strong>{formatDateString(qr.date)}</strong> {qr.creator} ({qr.totalPrice} )
{qr.purpose && <><br /><span className="text-muted">{qr.purpose}</span></>} {qr.purpose && <><br /><span className="text-muted">{qr.purpose}</span></>}
</p> </p>
<img src={`/api/qr?login=${auth.login}`} alt='QR kód' /> <img src={`/api/qr?login=${auth.login}&id=${qr.id}`} alt='QR kód' />
<div className='mt-2'> <div className='mt-2'>
<Button variant="success" onClick={async () => { <Button variant="success" onClick={async () => {
await dismissQr({ body: { date: qr.date } }); await dismissQr({ body: { id: qr.id } });
// Přenačteme data pro aktualizaci
const response = await getData({ query: { dayIndex } }); const response = await getData({ query: { dayIndex } });
if (response.data) { if (response.data) {
setData(response.data); setData(response.data);
@@ -853,6 +919,19 @@ function App() {
/> */} /> */}
<Footer /> <Footer />
<NoteModal isOpen={noteModalOpen} onClose={() => setNoteModalOpen(false)} onSave={saveNote} /> <NoteModal isOpen={noteModalOpen} onClose={() => setNoteModalOpen(false)} onSave={saveNote} />
{payForAllLocationKey && data && (
<PayForAllModal
isOpen
onClose={() => setPayForAllLocationKey(null)}
locationKey={payForAllLocationKey}
locationName={getLunchChoiceName(payForAllLocationKey)}
locationChoices={data.choices[payForAllLocationKey as keyof typeof data.choices] as LocationLunchChoicesMap}
menu={food?.[payForAllLocationKey as Restaurant]}
payerLogin={auth.login ?? ''}
bankAccount={settings?.bankAccount ?? ''}
bankAccountHolder={settings?.holderName ?? ''}
/>
)}
</div> </div>
); );
} }
+42 -14
View File
@@ -11,16 +11,12 @@ import GenerateMockDataModal from "./modals/GenerateMockDataModal";
import ClearMockDataModal from "./modals/ClearMockDataModal"; import ClearMockDataModal from "./modals/ClearMockDataModal";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { STATS_URL } from "../AppRoutes"; import { STATS_URL } from "../AppRoutes";
import { FeatureRequest, getVotes, updateVote, LunchChoices } from "../../../types"; import { FeatureRequest, getVotes, updateVote, LunchChoices, getChangelogs } from "../../../types";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons"; import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons";
import { formatDateString } from "../Utils";
const CHANGELOG = [ const LAST_SEEN_CHANGELOG_KEY = "lastChangelogDate";
"Nový moderní design aplikace",
"Oprava parsování Sladovnické a TechTower",
"Možnost označit se jako objednávající u volby \"budu objednávat\"",
"Možnost generovat QR kódy pro platby (i mimo Pizza day)",
];
const IS_DEV = process.env.NODE_ENV === 'development'; const IS_DEV = process.env.NODE_ENV === 'development';
@@ -38,6 +34,7 @@ export default function Header({ choices, dayIndex }: Props) {
const [pizzaModalOpen, setPizzaModalOpen] = useState<boolean>(false); const [pizzaModalOpen, setPizzaModalOpen] = useState<boolean>(false);
const [refreshMenuModalOpen, setRefreshMenuModalOpen] = useState<boolean>(false); const [refreshMenuModalOpen, setRefreshMenuModalOpen] = useState<boolean>(false);
const [changelogModalOpen, setChangelogModalOpen] = useState<boolean>(false); const [changelogModalOpen, setChangelogModalOpen] = useState<boolean>(false);
const [changelogEntries, setChangelogEntries] = useState<Record<string, string[]>>({});
const [qrModalOpen, setQrModalOpen] = useState<boolean>(false); const [qrModalOpen, setQrModalOpen] = useState<boolean>(false);
const [generateMockModalOpen, setGenerateMockModalOpen] = useState<boolean>(false); const [generateMockModalOpen, setGenerateMockModalOpen] = useState<boolean>(false);
const [clearMockModalOpen, setClearMockModalOpen] = useState<boolean>(false); const [clearMockModalOpen, setClearMockModalOpen] = useState<boolean>(false);
@@ -71,6 +68,19 @@ export default function Header({ choices, dayIndex }: Props) {
} }
}, [auth?.login]); }, [auth?.login]);
useEffect(() => {
if (!auth?.login) return;
const lastSeen = localStorage.getItem(LAST_SEEN_CHANGELOG_KEY) ?? undefined;
getChangelogs({ query: lastSeen ? { since: lastSeen } : {} }).then(response => {
const entries = response.data;
if (!entries || Object.keys(entries).length === 0) return;
setChangelogEntries(entries);
setChangelogModalOpen(true);
const newestDate = Object.keys(entries).sort((a, b) => b.localeCompare(a))[0];
localStorage.setItem(LAST_SEEN_CHANGELOG_KEY, newestDate);
});
}, [auth?.login]);
const closeSettingsModal = () => { const closeSettingsModal = () => {
setSettingsModalOpen(false); setSettingsModalOpen(false);
} }
@@ -197,7 +207,17 @@ export default function Header({ choices, dayIndex }: Props) {
<NavDropdown.Item onClick={() => setPizzaModalOpen(true)}>Pizza kalkulačka</NavDropdown.Item> <NavDropdown.Item onClick={() => setPizzaModalOpen(true)}>Pizza kalkulačka</NavDropdown.Item>
<NavDropdown.Item onClick={handleQrMenuClick}>Generování QR kódů</NavDropdown.Item> <NavDropdown.Item onClick={handleQrMenuClick}>Generování QR kódů</NavDropdown.Item>
<NavDropdown.Item onClick={() => navigate(STATS_URL)}>Statistiky</NavDropdown.Item> <NavDropdown.Item onClick={() => navigate(STATS_URL)}>Statistiky</NavDropdown.Item>
<NavDropdown.Item onClick={() => setChangelogModalOpen(true)}>Novinky</NavDropdown.Item> <NavDropdown.Item onClick={() => {
getChangelogs().then(response => {
const entries = response.data ?? {};
setChangelogEntries(entries);
setChangelogModalOpen(true);
const dates = Object.keys(entries).sort((a, b) => b.localeCompare(a));
if (dates.length > 0) {
localStorage.setItem(LAST_SEEN_CHANGELOG_KEY, dates[0]);
}
});
}}>Novinky</NavDropdown.Item>
{IS_DEV && ( {IS_DEV && (
<> <>
<NavDropdown.Divider /> <NavDropdown.Divider />
@@ -237,16 +257,24 @@ export default function Header({ choices, dayIndex }: Props) {
/> />
</> </>
)} )}
<Modal show={changelogModalOpen} onHide={() => setChangelogModalOpen(false)}> <Modal show={changelogModalOpen} onHide={() => setChangelogModalOpen(false)} size="lg">
<Modal.Header closeButton> <Modal.Header closeButton>
<Modal.Title><h2>Novinky</h2></Modal.Title> <Modal.Title><h2>Novinky</h2></Modal.Title>
</Modal.Header> </Modal.Header>
<Modal.Body> <Modal.Body>
<ul> {Object.keys(changelogEntries).sort((a, b) => b.localeCompare(a)).map(date => (
{CHANGELOG.map((item, index) => ( <div key={date}>
<li key={index}>{item}</li> <strong>{formatDateString(date)}</strong>
))} <ul>
</ul> {changelogEntries[date].map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</div>
))}
{Object.keys(changelogEntries).length === 0 && (
<p>Žádné novinky.</p>
)}
</Modal.Body> </Modal.Body>
<Modal.Footer> <Modal.Footer>
<Button variant="secondary" onClick={() => setChangelogModalOpen(false)}> <Button variant="secondary" onClick={() => setChangelogModalOpen(false)}>
@@ -0,0 +1,308 @@
import { useState, useEffect, useCallback } from "react";
import { Modal, Button, Form, Table, Alert } from "react-bootstrap";
import { generateQr, LunchChoice, LocationLunchChoicesMap, RestaurantDayMenu, QrRecipient } from "../../../../types";
import { parsePriceCzk } from "../../utils/parsePrice";
type DinerEntry = {
login: string;
selectedFoods: number[];
baseAmount: number;
baseAmountParseFailed: boolean;
surchargeText: string;
surchargeAmount: string;
included: boolean;
};
type Props = {
isOpen: boolean;
onClose: () => void;
locationKey: LunchChoice;
locationName: string;
locationChoices: LocationLunchChoicesMap;
menu: RestaurantDayMenu | undefined;
payerLogin: string;
bankAccount: string;
bankAccountHolder: string;
};
function sanitizeAmount(value: string): string {
return value.replace(/[^0-9.,]/g, '').replace(',', '.');
}
function parseAmount(s: string): number | null {
if (!s || s.trim().length === 0) return null;
const n = parseFloat(s);
if (isNaN(n) || n < 0) return null;
const parts = s.split('.');
if (parts.length === 2 && parts[1].length > 2) return null;
return Math.round(n * 100) / 100;
}
export default function PayForAllModal({ isOpen, onClose, locationName, locationChoices, menu, payerLogin, bankAccount, bankAccountHolder }: Readonly<Props>) {
const [diners, setDiners] = useState<DinerEntry[]>([]);
const [tipTotal, setTipTotal] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const hasMenu = !!menu;
useEffect(() => {
if (!isOpen) return;
const entries: DinerEntry[] = Object.entries(locationChoices).map(([login, choice]) => {
const selectedFoods = choice.selectedFoods ?? [];
let baseAmount = 0;
let baseAmountParseFailed = false;
if (menu) {
for (const idx of selectedFoods) {
const price = parsePriceCzk(menu.food?.[idx]?.price);
if (price === null) {
baseAmountParseFailed = true;
} else {
baseAmount += price;
}
}
}
return {
login,
selectedFoods,
baseAmount,
baseAmountParseFailed,
surchargeText: '',
surchargeAmount: '',
included: login !== payerLogin,
};
});
setDiners(entries);
setTipTotal('');
setError(null);
setSuccess(false);
}, [isOpen, locationChoices, menu, payerLogin]);
const includedDiners = diners.filter(d => d.included && d.login !== payerLogin);
const tipPerPerson = (() => {
if (includedDiners.length === 0) return 0;
const tip = parseAmount(tipTotal);
if (tip === null || tip === 0) return 0;
return Math.round((tip / includedDiners.length) * 100) / 100;
})();
const getTotal = (d: DinerEntry): number => {
const surcharge = parseAmount(d.surchargeAmount) ?? 0;
const tip = d.included && d.login !== payerLogin ? tipPerPerson : 0;
return Math.round((d.baseAmount + surcharge + tip) * 100) / 100;
};
const handleInclude = useCallback((login: string, checked: boolean) => {
setDiners(prev => prev.map(d => d.login === login ? { ...d, included: checked } : d));
}, []);
const handleSurchargeText = useCallback((login: string, value: string) => {
setDiners(prev => prev.map(d => d.login === login ? { ...d, surchargeText: value } : d));
}, []);
const handleSurchargeAmount = useCallback((login: string, value: string) => {
setDiners(prev => prev.map(d => d.login === login ? { ...d, surchargeAmount: sanitizeAmount(value) } : d));
}, []);
const handleGenerate = async () => {
setError(null);
const recipients: QrRecipient[] = [];
for (const d of diners) {
if (!d.included || d.login === payerLogin) continue;
const total = getTotal(d);
if (total <= 0) {
setError(`Celková částka pro ${d.login} musí být kladná`);
return;
}
const amountStr = total.toString();
if (amountStr.includes('.') && amountStr.split('.')[1].length > 2) {
setError(`Částka pro ${d.login} má více než 2 desetinná místa`);
return;
}
const foods = d.selectedFoods.map(i => menu?.food?.[i]?.name).filter(Boolean).join(', ');
const purposeBase = `Oběd ${locationName}${foods ? `${foods}` : ''}`;
recipients.push({
login: d.login,
purpose: purposeBase.substring(0, 60),
amount: total,
});
}
if (recipients.length === 0) {
setError("Nebyl vybrán žádný příjemce");
return;
}
setLoading(true);
try {
const response = await generateQr({
body: { recipients, bankAccount, bankAccountHolder },
});
if (response.error) {
setError((response.error as any).error || 'Nastala chyba při generování QR kódů');
} else {
setSuccess(true);
setTimeout(() => onClose(), 2000);
}
} catch (e: any) {
setError(e.message || 'Nastala chyba při generování QR kódů');
} finally {
setLoading(false);
}
};
const anyParseFailed = diners.some(d => d.baseAmountParseFailed && d.included);
return (
<Modal show={isOpen} onHide={onClose} size="lg">
<Modal.Header closeButton>
<Modal.Title><h2>Zaplatit za všechny {locationName}</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
{success ? (
<Alert variant="success">
QR kódy byly úspěšně vygenerovány! Uživatelé je uvidí v sekci Nevyřízené platby".
</Alert>
) : (
<>
<p>Zaplatili jste za skupinu v restauraci. Nastavte příplatky a dýško, poté vygenerujte QR kódy pro ostatní.</p>
{!hasMenu && (
<Alert variant="info">
Pro tuto skupinu nejsou k dispozici ceny jídel — vyplňte příplatky ručně.
</Alert>
)}
{anyParseFailed && (
<Alert variant="warning">
U některých jídel se nepodařilo načíst cenu — doplňte ji ručně v sloupci Příplatek.
</Alert>
)}
{error && (
<Alert variant="danger" onClose={() => setError(null)} dismissible>
{error}
</Alert>
)}
<Table striped bordered hover responsive size="sm">
<thead>
<tr>
<th style={{ width: 40 }}></th>
<th>Strávník</th>
<th>Jídla</th>
<th style={{ width: 220 }}>Příplatek</th>
<th style={{ width: 90 }}>Dýško</th>
<th style={{ width: 90 }}>Celkem</th>
</tr>
</thead>
<tbody>
{diners.map(d => {
const isPayer = d.login === payerLogin;
const foodNames = d.selectedFoods.map(i => menu?.food?.[i]?.name).filter(Boolean).join(', ');
const total = getTotal(d);
return (
<tr key={d.login} className={!d.included && !isPayer ? 'text-muted' : ''}>
<td className="text-center">
{isPayer ? (
<small className="text-muted">plátce</small>
) : (
<Form.Check
type="checkbox"
checked={d.included}
onChange={e => handleInclude(d.login, e.target.checked)}
/>
)}
</td>
<td><strong>{d.login}</strong></td>
<td>
<small>
{foodNames || <span className="text-muted">—</span>}
{hasMenu && d.baseAmount > 0 && <span className="text-muted"> ({d.baseAmount} Kč)</span>}
{d.baseAmountParseFailed && <span className="text-warning"> ⚠</span>}
</small>
</td>
<td>
{!isPayer && (
<div className="d-flex gap-1">
<Form.Control
type="text"
placeholder="popis"
value={d.surchargeText}
onChange={e => handleSurchargeText(d.login, e.target.value)}
disabled={!d.included}
size="sm"
onKeyDown={e => e.stopPropagation()}
/>
<Form.Control
type="text"
placeholder=""
value={d.surchargeAmount}
onChange={e => handleSurchargeAmount(d.login, e.target.value)}
disabled={!d.included}
size="sm"
style={{ width: 70 }}
onKeyDown={e => e.stopPropagation()}
/>
</div>
)}
</td>
<td className="text-end">
{!isPayer && d.included ? `${tipPerPerson} Kč` : '—'}
</td>
<td className="text-end fw-bold">
{!isPayer ? `${total} Kč` : '—'}
</td>
</tr>
);
})}
</tbody>
</Table>
<div className="d-flex align-items-center gap-2 mt-2">
<label className="mb-0 text-nowrap">Dýško celkem (Kč):</label>
<Form.Control
type="text"
placeholder="0"
value={tipTotal}
onChange={e => setTipTotal(sanitizeAmount(e.target.value))}
size="sm"
style={{ width: 100 }}
onKeyDown={e => e.stopPropagation()}
/>
<small className="text-muted">
{includedDiners.length > 0 && tipPerPerson > 0
? `(${tipPerPerson} Kč / osoba)`
: ''}
</small>
</div>
</>
)}
</Modal.Body>
<Modal.Footer>
{!success && (
<>
<span className="me-auto text-muted">
Příjemci: {includedDiners.length}
</span>
<Button variant="secondary" onClick={onClose} disabled={loading}>
Storno
</Button>
<Button
variant="primary"
onClick={handleGenerate}
disabled={loading || includedDiners.length === 0}
>
{loading ? 'Generuji...' : 'Vygenerovat QR'}
</Button>
</>
)}
{success && (
<Button variant="secondary" onClick={onClose}>Zavřít</Button>
)}
</Modal.Footer>
</Modal>
);
}
+11
View File
@@ -0,0 +1,11 @@
/**
* Parsuje cenu ve formátu "135 Kč", "135,50 Kč" nebo "135.50 Kč" na číslo.
* Vrátí null při selhání.
*/
export function parsePriceCzk(raw: string | undefined): number | null {
if (!raw) return null;
const m = raw.replace(',', '.').match(/(\d+(?:\.\d+)?)/);
if (!m) return null;
const n = parseFloat(m[1]);
return Number.isFinite(n) ? n : null;
}
+4
View File
@@ -0,0 +1,4 @@
[
"Zimní atmosféra",
"Skrytí podniku U Motlíků"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Přidání restaurace Zastávka u Michala"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Přidání restaurace Pivovarský šenk Šeříková"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Možnost výběru podniku/jídla kliknutím"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Stránka se statistikami nejoblíbenějších voleb"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Zobrazení počtu osob u každé volby"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Migrace na generované OpenApi"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Odebrání zimní atmosféry"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Možnost ručního přenačtení menu"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Parsování a zobrazení alergenů"
]
+4
View File
@@ -0,0 +1,4 @@
[
"Oddělení přenačtení menu do vlastního dialogu",
"Podzimní atmosféra"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Možnost převzetí poznámky ostatních uživatelů"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Zimní atmosféra"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Možnost označit se jako objednávající u volby \"Budu objednávat\""
]
+3
View File
@@ -0,0 +1,3 @@
[
"Podpora dark mode"
]
+7
View File
@@ -0,0 +1,7 @@
[
"Redesign aplikace pomocí Claude Code",
"Zobrazení uplynulého týdne i o víkendu",
"Podpora Discord, ntfy a Teams notifikací (v Nastavení)",
"Trvalé zobrazení QR kódů do ručního zavření",
"Zobrazení nejvíce požadovaných funkcí (na stránce Statistiky)"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Zobrazení sekce Pizza day pouze při volbě \"Pizza day\""
]
+3
View File
@@ -0,0 +1,3 @@
[
"Možnost generování obecných QR kódů pro platby i mimo Pizza day (v uživatelském menu)"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Podpora push notifikací pro připomenutí výběru oběda (v nastavení)"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Oprava detekce zastaralého menu"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Automatické zobrazení dialogu s dosud nezobrazenými novinkami"
]
+3
View File
@@ -0,0 +1,3 @@
[
"Automatický výběr výchozího času preferovaného odchodu"
]
+41 -2
View File
@@ -1,6 +1,7 @@
import axios from 'axios'; import axios from 'axios';
import { load } from 'cheerio'; import { load } from 'cheerio';
import { getPizzaListMock } from './mock'; import { getPizzaListMock, getSalatListMock } from './mock';
import { Salat } from '../../types/gen/types.gen';
// TODO přesunout do types // TODO přesunout do types
type PizzaSize = { type PizzaSize = {
@@ -20,7 +21,8 @@ type Pizza = {
// TODO mělo by být konfigurovatelné proměnnou z prostředí s tímhle jako default // TODO mělo by být konfigurovatelné proměnnou z prostředí s tímhle jako default
const baseUrl = 'https://www.pizzachefie.cz'; const baseUrl = 'https://www.pizzachefie.cz';
const pizzyUrl = `${baseUrl}/pizzy.html?pobocka=plzen`; const pizzyUrl = `${baseUrl}/pizzy.html`;
const salayUrl = `${baseUrl}/salaty.html`;
const buildPizzaUrl = (pizzaUrl: string) => { const buildPizzaUrl = (pizzaUrl: string) => {
return `${baseUrl}/${pizzaUrl}`; return `${baseUrl}/${pizzaUrl}`;
@@ -34,6 +36,9 @@ const boxPrices: { [key: string]: number } = {
"50cm": 25 "50cm": 25
} }
// Cena obalu pro salát
const SALAT_BOX_PRICE = 13;
/** /**
* Stáhne a scrapne aktuální pizzy ze stránek Pizza Chefie. * Stáhne a scrapne aktuální pizzy ze stránek Pizza Chefie.
* *
@@ -85,3 +90,37 @@ export async function downloadPizzy(mock: boolean): Promise<Pizza[]> {
} }
return result; return result;
} }
/**
* Stáhne a scrapne aktuální saláty ze stránek Pizza Chefie.
* Příplatek za obal je pro každý salát pevně 13 Kč.
*
* @param mock zda vrátit pouze mock data
*/
export async function downloadSalaty(mock: boolean): Promise<Salat[]> {
if (mock) {
return new Promise((resolve) => setTimeout(() => resolve(getSalatListMock()), 1000));
}
const html = await axios.get(salayUrl).then(res => res.data);
const $ = load(html);
const links = $('.vypisproduktu > div > h4 > a');
const urls = [];
for (const element of links) {
if (element.name === 'a' && element.attribs?.href) {
urls.push(buildPizzaUrl(element.attribs.href));
}
}
const result: Salat[] = [];
for (const url of urls) {
const salatHtml = await axios.get(url).then(res => res.data);
const name = $('.produkt > h2', salatHtml).first().text().trim();
const ingredients: string[] = [];
$('.prisady > li', salatHtml).each((i, elm) => {
ingredients.push($(elm).text());
});
const priceText = $('.cena > span', salatHtml).first().text().trim();
const price = Number.parseInt(priceText.split(' Kč')[0]);
result.push({ name, ingredients, price: price + SALAT_BOX_PRICE });
}
return result;
}
+9 -4
View File
@@ -18,6 +18,7 @@ import statsRoutes from "./routes/statsRoutes";
import notificationRoutes from "./routes/notificationRoutes"; import notificationRoutes from "./routes/notificationRoutes";
import qrRoutes from "./routes/qrRoutes"; import qrRoutes from "./routes/qrRoutes";
import devRoutes from "./routes/devRoutes"; import devRoutes from "./routes/devRoutes";
import changelogRoutes from "./routes/changelogRoutes";
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}`) });
@@ -86,12 +87,15 @@ app.post("/api/login", (req, res) => {
} }
}); });
// TODO dočasné řešení - QR se zobrazuje přes <img>, nemáme sem jak dostat token // QR se zobrazuje přes <img>, nemáme sem jak dostat token
app.get("/api/qr", (req, res) => { app.get("/api/qr", async (req, res) => {
if (!req.query?.login) { if (!req.query?.login) {
throw Error("Nebyl předán login"); return res.status(400).json({ error: "Nebyl předán login" });
} }
const img = getQr(req.query.login as string); if (!req.query?.id) {
return res.status(400).json({ error: "Nebyl předán identifikátor QR kódu" });
}
const img = await getQr(req.query.login as string, req.query.id as string);
res.writeHead(200, { res.writeHead(200, {
'Content-Type': 'image/png', 'Content-Type': 'image/png',
'Content-Length': img.length 'Content-Length': img.length
@@ -165,6 +169,7 @@ app.use("/api/stats", statsRoutes);
app.use("/api/notifications", notificationRoutes); app.use("/api/notifications", notificationRoutes);
app.use("/api/qr", qrRoutes); app.use("/api/qr", qrRoutes);
app.use("/api/dev", devRoutes); app.use("/api/dev", devRoutes);
app.use("/api/changelogs", changelogRoutes);
app.use('/stats', express.static('public')); app.use('/stats', express.static('public'));
app.use(express.static('public')); app.use(express.static('public'));
+28
View File
@@ -1429,6 +1429,34 @@ export const getPizzaListMock = () => {
return MOCK_PIZZA_LIST; return MOCK_PIZZA_LIST;
} }
// Mockovací data pro saláty
const MOCK_SALAT_LIST = [
{
name: "Greek",
ingredients: ["Salát", "Černé olivy", "Paprika mix", "Červená cibule", "Rajčata", "Okurka salátová", "Jogurtový dresing"],
price: 174 + 13,
},
{
name: "Caesar",
ingredients: ["Salát", "Rajčata", "Kuřecí maso", "Krutony", "Parmazán", "Caesar dresing", "Olivový olej"],
price: 184 + 13,
},
{
name: "Šopský salát",
ingredients: ["Salátová okurka", "Rajčata", "Paprika mix", "Červená cibule", "Balkánský sýr"],
price: 164 + 13,
},
{
name: "Těstovinový salát",
ingredients: ["Penne", "Okurka", "Rajčata", "Paprika mix", "Kuřecí maso", "Jogurtový dresing"],
price: 184 + 13,
},
]
export const getSalatListMock = () => {
return MOCK_SALAT_LIST;
}
export const getStatsMock = (): WeeklyStats => { export const getStatsMock = (): WeeklyStats => {
return [ return [
{ {
+84 -11
View File
@@ -2,9 +2,10 @@ import { formatDate } from "./utils";
import { callNotifikace } from "./notifikace"; import { callNotifikace } from "./notifikace";
import { generateQr } from "./qr"; import { generateQr } from "./qr";
import getStorage from "./storage"; import getStorage from "./storage";
import { downloadPizzy } from "./chefie"; import { downloadPizzy, downloadSalaty } from "./chefie";
import { getClientData, getToday, initIfNeeded } from "./service"; import { getClientData, getToday, initIfNeeded } from "./service";
import { Pizza, ClientData, PizzaDayState, PizzaSize, PizzaOrder, PizzaVariant, UdalostEnum, PendingQr } from "../../types/gen/types.gen"; import { Pizza, Salat, ClientData, PizzaDayState, PizzaSize, PizzaOrder, PizzaVariant, UdalostEnum, PendingQr } from "../../types/gen/types.gen";
import crypto from "crypto";
const storage = getStorage(); const storage = getStorage();
const PENDING_QR_PREFIX = 'pending_qr'; const PENDING_QR_PREFIX = 'pending_qr';
@@ -38,6 +39,34 @@ export async function savePizzaList(pizzaList: Pizza[]): Promise<ClientData> {
return clientData; return clientData;
} }
/**
* Vrátí seznam dostupných salátů pro dnešní den.
* Stáhne je, pokud je pro dnešní den nemá.
*/
export async function getSalatList(): Promise<Salat[] | undefined> {
await initIfNeeded();
let clientData = await getClientData(getToday());
if (!clientData.salatList) {
const mock = process.env.MOCK_DATA === 'true';
clientData = await saveSalatList(await downloadSalaty(mock));
}
return Promise.resolve(clientData.salatList);
}
/**
* Uloží seznam dostupných salátů pro dnešní den.
*
* @param salatList seznam dostupných salátů
*/
export async function saveSalatList(salatList: Salat[]): Promise<ClientData> {
await initIfNeeded();
const today = formatDate(getToday());
const clientData = await getClientData(getToday());
clientData.salatList = salatList;
await storage.setData(today, clientData);
return clientData;
}
/** /**
* Vytvoří pizza day pro aktuální den a vrátí data pro klienta. * Vytvoří pizza day pro aktuální den a vrátí data pro klienta.
*/ */
@@ -48,8 +77,8 @@ export async function createPizzaDay(creator: string): Promise<ClientData> {
throw Error("Pizza day pro dnešní den již existuje"); throw Error("Pizza day pro dnešní den již existuje");
} }
// TODO berka rychlooprava, vyřešit lépe - stahovat jednou, na jediném místě! // TODO berka rychlooprava, vyřešit lépe - stahovat jednou, na jediném místě!
const pizzaList = await getPizzaList(); const [pizzaList, salatList] = await Promise.all([getPizzaList(), getSalatList()]);
const data: ClientData = { pizzaDay: { state: PizzaDayState.CREATED, creator, orders: [] }, pizzaList, ...clientData }; const data: ClientData = { pizzaDay: { state: PizzaDayState.CREATED, creator, orders: [] }, pizzaList, salatList, ...clientData };
const today = formatDate(getToday()); const today = formatDate(getToday());
await storage.setData(today, data); await storage.setData(today, data);
callNotifikace({ input: { udalost: UdalostEnum.ZAHAJENA_PIZZA, user: creator } }) callNotifikace({ input: { udalost: UdalostEnum.ZAHAJENA_PIZZA, user: creator } })
@@ -113,6 +142,46 @@ export async function addPizzaOrder(login: string, pizza: Pizza, size: PizzaSize
return clientData; return clientData;
} }
/**
* Přidá objednávku salátu uživateli.
*
* @param login login uživatele
* @param salat zvolený salát
*/
export async function addSalatOrder(login: string, salat: Salat) {
const today = formatDate(getToday());
const clientData = await getClientData(getToday());
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
throw Error("Pizza day není ve stavu " + PizzaDayState.CREATED);
}
let order: PizzaOrder | undefined = clientData.pizzaDay?.orders?.find(o => o.customer === login);
if (!order) {
order = {
customer: login,
pizzaList: [],
totalPrice: 0,
hasQr: false,
}
clientData.pizzaDay.orders ??= [];
clientData.pizzaDay.orders.push(order);
}
const salatOrder: PizzaVariant = {
varId: 0,
name: salat.name,
size: "1 porce",
price: salat.price,
category: 'salat',
}
order.pizzaList ??= [];
order.pizzaList.push(salatOrder);
order.totalPrice += salatOrder.price;
await storage.setData(today, clientData);
return clientData;
}
/** /**
* Odstraní všechny pizzy uživatele (celou jeho objednávku). * Odstraní všechny pizzy uživatele (celou jeho objednávku).
* Pokud Pizza day neexistuje nebo není ve stavu CREATED, neudělá nic. * Pokud Pizza day neexistuje nebo není ve stavu CREATED, neudělá nic.
@@ -269,11 +338,15 @@ export async function finishPizzaDelivery(login: string, bankAccount?: string, b
if (bankAccount?.length && bankAccountHolder?.length) { if (bankAccount?.length && bankAccountHolder?.length) {
for (const order of clientData.pizzaDay.orders!) { for (const order of clientData.pizzaDay.orders!) {
if (order.customer !== login) { // zatím platí creator = objednávající, a pro toho nemá QR kód smysl if (order.customer !== login) { // zatím platí creator = objednávající, a pro toho nemá QR kód smysl
let message = order.pizzaList!.map(pizza => `Pizza ${pizza.name} (${pizza.size})`).join(', '); const id = crypto.randomUUID();
await generateQr(order.customer, bankAccount, bankAccountHolder, order.totalPrice, message); let message = order.pizzaList!.map(item =>
item.category === 'salat' ? `Salát ${item.name}` : `Pizza ${item.name} (${item.size})`
).join(', ');
await generateQr(order.customer, bankAccount, bankAccountHolder, order.totalPrice, message, id);
order.hasQr = true; order.hasQr = true;
// Uložíme nevyřízený QR kód pro persistentní zobrazení // Uložíme nevyřízený QR kód pro persistentní zobrazení
await addPendingQr(order.customer, { await addPendingQr(order.customer, {
id,
date: today, date: today,
creator: login, creator: login,
totalPrice: order.totalPrice, totalPrice: order.totalPrice,
@@ -360,8 +433,8 @@ function getPendingQrKey(login: string): string {
export async function addPendingQr(login: string, pendingQr: PendingQr): Promise<void> { export async function addPendingQr(login: string, pendingQr: PendingQr): Promise<void> {
const key = getPendingQrKey(login); const key = getPendingQrKey(login);
const existing = await storage.getData<PendingQr[]>(key) ?? []; const existing = await storage.getData<PendingQr[]>(key) ?? [];
// Nepřidáváme duplicity pro stejný den // Deduplikace podle id (ne podle data — jeden den může mít uživatel víc QR kódů)
if (!existing.some(qr => qr.date === pendingQr.date)) { if (!existing.some(qr => qr.id === pendingQr.id)) {
existing.push(pendingQr); existing.push(pendingQr);
await storage.setData(key, existing); await storage.setData(key, existing);
} }
@@ -375,11 +448,11 @@ export async function getPendingQrs(login: string): Promise<PendingQr[]> {
} }
/** /**
* Označí QR kód pro daný den jako uhrazený (odstraní ho ze seznamu nevyřízených). * Označí QR kód jako uhrazený (odstraní ho ze seznamu nevyřízených).
*/ */
export async function dismissPendingQr(login: string, date: string): Promise<void> { export async function dismissPendingQr(login: string, id: string): Promise<void> {
const key = getPendingQrKey(login); const key = getPendingQrKey(login);
const existing = await storage.getData<PendingQr[]>(key) ?? []; const existing = await storage.getData<PendingQr[]>(key) ?? [];
const filtered = existing.filter(qr => qr.date !== date); const filtered = existing.filter(qr => qr.id !== id);
await storage.setData(key, filtered); await storage.setData(key, filtered);
} }
+22 -26
View File
@@ -1,15 +1,13 @@
import fs from "fs";
import axios from "axios"; import axios from "axios";
import os from "os";
import path from "path";
import crypto from "crypto"; import crypto from "crypto";
import { formatDate } from "./utils"; import getStorage from "./storage";
const QR_GENERATOR_URL = 'https://api.paylibo.com/paylibo/generator/image'; const QR_GENERATOR_URL = 'https://api.paylibo.com/paylibo/generator/image';
const COUNTRY_CODE = 'CZ'; const COUNTRY_CODE = 'CZ';
const CURRENCY_CODE = 'CZK'; const CURRENCY_CODE = 'CZK';
const QR_PIXEL_SIZE = 256; const QR_PIXEL_SIZE = 256;
const tmpDir = os.tmpdir();
const storage = getStorage();
/** /**
* Převede číslo účtu z BBAN do IBAN. Automaticky dopočítá kontrolní číslice. * Převede číslo účtu z BBAN do IBAN. Automaticky dopočítá kontrolní číslice.
@@ -41,26 +39,23 @@ function convertBbanToIban(bankAccountNumber: string): string {
return iban; return iban;
} }
function createNameHash(customerName: string): string { function createStorageKey(customerName: string, id: string): string {
return crypto.createHash('md5').update(customerName).digest('hex'); const nameHash = crypto.createHash('md5').update(customerName).digest('hex');
} return `qr_${nameHash}_${id}`;
function createFilePath(nameHash: string): string {
const fileName = `${formatDate(new Date())}_${nameHash}.png`;
return path.join(tmpDir, fileName);
} }
/** /**
* Vygeneruje, uloží a vrátí unikátní ID obrázku platebního QR kódu s danými parametry. * Vygeneruje a uloží obrázek platebního QR kódu do storage (Redis/JSON).
* Data přežijí redeploy — není třeba persistentní filesystém.
* *
* @param customerName jméno uživatele, pro kterého je QR kód generován * @param customerName jméno uživatele, pro kterého je QR kód generován
* @param bankAccountNumber číslo cílového bankovního účtu ve formátu BBAN * @param bankAccountNumber číslo cílového bankovního účtu ve formátu BBAN
* @param bankAccountHolder jméno držitele cílového bankovního účtu * @param bankAccountHolder jméno držitele cílového bankovního účtu
* @param amount částka v Kč * @param amount částka v Kč
* @param message zpráva pro příjemce * @param message zpráva pro příjemce
* @returns hash, pomocí kterého lze následně získat vygenerovaný obrázek * @param id unikátní identifikátor (UUID) tohoto QR kódu
*/ */
export async function generateQr(customerName: string, bankAccountNumber: string, bankAccountHolder: string, amount: number, message: string): Promise<string> { export async function generateQr(customerName: string, bankAccountNumber: string, bankAccountHolder: string, amount: number, message: string, id: string): Promise<void> {
// Zpráva pro příjemce nesmí dle standardu obsahovat '*' a být delší než 60 znaků // Zpráva pro příjemce nesmí dle standardu obsahovat '*' a být delší než 60 znaků
if (message.indexOf('*') >= 0) { if (message.indexOf('*') >= 0) {
message = message.replace('*', ''); message = message.replace('*', '');
@@ -77,22 +72,23 @@ export async function generateQr(customerName: string, bankAccountNumber: string
branding: false, branding: false,
compress: false, compress: false,
size: QR_PIXEL_SIZE, size: QR_PIXEL_SIZE,
} };
const response = await axios.get(QR_GENERATOR_URL, { responseType: 'stream', params: { ...payload } }); const response = await axios.get(QR_GENERATOR_URL, { responseType: 'arraybuffer', params: { ...payload } });
// Použijeme hash, abychom nemuseli řešit nepovolené znaky ve jménu uživatele const base64 = Buffer.from(response.data).toString('base64');
const nameHash = createNameHash(customerName); await storage.setData(createStorageKey(customerName, id), base64);
const imgPath = createFilePath(nameHash);
response.data.pipe(fs.createWriteStream(imgPath));
return nameHash;
} }
/** /**
* Vrátí obrázek s QR kódem, pokud existuje. * Vrátí obrázek s QR kódem ze storage.
* *
* @param customerName jméno uživatele * @param customerName jméno uživatele
* @param id unikátní identifikátor QR kódu
* @returns data obrázku * @returns data obrázku
*/ */
export function getQr(customerName: string): Buffer { export async function getQr(customerName: string, id: string): Promise<Buffer> {
const imgPath = createFilePath(createNameHash(customerName)); const base64 = await storage.getData<string>(createStorageKey(customerName, id));
return fs.readFileSync(imgPath); if (!base64) {
throw new Error("QR kód nebyl nalezen");
}
return Buffer.from(base64, 'base64');
} }
+17 -2
View File
@@ -280,6 +280,7 @@ export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = fal
const $ = load(html); const $ = load(html);
let secondTry = false; let secondTry = false;
let thirdTry = false;
// První pokus - varianta "Obědy" // První pokus - varianta "Obědy"
let fonts = $('font.wsw-41'); let fonts = $('font.wsw-41');
let font = undefined; let font = undefined;
@@ -288,7 +289,7 @@ export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = fal
font = f; font = f;
} }
}) })
// Druhý pokus - varianta "Jídelní lístek" // Druhý pokus - varianta "Jídelní lístek" (starší formát)
if (!font) { if (!font) {
fonts = $('font.wnd-font-size-90'); fonts = $('font.wnd-font-size-90');
fonts.each((i, f) => { fonts.each((i, f) => {
@@ -298,12 +299,26 @@ export const getMenuTechTower = async (firstDayOfWeek: Date, mock: boolean = fal
} }
}) })
} }
// Třetí pokus - nový formát: font.wsw-41 s textem "Jídelní lístek" (vše v jednom bloku)
if (!font) {
fonts = $('font.wsw-41');
fonts.each((i, f) => {
if ($(f).text().trim().startsWith('Jídelní lístek')) {
font = f;
thirdTry = true;
}
})
}
if (!font) { if (!font) {
throw new Error('Chyba: nenalezen <font> pro obědy v HTML Techtower.'); throw new Error('Chyba: nenalezen <font> pro obědy v HTML Techtower.');
} }
const result: Food[][] = []; const result: Food[][] = [];
const siblings = secondTry ? $(font).parent().parent().parent().siblings('p') : $(font).parent().parent().siblings(); const siblings = thirdTry
? $(font).parent().siblings('p')
: secondTry
? $(font).parent().parent().parent().siblings('p')
: $(font).parent().parent().siblings();
let parsing = false; let parsing = false;
let currentDayIndex = 0; let currentDayIndex = 0;
for (let i = 0; i < siblings.length; i++) { for (let i = 0; i < siblings.length; i++) {
+50
View File
@@ -0,0 +1,50 @@
import express, { Request, Response } from "express";
import fs from "fs";
import path from "path";
const router = express.Router();
const CHANGELOGS_DIR = path.resolve(__dirname, "../../changelogs");
// In-memory cache: datum → seznam změn
const cache: Record<string, string[]> = {};
function loadAllChangelogs(): Record<string, string[]> {
let files: string[];
try {
files = fs.readdirSync(CHANGELOGS_DIR).filter(f => f.endsWith(".json"));
} catch {
return {};
}
for (const file of files) {
const date = file.replace(".json", "");
if (!cache[date]) {
const content = fs.readFileSync(path.join(CHANGELOGS_DIR, file), "utf-8");
cache[date] = JSON.parse(content);
}
}
return cache;
}
router.get("/", (req: Request, res: Response) => {
const all = loadAllChangelogs();
const since = typeof req.query.since === "string" ? req.query.since : undefined;
// Seřazení od nejnovějšího po nejstarší
const sortedDates = Object.keys(all).sort((a, b) => b.localeCompare(a));
const filteredDates = since
? sortedDates.filter(date => date > since)
: sortedDates;
const result: Record<string, string[]> = {};
for (const date of filteredDates) {
result[date] = all[date];
}
res.status(200).json(result);
});
export default router;
+40 -24
View File
@@ -1,6 +1,6 @@
import express, { Request } from "express"; import express, { Request } from "express";
import { getLogin } from "../auth"; import { getLogin } from "../auth";
import { createPizzaDay, deletePizzaDay, getPizzaList, addPizzaOrder, removePizzaOrder, lockPizzaDay, unlockPizzaDay, finishPizzaOrder, finishPizzaDelivery, updatePizzaDayNote, updatePizzaFee, dismissPendingQr } from "../pizza"; import { createPizzaDay, deletePizzaDay, getPizzaList, getSalatList, addPizzaOrder, addSalatOrder, removePizzaOrder, lockPizzaDay, unlockPizzaDay, finishPizzaOrder, finishPizzaDelivery, updatePizzaDayNote, updatePizzaFee, dismissPendingQr } from "../pizza";
import { parseToken } from "../utils"; import { parseToken } from "../utils";
import { getWebsocket } from "../websocket"; import { getWebsocket } from "../websocket";
import { AddPizzaData, DismissQrData, FinishDeliveryData, RemovePizzaData, UpdatePizzaDayNoteData, UpdatePizzaFeeData } from "../../../types"; import { AddPizzaData, DismissQrData, FinishDeliveryData, RemovePizzaData, UpdatePizzaDayNoteData, UpdatePizzaFeeData } from "../../../types";
@@ -24,27 +24,43 @@ router.post("/delete", async (req: Request<{}, any, undefined>, res) => {
router.post("/add", async (req: Request<{}, any, AddPizzaData["body"]>, res) => { router.post("/add", async (req: Request<{}, any, AddPizzaData["body"]>, res) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
if (isNaN(req.body?.pizzaIndex)) { if (req.body?.salatIndex !== undefined && !isNaN(req.body.salatIndex)) {
throw Error("Nebyl předán index pizzy"); // Přidání salátu
const salatIndex = req.body.salatIndex;
const salaty = await getSalatList();
if (!salaty) {
throw Error("Selhalo získání seznamu dostupných salátů.");
}
if (!salaty[salatIndex]) {
throw Error("Neplatný index salátu: " + salatIndex);
}
const data = await addSalatOrder(login, salaty[salatIndex]);
getWebsocket().emit("message", data);
res.status(200).json({});
} else {
// Přidání pizzy
if (req.body?.pizzaIndex === undefined || isNaN(req.body.pizzaIndex)) {
throw Error("Nebyl předán index pizzy ani salátu");
}
const pizzaIndex = req.body.pizzaIndex;
if (req.body?.pizzaSizeIndex === undefined || isNaN(req.body.pizzaSizeIndex)) {
throw Error("Nebyl předán index velikosti pizzy");
}
const pizzaSizeIndex = req.body.pizzaSizeIndex;
let pizzy = await getPizzaList();
if (!pizzy) {
throw Error("Selhalo získání seznamu dostupných pizz.");
}
if (!pizzy[pizzaIndex]) {
throw Error("Neplatný index pizzy: " + pizzaIndex);
}
if (!pizzy[pizzaIndex].sizes[pizzaSizeIndex]) {
throw Error("Neplatný index velikosti pizzy: " + pizzaSizeIndex);
}
const data = await addPizzaOrder(login, pizzy[pizzaIndex], pizzy[pizzaIndex].sizes[pizzaSizeIndex]);
getWebsocket().emit("message", data);
res.status(200).json({});
} }
const pizzaIndex = req.body.pizzaIndex;
if (isNaN(req.body?.pizzaSizeIndex)) {
throw Error("Nebyl předán index velikosti pizzy");
}
const pizzaSizeIndex = req.body.pizzaSizeIndex;
let pizzy = await getPizzaList();
if (!pizzy) {
throw Error("Selhalo získání seznamu dostupných pizz.");
}
if (!pizzy[pizzaIndex]) {
throw Error("Neplatný index pizzy: " + pizzaIndex);
}
if (!pizzy[pizzaIndex].sizes[pizzaSizeIndex]) {
throw Error("Neplatný index velikosti pizzy: " + pizzaSizeIndex);
}
const data = await addPizzaOrder(login, pizzy[pizzaIndex], pizzy[pizzaIndex].sizes[pizzaSizeIndex]);
getWebsocket().emit("message", data);
res.status(200).json({});
}); });
router.post("/remove", async (req: Request<{}, any, RemovePizzaData["body"]>, res) => { router.post("/remove", async (req: Request<{}, any, RemovePizzaData["body"]>, res) => {
@@ -112,11 +128,11 @@ router.post("/updatePizzaFee", async (req: Request<{}, any, UpdatePizzaFeeData["
/** Označí QR kód jako uhrazený. */ /** Označí QR kód jako uhrazený. */
router.post("/dismissQr", async (req: Request<{}, any, DismissQrData["body"]>, res, next) => { router.post("/dismissQr", async (req: Request<{}, any, DismissQrData["body"]>, res, next) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
if (!req.body.date) { if (!req.body.id) {
return res.status(400).json({ error: "Nebyl předán datum" }); return res.status(400).json({ error: "Nebyl předán identifikátor QR kódu" });
} }
try { try {
await dismissPendingQr(login, req.body.date); await dismissPendingQr(login, req.body.id);
res.status(200).json({}); res.status(200).json({});
} catch (e: any) { next(e) } } catch (e: any) { next(e) }
}); });
+4 -1
View File
@@ -4,6 +4,7 @@ import { parseToken, formatDate } from "../utils";
import { generateQr } from "../qr"; import { generateQr } from "../qr";
import { addPendingQr } from "../pizza"; import { addPendingQr } from "../pizza";
import { GenerateQrData } from "../../../types"; import { GenerateQrData } from "../../../types";
import crypto from "crypto";
const router = express.Router(); const router = express.Router();
@@ -44,10 +45,12 @@ router.post("/generate", async (req: Request<{}, any, GenerateQrData["body"]>, r
} }
// Vygenerovat QR kód // Vygenerovat QR kód
await generateQr(recipient.login, bankAccount, bankAccountHolder, recipient.amount, recipient.purpose); const id = crypto.randomUUID();
await generateQr(recipient.login, bankAccount, bankAccountHolder, recipient.amount, recipient.purpose, id);
// Uložit jako nevyřízený QR kód // Uložit jako nevyřízený QR kód
await addPendingQr(recipient.login, { await addPendingQr(recipient.login, {
id,
date: today, date: today,
creator: login, creator: login,
totalPrice: recipient.amount, totalPrice: recipient.amount,
+4
View File
@@ -77,6 +77,10 @@ paths:
/voting/stats: /voting/stats:
$ref: "./paths/voting/getVotingStats.yml" $ref: "./paths/voting/getVotingStats.yml"
# Changelog (/api/changelogs)
/changelogs:
$ref: "./paths/changelogs/getChangelogs.yml"
# DEV endpointy (/api/dev) # DEV endpointy (/api/dev)
/dev/generate: /dev/generate:
$ref: "./paths/dev/generate.yml" $ref: "./paths/dev/generate.yml"
+21
View File
@@ -0,0 +1,21 @@
get:
operationId: getChangelogs
summary: Vrátí seznam změn (changelog). Pokud není předáno datum, vrátí všechny změny. Pokud je předáno datum, vrátí pouze změny po tomto datu.
parameters:
- in: query
name: since
required: false
schema:
type: string
description: Datum (formát YYYY-MM-DD) od kterého se mají vrátit změny (exkluzivně). Pokud není předáno, vrátí se všechny změny.
responses:
"200":
description: Slovník kde klíčem je datum (YYYY-MM-DD) a hodnotou seznam změn k danému datu. Seřazeno od nejnovějšího po nejstarší.
content:
application/json:
schema:
type: object
additionalProperties:
type: array
items:
type: string
+7 -1
View File
@@ -1,6 +1,6 @@
get: get:
operationId: getPizzaQr operationId: getPizzaQr
summary: Získání QR kódu pro platbu za Pizza day summary: Získání QR kódu pro platbu
security: [] # Nevyžaduje autentizaci security: [] # Nevyžaduje autentizaci
parameters: parameters:
- in: query - in: query
@@ -9,6 +9,12 @@ get:
type: string type: string
required: true required: true
description: Přihlašovací jméno uživatele, pro kterého bude vrácen QR kód description: Přihlašovací jméno uživatele, pro kterého bude vrácen QR kód
- in: query
name: id
schema:
type: string
required: true
description: Unikátní identifikátor QR kódu (z PendingQr.id)
responses: responses:
"200": "200":
description: Vygenerovaný QR kód pro platbu description: Vygenerovaný QR kód pro platbu
+7 -7
View File
@@ -1,21 +1,21 @@
post: post:
operationId: addPizza operationId: addPizza
summary: Přidání pizzy do objednávky. summary: Přidání pizzy nebo salátu do objednávky.
requestBody: requestBody:
required: true required: true
content: content:
application/json: application/json:
schema: schema:
required:
- pizzaIndex
- pizzaSizeIndex
properties: properties:
pizzaIndex: pizzaIndex:
description: Index pizzy v nabídce description: Index pizzy v nabídce (pro přidání pizzy)
type: integer type: integer
pizzaSizeIndex: pizzaSizeIndex:
description: Index velikosti pizzy v nabídce variant description: Index velikosti pizzy v nabídce variant (pro přidání pizzy)
type: integer
salatIndex:
description: Index salátu v nabídce (pro přidání salátu)
type: integer type: integer
responses: responses:
"200": "200":
description: Přidání pizzy do objednávky proběhlo úspěšně. description: Přidání pizzy nebo salátu do objednávky proběhlo úspěšně.
+4 -4
View File
@@ -1,17 +1,17 @@
post: post:
operationId: dismissQr operationId: dismissQr
summary: Označí QR kód pro daný den jako uhrazený (odstraní ho ze seznamu nevyřízených). summary: Označí QR kód jako uhrazený (odstraní ho ze seznamu nevyřízených).
requestBody: requestBody:
required: true required: true
content: content:
application/json: application/json:
schema: schema:
properties: properties:
date: id:
description: Datum Pizza day, ke kterému se QR kód vztahuje description: Unikátní identifikátor QR kódu (z PendingQr.id)
type: string type: string
required: required:
- date - id
responses: responses:
"200": "200":
description: QR kód byl označen jako uhrazený. description: QR kód byl označen jako uhrazený.
+41 -8
View File
@@ -53,6 +53,11 @@ ClientData:
description: Datum a čas poslední aktualizace pizz description: Datum a čas poslední aktualizace pizz
type: string type: string
format: date-time format: date-time
salatList:
description: Seznam dostupných salátů pro předaný den
type: array
items:
$ref: "#/Salat"
pendingQrs: pendingQrs:
description: Nevyřízené QR kódy pro platbu z předchozích pizza day description: Nevyřízené QR kódy pro platbu z předchozích pizza day
type: array type: array
@@ -426,7 +431,7 @@ Pizza:
items: items:
$ref: "#/PizzaSize" $ref: "#/PizzaSize"
PizzaVariant: PizzaVariant:
description: Konkrétní varianta (velikost) jedné pizzy. description: Konkrétní varianta (velikost) jedné pizzy nebo salátu.
type: object type: object
additionalProperties: false additionalProperties: false
required: required:
@@ -436,16 +441,40 @@ PizzaVariant:
- price - price
properties: properties:
varId: varId:
description: Unikátní identifikátor varianty pizzy description: Unikátní identifikátor varianty
type: integer type: integer
name: name:
description: Název pizzy description: Název pizzy nebo salátu
type: string type: string
size: size:
description: Velikost pizzy (např. "30cm") description: Velikost pizzy (např. "30cm"), nebo "1 porce" pro salát
type: string type: string
price: price:
description: Cena pizzy v Kč, včetně krabice description: Cena v Kč, včetně krabice/obalu
type: number
category:
description: Kategorie položky (pizza nebo salat)
type: string
enum: [pizza, salat]
Salat:
description: Salát z nabídky Pizza Chefie
type: object
additionalProperties: false
required:
- name
- ingredients
- price
properties:
name:
description: Název salátu
type: string
ingredients:
description: Seznam obsažených ingrediencí
type: array
items:
type: string
price:
description: Cena salátu v Kč (bez obalu)
type: number type: number
PizzaOrder: PizzaOrder:
description: Údaje o objednávce pizzy jednoho uživatele. description: Údaje o objednávce pizzy jednoho uživatele.
@@ -635,19 +664,23 @@ ClearMockDataRequest:
# --- NEVYŘÍZENÉ QR KÓDY --- # --- NEVYŘÍZENÉ QR KÓDY ---
PendingQr: PendingQr:
description: Nevyřízený QR kód pro platbu z předchozího Pizza day description: Nevyřízený QR kód pro platbu
type: object type: object
additionalProperties: false additionalProperties: false
required: required:
- id
- date - date
- creator - creator
- totalPrice - totalPrice
properties: properties:
id:
description: Unikátní identifikátor QR kódu (umožňuje více QR na strávníka na den)
type: string
date: date:
description: Datum Pizza day, ke kterému se QR kód vztahuje description: Datum, ke kterému se QR kód vztahuje
type: string type: string
creator: creator:
description: Jméno zakladatele Pizza day (objednávajícího) description: Jméno uživatele, který QR vygeneroval (příjemce platby)
type: string type: string
totalPrice: totalPrice:
description: Celková cena objednávky v Kč description: Celková cena objednávky v Kč