fix: počítání částek v haléřích z důvodu přesnosti
CI / Generate TypeScript types (push) Successful in 21s
CI / Build server (push) Successful in 25s
CI / Server unit tests (push) Successful in 55s
CI / Build client (push) Successful in 33s
CI / Playwright E2E tests (push) Successful in 1m20s
CI / Build and push Docker image (push) Successful in 35s
CI / Notify (push) Successful in 2s

This commit is contained in:
2026-05-07 13:05:04 +02:00
parent d91c8db49c
commit 8aef00ab05
18 changed files with 370 additions and 380 deletions
+1 -1
View File
@@ -48,7 +48,7 @@ export default function PizzaOrderList({ state, orders, onDelete, creator }: Rea
borderTop: '2px solid var(--luncher-border)' borderTop: '2px solid var(--luncher-border)'
}}> }}>
<td colSpan={4} style={{ padding: '16px 20px', border: 'none' }}>Celkem</td> <td colSpan={4} style={{ padding: '16px 20px', border: 'none' }}>Celkem</td>
<td style={{ padding: '16px 20px', border: 'none', textAlign: 'right', color: 'var(--luncher-primary)' }}>{`${total}`}</td> <td style={{ padding: '16px 20px', border: 'none', textAlign: 'right', color: 'var(--luncher-primary)' }}>{`${total / 100}`}</td>
</tr> </tr>
</tbody> </tbody>
</Table> </Table>
+4 -4
View File
@@ -26,7 +26,7 @@ export default function PizzaOrderRow({ creator, order, state, onDelete, onFeeMo
<td>{order.customer}</td> <td>{order.customer}</td>
<td>{order.pizzaList!.map<React.ReactNode>(pizzaOrder => <td>{order.pizzaList!.map<React.ReactNode>(pizzaOrder =>
<span key={pizzaOrder.name}> <span key={pizzaOrder.name}>
{`${pizzaOrder.name}, ${pizzaOrder.size} (${pizzaOrder.price} Kč)`} {`${pizzaOrder.name}, ${pizzaOrder.size} (${pizzaOrder.price / 100} Kč)`}
{auth?.login === order.customer && state === PizzaDayState.CREATED && {auth?.login === order.customer && state === PizzaDayState.CREATED &&
<span title='Odstranit'> <span title='Odstranit'>
<FontAwesomeIcon onClick={() => { <FontAwesomeIcon onClick={() => {
@@ -38,10 +38,10 @@ export default function PizzaOrderRow({ creator, order, state, onDelete, onFeeMo
.reduce((prev, curr, index) => [prev, <br key={`br-${index}`} />, curr])} .reduce((prev, curr, index) => [prev, <br key={`br-${index}`} />, curr])}
</td> </td>
<td style={{ maxWidth: "200px" }}>{order.note ?? '-'}</td> <td style={{ maxWidth: "200px" }}>{order.note ?? '-'}</td>
<td style={{ maxWidth: "200px" }}>{order.fee?.price ? `${order.fee.price}${order.fee.text ? ` (${order.fee.text})` : ''}` : '-'}</td> <td style={{ maxWidth: "200px" }}>{order.fee?.price ? `${order.fee.price / 100}${order.fee.text ? ` (${order.fee.text})` : ''}` : '-'}</td>
<td> <td>
{order.totalPrice} {auth?.login === creator && state === PizzaDayState.CREATED && <span title='Nastavit příplatek'><FontAwesomeIcon onClick={() => { setIsFeeModalOpen(true) }} className='action-icon' icon={faMoneyBill1} /></span>} {order.totalPrice / 100} {auth?.login === creator && state === PizzaDayState.CREATED && <span title='Nastavit příplatek'><FontAwesomeIcon onClick={() => { setIsFeeModalOpen(true) }} className='action-icon' icon={faMoneyBill1} /></span>}
</td> </td>
<PizzaAdditionalFeeModal customerName={order.customer} isOpen={isFeeModalOpen} onClose={() => setIsFeeModalOpen(false)} onSave={saveFees} initialValues={{ text: order.fee?.text, price: order.fee?.price?.toString() }} /> <PizzaAdditionalFeeModal customerName={order.customer} isOpen={isFeeModalOpen} onClose={() => setIsFeeModalOpen(false)} onSave={saveFees} initialValues={{ text: order.fee?.text, price: order.fee?.price != null ? String(order.fee.price / 100) : undefined }} />
</> </>
} }
@@ -9,18 +9,23 @@ type Props = {
onSaved: (data: any) => void; onSaved: (data: any) => void;
}; };
function parseNum(s: string): number { function parseHal(s: string): number {
const n = parseFloat(s.replace(',', '.')); const n = parseFloat(s.replace(',', '.'));
return isNaN(n) || n < 0 ? 0 : Math.round(n * 100) / 100; return isNaN(n) || n < 0 ? 0 : Math.round(n * 100);
}
function parsePercent(s: string): number {
const n = parseFloat(s.replace(',', '.'));
return isNaN(n) || n < 0 ? 0 : Math.round(n);
} }
function computeMemberTotal(member: OrderGroupMember, feeShare: number, discountType: string, discountValue: number, memberCount: number): number { function computeMemberTotal(member: OrderGroupMember, feeShare: number, discountType: string, discountValue: number, memberCount: number): number {
const base = member.amount ?? 0; const base = member.amount ?? 0;
const surcharge = member.surchargeAmount ?? 0; const surcharge = member.surchargeAmount ?? 0;
const discount = discountType === 'percent' const discount = discountType === 'percent'
? Math.round((base + surcharge) * discountValue / 100 * 100) / 100 ? Math.round((base + surcharge) * discountValue / 100)
: Math.round(discountValue / memberCount * 100) / 100; : Math.round(discountValue / memberCount);
return Math.round((base + surcharge + feeShare - discount) * 100) / 100; return base + surcharge + feeShare - discount;
} }
export default function EditGroupFeesModal({ isOpen, onClose, group, onSaved }: Readonly<Props>) { export default function EditGroupFeesModal({ isOpen, onClose, group, onSaved }: Readonly<Props>) {
@@ -34,23 +39,25 @@ export default function EditGroupFeesModal({ isOpen, onClose, group, onSaved }:
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!isOpen) return;
setFees(group.fees ? String(group.fees) : ''); setFees(group.fees ? String(group.fees / 100) : '');
setShipping(group.shipping ? String(group.shipping) : ''); setShipping(group.shipping ? String(group.shipping / 100) : '');
setTip(group.tip ? String(group.tip) : ''); setTip(group.tip ? String(group.tip / 100) : '');
setDiscountType((group.discountType as 'percent' | 'fixed') ?? 'percent'); setDiscountType((group.discountType as 'percent' | 'fixed') ?? 'percent');
setDiscountValue(group.discountValue ? String(group.discountValue) : ''); setDiscountValue(group.discountValue
? ((group.discountType as string) === 'fixed' ? String(group.discountValue / 100) : String(group.discountValue))
: '');
setError(null); setError(null);
}, [isOpen, group]); }, [isOpen, group]);
const memberEntries = Object.entries(group.members) as [string, OrderGroupMember][]; const memberEntries = Object.entries(group.members) as [string, OrderGroupMember][];
const memberCount = memberEntries.length; const memberCount = memberEntries.length;
const feesNum = parseNum(fees); const feesNum = parseHal(fees);
const shippingNum = parseNum(shipping); const shippingNum = parseHal(shipping);
const tipNum = parseNum(tip); const tipNum = parseHal(tip);
const discountNum = parseNum(discountValue); const discountNum = discountType === 'percent' ? parsePercent(discountValue) : parseHal(discountValue);
const totalFees = feesNum + shippingNum + tipNum; const totalFees = feesNum + shippingNum + tipNum;
const feeShare = memberCount > 0 ? Math.round(totalFees / memberCount * 100) / 100 : 0; const feeShare = memberCount > 0 ? Math.round(totalFees / memberCount) : 0;
const handleSave = async () => { const handleSave = async () => {
setError(null); setError(null);
@@ -64,7 +71,7 @@ export default function EditGroupFeesModal({ isOpen, onClose, group, onSaved }:
tip: tipNum, tip: tipNum,
discountType: discountNum > 0 ? discountType : undefined, discountType: discountNum > 0 ? discountType : undefined,
discountValue: discountNum > 0 ? discountNum : undefined, discountValue: discountNum > 0 ? discountNum : undefined,
}, }
}); });
if (res.error) { if (res.error) {
setError((res.error as any).error || 'Nastala chyba'); setError((res.error as any).error || 'Nastala chyba');
@@ -143,7 +150,7 @@ export default function EditGroupFeesModal({ isOpen, onClose, group, onSaved }:
</div> </div>
<hr /> <hr />
<h6>Náhled celkových částek ({memberCount} členů, {feeShare > 0 ? `poplatek ${feeShare} Kč/os.` : 'bez poplatku'})</h6> <h6>Náhled celkových částek ({memberCount} členů, {feeShare > 0 ? `poplatek ${feeShare / 100} Kč/os.` : 'bez poplatku'})</h6>
<Table size="sm" bordered> <Table size="sm" bordered>
<thead> <thead>
<tr> <tr>
@@ -161,18 +168,18 @@ export default function EditGroupFeesModal({ isOpen, onClose, group, onSaved }:
const surcharge = member.surchargeAmount ?? 0; const surcharge = member.surchargeAmount ?? 0;
const discount = discountNum > 0 const discount = discountNum > 0
? (discountType === 'percent' ? (discountType === 'percent'
? Math.round((base + surcharge) * discountNum / 100 * 100) / 100 ? Math.round((base + surcharge) * discountNum / 100)
: Math.round(discountNum / memberCount * 100) / 100) : Math.round(discountNum / memberCount))
: 0; : 0;
const total = computeMemberTotal(member, feeShare, discountType, discountNum, memberCount); const total = computeMemberTotal(member, feeShare, discountType, discountNum, memberCount);
return ( return (
<tr key={login}> <tr key={login}>
<td><strong>{login}</strong></td> <td><strong>{login}</strong></td>
<td className="text-end">{base > 0 ? `${base}` : '—'}</td> <td className="text-end">{base > 0 ? `${base / 100}` : '—'}</td>
<td className="text-end">{surcharge > 0 ? `${surcharge}` : '—'}</td> <td className="text-end">{surcharge > 0 ? `${surcharge / 100}` : '—'}</td>
<td className="text-end">{feeShare > 0 ? `${feeShare}` : '—'}</td> <td className="text-end">{feeShare > 0 ? `${feeShare / 100}` : '—'}</td>
<td className="text-end text-danger">{discount > 0 ? `-${discount}` : '—'}</td> <td className="text-end text-danger">{discount > 0 ? `-${discount / 100}` : '—'}</td>
<td className="text-end fw-bold">{total > 0 ? `${total}` : '—'}</td> <td className="text-end fw-bold">{total > 0 ? `${total / 100}` : '—'}</td>
</tr> </tr>
); );
})} })}
+12 -19
View File
@@ -33,9 +33,7 @@ function parseAmount(s: string): number | null {
if (!s || s.trim().length === 0) return null; if (!s || s.trim().length === 0) return null;
const n = parseFloat(s); const n = parseFloat(s);
if (isNaN(n) || n < 0) return null; if (isNaN(n) || n < 0) return null;
const parts = s.split('.'); return Math.round(n * 100);
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>) { export default function PayForAllModal({ isOpen, onClose, locationName, locationChoices, menu, payerLogin, bankAccount, bankAccountHolder }: Readonly<Props>) {
@@ -55,11 +53,11 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
let baseAmountParseFailed = false; let baseAmountParseFailed = false;
if (menu) { if (menu) {
for (const idx of selectedFoods) { for (const idx of selectedFoods) {
const price = parsePriceCzk(menu.food?.[idx]?.price); const priceKc = parsePriceCzk(menu.food?.[idx]?.price);
if (price === null) { if (priceKc === null) {
baseAmountParseFailed = true; baseAmountParseFailed = true;
} else { } else {
baseAmount += price; baseAmount += Math.round(priceKc * 100);
} }
} }
} }
@@ -84,19 +82,19 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
if (includedDiners.length === 0) return 0; if (includedDiners.length === 0) return 0;
const tip = parseAmount(tipTotal); const tip = parseAmount(tipTotal);
if (tip === null || tip === 0) return 0; if (tip === null || tip === 0) return 0;
const totalPeople = includedDiners.length + 1; // +1 for payer const totalPeople = includedDiners.length + 1;
return Math.round((tip / totalPeople) * 100) / 100; return Math.round(tip / totalPeople);
})(); })();
const payerTipShare = (() => { const payerTipShare = (() => {
const tip = parseAmount(tipTotal); const tip = parseAmount(tipTotal);
if (!tip) return 0; if (!tip) return 0;
return Math.round((tip - tipPerPerson * includedDiners.length) * 100) / 100; return tip - tipPerPerson * includedDiners.length;
})(); })();
const getTotal = (d: DinerEntry): number => { const getTotal = (d: DinerEntry): number => {
const surcharge = parseAmount(d.surchargeAmount) ?? 0; const surcharge = parseAmount(d.surchargeAmount) ?? 0;
const tip = d.login === payerLogin ? payerTipShare : tipPerPerson; const tip = d.login === payerLogin ? payerTipShare : tipPerPerson;
return Math.round((d.baseAmount + surcharge + tip) * 100) / 100; return d.baseAmount + surcharge + tip;
}; };
const handleInclude = useCallback((login: string, checked: boolean) => { const handleInclude = useCallback((login: string, checked: boolean) => {
@@ -122,11 +120,6 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
setError(`Celková částka pro ${d.login} musí být kladná`); setError(`Celková částka pro ${d.login} musí být kladná`);
return; 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 foods = d.selectedFoods.map(i => menu?.food?.[i]?.name).filter(Boolean).join(', ');
const purposeBase = `Oběd ${locationName}${foods ? `${foods}` : ''}`; const purposeBase = `Oběd ${locationName}${foods ? `${foods}` : ''}`;
recipients.push({ recipients.push({
@@ -226,7 +219,7 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
<td> <td>
<small> <small>
{foodNames || <span className="text-muted">—</span>} {foodNames || <span className="text-muted">—</span>}
{hasMenu && d.baseAmount > 0 && <span className="text-muted"> ({d.baseAmount} Kč)</span>} {hasMenu && d.baseAmount > 0 && <span className="text-muted"> ({d.baseAmount / 100} Kč)</span>}
{d.baseAmountParseFailed && <span className="text-warning"> ⚠</span>} {d.baseAmountParseFailed && <span className="text-warning"> ⚠</span>}
</small> </small>
</td> </td>
@@ -254,10 +247,10 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
</div> </div>
</td> </td>
<td className="text-end"> <td className="text-end">
{(() => { const s = isPayer ? payerTipShare : tipPerPerson; return s > 0 ? `${s} Kč` : '—'; })()} {(() => { const s = isPayer ? payerTipShare : tipPerPerson; return s > 0 ? `${s / 100} Kč` : '—'; })()}
</td> </td>
<td className="text-end fw-bold"> <td className="text-end fw-bold">
{`${total} Kč`} {`${total / 100} Kč`}
</td> </td>
</tr> </tr>
); );
@@ -278,7 +271,7 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
/> />
<small className="text-muted"> <small className="text-muted">
{includedDiners.length > 0 && tipPerPerson > 0 {includedDiners.length > 0 && tipPerPerson > 0
? `(${tipPerPerson} Kč / osoba)` ? `(${tipPerPerson / 100} Kč / osoba)`
: ''} : ''}
</small> </small>
</div> </div>
@@ -41,7 +41,7 @@ export default function PayForGroupModal({ isOpen, onClose, group, payerLogin, b
const shipping = group.shipping ?? 0; const shipping = group.shipping ?? 0;
const tip = group.tip ?? 0; const tip = group.tip ?? 0;
const totalFees = fees + shipping + tip; const totalFees = fees + shipping + tip;
const feeShare = memberCount > 0 ? Math.round(totalFees / memberCount * 100) / 100 : 0; const feeShare = memberCount > 0 ? Math.round(totalFees / memberCount) : 0;
const getMemberTotal = (entry: DinerEntry): number => { const getMemberTotal = (entry: DinerEntry): number => {
const base = entry.member.amount ?? 0; const base = entry.member.amount ?? 0;
@@ -50,10 +50,10 @@ export default function PayForGroupModal({ isOpen, onClose, group, payerLogin, b
const discountValue = group.discountValue ?? 0; const discountValue = group.discountValue ?? 0;
const discount = discountValue > 0 const discount = discountValue > 0
? (discountType === 'percent' ? (discountType === 'percent'
? Math.round((base + surcharge) * discountValue / 100 * 100) / 100 ? Math.round((base + surcharge) * discountValue / 100)
: Math.round(discountValue / memberCount * 100) / 100) : Math.round(discountValue / memberCount))
: 0; : 0;
return Math.round((base + surcharge + feeShare - discount) * 100) / 100; return base + surcharge + feeShare - discount;
}; };
const includedNonPayers = diners.filter(d => d.included && d.login !== payerLogin); const includedNonPayers = diners.filter(d => d.included && d.login !== payerLogin);
@@ -73,11 +73,6 @@ export default function PayForGroupModal({ isOpen, onClose, group, payerLogin, b
setError(`Celková částka pro ${d.login} musí být kladná`); setError(`Celková částka pro ${d.login} musí být kladná`);
return; 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({ recipients.push({
login: d.login, login: d.login,
purpose: `Objednávka ${group.name}`.substring(0, 60), purpose: `Objednávka ${group.name}`.substring(0, 60),
@@ -132,15 +127,15 @@ export default function PayForGroupModal({ isOpen, onClose, group, payerLogin, b
{hasFees && ( {hasFees && (
<div className="d-flex gap-3 mb-2 text-muted" style={{ fontSize: '0.9em' }}> <div className="d-flex gap-3 mb-2 text-muted" style={{ fontSize: '0.9em' }}>
{fees > 0 && <span>Poplatky: <strong>{fees} Kč</strong></span>} {fees > 0 && <span>Poplatky: <strong>{fees / 100} Kč</strong></span>}
{shipping > 0 && <span>Doprava: <strong>{shipping} Kč</strong></span>} {shipping > 0 && <span>Doprava: <strong>{shipping / 100} Kč</strong></span>}
{tip > 0 && <span>Spropitné: <strong>{tip} Kč</strong></span>} {tip > 0 && <span>Spropitné: <strong>{tip / 100} Kč</strong></span>}
<span>→ {feeShare} Kč/os.</span> <span>→ {feeShare / 100} Kč/os.</span>
</div> </div>
)} )}
{group.discountValue != null && group.discountValue > 0 && ( {group.discountValue != null && group.discountValue > 0 && (
<div className="mb-2 text-success" style={{ fontSize: '0.9em' }}> <div className="mb-2 text-success" style={{ fontSize: '0.9em' }}>
Sleva: {group.discountType === 'percent' ? `${group.discountValue}%` : `${group.discountValue} Kč`} Sleva: {group.discountType === 'percent' ? `${group.discountValue}%` : `${group.discountValue / 100} Kč`}
</div> </div>
)} )}
@@ -180,18 +175,18 @@ export default function PayForGroupModal({ isOpen, onClose, group, payerLogin, b
)} )}
</td> </td>
<td className="text-end"> <td className="text-end">
{(d.member.amount ?? 0) > 0 ? `${d.member.amount} Kč` : <span className="text-muted">—</span>} {(d.member.amount ?? 0) > 0 ? `${d.member.amount! / 100} Kč` : <span className="text-muted">—</span>}
</td> </td>
<td className="text-end"> <td className="text-end">
{surcharge > 0 ? `${surcharge} Kč` : <span className="text-muted">—</span>} {surcharge > 0 ? `${surcharge / 100} Kč` : <span className="text-muted">—</span>}
</td> </td>
{hasFees && ( {hasFees && (
<td className="text-end"> <td className="text-end">
{feeShare > 0 ? `${feeShare} Kč` : '—'} {feeShare > 0 ? `${feeShare / 100} Kč` : '—'}
</td> </td>
)} )}
<td className="text-end fw-bold"> <td className="text-end fw-bold">
{total > 0 ? `${total} Kč` : <span className="text-muted">—</span>} {total > 0 ? `${total / 100} Kč` : <span className="text-muted">—</span>}
</td> </td>
</tr> </tr>
); );
@@ -15,12 +15,12 @@ export default function PizzaAdditionalFeeModal({ customerName, isOpen, onClose,
const priceRef = useRef<HTMLInputElement>(null); const priceRef = useRef<HTMLInputElement>(null);
const doSubmit = () => { const doSubmit = () => {
onSave(customerName, textRef.current?.value, Number.parseInt(priceRef.current?.value ?? "0")); onSave(customerName, textRef.current?.value, Math.round(Number.parseFloat(priceRef.current?.value ?? "0") * 100));
} }
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
onSave(customerName, textRef.current?.value, Number.parseInt(priceRef.current?.value ?? "0")); onSave(customerName, textRef.current?.value, Math.round(Number.parseFloat(priceRef.current?.value ?? "0") * 100));
} }
} }
+16 -16
View File
@@ -125,7 +125,7 @@ export default function OrderGroupsPage() {
setPageError('Zadejte platnou kladnou částku'); setPageError('Zadejte platnou kladnou částku');
return; return;
} }
const ok = await refresh(() => updateGroupMember({ body: { id: groupId, login, amount: n } })); const ok = await refresh(() => updateGroupMember({ body: { id: groupId, login, amount: Math.round(n * 100) } }));
if (ok) setEditAmounts(prev => { const next = { ...prev }; delete next[key]; return next; }); if (ok) setEditAmounts(prev => { const next = { ...prev }; delete next[key]; return next; });
}; };
@@ -145,7 +145,7 @@ export default function OrderGroupsPage() {
setPageError('Zadejte platnou výši příplatku'); setPageError('Zadejte platnou výši příplatku');
return; return;
} }
const ok = await refresh(() => updateGroupMember({ body: { id: groupId, login, surchargeText, surchargeAmount: rawAmount === '' ? 0 : surchargeAmount } })); const ok = await refresh(() => updateGroupMember({ body: { id: groupId, login, surchargeText, surchargeAmount: rawAmount === '' ? 0 : Math.round(surchargeAmount * 100) } }));
if (ok) setEditSurcharges(prev => { const next = { ...prev }; delete next[key]; return next; }); if (ok) setEditSurcharges(prev => { const next = { ...prev }; delete next[key]; return next; });
}; };
@@ -254,17 +254,17 @@ export default function OrderGroupsPage() {
const editingTimes = group.id in editTimes; const editingTimes = group.id in editTimes;
const totalFees = (group.fees ?? 0) + (group.shipping ?? 0) + (group.tip ?? 0); const totalFees = (group.fees ?? 0) + (group.shipping ?? 0) + (group.tip ?? 0);
const feeShare = memberCount > 0 ? Math.round(totalFees / memberCount * 100) / 100 : 0; const feeShare = memberCount > 0 ? Math.round(totalFees / memberCount) : 0;
const getMemberTotal = (m: OrderGroupMember) => { const getMemberTotal = (m: OrderGroupMember) => {
const base = m.amount ?? 0; const base = m.amount ?? 0;
const surcharge = m.surchargeAmount ?? 0; const surcharge = m.surchargeAmount ?? 0;
const dv = group.discountValue ?? 0; const dv = group.discountValue ?? 0;
const discount = dv > 0 const discount = dv > 0
? (group.discountType === 'percent' ? (group.discountType === 'percent'
? Math.round((base + surcharge) * dv / 100 * 100) / 100 ? Math.round((base + surcharge) * dv / 100)
: Math.round(dv / memberCount * 100) / 100) : Math.round(dv / memberCount))
: 0; : 0;
return Math.round((base + surcharge + feeShare - discount) * 100) / 100; return base + surcharge + feeShare - discount;
}; };
return ( return (
@@ -370,10 +370,10 @@ export default function OrderGroupsPage() {
) : ( ) : (
<span <span
style={{ cursor: canEdit ? 'pointer' : undefined }} style={{ cursor: canEdit ? 'pointer' : undefined }}
onClick={() => canEdit && setEditAmounts(prev => ({ ...prev, [key]: String(member.amount ?? '') }))} onClick={() => canEdit && setEditAmounts(prev => ({ ...prev, [key]: member.amount != null ? String(member.amount / 100) : '' }))}
title={canEdit ? 'Klikněte pro úpravu' : undefined} title={canEdit ? 'Klikněte pro úpravu' : undefined}
> >
{member.amount != null ? `${member.amount}` : <span className="text-muted"></span>} {member.amount != null ? `${member.amount / 100}` : <span className="text-muted"></span>}
</span> </span>
)} )}
</td> </td>
@@ -404,11 +404,11 @@ export default function OrderGroupsPage() {
) : ( ) : (
<span <span
style={{ cursor: canEdit ? 'pointer' : undefined }} style={{ cursor: canEdit ? 'pointer' : undefined }}
onClick={() => canEdit && setEditSurcharges(prev => ({ ...prev, [key]: { text: member.surchargeText ?? '', amount: member.surchargeAmount != null ? String(member.surchargeAmount) : '' } }))} onClick={() => canEdit && setEditSurcharges(prev => ({ ...prev, [key]: { text: member.surchargeText ?? '', amount: member.surchargeAmount != null ? String(member.surchargeAmount / 100) : '' } }))}
title={canEdit ? 'Klikněte pro úpravu příplatku' : undefined} title={canEdit ? 'Klikněte pro úpravu příplatku' : undefined}
> >
{member.surchargeAmount != null && member.surchargeAmount > 0 ? ( {member.surchargeAmount != null && member.surchargeAmount > 0 ? (
<small>{member.surchargeText ? `${member.surchargeText}: ` : ''}<strong>{member.surchargeAmount} </strong></small> <small>{member.surchargeText ? `${member.surchargeText}: ` : ''}<strong>{member.surchargeAmount / 100} </strong></small>
) : ( ) : (
<small className="text-muted"></small> <small className="text-muted"></small>
)} )}
@@ -440,7 +440,7 @@ export default function OrderGroupsPage() {
</td> </td>
<td className="text-end"> <td className="text-end">
<small className={memberTotal > 0 ? 'fw-bold' : 'text-muted'}> <small className={memberTotal > 0 ? 'fw-bold' : 'text-muted'}>
{memberTotal > 0 ? `${memberTotal}` : '—'} {memberTotal > 0 ? `${memberTotal / 100}` : '—'}
</small> </small>
</td> </td>
<td> <td>
@@ -464,13 +464,13 @@ export default function OrderGroupsPage() {
{/* Souhrn poplatků a slevy */} {/* Souhrn poplatků a slevy */}
{(totalFees > 0 || (group.discountValue != null && group.discountValue > 0)) && ( {(totalFees > 0 || (group.discountValue != null && group.discountValue > 0)) && (
<div className="px-3 py-2 border-top d-flex gap-3 flex-wrap" style={{ fontSize: '0.85em', color: 'var(--luncher-text-muted)' }}> <div className="px-3 py-2 border-top d-flex gap-3 flex-wrap" style={{ fontSize: '0.85em', color: 'var(--luncher-text-muted)' }}>
{group.fees != null && group.fees > 0 && <span>Poplatky: <strong>{group.fees} </strong></span>} {group.fees != null && group.fees > 0 && <span>Poplatky: <strong>{group.fees / 100} </strong></span>}
{group.shipping != null && group.shipping > 0 && <span>Doprava: <strong>{group.shipping} </strong></span>} {group.shipping != null && group.shipping > 0 && <span>Doprava: <strong>{group.shipping / 100} </strong></span>}
{group.tip != null && group.tip > 0 && <span>Spropitné: <strong>{group.tip} </strong></span>} {group.tip != null && group.tip > 0 && <span>Spropitné: <strong>{group.tip / 100} </strong></span>}
{feeShare > 0 && <span> <strong>{feeShare} </strong>/os.</span>} {feeShare > 0 && <span> <strong>{feeShare / 100} </strong>/os.</span>}
{group.discountValue != null && group.discountValue > 0 && ( {group.discountValue != null && group.discountValue > 0 && (
<span className="text-success"> <span className="text-success">
Sleva: <strong>{group.discountType === 'percent' ? `${group.discountValue}%` : `${group.discountValue}`}</strong> Sleva: <strong>{group.discountType === 'percent' ? `${group.discountValue}%` : `${group.discountValue / 100}`}</strong>
</span> </span>
)} )}
</div> </div>
+9 -9
View File
@@ -28,16 +28,16 @@ const buildPizzaUrl = (pizzaUrl: string) => {
return `${baseUrl}/${pizzaUrl}`; return `${baseUrl}/${pizzaUrl}`;
} }
// Ceny krabic dle velikosti // Ceny krabic dle velikosti v haléřích
const boxPrices: { [key: string]: number } = { const boxPrices: { [key: string]: number } = {
"30cm": 13, "30cm": 1300,
"35cm": 15, "35cm": 1500,
"40cm": 18, "40cm": 1800,
"50cm": 25 "50cm": 2500
} }
// Cena obalu pro salát // Cena obalu pro salát v haléřích
const SALAT_BOX_PRICE = 13; const SALAT_BOX_PRICE = 1300;
/** /**
* Stáhne a scrapne aktuální pizzy ze stránek Pizza Chefie. * Stáhne a scrapne aktuální pizzy ze stránek Pizza Chefie.
@@ -79,7 +79,7 @@ export async function downloadPizzy(mock: boolean): Promise<Pizza[]> {
a.each((i, elm) => { a.each((i, elm) => {
const varId = Number.parseInt(elm.attribs.href.split('?varianta=')[1].trim()); const varId = Number.parseInt(elm.attribs.href.split('?varianta=')[1].trim());
const size = $($(elm).contents().get(0)).text().trim(); const size = $($(elm).contents().get(0)).text().trim();
const price = Number.parseInt($($(elm).contents().get(1)).text().trim().split(" Kč")[0]); const price = Number.parseInt($($(elm).contents().get(1)).text().trim().split(" Kč")[0]) * 100;
sizes.push({ varId, size, pizzaPrice: price, boxPrice: boxPrices[size], price: price + boxPrices[size] }); sizes.push({ varId, size, pizzaPrice: price, boxPrice: boxPrices[size], price: price + boxPrices[size] });
}) })
result.push({ result.push({
@@ -119,7 +119,7 @@ export async function downloadSalaty(mock: boolean): Promise<Salat[]> {
ingredients.push($(elm).text()); ingredients.push($(elm).text());
}); });
const priceText = $('.cena > span', salatHtml).first().text().trim(); const priceText = $('.cena > span', salatHtml).first().text().trim();
const price = Number.parseInt(priceText.split(' Kč')[0]); const price = Number.parseInt(priceText.split(' Kč')[0]) * 100;
result.push({ name, ingredients, price: price + SALAT_BOX_PRICE }); result.push({ name, ingredients, price: price + SALAT_BOX_PRICE });
} }
return result; return result;
+220 -220
View File
@@ -661,30 +661,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 1, varId: 1,
size: "30cm", size: "30cm",
pizzaPrice: 138, pizzaPrice: 13800,
boxPrice: 13, boxPrice: 1300,
price: 151 price: 15100
}, },
{ {
varId: 2, varId: 2,
size: "35cm", size: "35cm",
pizzaPrice: 166, pizzaPrice: 16600,
boxPrice: 15, boxPrice: 1500,
price: 181 price: 18100
}, },
{ {
varId: 3, varId: 3,
size: "40cm", size: "40cm",
pizzaPrice: 223, pizzaPrice: 22300,
boxPrice: 18, boxPrice: 1800,
price: 241 price: 24100
}, },
{ {
varId: 4, varId: 4,
size: "50cm", size: "50cm",
pizzaPrice: 306, pizzaPrice: 30600,
boxPrice: 25, boxPrice: 2500,
price: 331 price: 33100
} }
] ]
}, },
@@ -700,30 +700,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 6, varId: 6,
size: "30cm", size: "30cm",
pizzaPrice: 142, pizzaPrice: 14200,
boxPrice: 13, boxPrice: 1300,
price: 155 price: 15500
}, },
{ {
varId: 7, varId: 7,
size: "35cm", size: "35cm",
pizzaPrice: 172, pizzaPrice: 17200,
boxPrice: 15, boxPrice: 1500,
price: 187 price: 18700
}, },
{ {
varId: 8, varId: 8,
size: "40cm", size: "40cm",
pizzaPrice: 233, pizzaPrice: 23300,
boxPrice: 18, boxPrice: 1800,
price: 251 price: 25100
}, },
{ {
varId: 9, varId: 9,
size: "50cm", size: "50cm",
pizzaPrice: 316, pizzaPrice: 31600,
boxPrice: 25, boxPrice: 2500,
price: 341 price: 34100
} }
] ]
}, },
@@ -741,30 +741,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 10, varId: 10,
size: "30cm", size: "30cm",
pizzaPrice: 142, pizzaPrice: 14200,
boxPrice: 13, boxPrice: 1300,
price: 155 price: 15500
}, },
{ {
varId: 11, varId: 11,
size: "35cm", size: "35cm",
pizzaPrice: 172, pizzaPrice: 17200,
boxPrice: 15, boxPrice: 1500,
price: 187 price: 18700
}, },
{ {
varId: 12, varId: 12,
size: "40cm", size: "40cm",
pizzaPrice: 233, pizzaPrice: 23300,
boxPrice: 18, boxPrice: 1800,
price: 251 price: 25100
}, },
{ {
varId: 13, varId: 13,
size: "50cm", size: "50cm",
pizzaPrice: 316, pizzaPrice: 31600,
boxPrice: 25, boxPrice: 2500,
price: 341 price: 34100
} }
] ]
}, },
@@ -780,30 +780,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 14, varId: 14,
size: "30cm", size: "30cm",
pizzaPrice: 142, pizzaPrice: 14200,
boxPrice: 13, boxPrice: 1300,
price: 155 price: 15500
}, },
{ {
varId: 15, varId: 15,
size: "35cm", size: "35cm",
pizzaPrice: 172, pizzaPrice: 17200,
boxPrice: 15, boxPrice: 1500,
price: 187 price: 18700
}, },
{ {
varId: 16, varId: 16,
size: "40cm", size: "40cm",
pizzaPrice: 233, pizzaPrice: 23300,
boxPrice: 18, boxPrice: 1800,
price: 251 price: 25100
}, },
{ {
varId: 17, varId: 17,
size: "50cm", size: "50cm",
pizzaPrice: 294, pizzaPrice: 29400,
boxPrice: 25, boxPrice: 2500,
price: 319 price: 31900
} }
] ]
}, },
@@ -821,30 +821,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 22, varId: 22,
size: "30cm", size: "30cm",
pizzaPrice: 162, pizzaPrice: 16200,
boxPrice: 13, boxPrice: 1300,
price: 175 price: 17500
}, },
{ {
varId: 23, varId: 23,
size: "35cm", size: "35cm",
pizzaPrice: 186, pizzaPrice: 18600,
boxPrice: 15, boxPrice: 1500,
price: 201 price: 20100
}, },
{ {
varId: 24, varId: 24,
size: "40cm", size: "40cm",
pizzaPrice: 263, pizzaPrice: 26300,
boxPrice: 18, boxPrice: 1800,
price: 281 price: 28100
}, },
{ {
varId: 25, varId: 25,
size: "50cm", size: "50cm",
pizzaPrice: 346, pizzaPrice: 34600,
boxPrice: 25, boxPrice: 2500,
price: 371 price: 37100
} }
] ]
}, },
@@ -861,30 +861,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 26, varId: 26,
size: "30cm", size: "30cm",
pizzaPrice: 162, pizzaPrice: 16200,
boxPrice: 13, boxPrice: 1300,
price: 175 price: 17500
}, },
{ {
varId: 27, varId: 27,
size: "35cm", size: "35cm",
pizzaPrice: 186, pizzaPrice: 18600,
boxPrice: 15, boxPrice: 1500,
price: 201 price: 20100
}, },
{ {
varId: 28, varId: 28,
size: "40cm", size: "40cm",
pizzaPrice: 263, pizzaPrice: 26300,
boxPrice: 18, boxPrice: 1800,
price: 281 price: 28100
}, },
{ {
varId: 29, varId: 29,
size: "50cm", size: "50cm",
pizzaPrice: 346, pizzaPrice: 34600,
boxPrice: 25, boxPrice: 2500,
price: 371 price: 37100
} }
] ]
}, },
@@ -902,30 +902,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 30, varId: 30,
size: "30cm", size: "30cm",
pizzaPrice: 162, pizzaPrice: 16200,
boxPrice: 13, boxPrice: 1300,
price: 175 price: 17500
}, },
{ {
varId: 31, varId: 31,
size: "35cm", size: "35cm",
pizzaPrice: 186, pizzaPrice: 18600,
boxPrice: 15, boxPrice: 1500,
price: 201 price: 20100
}, },
{ {
varId: 32, varId: 32,
size: "40cm", size: "40cm",
pizzaPrice: 263, pizzaPrice: 26300,
boxPrice: 18, boxPrice: 1800,
price: 281 price: 28100
}, },
{ {
varId: 33, varId: 33,
size: "50cm", size: "50cm",
pizzaPrice: 346, pizzaPrice: 34600,
boxPrice: 25, boxPrice: 2500,
price: 371 price: 37100
} }
] ]
}, },
@@ -946,30 +946,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 34, varId: 34,
size: "30cm", size: "30cm",
pizzaPrice: 162, pizzaPrice: 16200,
boxPrice: 13, boxPrice: 1300,
price: 175 price: 17500
}, },
{ {
varId: 35, varId: 35,
size: "35cm", size: "35cm",
pizzaPrice: 186, pizzaPrice: 18600,
boxPrice: 15, boxPrice: 1500,
price: 201 price: 20100
}, },
{ {
varId: 36, varId: 36,
size: "40cm", size: "40cm",
pizzaPrice: 263, pizzaPrice: 26300,
boxPrice: 18, boxPrice: 1800,
price: 281 price: 28100
}, },
{ {
varId: 37, varId: 37,
size: "50cm", size: "50cm",
pizzaPrice: 346, pizzaPrice: 34600,
boxPrice: 25, boxPrice: 2500,
price: 371 price: 37100
} }
] ]
}, },
@@ -987,30 +987,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 38, varId: 38,
size: "30cm", size: "30cm",
pizzaPrice: 162, pizzaPrice: 16200,
boxPrice: 13, boxPrice: 1300,
price: 175 price: 17500
}, },
{ {
varId: 39, varId: 39,
size: "35cm", size: "35cm",
pizzaPrice: 186, pizzaPrice: 18600,
boxPrice: 15, boxPrice: 1500,
price: 201 price: 20100
}, },
{ {
varId: 40, varId: 40,
size: "40cm", size: "40cm",
pizzaPrice: 263, pizzaPrice: 26300,
boxPrice: 18, boxPrice: 1800,
price: 281 price: 28100
}, },
{ {
varId: 41, varId: 41,
size: "50cm", size: "50cm",
pizzaPrice: 346, pizzaPrice: 34600,
boxPrice: 25, boxPrice: 2500,
price: 371 price: 37100
} }
] ]
}, },
@@ -1028,30 +1028,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 42, varId: 42,
size: "30cm", size: "30cm",
pizzaPrice: 172, pizzaPrice: 17200,
boxPrice: 13, boxPrice: 1300,
price: 185 price: 18500
}, },
{ {
varId: 43, varId: 43,
size: "35cm", size: "35cm",
pizzaPrice: 212, pizzaPrice: 21200,
boxPrice: 15, boxPrice: 1500,
price: 227 price: 22700
}, },
{ {
varId: 44, varId: 44,
size: "40cm", size: "40cm",
pizzaPrice: 293, pizzaPrice: 29300,
boxPrice: 18, boxPrice: 1800,
price: 311 price: 31100
}, },
{ {
varId: 45, varId: 45,
size: "50cm", size: "50cm",
pizzaPrice: 376, pizzaPrice: 37600,
boxPrice: 25, boxPrice: 2500,
price: 401 price: 40100
} }
] ]
}, },
@@ -1069,30 +1069,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 46, varId: 46,
size: "30cm", size: "30cm",
pizzaPrice: 182, pizzaPrice: 18200,
boxPrice: 13, boxPrice: 1300,
price: 195 price: 19500
}, },
{ {
varId: 47, varId: 47,
size: "35cm", size: "35cm",
pizzaPrice: 222, pizzaPrice: 22200,
boxPrice: 15, boxPrice: 1500,
price: 237 price: 23700
}, },
{ {
varId: 48, varId: 48,
size: "40cm", size: "40cm",
pizzaPrice: 303, pizzaPrice: 30300,
boxPrice: 18, boxPrice: 1800,
price: 321 price: 32100
}, },
{ {
varId: 49, varId: 49,
size: "50cm", size: "50cm",
pizzaPrice: 386, pizzaPrice: 38600,
boxPrice: 25, boxPrice: 2500,
price: 411 price: 41100
} }
] ]
}, },
@@ -1114,30 +1114,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 50, varId: 50,
size: "30cm", size: "30cm",
pizzaPrice: 182, pizzaPrice: 18200,
boxPrice: 13, boxPrice: 1300,
price: 195 price: 19500
}, },
{ {
varId: 51, varId: 51,
size: "35cm", size: "35cm",
pizzaPrice: 222, pizzaPrice: 22200,
boxPrice: 15, boxPrice: 1500,
price: 237 price: 23700
}, },
{ {
varId: 52, varId: 52,
size: "40cm", size: "40cm",
pizzaPrice: 303, pizzaPrice: 30300,
boxPrice: 18, boxPrice: 1800,
price: 321 price: 32100
}, },
{ {
varId: 53, varId: 53,
size: "50cm", size: "50cm",
pizzaPrice: 396, pizzaPrice: 39600,
boxPrice: 25, boxPrice: 2500,
price: 421 price: 42100
} }
] ]
}, },
@@ -1156,30 +1156,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 54, varId: 54,
size: "30cm", size: "30cm",
pizzaPrice: 182, pizzaPrice: 18200,
boxPrice: 13, boxPrice: 1300,
price: 195 price: 19500
}, },
{ {
varId: 55, varId: 55,
size: "35cm", size: "35cm",
pizzaPrice: 222, pizzaPrice: 22200,
boxPrice: 15, boxPrice: 1500,
price: 237 price: 23700
}, },
{ {
varId: 56, varId: 56,
size: "40cm", size: "40cm",
pizzaPrice: 303, pizzaPrice: 30300,
boxPrice: 18, boxPrice: 1800,
price: 321 price: 32100
}, },
{ {
varId: 57, varId: 57,
size: "50cm", size: "50cm",
pizzaPrice: 396, pizzaPrice: 39600,
boxPrice: 25, boxPrice: 2500,
price: 421 price: 42100
} }
] ]
}, },
@@ -1199,30 +1199,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 58, varId: 58,
size: "30cm", size: "30cm",
pizzaPrice: 182, pizzaPrice: 18200,
boxPrice: 13, boxPrice: 1300,
price: 195 price: 19500
}, },
{ {
varId: 59, varId: 59,
size: "35cm", size: "35cm",
pizzaPrice: 222, pizzaPrice: 22200,
boxPrice: 15, boxPrice: 1500,
price: 237 price: 23700
}, },
{ {
varId: 60, varId: 60,
size: "40cm", size: "40cm",
pizzaPrice: 303, pizzaPrice: 30300,
boxPrice: 18, boxPrice: 1800,
price: 321 price: 32100
}, },
{ {
varId: 61, varId: 61,
size: "50cm", size: "50cm",
pizzaPrice: 396, pizzaPrice: 39600,
boxPrice: 25, boxPrice: 2500,
price: 421 price: 42100
} }
] ]
}, },
@@ -1241,30 +1241,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 62, varId: 62,
size: "30cm", size: "30cm",
pizzaPrice: 188, pizzaPrice: 18800,
boxPrice: 13, boxPrice: 1300,
price: 201 price: 20100
}, },
{ {
varId: 63, varId: 63,
size: "35cm", size: "35cm",
pizzaPrice: 226, pizzaPrice: 22600,
boxPrice: 15, boxPrice: 1500,
price: 241 price: 24100
}, },
{ {
varId: 64, varId: 64,
size: "40cm", size: "40cm",
pizzaPrice: 313, pizzaPrice: 31300,
boxPrice: 18, boxPrice: 1800,
price: 331 price: 33100
}, },
{ {
varId: 65, varId: 65,
size: "50cm", size: "50cm",
pizzaPrice: 426, pizzaPrice: 42600,
boxPrice: 25, boxPrice: 2500,
price: 451 price: 45100
} }
] ]
}, },
@@ -1283,30 +1283,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 66, varId: 66,
size: "30cm", size: "30cm",
pizzaPrice: 188, pizzaPrice: 18800,
boxPrice: 13, boxPrice: 1300,
price: 201 price: 20100
}, },
{ {
varId: 67, varId: 67,
size: "35cm", size: "35cm",
pizzaPrice: 226, pizzaPrice: 22600,
boxPrice: 15, boxPrice: 1500,
price: 241 price: 24100
}, },
{ {
varId: 68, varId: 68,
size: "40cm", size: "40cm",
pizzaPrice: 313, pizzaPrice: 31300,
boxPrice: 18, boxPrice: 1800,
price: 331 price: 33100
}, },
{ {
varId: 69, varId: 69,
size: "50cm", size: "50cm",
pizzaPrice: 426, pizzaPrice: 42600,
boxPrice: 25, boxPrice: 2500,
price: 451 price: 45100
} }
] ]
}, },
@@ -1327,30 +1327,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 309, varId: 309,
size: "30cm", size: "30cm",
pizzaPrice: 182, pizzaPrice: 18200,
boxPrice: 13, boxPrice: 1300,
price: 195 price: 19500
}, },
{ {
varId: 310, varId: 310,
size: "35cm", size: "35cm",
pizzaPrice: 222, pizzaPrice: 22200,
boxPrice: 15, boxPrice: 1500,
price: 237 price: 23700
}, },
{ {
varId: 311, varId: 311,
size: "40cm", size: "40cm",
pizzaPrice: 303, pizzaPrice: 30300,
boxPrice: 18, boxPrice: 1800,
price: 321 price: 32100
}, },
{ {
varId: 312, varId: 312,
size: "50cm", size: "50cm",
pizzaPrice: 396, pizzaPrice: 39600,
boxPrice: 25, boxPrice: 2500,
price: 421 price: 42100
} }
] ]
}, },
@@ -1369,30 +1369,30 @@ const MOCK_PIZZA_LIST = [
{ {
varId: 394, varId: 394,
size: "30cm", size: "30cm",
pizzaPrice: 188, pizzaPrice: 18800,
boxPrice: 13, boxPrice: 1300,
price: 201 price: 20100
}, },
{ {
varId: 395, varId: 395,
size: "35cm", size: "35cm",
pizzaPrice: 226, pizzaPrice: 22600,
boxPrice: 15, boxPrice: 1500,
price: 241 price: 24100
}, },
{ {
varId: 396, varId: 396,
size: "40cm", size: "40cm",
pizzaPrice: 313, pizzaPrice: 31300,
boxPrice: 18, boxPrice: 1800,
price: 331 price: 33100
}, },
{ {
varId: 397, varId: 397,
size: "50cm", size: "50cm",
pizzaPrice: 426, pizzaPrice: 42600,
boxPrice: 25, boxPrice: 2500,
price: 451 price: 45100
} }
] ]
} }
@@ -1434,22 +1434,22 @@ const MOCK_SALAT_LIST = [
{ {
name: "Greek", name: "Greek",
ingredients: ["Salát", "Černé olivy", "Paprika mix", "Červená cibule", "Rajčata", "Okurka salátová", "Jogurtový dresing"], ingredients: ["Salát", "Černé olivy", "Paprika mix", "Červená cibule", "Rajčata", "Okurka salátová", "Jogurtový dresing"],
price: 174 + 13, price: (174 + 13) * 100,
}, },
{ {
name: "Caesar", name: "Caesar",
ingredients: ["Salát", "Rajčata", "Kuřecí maso", "Krutony", "Parmazán", "Caesar dresing", "Olivový olej"], ingredients: ["Salát", "Rajčata", "Kuřecí maso", "Krutony", "Parmazán", "Caesar dresing", "Olivový olej"],
price: 184 + 13, price: (184 + 13) * 100,
}, },
{ {
name: "Šopský salát", name: "Šopský salát",
ingredients: ["Salátová okurka", "Rajčata", "Paprika mix", "Červená cibule", "Balkánský sýr"], ingredients: ["Salátová okurka", "Rajčata", "Paprika mix", "Červená cibule", "Balkánský sýr"],
price: 164 + 13, price: (164 + 13) * 100,
}, },
{ {
name: "Těstovinový salát", name: "Těstovinový salát",
ingredients: ["Penne", "Okurka", "Rajčata", "Paprika mix", "Kuřecí maso", "Jogurtový dresing"], ingredients: ["Penne", "Okurka", "Rajčata", "Paprika mix", "Kuřecí maso", "Jogurtový dresing"],
price: 184 + 13, price: (184 + 13) * 100,
}, },
] ]
+1 -1
View File
@@ -342,7 +342,7 @@ export async function finishPizzaDelivery(login: string, bankAccount?: string, b
let message = order.pizzaList!.map(item => let message = order.pizzaList!.map(item =>
item.category === 'salat' ? `Salát ${item.name}` : `Pizza ${item.name} (${item.size})` item.category === 'salat' ? `Salát ${item.name}` : `Pizza ${item.name} (${item.size})`
).join(', '); ).join(', ');
await generateQr(order.customer, bankAccount, bankAccountHolder, order.totalPrice, message, id); await generateQr(order.customer, bankAccount, bankAccountHolder, order.totalPrice / 100, message, id);
order.hasQr = true; order.hasQr = true;
// Uložíme nevyřízený QR kód pro persistentní zobrazení // Uložíme nevyřízený QR kód pro persistentní zobrazení
await addPendingQr(order.customer, { await addPendingQr(order.customer, {
+6 -6
View File
@@ -69,7 +69,7 @@ router.post("/updateMember", async (req: Request, res, next) => {
if (!targetLogin) return res.status(400).json({ error: 'Nebyl předán login uživatele' }); if (!targetLogin) return res.status(400).json({ error: 'Nebyl předán login uživatele' });
const patch: Record<string, any> = {}; const patch: Record<string, any> = {};
if (amount !== undefined) { if (amount !== undefined) {
if (typeof amount !== 'number' || !Number.isFinite(amount) || amount < 0) { if (!Number.isInteger(amount) || amount < 0) {
return res.status(400).json({ error: 'Neplatná částka' }); return res.status(400).json({ error: 'Neplatná částka' });
} }
patch.amount = amount; patch.amount = amount;
@@ -83,7 +83,7 @@ router.post("/updateMember", async (req: Request, res, next) => {
patch.surchargeText = surchargeText; patch.surchargeText = surchargeText;
} }
if (surchargeAmount !== undefined) { if (surchargeAmount !== undefined) {
if (typeof surchargeAmount !== 'number' || !Number.isFinite(surchargeAmount) || surchargeAmount < 0) { if (!Number.isInteger(surchargeAmount) || surchargeAmount < 0) {
return res.status(400).json({ error: 'Neplatná výše příplatku' }); return res.status(400).json({ error: 'Neplatná výše příplatku' });
} }
patch.surchargeAmount = surchargeAmount; patch.surchargeAmount = surchargeAmount;
@@ -113,19 +113,19 @@ router.post("/updateFees", async (req: Request, res, next) => {
const login = getLogin(parseToken(req)); const login = getLogin(parseToken(req));
const { id, fees, shipping, tip, discountType, discountValue } = req.body ?? {}; const { id, fees, shipping, tip, discountType, discountValue } = req.body ?? {};
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' }); if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
if (fees !== undefined && (typeof fees !== 'number' || !Number.isFinite(fees) || fees < 0)) { if (fees !== undefined && (!Number.isInteger(fees) || fees < 0)) {
return res.status(400).json({ error: 'Neplatná výše poplatků' }); return res.status(400).json({ error: 'Neplatná výše poplatků' });
} }
if (shipping !== undefined && (typeof shipping !== 'number' || !Number.isFinite(shipping) || shipping < 0)) { if (shipping !== undefined && (!Number.isInteger(shipping) || shipping < 0)) {
return res.status(400).json({ error: 'Neplatná výše dopravy' }); return res.status(400).json({ error: 'Neplatná výše dopravy' });
} }
if (tip !== undefined && (typeof tip !== 'number' || !Number.isFinite(tip) || tip < 0)) { if (tip !== undefined && (!Number.isInteger(tip) || tip < 0)) {
return res.status(400).json({ error: 'Neplatná výše spropitného' }); return res.status(400).json({ error: 'Neplatná výše spropitného' });
} }
if (discountType !== undefined && discountType !== '' && !['percent', 'fixed'].includes(discountType)) { if (discountType !== undefined && discountType !== '' && !['percent', 'fixed'].includes(discountType)) {
return res.status(400).json({ error: 'Neplatný typ slevy' }); return res.status(400).json({ error: 'Neplatný typ slevy' });
} }
if (discountValue !== undefined && (typeof discountValue !== 'number' || !Number.isFinite(discountValue) || discountValue < 0)) { if (discountValue !== undefined && (!Number.isInteger(discountValue) || discountValue < 0)) {
return res.status(400).json({ error: 'Neplatná výše slevy' }); return res.status(400).json({ error: 'Neplatná výše slevy' });
} }
try { try {
+2 -7
View File
@@ -36,18 +36,13 @@ router.post("/generate", async (req: Request<{}, any, GenerateQrData["body"]>, r
if (!recipient.purpose || recipient.purpose.trim().length === 0) { if (!recipient.purpose || recipient.purpose.trim().length === 0) {
return res.status(400).json({ error: `Příjemce ${recipient.login} nemá vyplněný účel platby` }); return res.status(400).json({ error: `Příjemce ${recipient.login} nemá vyplněný účel platby` });
} }
if (typeof recipient.amount !== 'number' || recipient.amount <= 0) { if (!Number.isInteger(recipient.amount) || recipient.amount <= 0) {
return res.status(400).json({ error: `Příjemce ${recipient.login} má neplatnou částku` }); 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 // Vygenerovat QR kód
const id = crypto.randomUUID(); const id = crypto.randomUUID();
await generateQr(recipient.login, bankAccount, bankAccountHolder, recipient.amount, recipient.purpose, id); await generateQr(recipient.login, bankAccount, bankAccountHolder, recipient.amount / 100, recipient.purpose, id);
// Uložit jako nevyřízený QR kód a okamžitě doručit příjemci // Uložit jako nevyřízený QR kód a okamžitě doručit příjemci
const pendingQr = { const pendingQr = {
+4 -4
View File
@@ -31,8 +31,8 @@ test('saláty mají name a ingredients', async () => {
test('cena salátu zahrnuje pevný příplatek 13 Kč za obal', async () => { test('cena salátu zahrnuje pevný příplatek 13 Kč za obal', async () => {
const salaty = await downloadSalaty(false); const salaty = await downloadSalaty(false);
// Caesar sticker price = 129, box = 13 // Caesar sticker price = 129, box = 13 → (129 + 13) * 100 haléřů
expect(salaty[0].price).toBe(129 + 13); expect(salaty[0].price).toBe((129 + 13) * 100);
// Řecký sticker price = 119, box = 13 // Řecký sticker price = 119, box = 13 → (119 + 13) * 100 haléřů
expect(salaty[1].price).toBe(119 + 13); expect(salaty[1].price).toBe((119 + 13) * 100);
}); });
+5 -5
View File
@@ -28,8 +28,8 @@ beforeEach(() => {
const VALID_BODY = { const VALID_BODY = {
recipients: [ recipients: [
{ login: 'uzivatel1', purpose: 'Pizza Margherita', amount: 149 }, { login: 'uzivatel1', purpose: 'Pizza Margherita', amount: 14900 },
{ login: 'uzivatel2', purpose: 'Pizza Diavola', amount: 179 }, { login: 'uzivatel2', purpose: 'Pizza Diavola', amount: 17900 },
], ],
bankAccount: '19-2000145399/0800', bankAccount: '19-2000145399/0800',
bankAccountHolder: 'Jan Novák', bankAccountHolder: 'Jan Novák',
@@ -76,17 +76,17 @@ test('POST /generate vrátí 400 pro zápornou částku', async () => {
expect(res.body.error).toContain('částku'); expect(res.body.error).toContain('částku');
}); });
test('POST /generate vrátí 400 pro částku s více než 2 desetinnými místy', async () => { test('POST /generate vrátí 400 pro necelou částku', async () => {
const body = { const body = {
...VALID_BODY, ...VALID_BODY,
recipients: [{ login: 'uzivatel1', purpose: 'Pizza', amount: 149.999 }], recipients: [{ login: 'uzivatel1', purpose: 'Pizza', amount: 149.5 }],
}; };
const res = await request(buildApp()) const res = await request(buildApp())
.post('/api/qr/generate') .post('/api/qr/generate')
.set('Authorization', TOKEN) .set('Authorization', TOKEN)
.send(body); .send(body);
expect(res.status).toBe(400); expect(res.status).toBe(400);
expect(res.body.error).toContain('desetinná'); expect(res.body.error).toContain('částku');
}); });
test('POST /generate vrátí 400 pro příjemce bez login', async () => { test('POST /generate vrátí 400 pro příjemce bez login', async () => {
+8 -8
View File
@@ -14,21 +14,21 @@ post:
description: ID skupiny description: ID skupiny
type: string type: string
fees: fees:
description: Poplatky () description: Poplatky (haléře)
type: number type: integer
shipping: shipping:
description: Doprava () description: Doprava (haléře)
type: number type: integer
tip: tip:
description: Spropitné () description: Spropitné (haléře)
type: number type: integer
discountType: discountType:
description: Typ slevy description: Typ slevy
type: string type: string
enum: [percent, fixed] enum: [percent, fixed]
discountValue: discountValue:
description: Hodnota slevy description: Hodnota slevy (procenta nebo haléře pro fixed)
type: number type: integer
responses: responses:
"200": "200":
$ref: "../../api.yml#/components/responses/ClientDataResponse" $ref: "../../api.yml#/components/responses/ClientDataResponse"
+4 -4
View File
@@ -18,8 +18,8 @@ post:
description: Login člena ke změně description: Login člena ke změně
type: string type: string
amount: amount:
description: Částka k úhradě v description: Částka k úhradě v haléřích
type: number type: integer
note: note:
description: Poznámka description: Poznámka
type: string type: string
@@ -27,8 +27,8 @@ post:
description: Popis příplatku description: Popis příplatku
type: string type: string
surchargeAmount: surchargeAmount:
description: Výše příplatku v description: Výše příplatku v haléřích
type: number type: integer
responses: responses:
"200": "200":
$ref: "../../api.yml#/components/responses/ClientDataResponse" $ref: "../../api.yml#/components/responses/ClientDataResponse"
+2 -2
View File
@@ -16,8 +16,8 @@ post:
type: string type: string
description: Textový popis přirážky/slevy description: Textový popis přirážky/slevy
price: price:
type: number type: integer
description: Částka přirážky/slevy v description: Částka přirážky/slevy v haléřích
responses: responses:
"200": "200":
description: Nastavení přirážky/slevy proběhlo úspěšně. description: Nastavení přirážky/slevy proběhlo úspěšně.
+31 -31
View File
@@ -420,14 +420,14 @@ PizzaSize:
description: Velikost pizzy, např. "30cm" description: Velikost pizzy, např. "30cm"
type: string type: string
pizzaPrice: pizzaPrice:
description: Cena samotné pizzy v description: Cena samotné pizzy v haléřích
type: number type: integer
boxPrice: boxPrice:
description: Cena krabice pizzy v description: Cena krabice pizzy v haléřích
type: number type: integer
price: price:
description: Celková cena (pizza + krabice) description: Celková cena (pizza + krabice) v haléřích
type: number type: integer
Pizza: Pizza:
description: Údaje o konkrétní pizze. description: Údaje o konkrétní pizze.
type: object type: object
@@ -470,8 +470,8 @@ PizzaVariant:
description: Velikost pizzy (např. "30cm"), nebo "1 porce" pro salát description: Velikost pizzy (např. "30cm"), nebo "1 porce" pro salát
type: string type: string
price: price:
description: Cena v , včetně krabice/obalu description: Cena v haléřích, včetně krabice/obalu
type: number type: integer
category: category:
description: Kategorie položky (pizza nebo salat) description: Kategorie položky (pizza nebo salat)
type: string type: string
@@ -494,8 +494,8 @@ Salat:
items: items:
type: string type: string
price: price:
description: Cena salátu v (bez obalu) description: Cena salátu v haléřích (bez obalu)
type: number type: integer
PizzaOrder: PizzaOrder:
description: Údaje o objednávce pizzy jednoho uživatele. description: Údaje o objednávce pizzy jednoho uživatele.
type: object type: object
@@ -521,11 +521,11 @@ PizzaOrder:
description: Popis příplatku (např. "kuřecí maso navíc") description: Popis příplatku (např. "kuřecí maso navíc")
type: string type: string
price: price:
description: Cena příplatku v description: Cena příplatku v haléřích
type: number type: integer
totalPrice: totalPrice:
description: Celková cena všech objednaných pizz daného uživatele, včetně krabic a příplatků description: Celková cena všech objednaných pizz daného uživatele v haléřích, včetně krabic a příplatků
type: number type: integer
hasQr: hasQr:
description: | description: |
Příznak, pokud je k této objednávce vygenerován QR kód pro platbu. To je typicky pravda, pokud: Příznak, pokud je k této objednávce vygenerován QR kód pro platbu. To je typicky pravda, pokud:
@@ -635,9 +635,9 @@ QrRecipient:
description: Účel platby (např. "Pizza prosciutto") description: Účel platby (např. "Pizza prosciutto")
type: string type: string
amount: amount:
description: Částka v (kladné číslo, max 2 desetinná místa) description: Částka v haléřích (kladné celé číslo)
type: number type: integer
minimum: 0.01 minimum: 1
GenerateQrRequest: GenerateQrRequest:
description: Request pro generování QR kódů description: Request pro generování QR kódů
type: object type: object
@@ -704,8 +704,8 @@ OrderGroupMember:
additionalProperties: false additionalProperties: false
properties: properties:
amount: amount:
description: Částka k úhradě v description: Částka k úhradě v haléřích
type: number type: integer
note: note:
description: Volitelná poznámka (např. co si objednává) description: Volitelná poznámka (např. co si objednává)
type: string type: string
@@ -713,8 +713,8 @@ OrderGroupMember:
description: Popis příplatku description: Popis příplatku
type: string type: string
surchargeAmount: surchargeAmount:
description: Výše příplatku v description: Výše příplatku v haléřích
type: number type: integer
paid: paid:
description: Příznak, zda člen uhradil svůj podíl objednávky description: Příznak, zda člen uhradil svůj podíl objednávky
type: boolean type: boolean
@@ -753,21 +753,21 @@ OrderGroup:
description: Očekávaný čas doručení ve formátu HH:MM description: Očekávaný čas doručení ve formátu HH:MM
type: string type: string
fees: fees:
description: Poplatky (balení apod.) celkem v description: Poplatky (balení apod.) celkem v haléřích
type: number type: integer
shipping: shipping:
description: Doprava v description: Doprava v haléřích
type: number type: integer
tip: tip:
description: Spropitné v description: Spropitné v haléřích
type: number type: integer
discountType: discountType:
description: Typ slevy aplikované na objednávku description: Typ slevy aplikované na objednávku
type: string type: string
enum: [percent, fixed] enum: [percent, fixed]
discountValue: discountValue:
description: Hodnota slevy (procenta nebo Kč) description: Hodnota slevy (procenta pro typ 'percent', haléře pro typ 'fixed')
type: number type: integer
# --- NEVYŘÍZENÉ QR KÓDY --- # --- NEVYŘÍZENÉ QR KÓDY ---
PendingQr: PendingQr:
@@ -790,8 +790,8 @@ PendingQr:
description: Jméno uživatele, který QR vygeneroval (příjemce platby) description: Jméno uživatele, který QR vygeneroval (příjemce platby)
type: string type: string
totalPrice: totalPrice:
description: Celková cena objednávky v description: Celková cena objednávky v haléřích
type: number type: integer
purpose: purpose:
description: Účel platby (např. "Pizza prosciutto") description: Účel platby (např. "Pizza prosciutto")
type: string type: string