CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 26s
CI / Build server (push) Successful in 28s
CI / Build client (push) Successful in 42s
CI / Playwright E2E tests (push) Successful in 1m43s
CI / Build and push Docker image (push) Successful in 48s
CI / Notify (push) Successful in 1s
229 lines
11 KiB
TypeScript
229 lines
11 KiB
TypeScript
import { useState, useEffect } from "react";
|
|
import { Modal, Button, Form, Table, Alert } from "react-bootstrap";
|
|
import { generateQr, GroupState, OrderGroup, OrderGroupMember, QrRecipient } from "../../../../types";
|
|
import { sanitizeQrMessage } from "../../Utils";
|
|
import { computeFeeShare, computeMemberTotal, countActiveMembers, isActiveMember } from "../../utils/groupFees";
|
|
|
|
type Props = {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onSuccess?: () => void;
|
|
group: OrderGroup;
|
|
payerLogin: string;
|
|
bankAccount: string;
|
|
bankAccountHolder: string;
|
|
groupId?: string;
|
|
};
|
|
|
|
type DinerEntry = {
|
|
login: string;
|
|
member: OrderGroupMember;
|
|
included: boolean;
|
|
};
|
|
|
|
export default function PayForGroupModal({ isOpen, onClose, onSuccess, group, payerLogin, bankAccount, bankAccountHolder, groupId }: Readonly<Props>) {
|
|
const [diners, setDiners] = useState<DinerEntry[]>([]);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [success, setSuccess] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
const entries: DinerEntry[] = (Object.entries(group.members) as [string, OrderGroupMember][]).map(([login, member]) => ({
|
|
login,
|
|
member,
|
|
// Standardně zahrnout všechny, kdo nejsou plátce a něco si objednali.
|
|
included: login !== payerLogin && isActiveMember(member),
|
|
}));
|
|
setDiners(entries);
|
|
setError(null);
|
|
setSuccess(false);
|
|
}, [isOpen, group, payerLogin]);
|
|
|
|
const fees = group.fees ?? 0;
|
|
const shipping = group.shipping ?? 0;
|
|
const tip = group.tip ?? 0;
|
|
const totalFees = fees + shipping + tip;
|
|
// Poplatky se dělí jen mezi aktivní strávníky (kdo si reálně něco objednal).
|
|
const activeCount = countActiveMembers(group.members);
|
|
const feeShare = computeFeeShare(totalFees, activeCount);
|
|
const feeParams = { totalFees, discountType: group.discountType, discountValue: group.discountValue ?? 0 };
|
|
|
|
const getMemberTotal = (entry: DinerEntry): number =>
|
|
computeMemberTotal(entry.member, feeParams, feeShare, activeCount);
|
|
|
|
const includedNonPayers = diners.filter(d => d.included && d.login !== payerLogin);
|
|
|
|
const handleInclude = (login: string, checked: boolean) => {
|
|
setDiners(prev => prev.map(d => d.login === login ? { ...d, included: checked } : d));
|
|
};
|
|
|
|
const handleGenerate = async () => {
|
|
setError(null);
|
|
const recipients: QrRecipient[] = [];
|
|
|
|
for (const d of diners) {
|
|
if (!d.included || d.login === payerLogin) continue;
|
|
const total = getMemberTotal(d);
|
|
if (total <= 0) {
|
|
setError(`Celková částka pro ${d.login} musí být kladná`);
|
|
return;
|
|
}
|
|
const note = d.member.note?.trim();
|
|
recipients.push({
|
|
login: d.login,
|
|
purpose: sanitizeQrMessage(note || `Objednávka ${group.name}`),
|
|
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, ...(groupId ? { groupId } : {}) },
|
|
});
|
|
if (response.error) {
|
|
setError((response.error as any).error || 'Nastala chyba při generování QR kódů');
|
|
} else {
|
|
setSuccess(true);
|
|
onSuccess?.();
|
|
setTimeout(() => onClose(), 2000);
|
|
}
|
|
} catch (e: any) {
|
|
setError(e.message || 'Nastala chyba při generování QR kódů');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const hasFees = totalFees > 0;
|
|
// Generování QR je poslední krok objednávky — server skupinu zároveň překlopí do stavu Doručeno
|
|
const willBeMarkedDelivered = group.state !== GroupState.DELIVERED;
|
|
|
|
return (
|
|
<Modal show={isOpen} onHide={onClose} size="lg">
|
|
<Modal.Header closeButton>
|
|
<Modal.Title><h2>Generovat QR — {group.name}</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>
|
|
) : (
|
|
<>
|
|
{willBeMarkedDelivered && (
|
|
<Alert variant="info">
|
|
Objednávka není ve stavu <strong>Doručeno</strong> — vygenerováním QR kódů se do tohoto stavu automaticky přepne.
|
|
</Alert>
|
|
)}
|
|
|
|
<p>Zaplatili jste za skupinu. Vyberte, komu vygenerovat QR kód k úhradě.</p>
|
|
|
|
{error && (
|
|
<Alert variant="danger" onClose={() => setError(null)} dismissible>
|
|
{error}
|
|
</Alert>
|
|
)}
|
|
|
|
{hasFees && (
|
|
<div className="d-flex gap-3 mb-2 text-muted" style={{ fontSize: '0.9em' }}>
|
|
{fees > 0 && <span>Poplatky: <strong>{fees / 100} Kč</strong></span>}
|
|
{shipping > 0 && <span>Doprava: <strong>{shipping / 100} Kč</strong></span>}
|
|
{tip > 0 && <span>Spropitné: <strong>{tip / 100} Kč</strong></span>}
|
|
<span>→ {feeShare / 100} Kč/os.</span>
|
|
</div>
|
|
)}
|
|
{group.discountValue != null && group.discountValue > 0 && (
|
|
<div className="mb-2 text-success" style={{ fontSize: '0.9em' }}>
|
|
Sleva: {group.discountType === 'percent' ? `${group.discountValue}%` : `${group.discountValue / 100} Kč`}
|
|
</div>
|
|
)}
|
|
|
|
<Table striped bordered hover responsive size="sm">
|
|
<thead>
|
|
<tr>
|
|
<th style={{ width: 40 }}></th>
|
|
<th>Člen</th>
|
|
<th style={{ width: 90 }} className="text-end">Základ</th>
|
|
<th style={{ width: 90 }} className="text-end">Příplatek</th>
|
|
{hasFees && <th style={{ width: 90 }} className="text-end">Poplatek</th>}
|
|
<th style={{ width: 90 }} className="text-end fw-bold">Celkem</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{diners.map(d => {
|
|
const isPayer = d.login === payerLogin;
|
|
const active = isActiveMember(d.member);
|
|
const total = getMemberTotal(d);
|
|
const surcharge = d.member.surchargeAmount ?? 0;
|
|
return (
|
|
<tr key={d.login} className={(!d.included && !isPayer) || !active ? 'text-muted' : ''}>
|
|
<td className="text-center">
|
|
{isPayer ? (
|
|
<small className="text-muted">plátce</small>
|
|
) : !active ? (
|
|
<small className="text-muted">jen objednává</small>
|
|
) : (
|
|
<Form.Check
|
|
type="checkbox"
|
|
checked={d.included}
|
|
onChange={e => handleInclude(d.login, e.target.checked)}
|
|
/>
|
|
)}
|
|
</td>
|
|
<td>
|
|
<strong>{d.login}</strong>
|
|
{d.member.surchargeText && (
|
|
<small className="text-muted ms-1">({d.member.surchargeText})</small>
|
|
)}
|
|
</td>
|
|
<td className="text-end">
|
|
{(d.member.amount ?? 0) > 0 ? `${d.member.amount! / 100} Kč` : <span className="text-muted">—</span>}
|
|
</td>
|
|
<td className="text-end">
|
|
{surcharge > 0 ? `${surcharge / 100} Kč` : <span className="text-muted">—</span>}
|
|
</td>
|
|
{hasFees && (
|
|
<td className="text-end">
|
|
{active && feeShare > 0 ? `${feeShare / 100} Kč` : '—'}
|
|
</td>
|
|
)}
|
|
<td className="text-end fw-bold">
|
|
{total > 0 ? `${total / 100} Kč` : <span className="text-muted">—</span>}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</Table>
|
|
</>
|
|
)}
|
|
</Modal.Body>
|
|
<Modal.Footer>
|
|
{!success && (
|
|
<>
|
|
<span className="me-auto text-muted">Příjemci: {includedNonPayers.length}</span>
|
|
<Button variant="secondary" onClick={onClose} disabled={loading}>Storno</Button>
|
|
<Button
|
|
variant="primary"
|
|
onClick={handleGenerate}
|
|
disabled={loading || includedNonPayers.length === 0}
|
|
>
|
|
{loading ? 'Generuji...' : 'Vygenerovat QR'}
|
|
</Button>
|
|
</>
|
|
)}
|
|
{success && (
|
|
<Button variant="secondary" onClick={onClose}>Zavřít</Button>
|
|
)}
|
|
</Modal.Footer>
|
|
</Modal>
|
|
);
|
|
}
|