feat: podpora ručního generování QR kódů pro platby
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
All checks were successful
ci/woodpecker/push/workflow Pipeline was successful
This commit is contained in:
@@ -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 (
|
||||
<div className="app-container">
|
||||
{easterEgg && eggImage && <img ref={eggRef} alt='' src={URL.createObjectURL(eggImage)} style={{ position: 'absolute', ...EASTER_EGG_STYLE, ...style, animationDuration: `${duration ?? EASTER_EGG_DEFAULT_DURATION}s` }} />}
|
||||
<Header />
|
||||
<Header choices={data?.choices} dayIndex={dayIndex} />
|
||||
<div className='wrapper'>
|
||||
{data.todayDayIndex != null && data.todayDayIndex > 4 &&
|
||||
<Alert variant="info" className="mb-3">
|
||||
@@ -821,11 +821,12 @@ function App() {
|
||||
{data.pendingQrs && data.pendingQrs.length > 0 &&
|
||||
<div className='pizza-section fade-in mt-4'>
|
||||
<h3>Nevyřízené platby</h3>
|
||||
<p>Máte neuhrazené QR kódy z předchozích Pizza day.</p>
|
||||
<p>Máte neuhrazené platby z předchozích dní.</p>
|
||||
{data.pendingQrs.map(qr => (
|
||||
<div key={qr.date} className='qr-code mb-3'>
|
||||
<p>
|
||||
<strong>{qr.date}</strong> — {qr.creator} ({qr.totalPrice} Kč)
|
||||
<strong>{formatDateString(qr.date)}</strong> — {qr.creator} ({qr.totalPrice} Kč)
|
||||
{qr.purpose && <><br /><span className="text-muted">{qr.purpose}</span></>}
|
||||
</p>
|
||||
<img src={`/api/qr?login=${auth.login}`} alt='QR kód' />
|
||||
<div className='mt-2'>
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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<boolean>(false);
|
||||
const [refreshMenuModalOpen, setRefreshMenuModalOpen] = useState<boolean>(false);
|
||||
const [changelogModalOpen, setChangelogModalOpen] = useState<boolean>(false);
|
||||
const [qrModalOpen, setQrModalOpen] = useState<boolean>(false);
|
||||
const [generateMockModalOpen, setGenerateMockModalOpen] = useState<boolean>(false);
|
||||
const [clearMockModalOpen, setClearMockModalOpen] = useState<boolean>(false);
|
||||
const [featureVotes, setFeatureVotes] = useState<FeatureRequest[] | undefined>([]);
|
||||
|
||||
// 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() {
|
||||
<NavDropdown.Item onClick={() => setRefreshMenuModalOpen(true)}>Přenačtení menu</NavDropdown.Item>
|
||||
<NavDropdown.Item onClick={() => setVotingModalOpen(true)}>Hlasovat o nových funkcích</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={() => navigate(STATS_URL)}>Statistiky</NavDropdown.Item>
|
||||
<NavDropdown.Item onClick={() => setChangelogModalOpen(true)}>Novinky</NavDropdown.Item>
|
||||
{IS_DEV && (
|
||||
<>
|
||||
<NavDropdown.Divider />
|
||||
<NavDropdown.Item onClick={() => setGenerateMockModalOpen(true)}>🔧 Generovat mock data</NavDropdown.Item>
|
||||
<NavDropdown.Item onClick={() => setClearMockModalOpen(true)}>🔧 Smazat data dne</NavDropdown.Item>
|
||||
</>
|
||||
)}
|
||||
<NavDropdown.Divider />
|
||||
<NavDropdown.Item onClick={auth?.logout}>Odhlásit se</NavDropdown.Item>
|
||||
</NavDropdown>
|
||||
@@ -180,6 +214,29 @@ export default function Header() {
|
||||
<RefreshMenuModal isOpen={refreshMenuModalOpen} onClose={closeRefreshMenuModal} />
|
||||
<FeaturesVotingModal isOpen={votingModalOpen} onClose={closeVotingModal} onChange={saveFeatureVote} initialValues={featureVotes} />
|
||||
<PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} />
|
||||
{choices && settings?.bankAccount && settings?.holderName && (
|
||||
<GenerateQrModal
|
||||
isOpen={qrModalOpen}
|
||||
onClose={closeQrModal}
|
||||
choices={choices}
|
||||
bankAccount={settings.bankAccount}
|
||||
bankAccountHolder={settings.holderName}
|
||||
/>
|
||||
)}
|
||||
{IS_DEV && (
|
||||
<>
|
||||
<GenerateMockDataModal
|
||||
isOpen={generateMockModalOpen}
|
||||
onClose={() => setGenerateMockModalOpen(false)}
|
||||
currentDayIndex={dayIndex}
|
||||
/>
|
||||
<ClearMockDataModal
|
||||
isOpen={clearMockModalOpen}
|
||||
onClose={() => setClearMockModalOpen(false)}
|
||||
currentDayIndex={dayIndex}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Modal show={changelogModalOpen} onHide={() => setChangelogModalOpen(false)}>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title><h2>Novinky</h2></Modal.Title>
|
||||
|
||||
104
client/src/components/modals/ClearMockDataModal.tsx
Normal file
104
client/src/components/modals/ClearMockDataModal.tsx
Normal file
@@ -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<Props>) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<Modal show={isOpen} onHide={handleClose}>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title><h2>Smazat data</h2></Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
{success ? (
|
||||
<Alert variant="success">
|
||||
Data byla úspěšně smazána!
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<Alert variant="warning">
|
||||
<strong>DEV režim</strong> - Tato funkce je dostupná pouze ve vývojovém prostředí.
|
||||
</Alert>
|
||||
|
||||
{error && (
|
||||
<Alert variant="danger" onClose={() => setError(null)} dismissible>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<p>
|
||||
Opravdu chcete smazat všechny volby stravování pro <strong>{dayName}</strong>?
|
||||
</p>
|
||||
<p className="text-muted">
|
||||
Tato akce je nevratná.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
{!success && (
|
||||
<>
|
||||
<Button variant="secondary" onClick={handleClose} disabled={loading}>
|
||||
Ne, zrušit
|
||||
</Button>
|
||||
<Button variant="danger" onClick={handleClear} disabled={loading}>
|
||||
{loading ? 'Mažu...' : 'Ano, smazat'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{success && (
|
||||
<Button variant="secondary" onClick={handleClose}>
|
||||
Zavřít
|
||||
</Button>
|
||||
)}
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
140
client/src/components/modals/GenerateMockDataModal.tsx
Normal file
140
client/src/components/modals/GenerateMockDataModal.tsx
Normal file
@@ -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<Props>) {
|
||||
const [dayIndex, setDayIndex] = useState<number | undefined>(currentDayIndex);
|
||||
const [count, setCount] = useState<string>('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<Modal show={isOpen} onHide={handleClose}>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title><h2>Generovat mock data</h2></Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
{success ? (
|
||||
<Alert variant="success">
|
||||
Mock data byla úspěšně vygenerována!
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<Alert variant="warning">
|
||||
<strong>DEV režim</strong> - Tato funkce je dostupná pouze ve vývojovém prostředí.
|
||||
</Alert>
|
||||
|
||||
{error && (
|
||||
<Alert variant="danger" onClose={() => setError(null)} dismissible>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Den</Form.Label>
|
||||
<Form.Select
|
||||
value={dayIndex ?? ''}
|
||||
onChange={e => setDayIndex(e.target.value === '' ? undefined : parseInt(e.target.value, 10))}
|
||||
>
|
||||
<option value="">Aktuální den</option>
|
||||
{DAY_NAMES.map((name, index) => (
|
||||
<option key={index} value={index}>{name}</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
<Form.Text className="text-muted">
|
||||
Pokud není vybráno, použije se aktuální den.
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Počet záznamů</Form.Label>
|
||||
<Form.Control
|
||||
type="number"
|
||||
placeholder="Náhodný (5-20)"
|
||||
value={count}
|
||||
onChange={e => setCount(e.target.value)}
|
||||
min={1}
|
||||
max={100}
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
/>
|
||||
<Form.Text className="text-muted">
|
||||
Pokud není zadáno, vybere se náhodný počet 5-20.
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
</>
|
||||
)}
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
{!success && (
|
||||
<>
|
||||
<Button variant="secondary" onClick={handleClose} disabled={loading}>
|
||||
Storno
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleGenerate} disabled={loading}>
|
||||
{loading ? 'Generuji...' : 'Generovat'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{success && (
|
||||
<Button variant="secondary" onClick={handleClose}>
|
||||
Zavřít
|
||||
</Button>
|
||||
)}
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
255
client/src/components/modals/GenerateQrModal.tsx
Normal file
255
client/src/components/modals/GenerateQrModal.tsx
Normal file
@@ -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<Props>) {
|
||||
const [users, setUsers] = useState<UserEntry[]>([]);
|
||||
const [error, setError] = useState<string | null>(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<string>();
|
||||
// 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 (
|
||||
<Modal show={isOpen} onHide={handleClose} size="lg">
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title><h2>Generování QR kódů</h2></Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
{success ? (
|
||||
<Alert variant="success">
|
||||
QR kódy byly úspěšně vygenerovány! Uživatelé je uvidí v sekci "Nevyřízené platby".
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
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".
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<Alert variant="danger" onClose={() => setError(null)} dismissible>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{users.length === 0 ? (
|
||||
<Alert variant="info">
|
||||
V tento den nemá žádný uživatel zvolenou možnost stravování.
|
||||
</Alert>
|
||||
) : (
|
||||
<Table striped bordered hover responsive>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '50px' }}></th>
|
||||
<th>Uživatel</th>
|
||||
<th>Účel platby</th>
|
||||
<th style={{ width: '120px' }}>Částka (Kč)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map(user => (
|
||||
<tr key={user.login} className={user.selected ? '' : 'text-muted'}>
|
||||
<td className="text-center">
|
||||
<Form.Check
|
||||
type="checkbox"
|
||||
checked={user.selected}
|
||||
onChange={e => handleCheckboxChange(user.login, e.target.checked)}
|
||||
/>
|
||||
</td>
|
||||
<td>{user.login}</td>
|
||||
<td>
|
||||
<Form.Control
|
||||
type="text"
|
||||
placeholder="např. Pizza prosciutto"
|
||||
value={user.purpose}
|
||||
onChange={e => handlePurposeChange(user.login, e.target.value)}
|
||||
disabled={!user.selected}
|
||||
size="sm"
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<Form.Control
|
||||
type="text"
|
||||
placeholder="0.00"
|
||||
value={user.amount}
|
||||
onChange={e => handleAmountChange(user.login, e.target.value)}
|
||||
disabled={!user.selected}
|
||||
size="sm"
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
{!success && (
|
||||
<>
|
||||
<span className="me-auto text-muted">
|
||||
Vybráno: {selectedCount} / {users.length}
|
||||
</span>
|
||||
<Button variant="secondary" onClick={handleClose} disabled={loading}>
|
||||
Storno
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleGenerate}
|
||||
disabled={loading || selectedCount === 0}
|
||||
>
|
||||
{loading ? 'Generuji...' : 'Generovat'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{success && (
|
||||
<Button variant="secondary" onClick={handleClose}>
|
||||
Zavřít
|
||||
</Button>
|
||||
)}
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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'));
|
||||
|
||||
@@ -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<void> {
|
||||
export async function addPendingQr(login: string, pendingQr: PendingQr): Promise<void> {
|
||||
const key = getPendingQrKey(login);
|
||||
const existing = await storage.getData<PendingQr[]>(key) ?? [];
|
||||
// Nepřidáváme duplicity pro stejný den
|
||||
|
||||
157
server/src/routes/devRoutes.ts
Normal file
157
server/src/routes/devRoutes.ts
Normal file
@@ -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<any>(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<string>();
|
||||
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<any>(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;
|
||||
64
server/src/routes/qrRoutes.ts
Normal file
64
server/src/routes/qrRoutes.ts
Normal file
@@ -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;
|
||||
@@ -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"
|
||||
|
||||
23
types/paths/dev/clear.yml
Normal file
23
types/paths/dev/clear.yml
Normal file
@@ -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
|
||||
25
types/paths/dev/generate.yml
Normal file
25
types/paths/dev/generate.yml
Normal file
@@ -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
|
||||
16
types/paths/qr/generate.yml
Normal file
16
types/paths/qr/generate.yml
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user