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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user