From 2e8774900ff1b03bb850a0f46b5b84fea2a314f7 Mon Sep 17 00:00:00 2001 From: Martin Berka Date: Tue, 2 Dec 2025 11:46:52 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Z=C3=A1klad=20generov=C3=A1n=C3=AD=20QR?= =?UTF-8?q?=20k=C3=B3d=C5=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/App.tsx | 2 +- client/src/components/Header.tsx | 14 +- .../src/components/modals/GenerateQRModal.tsx | 176 ++++++++++++++++++ client/src/pages/StatsPage.tsx | 2 +- server/src/index.ts | 5 + server/src/mock.ts | 122 +++++++++++- server/src/routes/debugRoutes.ts | 45 +++++ server/src/routes/qrRoutes.ts | 15 ++ types/api.yml | 4 + types/paths/qr/generateQr.yml | 12 ++ types/schemas/_index.yml | 37 ++++ 11 files changed, 430 insertions(+), 4 deletions(-) create mode 100644 client/src/components/modals/GenerateQRModal.tsx create mode 100644 server/src/routes/debugRoutes.ts create mode 100644 server/src/routes/qrRoutes.ts create mode 100644 types/paths/qr/generateQr.yml diff --git a/client/src/App.tsx b/client/src/App.tsx index 6725bc5..29fa0b1 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -452,7 +452,7 @@ function App() { return (
{easterEgg && eggImage && } -
+
{isTodayWeekend ?

Užívejte víkend :)

: <> diff --git a/client/src/components/Header.tsx b/client/src/components/Header.tsx index adbb2d7..4a0dc4a 100644 --- a/client/src/components/Header.tsx +++ b/client/src/components/Header.tsx @@ -6,11 +6,16 @@ import { useSettings } from "../context/settings"; import FeaturesVotingModal from "./modals/FeaturesVotingModal"; import PizzaCalculatorModal from "./modals/PizzaCalculatorModal"; import RefreshMenuModal from "./modals/RefreshMenuModal"; +import GenerateQRModal from "./modals/GenerateQRModal"; import { useNavigate } from "react-router"; import { STATS_URL } from "../AppRoutes"; import { FeatureRequest, getVotes, updateVote } from "../../../types"; -export default function Header() { +type Props = { + dayIndex?: number; +} + +export default function Header({ dayIndex }: Readonly) { const auth = useAuth(); const settings = useSettings(); const navigate = useNavigate(); @@ -18,6 +23,7 @@ export default function Header() { const [votingModalOpen, setVotingModalOpen] = useState(false); const [pizzaModalOpen, setPizzaModalOpen] = useState(false); const [refreshMenuModalOpen, setRefreshMenuModalOpen] = useState(false); + const [generateQRModalOpen, setGenerateQRModalOpen] = useState(false); const [featureVotes, setFeatureVotes] = useState([]); useEffect(() => { @@ -44,6 +50,10 @@ export default function Header() { setRefreshMenuModalOpen(false); } + const closeGenerateQRModal = () => { + setGenerateQRModalOpen(false); + } + const isValidInteger = (str: string) => { str = str.trim(); if (!str) { @@ -121,6 +131,7 @@ export default function Header() { setSettingsModalOpen(true)}>Nastavení setRefreshMenuModalOpen(true)}>Přenačtení menu + setGenerateQRModalOpen(true)}>Generování QR setVotingModalOpen(true)}>Hlasovat o nových funkcích setPizzaModalOpen(true)}>Pizza kalkulačka navigate(STATS_URL)}>Statistiky @@ -131,6 +142,7 @@ export default function Header() { + diff --git a/client/src/components/modals/GenerateQRModal.tsx b/client/src/components/modals/GenerateQRModal.tsx new file mode 100644 index 0000000..82e5dab --- /dev/null +++ b/client/src/components/modals/GenerateQRModal.tsx @@ -0,0 +1,176 @@ +import { useEffect, useState } from "react"; +import { Modal, Button, Table, Form, Alert } from "react-bootstrap"; +import { ClientData, generateQr, getData } from "../../../../types"; + +type Props = { + isOpen: boolean, + onClose: () => void, + dayIndex?: number, + bankAccount?: string, + bankAccountHolder?: string, +} + +type UserQRData = { + login: string; + selected: boolean; + note: string; + amount: string; +} + +/** Modální dialog pro generování QR kódů. */ +export default function GenerateQRModal({ isOpen, onClose, dayIndex, bankAccount, bankAccountHolder }: Readonly) { + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(false); + + const isBankDataValid = bankAccount && bankAccountHolder; + + useEffect(() => { + if (isOpen) { + setLoading(true); + getData({ query: { dayIndex } }).then(response => { + const data: ClientData = response.data; + const userList: UserQRData[] = []; + + // Projdeme všechny volby stravování a získáme uživatele + if (data.choices) { + Object.entries(data.choices).forEach(([locationKey, locationUsers]) => { + Object.keys(locationUsers).forEach(login => { + // Přidáme uživatele pouze pokud tam ještě není + if (!userList.find(u => u.login === login)) { + userList.push({ + login, + selected: false, + note: '', + amount: '' + }); + } + }); + }); + } + + setUsers(userList); + setLoading(false); + }).catch(() => { + setLoading(false); + }); + } + }, [isOpen, dayIndex]); + + const handleCheckboxChange = (login: string) => { + setUsers(users.map(u => + u.login === login ? { ...u, selected: !u.selected } : u + )); + }; + + const handleNoteChange = (login: string, note: string) => { + setUsers(users.map(u => + u.login === login ? { ...u, note } : u + )); + }; + + const handleAmountChange = (login: string, amount: string) => { + setUsers(users.map(u => + u.login === login ? { ...u, amount } : u + )); + }; + + const handleGenerate = async () => { + const selectedUsers = users.filter(u => u.selected); + // TODO: Implementovat generování QR kódů + console.log('Generování QR pro:', selectedUsers); + alert('Funkce generování QR bude implementována'); + await generateQr({ + body: { + bankAccount: bankAccount!, + bankAccountHolder: bankAccountHolder!, + qrCodes: selectedUsers.map(u => ({ + login: u.login, + des: u.note, + amount: Number.parseFloat(u.amount) + })) + }, + }) + }; + + const handleClose = () => { + setUsers([]); + onClose(); + }; + + return ( + + +

Generování QR kódů

+
+ + {!isBankDataValid && ( + + Upozornění: Pro generování QR kódů je nutné mít v nastavení vyplněné číslo bankovního účtu a jméno majitele účtu. + + )} + {loading ? ( +

Načítání uživatelů...

+ ) : users.length === 0 ? ( +

Pro aktuální den nemá žádný uživatel vybranou volbu stravování.

+ ) : ( + + + + + + + + + + + {users.map(user => ( + + + + + + + ))} + +
UživatelPoznámkaČástka (Kč)
+ handleCheckboxChange(user.login)} + /> + {user.login} + handleNoteChange(user.login, e.target.value)} + placeholder="Poznámka" + disabled={!user.selected} + /> + + handleAmountChange(user.login, e.target.value)} + placeholder="0.00" + disabled={!user.selected} + /> +
+ )} +
+ + + + +
+ ); +} diff --git a/client/src/pages/StatsPage.tsx b/client/src/pages/StatsPage.tsx index 33f83de..5c53ae6 100644 --- a/client/src/pages/StatsPage.tsx +++ b/client/src/pages/StatsPage.tsx @@ -103,7 +103,7 @@ export default function StatsPage() { return ( <> -
+

Statistiky

diff --git a/server/src/index.ts b/server/src/index.ts index 4fada8e..631c264 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -13,6 +13,8 @@ import foodRoutes, { refreshMetoda } from "./routes/foodRoutes"; import votingRoutes from "./routes/votingRoutes"; import easterEggRoutes from "./routes/easterEggRoutes"; import statsRoutes from "./routes/statsRoutes"; +import debugRoutes from "./routes/debugRoutes"; +import qrRoutes from "./routes/qrRoutes"; const ENVIRONMENT = process.env.NODE_ENV ?? 'production'; dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) }); @@ -99,6 +101,8 @@ app.get("/api/qr", (req, res) => { // Přeskočení auth pro refresh dat xd app.use("/api/food/refresh", refreshMetoda); +app.use("/api/debug", debugRoutes); + /** Middleware ověřující JWT token */ app.use("/api/", (req, res, next) => { if (HTTP_REMOTE_USER_ENABLED) { @@ -143,6 +147,7 @@ app.use("/api/food", foodRoutes); app.use("/api/voting", votingRoutes); app.use("/api/easterEggs", easterEggRoutes); app.use("/api/stats", statsRoutes); +app.use("/api/qr", qrRoutes); app.use('/stats', express.static('public')); app.use(express.static('public')); diff --git a/server/src/mock.ts b/server/src/mock.ts index 68ecb13..eb2c340 100644 --- a/server/src/mock.ts +++ b/server/src/mock.ts @@ -517,6 +517,24 @@ const MOCK_DATA = { name: "Pečené vepřové koleno, křen, hořčice, chléb", price: "320\xA0Kč", isSoup: false, + }, + { + amount: "-", + name: "Slovácké strapačky s uzenou slaninou, zelím, mletým pepřem & sekanou petrželkou", + price: "140\xA0Kč", + isSoup: false, + }, + { + amount: "-", + name: "Hovězí guláš s vejcem, zeleninovou garniturkou & žemlovými knedlíky", + price: "145\xA0Kč", + isSoup: false, + }, + { + amount: "-", + name: "Kuřecí roláda s kaštanovou nádivkou, demi-glace & smetanovou bramborovou kaší", + price: "150\xA0Kč", + isSoup: false, } ], [ @@ -531,6 +549,24 @@ const MOCK_DATA = { name: "Poutine (trhané vepřové, hranolky, sýr, čalamáda, pikantní omáčka)", price: "190\xA0Kč", isSoup: false, + }, + { + amount: "-", + name: "Slovácké strapačky s uzenou slaninou, zelím, mletým pepřem & sekanou petrželkou", + price: "140\xA0Kč", + isSoup: false, + }, + { + amount: "-", + name: "Hovězí guláš s vejcem, zeleninovou garniturkou & žemlovými knedlíky", + price: "145\xA0Kč", + isSoup: false, + }, + { + amount: "-", + name: "Kuřecí roláda s kaštanovou nádivkou, demi-glace & smetanovou bramborovou kaší", + price: "150\xA0Kč", + isSoup: false, } ], [ @@ -545,6 +581,24 @@ const MOCK_DATA = { name: "Vepřový řízek z kotlety, domácí bramborový salát", price: "170\xA0Kč", isSoup: false, + }, + { + amount: "-", + name: "Slovácké strapačky s uzenou slaninou, zelím, mletým pepřem & sekanou petrželkou", + price: "140\xA0Kč", + isSoup: false, + }, + { + amount: "-", + name: "Hovězí guláš s vejcem, zeleninovou garniturkou & žemlovými knedlíky", + price: "145\xA0Kč", + isSoup: false, + }, + { + amount: "-", + name: "Kuřecí roláda s kaštanovou nádivkou, demi-glace & smetanovou bramborovou kaší", + price: "150\xA0Kč", + isSoup: false, } ], [ @@ -559,6 +613,24 @@ const MOCK_DATA = { name: "Burger z Chuck rollu, hranolky, tatarská omáčka", price: "200\xA0Kč", isSoup: false, + }, + { + amount: "-", + name: "Slovácké strapačky s uzenou slaninou, zelím, mletým pepřem & sekanou petrželkou", + price: "140\xA0Kč", + isSoup: false, + }, + { + amount: "-", + name: "Hovězí guláš s vejcem, zeleninovou garniturkou & žemlovými knedlíky", + price: "145\xA0Kč", + isSoup: false, + }, + { + amount: "-", + name: "Kuřecí roláda s kaštanovou nádivkou, demi-glace & smetanovou bramborovou kaší", + price: "150\xA0Kč", + isSoup: false, } ], ], @@ -601,6 +673,18 @@ const MOCK_DATA = { name: "Hovězí po Burgundsku, bramborová kaše", price: "155\xA0Kč", isSoup: false, + }, + { + amount: "-", + name: "Špagety s kuřecím masem, špenátem a smetanou", + price: "145\xA0Kč", + isSoup: false, + }, + { + amount: "-", + name: "Medailonky z vepřové panenky s fazolkami se slaninou, šťouchané brambory", + price: "185\xA0Kč", + isSoup: false, } ], [ @@ -615,6 +699,18 @@ const MOCK_DATA = { name: "Kuřecí plátky na sušených rajčatech, bylinkách a česneku, bramborová kaše", price: "155\xA0Kč", isSoup: false, + }, + { + amount: "-", + name: "Špagety s kuřecím masem, špenátem a smetanou", + price: "145\xA0Kč", + isSoup: false, + }, + { + amount: "-", + name: "Medailonky z vepřové panenky s fazolkami se slaninou, šťouchané brambory", + price: "185\xA0Kč", + isSoup: false, } ], [ @@ -629,6 +725,18 @@ const MOCK_DATA = { name: "Rajská s plněnou paprikou, knedlík", price: "170\xA0Kč", isSoup: false, + }, + { + amount: "-", + name: "Špagety s kuřecím masem, špenátem a smetanou", + price: "145\xA0Kč", + isSoup: false, + }, + { + amount: "-", + name: "Medailonky z vepřové panenky s fazolkami se slaninou, šťouchané brambory", + price: "185\xA0Kč", + isSoup: false, } ], [ @@ -643,6 +751,18 @@ const MOCK_DATA = { name: "Ragú z trhané kachny, onsen vejce, soté ze špenátu a ředkvičky, bramborové pyré, lanýžová sůl, zelený olej", price: "189\xA0Kč", isSoup: false, + }, + { + amount: "-", + name: "Špagety s kuřecím masem, špenátem a smetanou", + price: "145\xA0Kč", + isSoup: false, + }, + { + amount: "-", + name: "Medailonky z vepřové panenky s fazolkami se slaninou, šťouchané brambory", + price: "185\xA0Kč", + isSoup: false, } ], ], @@ -1402,7 +1522,7 @@ const MOCK_PIZZA_LIST = [ * Funkce vrací mock datu ve formátu YYYY-MM-DD */ export const getTodayMock = (): Date => { - return new Date('2025-01-10'); // pátek + return new Date('2025-01-08'); // středa } export const getMenuSladovnickaMock = () => { diff --git a/server/src/routes/debugRoutes.ts b/server/src/routes/debugRoutes.ts new file mode 100644 index 0000000..3345091 --- /dev/null +++ b/server/src/routes/debugRoutes.ts @@ -0,0 +1,45 @@ +import express, { Request } from "express"; +import { addChoice, getData, removeChoices } from "../service"; +import { ClientData, LunchChoice } from "../../../types"; + +const NAMES = ["alice", "bob", "carol", "dave", "eve", "frank", "grace", "heidi", "ivan", "judy"]; +const DATES = ["2025-01-06", "2025-01-07", "2025-01-08", "2025-01-09", "2025-01-10"]; + +const router = express.Router(); + +router.get("/createUsers", async (req: Request<{}, any, any>, res) => { + for (const element of NAMES) { + for (const dateStr of DATES) { + // Se šancí 50 % přidat pro tohoto uživatele tento den náhodnou volbu + if (Math.random() > 0.5) { + const foodIndex = Math.floor(Math.random() * 3); // Předpokládáme, že jsou 3 možnosti jídla + const date = new Date(dateStr); + // Náhodná volba z LunchChoice + const lunchChoices = [ + "SLADOVNICKA", + "TECHTOWER", + "ZASTAVKAUMICHALA", + "SENKSERIKOVA", + ]; + const randomLunchChoice = lunchChoices[Math.floor(Math.random() * lunchChoices.length)]; + await addChoice(element, true, randomLunchChoice as LunchChoice, foodIndex, date); + } + } + } + res.status(200).json({}); +}); + +router.get("/clearUsers", async (req: Request<{}, any, any>, res) => { + for (const dateStr of DATES) { + const date = new Date(dateStr); + const data: ClientData = await getData(date); + for (const user of NAMES) { + for (const locationKey in data.choices) { + await removeChoices(user, true, locationKey as keyof ClientData["choices"], date); + } + } + } + res.status(200).json({}); +}); + +export default router; \ No newline at end of file diff --git a/server/src/routes/qrRoutes.ts b/server/src/routes/qrRoutes.ts new file mode 100644 index 0000000..61cdec4 --- /dev/null +++ b/server/src/routes/qrRoutes.ts @@ -0,0 +1,15 @@ +import express, { Request, Response } from "express"; +import { getLogin } from "../auth"; +import { parseToken } from "../utils"; +import { GenerateQrData } from "../../../types"; + +const router = express.Router(); + +router.post("/generate", async (req: Request<{}, any, GenerateQrData["body"]>, res: Response) => { + getLogin(parseToken(req)); + console.log("Bank account for QR codes:", req.body.bankAccount); + console.log("Bank account holder for QR codes:", req.body.bankAccountHolder); + console.log("Requested QR codes for users:", req.body.qrCodes); +}); + +export default router; \ No newline at end of file diff --git a/types/api.yml b/types/api.yml index 8311627..ae285f4 100644 --- a/types/api.yml +++ b/types/api.yml @@ -65,6 +65,10 @@ paths: /voting/updateVote: $ref: "./paths/voting/updateVote.yml" + # QR kódy (/api/qr) + /qr/generate: + $ref: "./paths/qr/generateQr.yml" + components: schemas: $ref: "./schemas/_index.yml" diff --git a/types/paths/qr/generateQr.yml b/types/paths/qr/generateQr.yml new file mode 100644 index 0000000..75c42ab --- /dev/null +++ b/types/paths/qr/generateQr.yml @@ -0,0 +1,12 @@ +post: + operationId: generateQr + summary: Generování QR kódů. + requestBody: + required: true + content: + application/json: + schema: + $ref: "../../schemas/_index.yml#/GenerateQrCodesRequest" + responses: + "200": + description: QR kódy byly úspěšně vygenerovány. \ No newline at end of file diff --git a/types/schemas/_index.yml b/types/schemas/_index.yml index d74f723..8618cab 100644 --- a/types/schemas/_index.yml +++ b/types/schemas/_index.yml @@ -469,6 +469,43 @@ PizzaDay: items: $ref: "#/PizzaOrder" +# --- QR KÓDY --- +QrCodeRequest: + description: Data potřebná pro vygenerování jednoho QR kódu pro platbu + type: object + required: + - login + - note + - amount + properties: + login: + description: Přihlašovací jméno uživatele, pro kterého bude QR kód vygenerován + type: string + note: + description: Popis platby + type: string + amount: + description: Částka platby v Kč + type: number +GenerateQrCodesRequest: + description: Data potřebná pro vygenerování QR kódů pro platbu + type: object + required: + - bankAccount + - bankAccountHolder + properties: + bankAccount: + description: Číslo bankovního účtu objednávajícího + type: string + bankAccountHolder: + description: Jméno majitele bankovního účtu + type: string + qrCodes: + description: Pole požadavků na vygenerování QR kódů + type: array + items: + $ref: "#/QrCodeRequest" + # --- NOTIFIKACE --- UdalostEnum: type: string