feat: vylepšení objednávek
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

This commit is contained in:
2026-05-07 09:50:51 +02:00
parent 1efe2b8f7d
commit c7f78cf2c9
14 changed files with 1163 additions and 186 deletions
+131 -17
View File
@@ -1,5 +1,5 @@
import { useContext, useEffect, useRef, useState } from 'react';
import { Alert, Badge, Button, Card, Form, Modal, Table } from 'react-bootstrap';
import { Alert, Badge, Button, Card, Form, Modal, OverlayTrigger, Table, Tooltip } from 'react-bootstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTrashCan } from '@fortawesome/free-regular-svg-icons';
import { faBasketShopping, faCircleCheck, faGear, faLock, faLockOpen, faSearch, faUserPlus } from '@fortawesome/free-solid-svg-icons';
@@ -16,6 +16,7 @@ import Footer from '../components/Footer';
import Loader from '../components/Loader';
import StoreAdminModal from '../components/modals/StoreAdminModal';
import PayForGroupModal from '../components/modals/PayForGroupModal';
import EditGroupFeesModal from '../components/modals/EditGroupFeesModal';
const SLOT = MealSlot.EXTRA;
const TIME_REGEX = /^([01]\d|2[0-3]):[0-5]\d$/;
@@ -41,8 +42,10 @@ export default function OrderGroupsPage() {
const [adminModalOpen, setAdminModalOpen] = useState(false);
const [editAmounts, setEditAmounts] = useState<Record<string, string>>({});
const [editNotes, setEditNotes] = useState<Record<string, string>>({});
const [editSurcharges, setEditSurcharges] = useState<Record<string, { text: string; amount: string }>>({});
const [editTimes, setEditTimes] = useState<Record<string, { orderedAt: string; deliveryAt: string }>>({});
const [payModal, setPayModal] = useState<OrderGroup | null>(null);
const [feesModal, setFeesModal] = useState<OrderGroup | null>(null);
const [confirmOrderGroup, setConfirmOrderGroup] = useState<OrderGroup | null>(null);
const [pageError, setPageError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
@@ -133,6 +136,19 @@ export default function OrderGroupsPage() {
if (ok) setEditNotes(prev => { const next = { ...prev }; delete next[key]; return next; });
};
const handleSaveSurcharge = async (groupId: string, login: string) => {
const key = `${groupId}:${login}`;
const surchargeText = editSurcharges[key]?.text ?? '';
const rawAmount = editSurcharges[key]?.amount ?? '';
const surchargeAmount = rawAmount === '' ? 0 : parseFloat(rawAmount.replace(',', '.'));
if (rawAmount !== '' && (isNaN(surchargeAmount) || surchargeAmount < 0)) {
setPageError('Zadejte platnou výši příplatku');
return;
}
const ok = await refresh(() => updateGroupMember({ body: { id: groupId, login, surchargeText, surchargeAmount: rawAmount === '' ? 0 : surchargeAmount } }));
if (ok) setEditSurcharges(prev => { const next = { ...prev }; delete next[key]; return next; });
};
const handleSaveTimes = async (group: OrderGroup) => {
const times = editTimes[group.id];
if (!times) return;
@@ -234,8 +250,23 @@ 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 * 100) / 100 : 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 * 100) / 100
: Math.round(dv / memberCount * 100) / 100)
: 0;
return Math.round((base + surcharge + feeShare - discount) * 100) / 100;
};
return (
<Card key={group.id} className="mb-3 fade-in">
<Card.Header className="d-flex justify-content-between align-items-center">
@@ -247,6 +278,9 @@ export default function OrderGroupsPage() {
<div className="d-flex gap-2">
{isCreator && !isOrdered && (
<>
<Button variant="outline-info" size="sm" onClick={() => setFeesModal(group)} title="Upravit poplatky a slevu">
Poplatky
</Button>
<Button variant="outline-secondary" size="sm" onClick={() => handleToggleLock(group)} title={isLocked ? 'Odemknout' : 'Uzamknout'}>
<FontAwesomeIcon icon={isLocked ? faLockOpen : faLock} />
</Button>
@@ -286,28 +320,35 @@ export default function OrderGroupsPage() {
<thead>
<tr>
<th>Člen</th>
<th style={{ width: 130 }}>Částka ()</th>
<th style={{ width: 120 }}>Částka ()</th>
<th style={{ width: 180 }}>Příplatek</th>
<th>Poznámka</th>
<th style={{ width: 90 }}>Celkem</th>
<th style={{ width: 40 }}></th>
</tr>
</thead>
<tbody>
{memberEntries.map(([memberLogin, member]) => {
const amountKey = `${group.id}:${memberLogin}`;
const noteKey = `${group.id}:${memberLogin}`;
const editingAmount = amountKey in editAmounts;
const editingNote = noteKey in editNotes;
const key = `${group.id}:${memberLogin}`;
const editingAmount = key in editAmounts;
const editingNote = key in editNotes;
const editingSurcharge = key in editSurcharges;
const canEdit = canEditMember(group, memberLogin);
const memberTotal = getMemberTotal(member);
return (
<tr key={memberLogin}>
<td>
<span className="user-info">
<strong>{memberLogin}</strong>
{memberLogin === group.creatorLogin && (
<FontAwesomeIcon icon={faBasketShopping} className="ms-1 buyer-icon" title="Zakladatel / objednávající" />
<OverlayTrigger placement="top" overlay={<Tooltip>Zakladatel / objednávající</Tooltip>}>
<span className="ms-1"><FontAwesomeIcon icon={faBasketShopping} className="buyer-icon" /></span>
</OverlayTrigger>
)}
{member.paid && (
<FontAwesomeIcon icon={faCircleCheck} className="ms-1 text-success" title="Zaplaceno" />
<OverlayTrigger placement="top" overlay={<Tooltip>Zaplaceno</Tooltip>}>
<span className="ms-1"><FontAwesomeIcon icon={faCircleCheck} className="text-success" /></span>
</OverlayTrigger>
)}
</span>
</td>
@@ -318,10 +359,10 @@ export default function OrderGroupsPage() {
ref={memberLogin === login ? inputRef : undefined}
type="number"
size="sm"
value={editAmounts[amountKey]}
onChange={e => setEditAmounts(prev => ({ ...prev, [amountKey]: e.target.value }))}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveAmount(group.id, memberLogin); if (e.key === 'Escape') setEditAmounts(prev => { const n = { ...prev }; delete n[amountKey]; return n; }); }}
style={{ width: 80 }}
value={editAmounts[key]}
onChange={e => setEditAmounts(prev => ({ ...prev, [key]: e.target.value }))}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveAmount(group.id, memberLogin); if (e.key === 'Escape') setEditAmounts(prev => { const n = { ...prev }; delete n[key]; return n; }); }}
style={{ width: 75 }}
autoFocus={memberLogin === login}
/>
<Button size="sm" variant="outline-success" onClick={() => handleSaveAmount(group.id, memberLogin)}></Button>
@@ -329,22 +370,60 @@ export default function OrderGroupsPage() {
) : (
<span
style={{ cursor: canEdit ? 'pointer' : undefined }}
onClick={() => canEdit && setEditAmounts(prev => ({ ...prev, [amountKey]: String(member.amount ?? '') }))}
onClick={() => canEdit && setEditAmounts(prev => ({ ...prev, [key]: String(member.amount ?? '') }))}
title={canEdit ? 'Klikněte pro úpravu' : undefined}
>
{member.amount != null ? `${member.amount}` : <span className="text-muted"></span>}
</span>
)}
</td>
<td>
{canEdit && editingSurcharge ? (
<div className="d-flex gap-1">
<Form.Control
type="text"
size="sm"
placeholder="popis"
value={editSurcharges[key]?.text ?? ''}
onChange={e => setEditSurcharges(prev => ({ ...prev, [key]: { ...prev[key], text: e.target.value } }))}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveSurcharge(group.id, memberLogin); if (e.key === 'Escape') setEditSurcharges(prev => { const n = { ...prev }; delete n[key]; return n; }); }}
style={{ width: 80 }}
autoFocus
/>
<Form.Control
type="number"
size="sm"
placeholder="Kč"
value={editSurcharges[key]?.amount ?? ''}
onChange={e => setEditSurcharges(prev => ({ ...prev, [key]: { ...prev[key], amount: e.target.value } }))}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveSurcharge(group.id, memberLogin); if (e.key === 'Escape') setEditSurcharges(prev => { const n = { ...prev }; delete n[key]; return n; }); }}
style={{ width: 60 }}
/>
<Button size="sm" variant="outline-success" onClick={() => handleSaveSurcharge(group.id, memberLogin)}></Button>
</div>
) : (
<span
style={{ cursor: canEdit ? 'pointer' : undefined }}
onClick={() => canEdit && setEditSurcharges(prev => ({ ...prev, [key]: { text: member.surchargeText ?? '', amount: member.surchargeAmount != null ? String(member.surchargeAmount) : '' } }))}
title={canEdit ? 'Klikněte pro úpravu příplatku' : undefined}
>
{member.surchargeAmount != null && member.surchargeAmount > 0 ? (
<small>{member.surchargeText ? `${member.surchargeText}: ` : ''}<strong>{member.surchargeAmount} </strong></small>
) : (
<small className="text-muted"></small>
)}
</span>
)}
</td>
<td>
{canEdit && editingNote ? (
<div className="d-flex gap-1">
<Form.Control
type="text"
size="sm"
value={editNotes[noteKey]}
onChange={e => setEditNotes(prev => ({ ...prev, [noteKey]: e.target.value }))}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveNote(group.id, memberLogin); if (e.key === 'Escape') setEditNotes(prev => { const n = { ...prev }; delete n[noteKey]; return n; }); }}
value={editNotes[key]}
onChange={e => setEditNotes(prev => ({ ...prev, [key]: e.target.value }))}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveNote(group.id, memberLogin); if (e.key === 'Escape') setEditNotes(prev => { const n = { ...prev }; delete n[key]; return n; }); }}
autoFocus
/>
<Button size="sm" variant="outline-success" onClick={() => handleSaveNote(group.id, memberLogin)}></Button>
@@ -352,13 +431,18 @@ export default function OrderGroupsPage() {
) : (
<span
style={{ cursor: canEdit ? 'pointer' : undefined }}
onClick={() => canEdit && setEditNotes(prev => ({ ...prev, [noteKey]: member.note ?? '' }))}
onClick={() => canEdit && setEditNotes(prev => ({ ...prev, [key]: member.note ?? '' }))}
title={canEdit ? 'Klikněte pro úpravu poznámky' : undefined}
>
<small className="text-muted">{member.note || '—'}</small>
</span>
)}
</td>
<td className="text-end">
<small className={memberTotal > 0 ? 'fw-bold' : 'text-muted'}>
{memberTotal > 0 ? `${memberTotal}` : '—'}
</small>
</td>
<td>
<div className="d-flex gap-1 justify-content-end">
{canManageMembers(group) && (isCreator || memberLogin === login) && (memberLogin !== group.creatorLogin) && (
@@ -377,6 +461,21 @@ export default function OrderGroupsPage() {
</tbody>
</Table>
{/* Souhrn poplatků a slevy */}
{(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)' }}>
{group.fees != null && group.fees > 0 && <span>Poplatky: <strong>{group.fees} </strong></span>}
{group.shipping != null && group.shipping > 0 && <span>Doprava: <strong>{group.shipping} </strong></span>}
{group.tip != null && group.tip > 0 && <span>Spropitné: <strong>{group.tip} </strong></span>}
{feeShare > 0 && <span> <strong>{feeShare} </strong>/os.</span>}
{group.discountValue != null && group.discountValue > 0 && (
<span className="text-success">
Sleva: <strong>{group.discountType === 'percent' ? `${group.discountValue}%` : `${group.discountValue}`}</strong>
</span>
)}
</div>
)}
{/* Časy objednání a doručení */}
{isOrdered && (
<div className="px-3 py-2 border-top">
@@ -472,6 +571,21 @@ export default function OrderGroupsPage() {
bankAccountHolder={settings.holderName}
/>
)}
{feesModal && (
<EditGroupFeesModal
isOpen={!!feesModal}
onClose={() => setFeesModal(null)}
group={feesModal}
onSaved={newData => {
if (newData) {
setData(newData);
socket.emit?.('message', newData as ClientData);
}
setFeesModal(null);
}}
/>
)}
</div>
);
}