From cc98c2be0decb2233e8a68934b60542a829256fa Mon Sep 17 00:00:00 2001 From: Martin Berka Date: Fri, 20 Feb 2026 14:17:39 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20podpora=20ru=C4=8Dn=C3=ADho=20generov?= =?UTF-8?q?=C3=A1n=C3=AD=20QR=20k=C3=B3d=C5=AF=20pro=20platby?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/App.tsx | 9 +- client/src/Utils.tsx | 6 + client/src/components/Header.tsx | 61 ++++- .../components/modals/ClearMockDataModal.tsx | 104 +++++++ .../modals/GenerateMockDataModal.tsx | 140 ++++++++++ .../src/components/modals/GenerateQrModal.tsx | 255 ++++++++++++++++++ server/src/index.ts | 4 + server/src/pizza.ts | 3 +- server/src/routes/devRoutes.ts | 157 +++++++++++ server/src/routes/qrRoutes.ts | 64 +++++ types/api.yml | 8 + types/paths/dev/clear.yml | 23 ++ types/paths/dev/generate.yml | 25 ++ types/paths/qr/generate.yml | 16 ++ types/schemas/_index.yml | 67 +++++ 15 files changed, 935 insertions(+), 7 deletions(-) create mode 100644 client/src/components/modals/ClearMockDataModal.tsx create mode 100644 client/src/components/modals/GenerateMockDataModal.tsx create mode 100644 client/src/components/modals/GenerateQrModal.tsx create mode 100644 server/src/routes/devRoutes.ts create mode 100644 server/src/routes/qrRoutes.ts create mode 100644 types/paths/dev/clear.yml create mode 100644 types/paths/dev/generate.yml create mode 100644 types/paths/qr/generate.yml diff --git a/client/src/App.tsx b/client/src/App.tsx index b6b4279..1743c5c 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -15,7 +15,7 @@ import { useSettings } from './context/settings'; import Footer from './components/Footer'; import { faBasketShopping, faChainBroken, faChevronLeft, faChevronRight, faGear, faSatelliteDish, faSearch, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'; import Loader from './components/Loader'; -import { getHumanDateTime, isInTheFuture } from './Utils'; +import { getHumanDateTime, isInTheFuture, formatDateString } from './Utils'; import NoteModal from './components/modals/NoteModal'; import { useEasterEgg } from './context/eggs'; import { ClientData, Food, PizzaOrder, DepartureTime, PizzaDayState, Restaurant, RestaurantDayMenu, RestaurantDayMenuMap, LunchChoice, UserLunchChoice, PizzaVariant, getData, getEasterEggImage, addPizza, removePizza, updatePizzaDayNote, createPizzaDay, deletePizzaDay, lockPizzaDay, unlockPizzaDay, finishOrder, finishDelivery, addChoice, jdemeObed, removeChoices, removeChoice, updateNote, changeDepartureTime, setBuyer, dismissQr } from '../../types'; @@ -542,7 +542,7 @@ function App() { return (
{easterEgg && eggImage && } -
+
{data.todayDayIndex != null && data.todayDayIndex > 4 && @@ -821,11 +821,12 @@ function App() { {data.pendingQrs && data.pendingQrs.length > 0 &&

Nevyřízené platby

-

Máte neuhrazené QR kódy z předchozích Pizza day.

+

Máte neuhrazené platby z předchozích dní.

{data.pendingQrs.map(qr => (

- {qr.date} — {qr.creator} ({qr.totalPrice} Kč) + {formatDateString(qr.date)} — {qr.creator} ({qr.totalPrice} Kč) + {qr.purpose && <>
{qr.purpose}}

QR kód
diff --git a/client/src/Utils.tsx b/client/src/Utils.tsx index 1c89fb3..cb10b36 100644 --- a/client/src/Utils.tsx +++ b/client/src/Utils.tsx @@ -103,4 +103,10 @@ export function getHumanDate(date: Date) { let currentMonth = String(date.getMonth() + 1).padStart(2, "0"); let currentYear = date.getFullYear(); return `${currentDay}.${currentMonth}.${currentYear}`; +} + +/** Převede datum ve formátu YYYY-MM-DD na DD.MM.YYYY */ +export function formatDateString(dateString: string): string { + const [year, month, day] = dateString.split('-'); + return `${day}.${month}.${year}`; } \ No newline at end of file diff --git a/client/src/components/Header.tsx b/client/src/components/Header.tsx index 9fdd257..0af7f47 100644 --- a/client/src/components/Header.tsx +++ b/client/src/components/Header.tsx @@ -6,9 +6,12 @@ import { useSettings, ThemePreference } from "../context/settings"; import FeaturesVotingModal from "./modals/FeaturesVotingModal"; import PizzaCalculatorModal from "./modals/PizzaCalculatorModal"; import RefreshMenuModal from "./modals/RefreshMenuModal"; +import GenerateQrModal from "./modals/GenerateQrModal"; +import GenerateMockDataModal from "./modals/GenerateMockDataModal"; +import ClearMockDataModal from "./modals/ClearMockDataModal"; import { useNavigate } from "react-router"; import { STATS_URL } from "../AppRoutes"; -import { FeatureRequest, getVotes, updateVote } from "../../../types"; +import { FeatureRequest, getVotes, updateVote, LunchChoices } from "../../../types"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons"; @@ -16,9 +19,17 @@ const CHANGELOG = [ "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)", ]; -export default function Header() { +const IS_DEV = process.env.NODE_ENV === 'development'; + +type Props = { + choices?: LunchChoices; + dayIndex?: number; +}; + +export default function Header({ choices, dayIndex }: Props) { const auth = useAuth(); const settings = useSettings(); const navigate = useNavigate(); @@ -27,6 +38,9 @@ export default function Header() { const [pizzaModalOpen, setPizzaModalOpen] = useState(false); const [refreshMenuModalOpen, setRefreshMenuModalOpen] = useState(false); const [changelogModalOpen, setChangelogModalOpen] = useState(false); + const [qrModalOpen, setQrModalOpen] = useState(false); + const [generateMockModalOpen, setGenerateMockModalOpen] = useState(false); + const [clearMockModalOpen, setClearMockModalOpen] = useState(false); const [featureVotes, setFeatureVotes] = useState([]); // Zjistíme aktuální efektivní téma (pro zobrazení správné ikony) @@ -73,6 +87,18 @@ export default function Header() { setRefreshMenuModalOpen(false); } + const closeQrModal = () => { + setQrModalOpen(false); + } + + const handleQrMenuClick = () => { + if (!settings?.bankAccount || !settings?.holderName) { + alert('Pro generování QR kódů je nutné mít v nastavení vyplněné číslo účtu a jméno držitele účtu.'); + return; + } + setQrModalOpen(true); + } + const toggleTheme = () => { // Přepínáme mezi light a dark (ignorujeme system pro jednoduchost) const newTheme: ThemePreference = effectiveTheme === 'dark' ? 'light' : 'dark'; @@ -169,8 +195,16 @@ export default function Header() { setRefreshMenuModalOpen(true)}>Přenačtení menu setVotingModalOpen(true)}>Hlasovat o nových funkcích setPizzaModalOpen(true)}>Pizza kalkulačka + Generování QR kódů navigate(STATS_URL)}>Statistiky setChangelogModalOpen(true)}>Novinky + {IS_DEV && ( + <> + + setGenerateMockModalOpen(true)}>🔧 Generovat mock data + setClearMockModalOpen(true)}>🔧 Smazat data dne + + )} Odhlásit se @@ -180,6 +214,29 @@ export default function Header() { + {choices && settings?.bankAccount && settings?.holderName && ( + + )} + {IS_DEV && ( + <> + setGenerateMockModalOpen(false)} + currentDayIndex={dayIndex} + /> + setClearMockModalOpen(false)} + currentDayIndex={dayIndex} + /> + + )} setChangelogModalOpen(false)}>

Novinky

diff --git a/client/src/components/modals/ClearMockDataModal.tsx b/client/src/components/modals/ClearMockDataModal.tsx new file mode 100644 index 0000000..e1b9492 --- /dev/null +++ b/client/src/components/modals/ClearMockDataModal.tsx @@ -0,0 +1,104 @@ +import { useState } from "react"; +import { Modal, Button, Alert } from "react-bootstrap"; +import { clearMockData, DayIndex } from "../../../../types"; + +type Props = { + isOpen: boolean; + onClose: () => void; + currentDayIndex?: number; +}; + +const DAY_NAMES = ['Pondělí', 'Úterý', 'Středa', 'Čtvrtek', 'Pátek']; + +/** Modální dialog pro smazání mock dat (pouze DEV). */ +export default function ClearMockDataModal({ isOpen, onClose, currentDayIndex }: Readonly) { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + const handleClear = async () => { + setError(null); + setLoading(true); + + try { + const body: any = {}; + if (currentDayIndex !== undefined) { + body.dayIndex = currentDayIndex as DayIndex; + } + + const response = await clearMockData({ body }); + if (response.error) { + setError((response.error as any).error || 'Nastala chyba při mazání dat'); + } else { + setSuccess(true); + setTimeout(() => { + onClose(); + setSuccess(false); + }, 1500); + } + } catch (e: any) { + setError(e.message || 'Nastala chyba při mazání dat'); + } finally { + setLoading(false); + } + }; + + const handleClose = () => { + setError(null); + setSuccess(false); + onClose(); + }; + + const dayName = currentDayIndex !== undefined ? DAY_NAMES[currentDayIndex] : 'aktuální den'; + + return ( + + +

Smazat data

+
+ + {success ? ( + + Data byla úspěšně smazána! + + ) : ( + <> + + DEV režim - Tato funkce je dostupná pouze ve vývojovém prostředí. + + + {error && ( + setError(null)} dismissible> + {error} + + )} + +

+ Opravdu chcete smazat všechny volby stravování pro {dayName}? +

+

+ Tato akce je nevratná. +

+ + )} +
+ + {!success && ( + <> + + + + )} + {success && ( + + )} + +
+ ); +} diff --git a/client/src/components/modals/GenerateMockDataModal.tsx b/client/src/components/modals/GenerateMockDataModal.tsx new file mode 100644 index 0000000..cb0cc30 --- /dev/null +++ b/client/src/components/modals/GenerateMockDataModal.tsx @@ -0,0 +1,140 @@ +import { useState } from "react"; +import { Modal, Button, Form, Alert } from "react-bootstrap"; +import { generateMockData, DayIndex } from "../../../../types"; + +type Props = { + isOpen: boolean; + onClose: () => void; + currentDayIndex?: number; +}; + +const DAY_NAMES = ['Pondělí', 'Úterý', 'Středa', 'Čtvrtek', 'Pátek']; + +/** Modální dialog pro generování mock dat (pouze DEV). */ +export default function GenerateMockDataModal({ isOpen, onClose, currentDayIndex }: Readonly) { + const [dayIndex, setDayIndex] = useState(currentDayIndex); + const [count, setCount] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + const handleGenerate = async () => { + setError(null); + setLoading(true); + + try { + const body: any = {}; + if (dayIndex !== undefined) { + body.dayIndex = dayIndex as DayIndex; + } + if (count && count.trim() !== '') { + const countNum = parseInt(count, 10); + if (isNaN(countNum) || countNum < 1 || countNum > 100) { + setError('Počet musí být číslo mezi 1 a 100'); + setLoading(false); + return; + } + body.count = countNum; + } + + const response = await generateMockData({ body }); + if (response.error) { + setError((response.error as any).error || 'Nastala chyba při generování dat'); + } else { + setSuccess(true); + setTimeout(() => { + onClose(); + setSuccess(false); + setCount(''); + }, 1500); + } + } catch (e: any) { + setError(e.message || 'Nastala chyba při generování dat'); + } finally { + setLoading(false); + } + }; + + const handleClose = () => { + setError(null); + setSuccess(false); + setCount(''); + onClose(); + }; + + return ( + + +

Generovat mock data

+
+ + {success ? ( + + Mock data byla úspěšně vygenerována! + + ) : ( + <> + + DEV režim - Tato funkce je dostupná pouze ve vývojovém prostředí. + + + {error && ( + setError(null)} dismissible> + {error} + + )} + + + Den + setDayIndex(e.target.value === '' ? undefined : parseInt(e.target.value, 10))} + > + + {DAY_NAMES.map((name, index) => ( + + ))} + + + Pokud není vybráno, použije se aktuální den. + + + + + Počet záznamů + setCount(e.target.value)} + min={1} + max={100} + onKeyDown={e => e.stopPropagation()} + /> + + Pokud není zadáno, vybere se náhodný počet 5-20. + + + + )} + + + {!success && ( + <> + + + + )} + {success && ( + + )} + +
+ ); +} diff --git a/client/src/components/modals/GenerateQrModal.tsx b/client/src/components/modals/GenerateQrModal.tsx new file mode 100644 index 0000000..0f1df55 --- /dev/null +++ b/client/src/components/modals/GenerateQrModal.tsx @@ -0,0 +1,255 @@ +import { useState, useEffect, useCallback } from "react"; +import { Modal, Button, Form, Table, Alert } from "react-bootstrap"; +import { generateQr, LunchChoices, QrRecipient } from "../../../../types"; + +type UserEntry = { + login: string; + selected: boolean; + purpose: string; + amount: string; +}; + +type Props = { + isOpen: boolean; + onClose: () => void; + choices: LunchChoices; + bankAccount: string; + bankAccountHolder: string; +}; + +/** Modální dialog pro generování QR kódů pro platbu. */ +export default function GenerateQrModal({ isOpen, onClose, choices, bankAccount, bankAccountHolder }: Readonly) { + const [users, setUsers] = useState([]); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(false); + + // Při otevření modálu načteme seznam uživatelů z choices + useEffect(() => { + if (isOpen && choices) { + const userLogins = new Set(); + // Projdeme všechny lokace a získáme unikátní loginy + Object.values(choices).forEach(locationChoices => { + if (locationChoices) { + Object.keys(locationChoices).forEach(login => { + userLogins.add(login); + }); + } + }); + // Vytvoříme seznam uživatelů + const userList: UserEntry[] = Array.from(userLogins) + .sort((a, b) => a.localeCompare(b, 'cs')) + .map(login => ({ + login, + selected: false, + purpose: '', + amount: '', + })); + setUsers(userList); + setError(null); + setSuccess(false); + } + }, [isOpen, choices]); + + const handleCheckboxChange = useCallback((login: string, checked: boolean) => { + setUsers(prev => prev.map(u => + u.login === login ? { ...u, selected: checked } : u + )); + }, []); + + const handlePurposeChange = useCallback((login: string, value: string) => { + setUsers(prev => prev.map(u => + u.login === login ? { ...u, purpose: value } : u + )); + }, []); + + const handleAmountChange = useCallback((login: string, value: string) => { + // Povolíme pouze čísla, tečku a čárku + const sanitized = value.replace(/[^0-9.,]/g, '').replace(',', '.'); + setUsers(prev => prev.map(u => + u.login === login ? { ...u, amount: sanitized } : u + )); + }, []); + + const validateAmount = (amountStr: string): number | null => { + if (!amountStr || amountStr.trim().length === 0) { + return null; + } + const amount = parseFloat(amountStr); + if (isNaN(amount) || amount <= 0) { + return null; + } + // Max 2 desetinná místa + const parts = amountStr.split('.'); + if (parts.length === 2 && parts[1].length > 2) { + return null; + } + return Math.round(amount * 100) / 100; // Zaokrouhlíme na 2 desetinná místa + }; + + const handleGenerate = async () => { + setError(null); + const selectedUsers = users.filter(u => u.selected); + + if (selectedUsers.length === 0) { + setError("Nebyl vybrán žádný uživatel"); + return; + } + + // Validace + const recipients: QrRecipient[] = []; + for (const user of selectedUsers) { + if (!user.purpose || user.purpose.trim().length === 0) { + setError(`Uživatel ${user.login} nemá vyplněný účel platby`); + return; + } + const amount = validateAmount(user.amount); + if (amount === null) { + setError(`Uživatel ${user.login} má neplatnou částku (musí být kladné číslo s max. 2 desetinnými místy)`); + return; + } + recipients.push({ + login: user.login, + purpose: user.purpose.trim(), + amount, + }); + } + + 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); + // Po 2 sekundách zavřeme modal + setTimeout(() => { + onClose(); + }, 2000); + } + } catch (e: any) { + setError(e.message || 'Nastala chyba při generování QR kódů'); + } finally { + setLoading(false); + } + }; + + const handleClose = () => { + setError(null); + setSuccess(false); + onClose(); + }; + + const selectedCount = users.filter(u => u.selected).length; + + return ( + + +

Generování QR kódů

+
+ + {success ? ( + + QR kódy byly úspěšně vygenerovány! Uživatelé je uvidí v sekci "Nevyřízené platby". + + ) : ( + <> +

+ Vyberte uživatele, kterým chcete vygenerovat QR kód pro platbu. + QR kódy se uživatelům zobrazí v sekci "Nevyřízené platby". +

+ + {error && ( + setError(null)} dismissible> + {error} + + )} + + {users.length === 0 ? ( + + V tento den nemá žádný uživatel zvolenou možnost stravování. + + ) : ( + + + + + + + + + + + {users.map(user => ( + + + + + + + ))} + +
UživatelÚčel platbyČástka (Kč)
+ handleCheckboxChange(user.login, e.target.checked)} + /> + {user.login} + handlePurposeChange(user.login, e.target.value)} + disabled={!user.selected} + size="sm" + onKeyDown={e => e.stopPropagation()} + /> + + handleAmountChange(user.login, e.target.value)} + disabled={!user.selected} + size="sm" + onKeyDown={e => e.stopPropagation()} + /> +
+ )} + + )} +
+ + {!success && ( + <> + + Vybráno: {selectedCount} / {users.length} + + + + + )} + {success && ( + + )} + +
+ ); +} diff --git a/server/src/index.ts b/server/src/index.ts index bc6a931..7aec7a7 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -15,6 +15,8 @@ import votingRoutes from "./routes/votingRoutes"; import easterEggRoutes from "./routes/easterEggRoutes"; import statsRoutes from "./routes/statsRoutes"; import notificationRoutes from "./routes/notificationRoutes"; +import qrRoutes from "./routes/qrRoutes"; +import devRoutes from "./routes/devRoutes"; const ENVIRONMENT = process.env.NODE_ENV ?? 'production'; dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) }); @@ -160,6 +162,8 @@ app.use("/api/voting", votingRoutes); app.use("/api/easterEggs", easterEggRoutes); app.use("/api/stats", statsRoutes); app.use("/api/notifications", notificationRoutes); +app.use("/api/qr", qrRoutes); +app.use("/api/dev", devRoutes); app.use('/stats', express.static('public')); app.use(express.static('public')); diff --git a/server/src/pizza.ts b/server/src/pizza.ts index 6e3166d..a9434c8 100644 --- a/server/src/pizza.ts +++ b/server/src/pizza.ts @@ -277,6 +277,7 @@ export async function finishPizzaDelivery(login: string, bankAccount?: string, b date: today, creator: login, totalPrice: order.totalPrice, + purpose: message, }); } } @@ -356,7 +357,7 @@ function getPendingQrKey(login: string): string { /** * Přidá nevyřízený QR kód pro uživatele. */ -async function addPendingQr(login: string, pendingQr: PendingQr): Promise { +export async function addPendingQr(login: string, pendingQr: PendingQr): Promise { const key = getPendingQrKey(login); const existing = await storage.getData(key) ?? []; // Nepřidáváme duplicity pro stejný den diff --git a/server/src/routes/devRoutes.ts b/server/src/routes/devRoutes.ts new file mode 100644 index 0000000..99aa1bb --- /dev/null +++ b/server/src/routes/devRoutes.ts @@ -0,0 +1,157 @@ +import express, { Request } from "express"; +import { getDateForWeekIndex, getData, getRestaurantMenu, getToday, initIfNeeded } from "../service"; +import { formatDate, getDayOfWeekIndex } from "../utils"; +import getStorage from "../storage"; +import { getWebsocket } from "../websocket"; +import { GenerateMockDataData, ClearMockDataData, LunchChoice, Restaurant } from "../../../types"; + +const router = express.Router(); +const storage = getStorage(); +const ENVIRONMENT = process.env.NODE_ENV ?? 'production'; + +// Seznam náhodných jmen pro generování mock dat +const MOCK_NAMES = [ + 'Alice', 'Bob', 'Charlie', 'David', 'Eva', 'Filip', 'Gita', 'Honza', + 'Ivana', 'Jakub', 'Kamila', 'Lukáš', 'Markéta', 'Nikola', 'Ondřej', + 'Petra', 'Quido', 'Radek', 'Simona', 'Tomáš', 'Ursula', 'Viktor', + 'Wanda', 'Xaver', 'Yvona', 'Zdeněk', 'Aneta', 'Boris', 'Cecílie', 'Daniel' +]; + +// Volby stravování pro mock data +const LUNCH_CHOICES: LunchChoice[] = [ + LunchChoice.SLADOVNICKA, + LunchChoice.TECHTOWER, + LunchChoice.ZASTAVKAUMICHALA, + LunchChoice.SENKSERIKOVA, + LunchChoice.OBJEDNAVAM, + LunchChoice.NEOBEDVAM, + LunchChoice.ROZHODUJI, +]; + +// Restaurace s menu +const RESTAURANTS_WITH_MENU: LunchChoice[] = [ + LunchChoice.SLADOVNICKA, + LunchChoice.TECHTOWER, + LunchChoice.ZASTAVKAUMICHALA, + LunchChoice.SENKSERIKOVA, +]; + +/** + * Middleware pro kontrolu DEV režimu + */ +function requireDevMode(req: any, res: any, next: any) { + if (ENVIRONMENT !== 'development') { + return res.status(403).json({ error: 'Tento endpoint je dostupný pouze ve vývojovém režimu' }); + } + next(); +} + +router.use(requireDevMode); + +/** + * Vygeneruje mock data pro testování. + */ +router.post("/generate", async (req: Request<{}, any, GenerateMockDataData["body"]>, res, next) => { + try { + const dayIndex = req.body?.dayIndex ?? getDayOfWeekIndex(getToday()); + const count = req.body?.count ?? Math.floor(Math.random() * 16) + 5; // 5-20 + + if (dayIndex < 0 || dayIndex > 4) { + return res.status(400).json({ error: 'Neplatný index dne (0-4)' }); + } + + const date = getDateForWeekIndex(dayIndex); + await initIfNeeded(date); + + const dateKey = formatDate(date); + const data = await storage.getData(dateKey); + + // Získání menu restaurací pro vybraný den + const menus: { [key: string]: any } = {}; + for (const restaurant of RESTAURANTS_WITH_MENU) { + const menu = await getRestaurantMenu(restaurant as Restaurant, date); + if (menu?.food?.length) { + menus[restaurant] = menu.food; + } + } + + // Vygenerování náhodných uživatelů + const usedNames = new Set(); + for (let i = 0; i < count && usedNames.size < MOCK_NAMES.length; i++) { + // Vybereme náhodné jméno, které ještě nebylo použito + let name: string; + do { + name = MOCK_NAMES[Math.floor(Math.random() * MOCK_NAMES.length)]; + } while (usedNames.has(name)); + usedNames.add(name); + + // Vybereme náhodnou volbu stravování + const choice = LUNCH_CHOICES[Math.floor(Math.random() * LUNCH_CHOICES.length)]; + + // Inicializace struktury pro volbu + data.choices[choice] ??= {}; + + const userChoice: any = { + trusted: false, + selectedFoods: [], + }; + + // Pokud má restaurace menu, vybereme náhodné jídlo + if (RESTAURANTS_WITH_MENU.includes(choice) && menus[choice]?.length) { + const foods = menus[choice]; + // Vybereme náhodné jídlo (ne polévku) + const mainFoods = foods.filter((f: any) => !f.isSoup); + if (mainFoods.length > 0) { + const randomFoodIndex = foods.indexOf(mainFoods[Math.floor(Math.random() * mainFoods.length)]); + userChoice.selectedFoods = [randomFoodIndex]; + } + } + + data.choices[choice][name] = userChoice; + } + + await storage.setData(dateKey, data); + + // Odeslat aktualizovaná data přes WebSocket + const clientData = await getData(date); + getWebsocket().emit("message", clientData); + + res.status(200).json({ success: true, count: usedNames.size, dayIndex }); + } catch (e: any) { + next(e); + } +}); + +/** + * Smaže všechny volby pro daný den. + */ +router.post("/clear", async (req: Request<{}, any, ClearMockDataData["body"]>, res, next) => { + try { + const dayIndex = req.body?.dayIndex ?? getDayOfWeekIndex(getToday()); + + if (dayIndex < 0 || dayIndex > 4) { + return res.status(400).json({ error: 'Neplatný index dne (0-4)' }); + } + + const date = getDateForWeekIndex(dayIndex); + await initIfNeeded(date); + + const dateKey = formatDate(date); + const data = await storage.getData(dateKey); + + // Vymažeme všechny volby + data.choices = {}; + + await storage.setData(dateKey, data); + + // Odeslat aktualizovaná data přes WebSocket + const clientData = await getData(date); + getWebsocket().emit("message", clientData); + + res.status(200).json({ success: true, dayIndex }); + } catch (e: any) { + next(e); + } +}); + +export default router; diff --git a/server/src/routes/qrRoutes.ts b/server/src/routes/qrRoutes.ts new file mode 100644 index 0000000..e89a217 --- /dev/null +++ b/server/src/routes/qrRoutes.ts @@ -0,0 +1,64 @@ +import express, { Request } from "express"; +import { getLogin } from "../auth"; +import { parseToken, formatDate } from "../utils"; +import { generateQr } from "../qr"; +import { addPendingQr } from "../pizza"; +import { GenerateQrData } from "../../../types"; + +const router = express.Router(); + +/** + * Vygeneruje QR kódy pro platbu vybraným uživatelům. + */ +router.post("/generate", async (req: Request<{}, any, GenerateQrData["body"]>, res, next) => { + const login = getLogin(parseToken(req)); + try { + const { recipients, bankAccount, bankAccountHolder } = req.body; + + if (!recipients || !Array.isArray(recipients) || recipients.length === 0) { + return res.status(400).json({ error: "Nebyl předán seznam příjemců" }); + } + if (!bankAccount) { + return res.status(400).json({ error: "Nebylo předáno číslo účtu" }); + } + if (!bankAccountHolder) { + return res.status(400).json({ error: "Nebylo předáno jméno držitele účtu" }); + } + + const today = formatDate(new Date()); + + for (const recipient of recipients) { + if (!recipient.login) { + return res.status(400).json({ error: "Příjemce nemá vyplněný login" }); + } + if (!recipient.purpose || recipient.purpose.trim().length === 0) { + return res.status(400).json({ error: `Příjemce ${recipient.login} nemá vyplněný účel platby` }); + } + if (typeof recipient.amount !== 'number' || recipient.amount <= 0) { + return res.status(400).json({ error: `Příjemce ${recipient.login} má neplatnou částku` }); + } + // Validace max 2 desetinná místa + const amountStr = recipient.amount.toString(); + if (amountStr.includes('.') && amountStr.split('.')[1].length > 2) { + return res.status(400).json({ error: `Částka pro ${recipient.login} má více než 2 desetinná místa` }); + } + + // Vygenerovat QR kód + await generateQr(recipient.login, bankAccount, bankAccountHolder, recipient.amount, recipient.purpose); + + // Uložit jako nevyřízený QR kód + await addPendingQr(recipient.login, { + date: today, + creator: login, + totalPrice: recipient.amount, + purpose: recipient.purpose, + }); + } + + res.status(200).json({ success: true, count: recipients.length }); + } catch (e: any) { + next(e); + } +}); + +export default router; diff --git a/types/api.yml b/types/api.yml index 8f9df9a..dc0dab3 100644 --- a/types/api.yml +++ b/types/api.yml @@ -10,6 +10,8 @@ paths: $ref: "./paths/login.yml" /qr: $ref: "./paths/getPizzaQr.yml" + /qr/generate: + $ref: "./paths/qr/generate.yml" /data: $ref: "./paths/getData.yml" @@ -75,6 +77,12 @@ paths: /voting/stats: $ref: "./paths/voting/getVotingStats.yml" + # DEV endpointy (/api/dev) + /dev/generate: + $ref: "./paths/dev/generate.yml" + /dev/clear: + $ref: "./paths/dev/clear.yml" + components: schemas: $ref: "./schemas/_index.yml" diff --git a/types/paths/dev/clear.yml b/types/paths/dev/clear.yml new file mode 100644 index 0000000..cebe229 --- /dev/null +++ b/types/paths/dev/clear.yml @@ -0,0 +1,23 @@ +post: + operationId: clearMockData + summary: Smazání všech voleb pro daný den (pouze DEV režim) + requestBody: + required: false + content: + application/json: + schema: + $ref: "../../schemas/_index.yml#/ClearMockDataRequest" + responses: + "200": + description: Data byla úspěšně smazána + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + dayIndex: + type: integer + "403": + description: Endpoint není dostupný v tomto režimu diff --git a/types/paths/dev/generate.yml b/types/paths/dev/generate.yml new file mode 100644 index 0000000..a4b1ab4 --- /dev/null +++ b/types/paths/dev/generate.yml @@ -0,0 +1,25 @@ +post: + operationId: generateMockData + summary: Vygenerování mock dat pro testování (pouze DEV režim) + requestBody: + required: false + content: + application/json: + schema: + $ref: "../../schemas/_index.yml#/GenerateMockDataRequest" + responses: + "200": + description: Mock data byla úspěšně vygenerována + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + count: + type: integer + dayIndex: + type: integer + "403": + description: Endpoint není dostupný v tomto režimu diff --git a/types/paths/qr/generate.yml b/types/paths/qr/generate.yml new file mode 100644 index 0000000..347acc7 --- /dev/null +++ b/types/paths/qr/generate.yml @@ -0,0 +1,16 @@ +post: + operationId: generateQr + summary: Vygenerování QR kódů pro platbu vybraným uživatelům + requestBody: + required: true + content: + application/json: + schema: + $ref: "../../schemas/_index.yml#/GenerateQrRequest" + responses: + "200": + description: QR kódy byly úspěšně vygenerovány + "400": + description: Neplatný požadavek (chybějící nebo nevalidní data) + "401": + description: Neautentizovaný uživatel diff --git a/types/schemas/_index.yml b/types/schemas/_index.yml index 7e6990f..f19e175 100644 --- a/types/schemas/_index.yml +++ b/types/schemas/_index.yml @@ -563,6 +563,70 @@ GotifyServer: items: type: string +# --- GENEROVÁNÍ QR KÓDŮ --- +QrRecipient: + description: Příjemce QR kódu pro platbu + type: object + additionalProperties: false + required: + - login + - purpose + - amount + properties: + login: + description: Přihlašovací jméno uživatele, kterému bude vygenerován QR kód + type: string + purpose: + description: Účel platby (např. "Pizza prosciutto") + type: string + amount: + description: Částka v Kč (kladné číslo, max 2 desetinná místa) + type: number + minimum: 0.01 +GenerateQrRequest: + description: Request pro generování QR kódů + type: object + additionalProperties: false + required: + - recipients + - bankAccount + - bankAccountHolder + properties: + recipients: + description: Seznam příjemců QR kódů + type: array + items: + $ref: "#/QrRecipient" + bankAccount: + description: Číslo bankovního účtu odesílatele ve formátu BBAN + type: string + bankAccountHolder: + description: Jméno držitele bankovního účtu + type: string + +# --- DEV MOCK DATA --- +GenerateMockDataRequest: + description: Request pro generování mock dat (pouze DEV režim) + type: object + additionalProperties: false + properties: + dayIndex: + description: Index dne v týdnu (0 = pondělí, 4 = pátek). Pokud není zadán, použije se aktuální den. + $ref: "#/DayIndex" + count: + description: Počet záznamů k vygenerování. Pokud není zadán, vybere se náhodný počet 5-20. + type: integer + minimum: 1 + maximum: 100 +ClearMockDataRequest: + description: Request pro smazání mock dat (pouze DEV režim) + type: object + additionalProperties: false + properties: + dayIndex: + description: Index dne v týdnu (0 = pondělí, 4 = pátek). Pokud není zadán, použije se aktuální den. + $ref: "#/DayIndex" + # --- NEVYŘÍZENÉ QR KÓDY --- PendingQr: description: Nevyřízený QR kód pro platbu z předchozího Pizza day @@ -582,3 +646,6 @@ PendingQr: totalPrice: description: Celková cena objednávky v Kč type: number + purpose: + description: Účel platby (např. "Pizza prosciutto") + type: string