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 Footer from './components/Footer';
|
||||||
import { faBasketShopping, faChainBroken, faChevronLeft, faChevronRight, faGear, faSatelliteDish, faSearch, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons';
|
import { faBasketShopping, faChainBroken, faChevronLeft, faChevronRight, faGear, faSatelliteDish, faSearch, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons';
|
||||||
import Loader from './components/Loader';
|
import Loader from './components/Loader';
|
||||||
import { getHumanDateTime, isInTheFuture } from './Utils';
|
import { getHumanDateTime, isInTheFuture, formatDateString } from './Utils';
|
||||||
import NoteModal from './components/modals/NoteModal';
|
import NoteModal from './components/modals/NoteModal';
|
||||||
import { useEasterEgg } from './context/eggs';
|
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';
|
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 (
|
return (
|
||||||
<div className="app-container">
|
<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` }} />}
|
{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'>
|
<div className='wrapper'>
|
||||||
{data.todayDayIndex != null && data.todayDayIndex > 4 &&
|
{data.todayDayIndex != null && data.todayDayIndex > 4 &&
|
||||||
<Alert variant="info" className="mb-3">
|
<Alert variant="info" className="mb-3">
|
||||||
@@ -821,11 +821,12 @@ function App() {
|
|||||||
{data.pendingQrs && data.pendingQrs.length > 0 &&
|
{data.pendingQrs && data.pendingQrs.length > 0 &&
|
||||||
<div className='pizza-section fade-in mt-4'>
|
<div className='pizza-section fade-in mt-4'>
|
||||||
<h3>Nevyřízené platby</h3>
|
<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 => (
|
{data.pendingQrs.map(qr => (
|
||||||
<div key={qr.date} className='qr-code mb-3'>
|
<div key={qr.date} className='qr-code mb-3'>
|
||||||
<p>
|
<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>
|
</p>
|
||||||
<img src={`/api/qr?login=${auth.login}`} alt='QR kód' />
|
<img src={`/api/qr?login=${auth.login}`} alt='QR kód' />
|
||||||
<div className='mt-2'>
|
<div className='mt-2'>
|
||||||
|
|||||||
@@ -103,4 +103,10 @@ export function getHumanDate(date: Date) {
|
|||||||
let currentMonth = String(date.getMonth() + 1).padStart(2, "0");
|
let currentMonth = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
let currentYear = date.getFullYear();
|
let currentYear = date.getFullYear();
|
||||||
return `${currentDay}.${currentMonth}.${currentYear}`;
|
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 FeaturesVotingModal from "./modals/FeaturesVotingModal";
|
||||||
import PizzaCalculatorModal from "./modals/PizzaCalculatorModal";
|
import PizzaCalculatorModal from "./modals/PizzaCalculatorModal";
|
||||||
import RefreshMenuModal from "./modals/RefreshMenuModal";
|
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 { useNavigate } from "react-router";
|
||||||
import { STATS_URL } from "../AppRoutes";
|
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 { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons";
|
import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
|
||||||
@@ -16,9 +19,17 @@ const CHANGELOG = [
|
|||||||
"Nový moderní design aplikace",
|
"Nový moderní design aplikace",
|
||||||
"Oprava parsování Sladovnické a TechTower",
|
"Oprava parsování Sladovnické a TechTower",
|
||||||
"Možnost označit se jako objednávající u volby \"budu objednávat\"",
|
"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 auth = useAuth();
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -27,6 +38,9 @@ export default function Header() {
|
|||||||
const [pizzaModalOpen, setPizzaModalOpen] = useState<boolean>(false);
|
const [pizzaModalOpen, setPizzaModalOpen] = useState<boolean>(false);
|
||||||
const [refreshMenuModalOpen, setRefreshMenuModalOpen] = useState<boolean>(false);
|
const [refreshMenuModalOpen, setRefreshMenuModalOpen] = useState<boolean>(false);
|
||||||
const [changelogModalOpen, setChangelogModalOpen] = useState<boolean>(false);
|
const [changelogModalOpen, setChangelogModalOpen] = useState<boolean>(false);
|
||||||
|
const [qrModalOpen, setQrModalOpen] = useState<boolean>(false);
|
||||||
|
const [generateMockModalOpen, setGenerateMockModalOpen] = useState<boolean>(false);
|
||||||
|
const [clearMockModalOpen, setClearMockModalOpen] = useState<boolean>(false);
|
||||||
const [featureVotes, setFeatureVotes] = useState<FeatureRequest[] | undefined>([]);
|
const [featureVotes, setFeatureVotes] = useState<FeatureRequest[] | undefined>([]);
|
||||||
|
|
||||||
// Zjistíme aktuální efektivní téma (pro zobrazení správné ikony)
|
// Zjistíme aktuální efektivní téma (pro zobrazení správné ikony)
|
||||||
@@ -73,6 +87,18 @@ export default function Header() {
|
|||||||
setRefreshMenuModalOpen(false);
|
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 = () => {
|
const toggleTheme = () => {
|
||||||
// Přepínáme mezi light a dark (ignorujeme system pro jednoduchost)
|
// Přepínáme mezi light a dark (ignorujeme system pro jednoduchost)
|
||||||
const newTheme: ThemePreference = effectiveTheme === 'dark' ? 'light' : 'dark';
|
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={() => setRefreshMenuModalOpen(true)}>Přenačtení menu</NavDropdown.Item>
|
||||||
<NavDropdown.Item onClick={() => setVotingModalOpen(true)}>Hlasovat o nových funkcích</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={() => 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={() => navigate(STATS_URL)}>Statistiky</NavDropdown.Item>
|
||||||
<NavDropdown.Item onClick={() => setChangelogModalOpen(true)}>Novinky</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.Divider />
|
||||||
<NavDropdown.Item onClick={auth?.logout}>Odhlásit se</NavDropdown.Item>
|
<NavDropdown.Item onClick={auth?.logout}>Odhlásit se</NavDropdown.Item>
|
||||||
</NavDropdown>
|
</NavDropdown>
|
||||||
@@ -180,6 +214,29 @@ export default function Header() {
|
|||||||
<RefreshMenuModal isOpen={refreshMenuModalOpen} onClose={closeRefreshMenuModal} />
|
<RefreshMenuModal isOpen={refreshMenuModalOpen} onClose={closeRefreshMenuModal} />
|
||||||
<FeaturesVotingModal isOpen={votingModalOpen} onClose={closeVotingModal} onChange={saveFeatureVote} initialValues={featureVotes} />
|
<FeaturesVotingModal isOpen={votingModalOpen} onClose={closeVotingModal} onChange={saveFeatureVote} initialValues={featureVotes} />
|
||||||
<PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} />
|
<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 show={changelogModalOpen} onHide={() => setChangelogModalOpen(false)}>
|
||||||
<Modal.Header closeButton>
|
<Modal.Header closeButton>
|
||||||
<Modal.Title><h2>Novinky</h2></Modal.Title>
|
<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 easterEggRoutes from "./routes/easterEggRoutes";
|
||||||
import statsRoutes from "./routes/statsRoutes";
|
import statsRoutes from "./routes/statsRoutes";
|
||||||
import notificationRoutes from "./routes/notificationRoutes";
|
import notificationRoutes from "./routes/notificationRoutes";
|
||||||
|
import qrRoutes from "./routes/qrRoutes";
|
||||||
|
import devRoutes from "./routes/devRoutes";
|
||||||
|
|
||||||
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
|
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
|
||||||
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
|
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
|
||||||
@@ -160,6 +162,8 @@ app.use("/api/voting", votingRoutes);
|
|||||||
app.use("/api/easterEggs", easterEggRoutes);
|
app.use("/api/easterEggs", easterEggRoutes);
|
||||||
app.use("/api/stats", statsRoutes);
|
app.use("/api/stats", statsRoutes);
|
||||||
app.use("/api/notifications", notificationRoutes);
|
app.use("/api/notifications", notificationRoutes);
|
||||||
|
app.use("/api/qr", qrRoutes);
|
||||||
|
app.use("/api/dev", devRoutes);
|
||||||
|
|
||||||
app.use('/stats', express.static('public'));
|
app.use('/stats', express.static('public'));
|
||||||
app.use(express.static('public'));
|
app.use(express.static('public'));
|
||||||
|
|||||||
@@ -277,6 +277,7 @@ export async function finishPizzaDelivery(login: string, bankAccount?: string, b
|
|||||||
date: today,
|
date: today,
|
||||||
creator: login,
|
creator: login,
|
||||||
totalPrice: order.totalPrice,
|
totalPrice: order.totalPrice,
|
||||||
|
purpose: message,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -356,7 +357,7 @@ function getPendingQrKey(login: string): string {
|
|||||||
/**
|
/**
|
||||||
* Přidá nevyřízený QR kód pro uživatele.
|
* 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 key = getPendingQrKey(login);
|
||||||
const existing = await storage.getData<PendingQr[]>(key) ?? [];
|
const existing = await storage.getData<PendingQr[]>(key) ?? [];
|
||||||
// Nepřidáváme duplicity pro stejný den
|
// 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"
|
$ref: "./paths/login.yml"
|
||||||
/qr:
|
/qr:
|
||||||
$ref: "./paths/getPizzaQr.yml"
|
$ref: "./paths/getPizzaQr.yml"
|
||||||
|
/qr/generate:
|
||||||
|
$ref: "./paths/qr/generate.yml"
|
||||||
/data:
|
/data:
|
||||||
$ref: "./paths/getData.yml"
|
$ref: "./paths/getData.yml"
|
||||||
|
|
||||||
@@ -75,6 +77,12 @@ paths:
|
|||||||
/voting/stats:
|
/voting/stats:
|
||||||
$ref: "./paths/voting/getVotingStats.yml"
|
$ref: "./paths/voting/getVotingStats.yml"
|
||||||
|
|
||||||
|
# DEV endpointy (/api/dev)
|
||||||
|
/dev/generate:
|
||||||
|
$ref: "./paths/dev/generate.yml"
|
||||||
|
/dev/clear:
|
||||||
|
$ref: "./paths/dev/clear.yml"
|
||||||
|
|
||||||
components:
|
components:
|
||||||
schemas:
|
schemas:
|
||||||
$ref: "./schemas/_index.yml"
|
$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:
|
items:
|
||||||
type: string
|
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 ---
|
# --- NEVYŘÍZENÉ QR KÓDY ---
|
||||||
PendingQr:
|
PendingQr:
|
||||||
description: Nevyřízený QR kód pro platbu z předchozího Pizza day
|
description: Nevyřízený QR kód pro platbu z předchozího Pizza day
|
||||||
@@ -582,3 +646,6 @@ PendingQr:
|
|||||||
totalPrice:
|
totalPrice:
|
||||||
description: Celková cena objednávky v Kč
|
description: Celková cena objednávky v Kč
|
||||||
type: number
|
type: number
|
||||||
|
purpose:
|
||||||
|
description: Účel platby (např. "Pizza prosciutto")
|
||||||
|
type: string
|
||||||
|
|||||||
Reference in New Issue
Block a user