c7f78cf2c9
CI / Generate TypeScript types (pull_request) Successful in 20s
CI / Server unit tests (pull_request) Failing after 20s
CI / Build client (pull_request) Failing after 30s
CI / Build server (pull_request) Successful in 3m13s
CI / Playwright E2E tests (pull_request) Has been skipped
CI / Build and push Docker image (pull_request) Has been skipped
CI / Notify (pull_request) Has been skipped
CI / Build client (push) Failing after 10m5s
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Failing after 22s
CI / Build server (push) Successful in 41s
CI / Playwright E2E tests (push) Has been skipped
CI / Build and push Docker image (push) Has been skipped
CI / Notify (push) Successful in 4s
225 lines
10 KiB
TypeScript
225 lines
10 KiB
TypeScript
import { useState, useEffect } from "react";
|
|
import { Modal, Button, Form, Table, Alert } from "react-bootstrap";
|
|
import { generateQr, OrderGroup, OrderGroupMember, QrRecipient } from "../../../../types";
|
|
|
|
type Props = {
|
|
isOpen: boolean;
|
|
onClose: () => 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, 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,
|
|
included: login !== payerLogin,
|
|
}));
|
|
setDiners(entries);
|
|
setError(null);
|
|
setSuccess(false);
|
|
}, [isOpen, group, payerLogin]);
|
|
|
|
const memberCount = diners.length;
|
|
const fees = group.fees ?? 0;
|
|
const shipping = group.shipping ?? 0;
|
|
const tip = group.tip ?? 0;
|
|
const totalFees = fees + shipping + tip;
|
|
const feeShare = memberCount > 0 ? Math.round(totalFees / memberCount * 100) / 100 : 0;
|
|
|
|
const getMemberTotal = (entry: DinerEntry): number => {
|
|
const base = entry.member.amount ?? 0;
|
|
const surcharge = entry.member.surchargeAmount ?? 0;
|
|
const discountType = group.discountType;
|
|
const discountValue = group.discountValue ?? 0;
|
|
const discount = discountValue > 0
|
|
? (discountType === 'percent'
|
|
? Math.round((base + surcharge) * discountValue / 100 * 100) / 100
|
|
: Math.round(discountValue / memberCount * 100) / 100)
|
|
: 0;
|
|
return Math.round((base + surcharge + feeShare - discount) * 100) / 100;
|
|
};
|
|
|
|
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 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;
|
|
}
|
|
recipients.push({
|
|
login: d.login,
|
|
purpose: `Objednávka ${group.name}`.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, ...(groupId ? { groupId } : {}) },
|
|
});
|
|
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 hasFees = totalFees > 0;
|
|
|
|
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>
|
|
) : (
|
|
<>
|
|
<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} Kč</strong></span>}
|
|
{shipping > 0 && <span>Doprava: <strong>{shipping} Kč</strong></span>}
|
|
{tip > 0 && <span>Spropitné: <strong>{tip} Kč</strong></span>}
|
|
<span>→ {feeShare} 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} 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 total = getMemberTotal(d);
|
|
const surcharge = d.member.surchargeAmount ?? 0;
|
|
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>
|
|
{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} Kč` : <span className="text-muted">—</span>}
|
|
</td>
|
|
<td className="text-end">
|
|
{surcharge > 0 ? `${surcharge} Kč` : <span className="text-muted">—</span>}
|
|
</td>
|
|
{hasFees && (
|
|
<td className="text-end">
|
|
{feeShare > 0 ? `${feeShare} Kč` : '—'}
|
|
</td>
|
|
)}
|
|
<td className="text-end fw-bold">
|
|
{total > 0 ? `${total} 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>
|
|
);
|
|
}
|