diff --git a/client/src/components/modals/EditGroupFeesModal.tsx b/client/src/components/modals/EditGroupFeesModal.tsx index 4d8f9d0..2c72366 100644 --- a/client/src/components/modals/EditGroupFeesModal.tsx +++ b/client/src/components/modals/EditGroupFeesModal.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from "react"; import { Modal, Button, Form, Table, Alert } from "react-bootstrap"; import { updateGroupFees, OrderGroup, OrderGroupMember } from "../../../../types"; +import { computeFeeShare, computeMemberTotal, countActiveMembers, isActiveMember } from "../../utils/groupFees"; type Props = { isOpen: boolean; @@ -19,15 +20,6 @@ function parsePercent(s: string): number { return isNaN(n) || n < 0 ? 0 : Math.round(n); } -function computeMemberTotal(member: OrderGroupMember, feeShare: number, discountType: string, discountValue: number, memberCount: number): number { - const base = member.amount ?? 0; - const surcharge = member.surchargeAmount ?? 0; - const discount = discountType === 'percent' - ? Math.round((base + surcharge) * discountValue / 100) - : Math.round(discountValue / memberCount); - return base + surcharge + feeShare - discount; -} - export default function EditGroupFeesModal({ isOpen, onClose, group, onSaved }: Readonly) { const [fees, setFees] = useState(''); const [shipping, setShipping] = useState(''); @@ -50,14 +42,16 @@ export default function EditGroupFeesModal({ isOpen, onClose, group, onSaved }: }, [isOpen, group]); const memberEntries = Object.entries(group.members) as [string, OrderGroupMember][]; - const memberCount = memberEntries.length; + // Poplatky se dělí jen mezi aktivní strávníky (kdo si reálně něco objednal). + const activeCount = countActiveMembers(group.members); const feesNum = parseHal(fees); const shippingNum = parseHal(shipping); const tipNum = parseHal(tip); const discountNum = discountType === 'percent' ? parsePercent(discountValue) : parseHal(discountValue); const totalFees = feesNum + shippingNum + tipNum; - const feeShare = memberCount > 0 ? Math.round(totalFees / memberCount) : 0; + const feeShare = computeFeeShare(totalFees, activeCount); + const feeParams = { totalFees, discountType, discountValue: discountNum }; const handleSave = async () => { setError(null); @@ -150,7 +144,7 @@ export default function EditGroupFeesModal({ isOpen, onClose, group, onSaved }:
-
Náhled celkových částek ({memberCount} členů, {feeShare > 0 ? `poplatek ${feeShare / 100} Kč/os.` : 'bez poplatku'})
+
Náhled celkových částek ({activeCount} {activeCount === 1 ? 'strávník' : 'strávníků'} s objednávkou, {feeShare > 0 ? `poplatek ${feeShare / 100} Kč/os.` : 'bez poplatku'})
@@ -166,18 +160,20 @@ export default function EditGroupFeesModal({ isOpen, onClose, group, onSaved }: {memberEntries.map(([login, member]) => { const base = member.amount ?? 0; const surcharge = member.surchargeAmount ?? 0; - const discount = discountNum > 0 + const active = isActiveMember(member); + const total = computeMemberTotal(member, feeParams, feeShare, activeCount); + // Sleva i poplatek se týkají jen aktivních strávníků. + const discount = active && discountNum > 0 ? (discountType === 'percent' ? Math.round((base + surcharge) * discountNum / 100) - : Math.round(discountNum / memberCount)) + : Math.round(discountNum / activeCount)) : 0; - const total = computeMemberTotal(member, feeShare, discountType, discountNum, memberCount); return ( - - + + - + diff --git a/client/src/components/modals/PayForGroupModal.tsx b/client/src/components/modals/PayForGroupModal.tsx index 69e9c8d..92f6d06 100644 --- a/client/src/components/modals/PayForGroupModal.tsx +++ b/client/src/components/modals/PayForGroupModal.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from "react"; import { Modal, Button, Form, Table, Alert } from "react-bootstrap"; import { generateQr, OrderGroup, OrderGroupMember, QrRecipient } from "../../../../types"; import { sanitizeQrMessage } from "../../Utils"; +import { computeFeeShare, computeMemberTotal, countActiveMembers, isActiveMember } from "../../utils/groupFees"; type Props = { isOpen: boolean; @@ -31,32 +32,25 @@ export default function PayForGroupModal({ isOpen, onClose, onSuccess, group, pa const entries: DinerEntry[] = (Object.entries(group.members) as [string, OrderGroupMember][]).map(([login, member]) => ({ login, member, - included: login !== payerLogin, + // 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 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) : 0; + // 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 => { - 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) - : Math.round(discountValue / memberCount)) - : 0; - return base + surcharge + feeShare - discount; - }; + const getMemberTotal = (entry: DinerEntry): number => + computeMemberTotal(entry.member, feeParams, feeShare, activeCount); const includedNonPayers = diners.filter(d => d.included && d.login !== payerLogin); @@ -157,13 +151,16 @@ export default function PayForGroupModal({ isOpen, onClose, onSuccess, group, pa {diners.map(d => { const isPayer = d.login === payerLogin; + const active = isActiveMember(d.member); const total = getMemberTotal(d); const surcharge = d.member.surchargeAmount ?? 0; return ( - + )}
{login}
{login}{!active && (jen objednává)} {base > 0 ? `${base / 100} Kč` : '—'} {surcharge > 0 ? `${surcharge / 100} Kč` : '—'}{feeShare > 0 ? `${feeShare / 100} Kč` : '—'}{active && feeShare > 0 ? `${feeShare / 100} Kč` : '—'} {discount > 0 ? `-${discount / 100} Kč` : '—'} {total > 0 ? `${total / 100} Kč` : '—'}
{isPayer ? ( plátce + ) : !active ? ( + jen objednává ) : ( {hasFees && ( - {feeShare > 0 ? `${feeShare / 100} Kč` : '—'} + {active && feeShare > 0 ? `${feeShare / 100} Kč` : '—'} diff --git a/client/src/pages/OrderGroupsPage.tsx b/client/src/pages/OrderGroupsPage.tsx index 9af734a..9ce8971 100644 --- a/client/src/pages/OrderGroupsPage.tsx +++ b/client/src/pages/OrderGroupsPage.tsx @@ -7,6 +7,7 @@ import { ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember, PendingQr, getData, createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, } from '../../../types'; +import { computeFeeShare, computeMemberTotal, countActiveMembers } from '../utils/groupFees'; import { EVENT_MESSAGE, EVENT_PENDING_QR, SocketContext } from '../context/socket'; import { useAuth } from '../context/auth'; import { useSettings } from '../context/settings'; @@ -254,22 +255,15 @@ export default function OrderGroupsPage() { const isOrdered = group.state === GroupState.ORDERED; const isLocked = group.state === GroupState.LOCKED; const memberEntries = Object.entries(group.members) as [string, OrderGroupMember][]; - const memberCount = memberEntries.length; const editingTimes = group.id in editTimes; const totalFees = (group.fees ?? 0) + (group.shipping ?? 0) + (group.tip ?? 0); - const feeShare = memberCount > 0 ? Math.round(totalFees / memberCount) : 0; - const getMemberTotal = (m: OrderGroupMember) => { - const base = m.amount ?? 0; - const surcharge = m.surchargeAmount ?? 0; - const dv = group.discountValue ?? 0; - const discount = dv > 0 - ? (group.discountType === 'percent' - ? Math.round((base + surcharge) * dv / 100) - : Math.round(dv / memberCount)) - : 0; - return base + surcharge + feeShare - discount; - }; + // 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 = (m: OrderGroupMember) => + computeMemberTotal(m, feeParams, feeShare, activeCount); return ( diff --git a/client/src/utils/groupFees.ts b/client/src/utils/groupFees.ts new file mode 100644 index 0000000..4b944f1 --- /dev/null +++ b/client/src/utils/groupFees.ts @@ -0,0 +1,67 @@ +import { OrderGroup, OrderGroupMember } from "../../../types"; + +/** + * Pomocné funkce pro výpočet částek ve skupinových objednávkách. + * + * Klíčové pravidlo: poplatky (balné + doprava + spropitné) se rozpočítávají + * pouze mezi "aktivní" strávníky — tedy ty, kteří si reálně něco objednali. + * Kdo si nic neobjedná (typicky objednávající, který nakupuje jen pro ostatní), + * neplatí nic a nezapočítává se mu ani poměrná část poplatků. + */ + +/** Parametry poplatků a slevy potřebné k výpočtu částky člena. */ +export type GroupFeeParams = { + /** Celkové poplatky skupiny v haléřích (balné + doprava + spropitné). */ + totalFees: number; + /** Typ slevy ('percent' = procenta, 'fixed' = pevná částka v haléřích). */ + discountType?: string; + /** Hodnota slevy — procenta, nebo pevná částka v haléřích dle discountType. */ + discountValue?: number; +}; + +/** Vrátí true, pokud si člen něco objednal (má kladnou částku nebo příplatek). */ +export function isActiveMember(member: OrderGroupMember): boolean { + return (member.amount ?? 0) + (member.surchargeAmount ?? 0) > 0; +} + +/** Počet aktivních strávníků — jen mezi ně se dělí poplatky. */ +export function countActiveMembers(members: OrderGroup["members"]): number { + return Object.values(members).filter(isActiveMember).length; +} + +/** Celkové poplatky skupiny (balné + doprava + spropitné) v haléřích. */ +export function totalGroupFees(group: OrderGroup): number { + return (group.fees ?? 0) + (group.shipping ?? 0) + (group.tip ?? 0); +} + +/** Poměrná část poplatků na jednoho aktivního strávníka v haléřích. */ +export function computeFeeShare(totalFees: number, activeCount: number): number { + return activeCount > 0 ? Math.round(totalFees / activeCount) : 0; +} + +/** + * Celková částka, kterou má člen zaplatit (v haléřích). + * Neaktivní člen (nic si neobjednal) platí 0 — nepodílí se ani na poplatcích. + * + * @param member člen skupiny + * @param params poplatky a sleva + * @param feeShare poměrná část poplatků na osobu (viz computeFeeShare) + * @param activeCount počet aktivních strávníků (dělitel pevné slevy) + */ +export function computeMemberTotal( + member: OrderGroupMember, + params: GroupFeeParams, + feeShare: number, + activeCount: number, +): number { + if (!isActiveMember(member)) return 0; + const base = member.amount ?? 0; + const surcharge = member.surchargeAmount ?? 0; + const discountValue = params.discountValue ?? 0; + const discount = discountValue > 0 + ? (params.discountType === 'percent' + ? Math.round((base + surcharge) * discountValue / 100) + : Math.round(discountValue / activeCount)) + : 0; + return base + surcharge + feeShare - discount; +} diff --git a/server/changelogs/2026-06-05.json b/server/changelogs/2026-06-05.json index 102156a..d1ea502 100644 --- a/server/changelogs/2026-06-05.json +++ b/server/changelogs/2026-06-05.json @@ -1,4 +1,5 @@ [ + "Podpora neplatících osob u objednávání", "Zobrazení neuhrazených plateb i na stránce objednávek", "Oprava duplicitního zobrazení QR kódu u Pizza day", "Odstranění diakritiky v platebních QR kódech"