diff --git a/client/public/sw.js b/client/public/sw.js index f357178..01900d5 100644 --- a/client/public/sw.js +++ b/client/public/sw.js @@ -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 }), }) ); } diff --git a/client/src/components/modals/PayForAllModal.tsx b/client/src/components/modals/PayForAllModal.tsx index f437625..a14016a 100644 --- a/client/src/components/modals/PayForAllModal.tsx +++ b/client/src/components/modals/PayForAllModal.tsx @@ -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 - {tipPerPerson > 0 ? `${tipPerPerson} Kč` : '—'} + {(() => { const s = isPayer ? payerTipShare : tipPerPerson; return s > 0 ? `${s} Kč` : '—'; })()} {`${total} Kč`} diff --git a/client/src/components/modals/PayForGroupModal.tsx b/client/src/components/modals/PayForGroupModal.tsx index 3e5a1e9..99f4a0e 100644 --- a/client/src/components/modals/PayForGroupModal.tsx +++ b/client/src/components/modals/PayForGroupModal.tsx @@ -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) { +export default function PayForGroupModal({ isOpen, onClose, group, payerLogin, bankAccount, bankAccountHolder, groupId }: Readonly) { const [diners, setDiners] = useState([]); const [tipTotal, setTipTotal] = useState(''); const [error, setError] = useState(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 - {tipPerPerson > 0 ? `${tipPerPerson} Kč` : '—'} + {(() => { const s = isPayer ? payerTipShare : tipPerPerson; return s > 0 ? `${s} Kč` : '—'; })()} {`${total} Kč`} diff --git a/client/src/pages/OrderGroupsPage.tsx b/client/src/pages/OrderGroupsPage.tsx index e8d51f0..2d0fef9 100644 --- a/client/src/pages/OrderGroupsPage.tsx +++ b/client/src/pages/OrderGroupsPage.tsx @@ -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 = { @@ -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>({}); + const [editNotes, setEditNotes] = useState>({}); + const [editTimes, setEditTimes] = useState>({}); const [payModal, setPayModal] = useState(null); + const [confirmOrderGroup, setConfirmOrderGroup] = useState(null); + const [pageError, setPageError] = useState(null); const inputRef = useRef(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) => { + const refresh = async (fn: () => Promise): Promise => { + 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() {

Skupinové objednávky z obchodů a restaurací

+ {pageError && ( + setPageError(null)} className="mt-2"> + {pageError} + + )} +
{/* 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 ( @@ -212,7 +251,7 @@ export default function OrderGroupsPage() { {isLocked && ( - )} @@ -221,13 +260,20 @@ export default function OrderGroupsPage() { )} - {isCreator && isOrdered && settings?.bankAccount && settings?.holderName && ( - + {isCreator && isOrdered && ( + <> + {settings?.bankAccount && settings?.holderName && ( + + )} + + )} - {!isMember && !isOrdered && ( + {!isMember && !isOrdered && !isLocked && ( +
+ ) : ( + canEdit && setEditNotes(prev => ({ ...prev, [noteKey]: member.note ?? '' }))} + title={canEdit ? 'Klikněte pro úpravu poznámky' : undefined} + > + {member.note || '—'} + + )}
- {memberLogin === login && ( - setNoteModal({ groupId: group.id, login: memberLogin })} - /> - )} - {canManageMembers(group) && (memberLogin !== group.creatorLogin) && ( + {canManageMembers(group) && (isCreator || memberLogin === login) && (memberLogin !== group.creatorLogin) && ( + + {/* Časy objednání a doručení */} + {isOrdered && ( +
+ {isCreator && editingTimes ? ( +
+
+ Objednáno v: + 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 + /> +
+
+ Doručení v: + 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 }} + /> +
+ + +
+ ) : ( +
isCreator && setEditTimes(prev => ({ ...prev, [group.id]: { orderedAt: group.orderedAt ?? '', deliveryAt: group.deliveryAt ?? '' } }))} + title={isCreator ? 'Klikněte pro úpravu časů' : undefined} + > + + Objednáno v: {group.orderedAt ?? '—'} + + + Doručení v: {group.deliveryAt ?? '—'} + + {isCreator && (upravit)} +
+ )} +
+ )} ); @@ -322,11 +434,22 @@ export default function OrderGroupsPage() {