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
38 changed files with 532 additions and 111 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
+83 -34
View File
@@ -289,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}`);
} }
@@ -315,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
@@ -339,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();
} }
} }
} }
@@ -387,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 } });
}
} }
} }
@@ -434,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;
@@ -584,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>)}
@@ -595,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>
</>} </>}
@@ -722,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>
} }
@@ -744,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>
} }
</> </>
@@ -760,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>
} }
</> </>
@@ -776,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>
} }
</> </>
@@ -798,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={_ => { }}
+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)}>
+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;
}
+2
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}`) });
@@ -168,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 [
{ {
+75 -5
View File
@@ -2,9 +2,9 @@ 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"; import crypto from "crypto";
const storage = getStorage(); const storage = getStorage();
@@ -39,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.
*/ */
@@ -49,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 } })
@@ -114,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.
@@ -271,7 +339,9 @@ export async function finishPizzaDelivery(login: string, bankAccount?: string, b
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
const id = crypto.randomUUID(); const id = crypto.randomUUID();
let message = order.pizzaList!.map(pizza => `Pizza ${pizza.name} (${pizza.size})`).join(', '); 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); 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í
+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;
+37 -21
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) => {
+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 -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ě.
+34 -5
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.