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} >}
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 && (
+ <>
+
+ Ne, zrušit
+
+
+ {loading ? 'Mažu...' : 'Ano, smazat'}
+
+ >
+ )}
+ {success && (
+
+ Zavřít
+
+ )}
+
+
+ );
+}
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))}
+ >
+ Aktuální den
+ {DAY_NAMES.map((name, index) => (
+ {name}
+ ))}
+
+
+ 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 && (
+ <>
+
+ Storno
+
+
+ {loading ? 'Generuji...' : 'Generovat'}
+
+ >
+ )}
+ {success && (
+
+ Zavřít
+
+ )}
+
+
+ );
+}
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í.
+
+ ) : (
+
+
+
+
+ Uživatel
+ Účel platby
+ Částka (Kč)
+
+
+
+ {users.map(user => (
+
+
+ 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}
+
+
+ Storno
+
+
+ {loading ? 'Generuji...' : 'Generovat'}
+
+ >
+ )}
+ {success && (
+
+ Zavřít
+
+ )}
+
+
+ );
+}
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