fix: opravy po review
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 21s
CI / Generate TypeScript types (pull_request) Successful in 47s
CI / Build server (push) Successful in 27s
CI / Server unit tests (pull_request) Successful in 20s
CI / Build server (pull_request) Successful in 27s
CI / Build client (pull_request) Successful in 40s
CI / Playwright E2E tests (pull_request) Successful in 1m20s
CI / Build and push Docker image (pull_request) Has been skipped
CI / Notify (pull_request) Has been skipped
CI / Build client (push) Successful in 4m13s
CI / Playwright E2E tests (push) Successful in 6m7s
CI / Build and push Docker image (push) Has been skipped
CI / Notify (push) Successful in 6s

This commit is contained in:
2026-05-07 08:56:49 +02:00
parent 21d7224fb4
commit 5f03471541
14 changed files with 359 additions and 88 deletions
+4 -4
View File
@@ -7,7 +7,7 @@ self.addEventListener('push', (event) => {
body: data.body,
icon: '/favicon.ico',
tag: 'lunch-reminder',
data: { login: data.login },
data: { login: data.login, token: data.token },
actions: [
{ action: 'neobedvam', title: 'Mám vlastní/neobědvám' },
],
@@ -19,13 +19,13 @@ self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'neobedvam') {
const login = event.notification.data?.login;
if (login) {
const { login, token } = event.notification.data ?? {};
if (login && token) {
event.waitUntil(
fetch('/api/notifications/push/quickChoice', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ login }),
body: JSON.stringify({ login, token }),
})
);
}
@@ -87,10 +87,16 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
const totalPeople = includedDiners.length + 1; // +1 for payer
return Math.round((tip / totalPeople) * 100) / 100;
})();
const payerTipShare = (() => {
const tip = parseAmount(tipTotal);
if (!tip) return 0;
return Math.round((tip - tipPerPerson * includedDiners.length) * 100) / 100;
})();
const getTotal = (d: DinerEntry): number => {
const surcharge = parseAmount(d.surchargeAmount) ?? 0;
return Math.round((d.baseAmount + surcharge + tipPerPerson) * 100) / 100;
const tip = d.login === payerLogin ? payerTipShare : tipPerPerson;
return Math.round((d.baseAmount + surcharge + tip) * 100) / 100;
};
const handleInclude = useCallback((login: string, checked: boolean) => {
@@ -248,7 +254,7 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
</div>
</td>
<td className="text-end">
{tipPerPerson > 0 ? `${tipPerPerson} Kč` : '—'}
{(() => { const s = isPayer ? payerTipShare : tipPerPerson; return s > 0 ? `${s} Kč` : '—'; })()}
</td>
<td className="text-end fw-bold">
{`${total} Kč`}
@@ -17,6 +17,7 @@ type Props = {
payerLogin: string;
bankAccount: string;
bankAccountHolder: string;
groupId?: string;
};
function sanitizeAmount(value: string): string {
@@ -32,7 +33,7 @@ function parseAmount(s: string): number | null {
return Math.round(n * 100) / 100;
}
export default function PayForGroupModal({ isOpen, onClose, group, payerLogin, bankAccount, bankAccountHolder }: Readonly<Props>) {
export default function PayForGroupModal({ isOpen, onClose, group, payerLogin, bankAccount, bankAccountHolder, groupId }: Readonly<Props>) {
const [diners, setDiners] = useState<DinerEntry[]>([]);
const [tipTotal, setTipTotal] = useState('');
const [error, setError] = useState<string | null>(null);
@@ -63,10 +64,16 @@ export default function PayForGroupModal({ isOpen, onClose, group, payerLogin, b
const totalPeople = includedNonPayers.length + 1; // +1 for payer
return Math.round((tip / totalPeople) * 100) / 100;
})();
const payerTipShare = (() => {
const tip = parseAmount(tipTotal);
if (!tip) return 0;
return Math.round((tip - tipPerPerson * includedNonPayers.length) * 100) / 100;
})();
const getTotal = (d: DinerEntry): number => {
const surcharge = parseAmount(d.surchargeAmount) ?? 0;
return Math.round((d.baseAmount + surcharge + tipPerPerson) * 100) / 100;
const tip = d.login === payerLogin ? payerTipShare : tipPerPerson;
return Math.round((d.baseAmount + surcharge + tip) * 100) / 100;
};
const handleInclude = useCallback((login: string, checked: boolean) => {
@@ -112,7 +119,7 @@ export default function PayForGroupModal({ isOpen, onClose, group, payerLogin, b
setLoading(true);
try {
const response = await generateQr({
body: { recipients, bankAccount, bankAccountHolder },
body: { recipients, bankAccount, bankAccountHolder, ...(groupId ? { groupId } : {}) },
});
if (response.error) {
setError((response.error as any).error || 'Nastala chyba při generování QR kódů');
@@ -203,7 +210,7 @@ export default function PayForGroupModal({ isOpen, onClose, group, payerLogin, b
</div>
</td>
<td className="text-end">
{tipPerPerson > 0 ? `${tipPerPerson} Kč` : '—'}
{(() => { const s = isPayer ? payerTipShare : tipPerPerson; return s > 0 ? `${s} Kč` : '—'; })()}
</td>
<td className="text-end fw-bold">
{`${total} Kč`}
+175 -51
View File
@@ -1,11 +1,11 @@
import { useContext, useEffect, useRef, useState } from 'react';
import { Badge, Button, Card, Form, Table } from 'react-bootstrap';
import { Alert, Badge, Button, Card, Form, Modal, Table } from 'react-bootstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faNoteSticky, faTrashCan } from '@fortawesome/free-regular-svg-icons';
import { faTrashCan } from '@fortawesome/free-regular-svg-icons';
import { faBasketShopping, faGear, faLock, faLockOpen, faSearch, faUserPlus } from '@fortawesome/free-solid-svg-icons';
import {
ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember,
getData, createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState,
getData, createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes,
} from '../../../types';
import { EVENT_MESSAGE, SocketContext } from '../context/socket';
import { useAuth } from '../context/auth';
@@ -14,11 +14,11 @@ import Login from '../Login';
import Header from '../components/Header';
import Footer from '../components/Footer';
import Loader from '../components/Loader';
import NoteModal from '../components/modals/NoteModal';
import StoreAdminModal from '../components/modals/StoreAdminModal';
import PayForGroupModal from '../components/modals/PayForGroupModal';
const SLOT = MealSlot.EXTRA;
const TIME_REGEX = /^([01]\d|2[0-3]):[0-5]\d$/;
function stateBadge(state: GroupState) {
const map: Record<GroupState, { bg: string; label: string }> = {
@@ -39,9 +39,12 @@ export default function OrderGroupsPage() {
const [newGroupName, setNewGroupName] = useState('');
const [creating, setCreating] = useState(false);
const [adminModalOpen, setAdminModalOpen] = useState(false);
const [noteModal, setNoteModal] = useState<{ groupId: string; login: string } | null>(null);
const [editAmounts, setEditAmounts] = useState<Record<string, string>>({});
const [editNotes, setEditNotes] = useState<Record<string, string>>({});
const [editTimes, setEditTimes] = useState<Record<string, { orderedAt: string; deliveryAt: string }>>({});
const [payModal, setPayModal] = useState<OrderGroup | null>(null);
const [confirmOrderGroup, setConfirmOrderGroup] = useState<OrderGroup | null>(null);
const [pageError, setPageError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const fetchData = async () => {
@@ -60,61 +63,90 @@ export default function OrderGroupsPage() {
useEffect(() => {
socket.on(EVENT_MESSAGE, (newData: ClientData) => {
if (newData.slot === SLOT) setData(newData);
if (newData.slot === SLOT) setData(prev => ({
...newData,
stores: newData.stores ?? prev?.stores,
}));
});
return () => { socket.off(EVENT_MESSAGE); };
}, [socket]);
const refresh = async (fn: () => Promise<any>) => {
const refresh = async (fn: () => Promise<any>): Promise<boolean> => {
setPageError(null);
const result = await fn();
if (result?.error) {
setPageError((result.error as any).error || 'Nastala chyba');
await fetchData();
return false;
}
if (result?.data) {
setData(result.data);
const ws = result.data as ClientData;
socket.emit?.('message', ws);
socket.emit?.('message', result.data as ClientData);
}
await fetchData();
return true;
};
const handleCreate = async () => {
if (!newGroupName || !auth?.login) return;
setCreating(true);
try {
await refresh(() => createGroup({ body: { name: newGroupName } }));
setNewGroupName('');
} catch { /* swallow */ }
const ok = await refresh(() => createGroup({ body: { name: newGroupName } }));
if (ok) setNewGroupName('');
setCreating(false);
};
const handleJoin = (groupId: string) =>
refresh(() => addGroupMember({ body: { id: groupId } }));
const handleLeave = (groupId: string) =>
refresh(() => removeGroupMember({ body: { id: groupId, login: auth?.login ?? '' } }));
const handleToggleLock = (group: OrderGroup) => {
const next = group.state === GroupState.OPEN ? GroupState.LOCKED : GroupState.OPEN;
return refresh(() => setGroupState({ body: { id: group.id, state: next } }));
};
const handleMarkOrdered = (group: OrderGroup) =>
refresh(() => setGroupState({ body: { id: group.id, state: GroupState.ORDERED } }));
const handleConfirmOrdered = async (group: OrderGroup) => {
setConfirmOrderGroup(null);
await refresh(() => setGroupState({ body: { id: group.id, state: GroupState.ORDERED } }));
};
const handleRevertOrdered = (group: OrderGroup) =>
refresh(() => setGroupState({ body: { id: group.id, state: GroupState.LOCKED } }));
const handleDelete = (groupId: string) =>
refresh(() => deleteGroup({ body: { id: groupId } }));
const handleSaveNote = async (note?: string) => {
if (!noteModal || !auth?.login) return;
await refresh(() => updateGroupMember({ body: { id: noteModal.groupId, login: noteModal.login, note } }));
setNoteModal(null);
};
const handleSaveAmount = async (groupId: string, login: string) => {
const key = `${groupId}:${login}`;
const raw = editAmounts[key];
const n = parseFloat(raw ?? '');
const amount = isNaN(n) || n < 0 ? undefined : n;
await refresh(() => updateGroupMember({ body: { id: groupId, login, amount } }));
setEditAmounts(prev => { const next = { ...prev }; delete next[key]; return next; });
if (!raw || isNaN(n) || n < 0) {
setPageError('Zadejte platnou kladnou částku');
return;
}
const ok = await refresh(() => updateGroupMember({ body: { id: groupId, login, amount: n } }));
if (ok) setEditAmounts(prev => { const next = { ...prev }; delete next[key]; return next; });
};
const handleSaveNote = async (groupId: string, login: string) => {
const key = `${groupId}:${login}`;
const note = editNotes[key] ?? '';
const ok = await refresh(() => updateGroupMember({ body: { id: groupId, login, note } }));
if (ok) setEditNotes(prev => { const next = { ...prev }; delete next[key]; return next; });
};
const handleSaveTimes = async (group: OrderGroup) => {
const times = editTimes[group.id];
if (!times) return;
const { orderedAt, deliveryAt } = times;
if (orderedAt && !TIME_REGEX.test(orderedAt)) {
setPageError('Čas objednání musí být ve formátu HH:MM');
return;
}
if (deliveryAt && !TIME_REGEX.test(deliveryAt)) {
setPageError('Čas doručení musí být ve formátu HH:MM');
return;
}
const ok = await refresh(() => updateGroupTimes({ body: { id: group.id, orderedAt, deliveryAt } }));
if (ok) setEditTimes(prev => { const next = { ...prev }; delete next[group.id]; return next; });
};
const canEditMember = (group: OrderGroup, targetLogin: string) => {
@@ -155,6 +187,12 @@ export default function OrderGroupsPage() {
</div>
<p style={{ color: 'var(--luncher-text-muted)' }}>Skupinové objednávky z obchodů a restaurací</p>
{pageError && (
<Alert variant="danger" dismissible onClose={() => setPageError(null)} className="mt-2">
{pageError}
</Alert>
)}
<div className="content-wrapper">
<div className="content">
{/* Vytvoření nové skupiny */}
@@ -196,6 +234,7 @@ 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 editingTimes = group.id in editTimes;
return (
<Card key={group.id} className="mb-3 fade-in">
@@ -212,7 +251,7 @@ export default function OrderGroupsPage() {
<FontAwesomeIcon icon={isLocked ? faLockOpen : faLock} />
</Button>
{isLocked && (
<Button variant="outline-primary" size="sm" onClick={() => handleMarkOrdered(group)}>
<Button variant="outline-primary" size="sm" onClick={() => setConfirmOrderGroup(group)}>
Objednáno
</Button>
)}
@@ -221,13 +260,20 @@ export default function OrderGroupsPage() {
</Button>
</>
)}
{isCreator && isOrdered && settings?.bankAccount && settings?.holderName && (
<Button variant="primary" size="sm" onClick={() => setPayModal(group)}>
<FontAwesomeIcon icon={faBasketShopping} className="me-1" />
Generovat QR
</Button>
{isCreator && isOrdered && (
<>
{settings?.bankAccount && settings?.holderName && (
<Button variant="primary" size="sm" onClick={() => setPayModal(group)}>
<FontAwesomeIcon icon={faBasketShopping} className="me-1" />
Generovat QR
</Button>
)}
<Button variant="outline-warning" size="sm" onClick={() => handleRevertOrdered(group)} title="Vrátit na Uzamčeno (smaže QR kódy)">
<FontAwesomeIcon icon={faLockOpen} />
</Button>
</>
)}
{!isMember && !isOrdered && (
{!isMember && !isOrdered && !isLocked && (
<Button variant="outline-success" size="sm" onClick={() => handleJoin(group.id)}>
<FontAwesomeIcon icon={faUserPlus} className="me-1" />
Přidat se
@@ -242,13 +288,15 @@ export default function OrderGroupsPage() {
<th>Člen</th>
<th style={{ width: 130 }}>Částka ()</th>
<th>Poznámka</th>
<th style={{ width: 80 }}></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 canEdit = canEditMember(group, memberLogin);
return (
<tr key={memberLogin}>
@@ -269,7 +317,7 @@ export default function OrderGroupsPage() {
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); }}
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 }}
autoFocus={memberLogin === login}
/>
@@ -286,19 +334,31 @@ export default function OrderGroupsPage() {
)}
</td>
<td>
<small className="text-muted">{member.note || '—'}</small>
{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; }); }}
autoFocus
/>
<Button size="sm" variant="outline-success" onClick={() => handleSaveNote(group.id, memberLogin)}></Button>
</div>
) : (
<span
style={{ cursor: canEdit ? 'pointer' : undefined }}
onClick={() => canEdit && setEditNotes(prev => ({ ...prev, [noteKey]: member.note ?? '' }))}
title={canEdit ? 'Klikněte pro úpravu poznámky' : undefined}
>
<small className="text-muted">{member.note || '—'}</small>
</span>
)}
</td>
<td>
<div className="d-flex gap-1 justify-content-end">
{memberLogin === login && (
<FontAwesomeIcon
icon={faNoteSticky}
className="action-icon"
title="Upravit poznámku"
onClick={() => setNoteModal({ groupId: group.id, login: memberLogin })}
/>
)}
{canManageMembers(group) && (memberLogin !== group.creatorLogin) && (
{canManageMembers(group) && (isCreator || memberLogin === login) && (memberLogin !== group.creatorLogin) && (
<FontAwesomeIcon
icon={faTrashCan}
className="action-icon"
@@ -313,6 +373,58 @@ export default function OrderGroupsPage() {
})}
</tbody>
</Table>
{/* Časy objednání a doručení */}
{isOrdered && (
<div className="px-3 py-2 border-top">
{isCreator && editingTimes ? (
<div className="d-flex align-items-center gap-3 flex-wrap">
<div className="d-flex align-items-center gap-1">
<small className="text-muted text-nowrap">Objednáno v:</small>
<Form.Control
type="text"
size="sm"
placeholder="HH:MM"
value={editTimes[group.id]?.orderedAt ?? ''}
onChange={e => setEditTimes(prev => ({ ...prev, [group.id]: { ...prev[group.id], orderedAt: e.target.value } }))}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveTimes(group); }}
style={{ width: 75 }}
autoFocus
/>
</div>
<div className="d-flex align-items-center gap-1">
<small className="text-muted text-nowrap">Doručení v:</small>
<Form.Control
type="text"
size="sm"
placeholder="HH:MM"
value={editTimes[group.id]?.deliveryAt ?? ''}
onChange={e => setEditTimes(prev => ({ ...prev, [group.id]: { ...prev[group.id], deliveryAt: e.target.value } }))}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveTimes(group); }}
style={{ width: 75 }}
/>
</div>
<Button size="sm" variant="outline-success" onClick={() => handleSaveTimes(group)}>Uložit</Button>
<Button size="sm" variant="outline-secondary" onClick={() => setEditTimes(prev => { const n = { ...prev }; delete n[group.id]; return n; })}>Zrušit</Button>
</div>
) : (
<div
className="d-flex align-items-center gap-3 flex-wrap"
style={{ cursor: isCreator ? 'pointer' : undefined }}
onClick={() => isCreator && setEditTimes(prev => ({ ...prev, [group.id]: { orderedAt: group.orderedAt ?? '', deliveryAt: group.deliveryAt ?? '' } }))}
title={isCreator ? 'Klikněte pro úpravu časů' : undefined}
>
<small className="text-muted">
Objednáno v: <strong>{group.orderedAt ?? '—'}</strong>
</small>
<small className="text-muted">
Doručení v: <strong>{group.deliveryAt ?? '—'}</strong>
</small>
{isCreator && <small className="text-muted fst-italic">(upravit)</small>}
</div>
)}
</div>
)}
</Card.Body>
</Card>
);
@@ -322,11 +434,22 @@ export default function OrderGroupsPage() {
</div>
<Footer />
<NoteModal
isOpen={!!noteModal}
onClose={() => setNoteModal(null)}
onSave={handleSaveNote}
/>
{/* Potvrzovací dialog pro přechod do stavu Objednáno */}
<Modal show={!!confirmOrderGroup} onHide={() => setConfirmOrderGroup(null)} centered>
<Modal.Header closeButton>
<Modal.Title>Potvrdit objednání</Modal.Title>
</Modal.Header>
<Modal.Body>
Opravdu chcete označit skupinu <strong>{confirmOrderGroup?.name}</strong> jako objednanou?
Tato akce uzavře skupinu a zaznamená čas objednání.
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={() => setConfirmOrderGroup(null)}>Zrušit</Button>
<Button variant="primary" onClick={() => confirmOrderGroup && handleConfirmOrdered(confirmOrderGroup)}>
Objednáno
</Button>
</Modal.Footer>
</Modal>
<StoreAdminModal
isOpen={adminModalOpen}
@@ -340,6 +463,7 @@ export default function OrderGroupsPage() {
isOpen={!!payModal}
onClose={() => setPayModal(null)}
group={payModal}
groupId={payModal.id}
payerLogin={auth.login}
bankAccount={settings.bankAccount}
bankAccountHolder={settings.holderName}