diff --git a/client/src/App.scss b/client/src/App.scss index 2e5a34b..b37eebd 100644 --- a/client/src/App.scss +++ b/client/src/App.scss @@ -279,6 +279,17 @@ body { } } +// Varianta navigace mezi dny na stránce objednávání – šipky kolem date pickeru +.order-day-navigator { + margin-bottom: 16px; + gap: 16px; + + input[type="date"] { + text-align: center; + font-weight: 600; + } +} + // ============================================ // FOOD TABLES - CARD STYLE // ============================================ diff --git a/client/src/pages/OrderGroupsPage.tsx b/client/src/pages/OrderGroupsPage.tsx index 9ce8971..0ed6c6e 100644 --- a/client/src/pages/OrderGroupsPage.tsx +++ b/client/src/pages/OrderGroupsPage.tsx @@ -1,8 +1,8 @@ -import { useContext, useEffect, useState } from 'react'; +import { useCallback, useContext, useEffect, useRef, useState } from 'react'; 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'; +import { faBasketShopping, faChevronLeft, faChevronRight, faCircleCheck, faClockRotateLeft, faGear, faLock, faLockOpen, faSearch, faUserPlus } from '@fortawesome/free-solid-svg-icons'; import { ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember, PendingQr, getData, createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, @@ -11,6 +11,7 @@ import { computeFeeShare, computeMemberTotal, countActiveMembers } from '../util import { EVENT_MESSAGE, EVENT_PENDING_QR, SocketContext } from '../context/socket'; import { useAuth } from '../context/auth'; import { useSettings } from '../context/settings'; +import { formatDate, formatDateString } from '../Utils'; import Login from '../Login'; import Header from '../components/Header'; import Footer from '../components/Footer'; @@ -23,6 +24,13 @@ import PendingPayments from '../components/PendingPayments'; const SLOT = MealSlot.EXTRA; const TIME_REGEX = /^([01]\d|2[0-3]):[0-5]\d$/; +/** Vrátí ISO datum (YYYY-MM-DD) posunuté o předaný počet dní. */ +function shiftIsoDate(iso: string, days: number): string { + const date = new Date(`${iso}T00:00:00`); + date.setDate(date.getDate() + days); + return formatDate(date); +} + function stateBadge(state: GroupState) { const map: Record = { [GroupState.OPEN]: { bg: 'success', label: 'Otevřeno' }, @@ -39,6 +47,12 @@ export default function OrderGroupsPage() { const socket = useContext(SocketContext); const [data, setData] = useState(); const [failure, setFailure] = useState(false); + // Vybrané datum pro zobrazení historie (undefined = aktuální den) + const [selectedDate, setSelectedDate] = useState(); + // ISO datum dnešního dne dle serveru (horní hranice navigace), zjištěné při prvním načtení + const [todayIso, setTodayIso] = useState(); + // Ref pro socket handler – aby věděl, zda se zobrazuje historie (na ní se živé aktualizace neaplikují) + const selectedDateRef = useRef(undefined); const [newGroupName, setNewGroupName] = useState(''); const [creating, setCreating] = useState(false); const [adminModalOpen, setAdminModalOpen] = useState(false); @@ -51,22 +65,32 @@ export default function OrderGroupsPage() { const [confirmOrderGroup, setConfirmOrderGroup] = useState(null); const [pageError, setPageError] = useState(null); - const fetchData = async () => { + const fetchData = async (date?: string) => { try { - const r = await getData({ query: { slot: SLOT } }); - if (r.data) setData(r.data); + const r = await getData({ query: { slot: SLOT, date } }); + if (r.data) { + setData(r.data); + // Při zobrazení aktuálního dne si zapamatujeme dnešní ISO datum jako horní hranici navigace + if (!date && r.data.isoDate) setTodayIso(r.data.isoDate); + } } catch { setFailure(true); } }; + useEffect(() => { + selectedDateRef.current = selectedDate; + }, [selectedDate]); + useEffect(() => { if (!auth?.login) return; - fetchData(); - }, [auth?.login]); + fetchData(selectedDate); + }, [auth?.login, selectedDate]); useEffect(() => { socket.on(EVENT_MESSAGE, (newData: ClientData) => { + // Živé aktualizace se týkají vždy dneška – při zobrazení historie je ignorujeme + if (selectedDateRef.current) return; if (newData.slot === SLOT) setData(prev => ({ ...newData, stores: newData.stores ?? prev?.stores, @@ -74,11 +98,34 @@ export default function OrderGroupsPage() { }); // Nová nevyřízená platba (QR kód) – připojíme do dat, aby se zobrazila i bez znovunačtení stránky socket.on(EVENT_PENDING_QR, (pendingQr: PendingQr) => { + if (selectedDateRef.current) return; setData(prev => prev ? { ...prev, pendingQrs: [...(prev.pendingQrs ?? []), pendingQr] } : prev); }); return () => { socket.off(EVENT_MESSAGE); socket.off(EVENT_PENDING_QR); }; }, [socket]); + // Navigace mezi dny pomocí klávesových šipek (←/→), obdobně jako na hlavní stránce + const handleKeyDown = useCallback((e: KeyboardEvent) => { + // Ignorujeme, pokud uživatel právě píše do formulářového pole + const tag = (e.target as HTMLElement)?.tagName; + if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') return; + const currentIso = data?.isoDate; + if (!currentIso) return; + if (e.keyCode === 37) { + // Předchozí den – do minulosti bez omezení + setSelectedDate(shiftIsoDate(currentIso, -1)); + } else if (e.keyCode === 39 && todayIso != null && currentIso < todayIso) { + // Následující den – nejvýše po dnešek (na dnešek přes undefined kvůli živým aktualizacím) + const target = shiftIsoDate(currentIso, 1); + setSelectedDate(target >= todayIso ? undefined : target); + } + }, [data?.isoDate, todayIso]); + + useEffect(() => { + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [handleKeyDown]); + const refresh = async (fn: () => Promise): Promise => { setPageError(null); const result = await fn(); @@ -170,7 +217,10 @@ export default function OrderGroupsPage() { if (ok) setEditTimes(prev => { const next = { ...prev }; delete next[group.id]; return next; }); }; + // Pozn.: tyto funkce se volají až v renderu, kde je k dispozici `selectedDate`. + // Historie (jiný než aktuální den) je vždy read-only. const canEditMember = (group: OrderGroup, targetLogin: string) => { + if (selectedDate) return false; if (group.state === GroupState.ORDERED) return false; if (auth?.login === group.creatorLogin) return true; if (auth?.login === targetLogin && group.state === GroupState.OPEN) return true; @@ -178,6 +228,7 @@ export default function OrderGroupsPage() { }; const canManageMembers = (group: OrderGroup) => { + if (selectedDate) return false; if (group.state === GroupState.ORDERED) return false; if (auth?.login === group.creatorLogin) return true; return group.state === GroupState.OPEN; @@ -196,6 +247,24 @@ export default function OrderGroupsPage() { const stores = data.stores ?? []; const groups = data.groups ?? []; + // Zobrazené datum a režim historie (vše read-only, pokud nejde o aktuální den) + const displayedIso = data.isoDate; + const isToday = !selectedDate || (todayIso != null && displayedIso === todayIso); + const isReadOnly = !isToday; + const canGoNext = todayIso != null && displayedIso != null && displayedIso < todayIso; + + const goToDay = (offset: number) => { + if (!displayedIso) return; + const target = shiftIsoDate(displayedIso, offset); + // Na dnešek (či dál) se vracíme přes undefined, aby se obnovily živé aktualizace + setSelectedDate(todayIso != null && target >= todayIso ? undefined : target); + }; + + const handleDatePick = (value: string) => { + if (!value) return; + setSelectedDate(todayIso != null && value >= todayIso ? undefined : value); + }; + return (
@@ -208,6 +277,40 @@ export default function OrderGroupsPage() {

Skupinové objednávky z obchodů a restaurací

+ {/* Navigace mezi dny – šipky kolem výběru data (i klávesami ←/→) */} +
+ + goToDay(-1)} /> + + handleDatePick(e.target.value)} + className={isReadOnly ? 'text-muted' : ''} + style={{ maxWidth: 200 }} + /> + + canGoNext && goToDay(1)} + /> + +
+ + {isReadOnly && ( + + + + Prohlížíte historii ze dne {displayedIso ? formatDateString(displayedIso) : data.date} – data jsou pouze pro čtení. + + + + )} + {pageError && ( setPageError(null)} className="mt-2"> {pageError} @@ -216,7 +319,8 @@ export default function OrderGroupsPage() {
- {/* Vytvoření nové skupiny */} + {/* Vytvoření nové skupiny – pouze pro aktuální den */} + {!isReadOnly && (
Vytvořit skupinu
{stores.length === 0 ? ( @@ -242,10 +346,13 @@ export default function OrderGroupsPage() {
)}
+ )} {/* Seznam skupin */} {groups.length === 0 && ( -

Zatím žádné skupiny pro dnešní den.

+

+ {isReadOnly ? 'Pro tento den nejsou žádné skupiny.' : 'Zatím žádné skupiny pro dnešní den.'} +

)} {groups.map(group => { @@ -274,7 +381,7 @@ export default function OrderGroupsPage() { zakladatel: {group.creatorLogin}
- {isCreator && !isOrdered && ( + {!isReadOnly && isCreator && !isOrdered && ( <> )} - {isCreator && isOrdered && ( + {!isReadOnly && isCreator && isOrdered && ( <> {settings?.bankAccount && settings?.holderName && !group.qrGenerated && ( )} - {!isMember && !isOrdered && !isLocked && ( + {!isReadOnly && !isMember && !isOrdered && !isLocked && (