1e1e23df80
ci/woodpecker/push/workflow Pipeline was canceled
Přidává možnost, aby jeden strávník zaplatil celý účet v restauraci a ostatní
obdrželi QR kód pro refundaci.
Prerekvizita — podpora více QR kódů na (příjemce, den):
- PendingQr.id (UUID) nahrazuje deduplikaci podle data; každý QR má vlastní klíč
- QR obrázky uloženy do Redis/storage (base64) místo tmpdir — přežijí redeploy
- GET /api/qr vyžaduje ?id= parametr; dismissQr přijímá {id} místo {date}
Feature:
- Ikona 'Zaplatit za všechny' v choices-table pro každou LunchChoice (kromě
PIZZA/NEOBEDVAM/ROZHODUJI); viditelná jen při ≥2 strávnících a vyplněném účtu
- PayForAllModal: tabulka strávníků s prefillovanými cenami z menu, příplatky
per-diner, celkové dýško rozpočtené rovnoměrně, generování QR přes POST /api/qr/generate
- parsePriceCzk() helper pro parsing 'N Kč' → number
Co se nemění: POST /api/qr/generate API kontrakt, PizzaOrder.hasQr boolean
Co se mění v OpenAPI: PendingQr.id (required), getPizzaQr ?id param, dismissQr body
Co-Authored-By: opmrdkazkrtkaus <opmrdkazkrtkaus@melancholik.eu>
309 lines
14 KiB
TypeScript
309 lines
14 KiB
TypeScript
import { useState, useEffect, useCallback } from "react";
|
|
import { Modal, Button, Form, Table, Alert } from "react-bootstrap";
|
|
import { generateQr, LunchChoice, LocationLunchChoicesMap, RestaurantDayMenu, QrRecipient } from "../../../../types";
|
|
import { parsePriceCzk } from "../../utils/parsePrice";
|
|
|
|
type DinerEntry = {
|
|
login: string;
|
|
selectedFoods: number[];
|
|
baseAmount: number;
|
|
baseAmountParseFailed: boolean;
|
|
surchargeText: string;
|
|
surchargeAmount: string;
|
|
included: boolean;
|
|
};
|
|
|
|
type Props = {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
locationKey: LunchChoice;
|
|
locationName: string;
|
|
locationChoices: LocationLunchChoicesMap;
|
|
menu: RestaurantDayMenu | undefined;
|
|
payerLogin: string;
|
|
bankAccount: string;
|
|
bankAccountHolder: string;
|
|
};
|
|
|
|
function sanitizeAmount(value: string): string {
|
|
return value.replace(/[^0-9.,]/g, '').replace(',', '.');
|
|
}
|
|
|
|
function parseAmount(s: string): number | null {
|
|
if (!s || s.trim().length === 0) return null;
|
|
const n = parseFloat(s);
|
|
if (isNaN(n) || n < 0) return null;
|
|
const parts = s.split('.');
|
|
if (parts.length === 2 && parts[1].length > 2) return null;
|
|
return Math.round(n * 100) / 100;
|
|
}
|
|
|
|
export default function PayForAllModal({ isOpen, onClose, locationName, locationChoices, menu, payerLogin, bankAccount, bankAccountHolder }: Readonly<Props>) {
|
|
const [diners, setDiners] = useState<DinerEntry[]>([]);
|
|
const [tipTotal, setTipTotal] = useState('');
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [success, setSuccess] = useState(false);
|
|
|
|
const hasMenu = !!menu;
|
|
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
const entries: DinerEntry[] = Object.entries(locationChoices).map(([login, choice]) => {
|
|
const selectedFoods = choice.selectedFoods ?? [];
|
|
let baseAmount = 0;
|
|
let baseAmountParseFailed = false;
|
|
if (menu) {
|
|
for (const idx of selectedFoods) {
|
|
const price = parsePriceCzk(menu.food?.[idx]?.price);
|
|
if (price === null) {
|
|
baseAmountParseFailed = true;
|
|
} else {
|
|
baseAmount += price;
|
|
}
|
|
}
|
|
}
|
|
return {
|
|
login,
|
|
selectedFoods,
|
|
baseAmount,
|
|
baseAmountParseFailed,
|
|
surchargeText: '',
|
|
surchargeAmount: '',
|
|
included: login !== payerLogin,
|
|
};
|
|
});
|
|
setDiners(entries);
|
|
setTipTotal('');
|
|
setError(null);
|
|
setSuccess(false);
|
|
}, [isOpen, locationChoices, menu, payerLogin]);
|
|
|
|
const includedDiners = diners.filter(d => d.included && d.login !== payerLogin);
|
|
const tipPerPerson = (() => {
|
|
if (includedDiners.length === 0) return 0;
|
|
const tip = parseAmount(tipTotal);
|
|
if (tip === null || tip === 0) return 0;
|
|
return Math.round((tip / includedDiners.length) * 100) / 100;
|
|
})();
|
|
|
|
const getTotal = (d: DinerEntry): number => {
|
|
const surcharge = parseAmount(d.surchargeAmount) ?? 0;
|
|
const tip = d.included && d.login !== payerLogin ? tipPerPerson : 0;
|
|
return Math.round((d.baseAmount + surcharge + tip) * 100) / 100;
|
|
};
|
|
|
|
const handleInclude = useCallback((login: string, checked: boolean) => {
|
|
setDiners(prev => prev.map(d => d.login === login ? { ...d, included: checked } : d));
|
|
}, []);
|
|
|
|
const handleSurchargeText = useCallback((login: string, value: string) => {
|
|
setDiners(prev => prev.map(d => d.login === login ? { ...d, surchargeText: value } : d));
|
|
}, []);
|
|
|
|
const handleSurchargeAmount = useCallback((login: string, value: string) => {
|
|
setDiners(prev => prev.map(d => d.login === login ? { ...d, surchargeAmount: sanitizeAmount(value) } : d));
|
|
}, []);
|
|
|
|
const handleGenerate = async () => {
|
|
setError(null);
|
|
const recipients: QrRecipient[] = [];
|
|
|
|
for (const d of diners) {
|
|
if (!d.included || d.login === payerLogin) continue;
|
|
const total = getTotal(d);
|
|
if (total <= 0) {
|
|
setError(`Celková částka pro ${d.login} musí být kladná`);
|
|
return;
|
|
}
|
|
const amountStr = total.toString();
|
|
if (amountStr.includes('.') && amountStr.split('.')[1].length > 2) {
|
|
setError(`Částka pro ${d.login} má více než 2 desetinná místa`);
|
|
return;
|
|
}
|
|
const foods = d.selectedFoods.map(i => menu?.food?.[i]?.name).filter(Boolean).join(', ');
|
|
const purposeBase = `Oběd ${locationName}${foods ? ` — ${foods}` : ''}`;
|
|
recipients.push({
|
|
login: d.login,
|
|
purpose: purposeBase.substring(0, 60),
|
|
amount: total,
|
|
});
|
|
}
|
|
|
|
if (recipients.length === 0) {
|
|
setError("Nebyl vybrán žádný příjemce");
|
|
return;
|
|
}
|
|
|
|
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);
|
|
setTimeout(() => onClose(), 2000);
|
|
}
|
|
} catch (e: any) {
|
|
setError(e.message || 'Nastala chyba při generování QR kódů');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const anyParseFailed = diners.some(d => d.baseAmountParseFailed && d.included);
|
|
|
|
return (
|
|
<Modal show={isOpen} onHide={onClose} size="lg">
|
|
<Modal.Header closeButton>
|
|
<Modal.Title><h2>Zaplatit za všechny — {locationName}</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>Zaplatili jste za skupinu v restauraci. Nastavte příplatky a dýško, poté vygenerujte QR kódy pro ostatní.</p>
|
|
|
|
{!hasMenu && (
|
|
<Alert variant="info">
|
|
Pro tuto skupinu nejsou k dispozici ceny jídel — vyplňte příplatky ručně.
|
|
</Alert>
|
|
)}
|
|
|
|
{anyParseFailed && (
|
|
<Alert variant="warning">
|
|
U některých jídel se nepodařilo načíst cenu — doplňte ji ručně v sloupci Příplatek.
|
|
</Alert>
|
|
)}
|
|
|
|
{error && (
|
|
<Alert variant="danger" onClose={() => setError(null)} dismissible>
|
|
{error}
|
|
</Alert>
|
|
)}
|
|
|
|
<Table striped bordered hover responsive size="sm">
|
|
<thead>
|
|
<tr>
|
|
<th style={{ width: 40 }}></th>
|
|
<th>Strávník</th>
|
|
<th>Jídla</th>
|
|
<th style={{ width: 220 }}>Příplatek</th>
|
|
<th style={{ width: 90 }}>Dýško</th>
|
|
<th style={{ width: 90 }}>Celkem</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{diners.map(d => {
|
|
const isPayer = d.login === payerLogin;
|
|
const foodNames = d.selectedFoods.map(i => menu?.food?.[i]?.name).filter(Boolean).join(', ');
|
|
const total = getTotal(d);
|
|
return (
|
|
<tr key={d.login} className={!d.included && !isPayer ? 'text-muted' : ''}>
|
|
<td className="text-center">
|
|
{isPayer ? (
|
|
<small className="text-muted">plátce</small>
|
|
) : (
|
|
<Form.Check
|
|
type="checkbox"
|
|
checked={d.included}
|
|
onChange={e => handleInclude(d.login, e.target.checked)}
|
|
/>
|
|
)}
|
|
</td>
|
|
<td><strong>{d.login}</strong></td>
|
|
<td>
|
|
<small>
|
|
{foodNames || <span className="text-muted">—</span>}
|
|
{hasMenu && d.baseAmount > 0 && <span className="text-muted"> ({d.baseAmount} Kč)</span>}
|
|
{d.baseAmountParseFailed && <span className="text-warning"> ⚠</span>}
|
|
</small>
|
|
</td>
|
|
<td>
|
|
{!isPayer && (
|
|
<div className="d-flex gap-1">
|
|
<Form.Control
|
|
type="text"
|
|
placeholder="popis"
|
|
value={d.surchargeText}
|
|
onChange={e => handleSurchargeText(d.login, e.target.value)}
|
|
disabled={!d.included}
|
|
size="sm"
|
|
onKeyDown={e => e.stopPropagation()}
|
|
/>
|
|
<Form.Control
|
|
type="text"
|
|
placeholder="Kč"
|
|
value={d.surchargeAmount}
|
|
onChange={e => handleSurchargeAmount(d.login, e.target.value)}
|
|
disabled={!d.included}
|
|
size="sm"
|
|
style={{ width: 70 }}
|
|
onKeyDown={e => e.stopPropagation()}
|
|
/>
|
|
</div>
|
|
)}
|
|
</td>
|
|
<td className="text-end">
|
|
{!isPayer && d.included ? `${tipPerPerson} Kč` : '—'}
|
|
</td>
|
|
<td className="text-end fw-bold">
|
|
{!isPayer ? `${total} Kč` : '—'}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</Table>
|
|
|
|
<div className="d-flex align-items-center gap-2 mt-2">
|
|
<label className="mb-0 text-nowrap">Dýško celkem (Kč):</label>
|
|
<Form.Control
|
|
type="text"
|
|
placeholder="0"
|
|
value={tipTotal}
|
|
onChange={e => setTipTotal(sanitizeAmount(e.target.value))}
|
|
size="sm"
|
|
style={{ width: 100 }}
|
|
onKeyDown={e => e.stopPropagation()}
|
|
/>
|
|
<small className="text-muted">
|
|
{includedDiners.length > 0 && tipPerPerson > 0
|
|
? `(${tipPerPerson} Kč / osoba)`
|
|
: ''}
|
|
</small>
|
|
</div>
|
|
</>
|
|
)}
|
|
</Modal.Body>
|
|
<Modal.Footer>
|
|
{!success && (
|
|
<>
|
|
<span className="me-auto text-muted">
|
|
Příjemci: {includedDiners.length}
|
|
</span>
|
|
<Button variant="secondary" onClick={onClose} disabled={loading}>
|
|
Storno
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
onClick={handleGenerate}
|
|
disabled={loading || includedDiners.length === 0}
|
|
>
|
|
{loading ? 'Generuji...' : 'Vygenerovat QR'}
|
|
</Button>
|
|
</>
|
|
)}
|
|
{success && (
|
|
<Button variant="secondary" onClick={onClose}>Zavřít</Button>
|
|
)}
|
|
</Modal.Footer>
|
|
</Modal>
|
|
);
|
|
}
|