feat: možnost zobrazení objednávek z historie
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 20s
CI / Build server (push) Successful in 23s
CI / Build client (push) Successful in 33s
CI / Playwright E2E tests (push) Successful in 1m17s
CI / Build and push Docker image (push) Successful in 41s
CI / Notify (push) Successful in 2s

This commit is contained in:
2026-06-05 14:11:39 +02:00
parent c85842267a
commit fb84bff687
7 changed files with 168 additions and 24 deletions
+11
View File
@@ -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 // FOOD TABLES - CARD STYLE
// ============================================ // ============================================
+128 -19
View File
@@ -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 { Alert, Badge, Button, Card, Form, Modal, OverlayTrigger, Table, Tooltip } from 'react-bootstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTrashCan } from '@fortawesome/free-regular-svg-icons'; 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 { import {
ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember, PendingQr, ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember, PendingQr,
getData, createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, 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 { EVENT_MESSAGE, EVENT_PENDING_QR, SocketContext } from '../context/socket';
import { useAuth } from '../context/auth'; import { useAuth } from '../context/auth';
import { useSettings } from '../context/settings'; import { useSettings } from '../context/settings';
import { formatDate, formatDateString } from '../Utils';
import Login from '../Login'; import Login from '../Login';
import Header from '../components/Header'; import Header from '../components/Header';
import Footer from '../components/Footer'; import Footer from '../components/Footer';
@@ -23,6 +24,13 @@ import PendingPayments from '../components/PendingPayments';
const SLOT = MealSlot.EXTRA; const SLOT = MealSlot.EXTRA;
const TIME_REGEX = /^([01]\d|2[0-3]):[0-5]\d$/; 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) { function stateBadge(state: GroupState) {
const map: Record<GroupState, { bg: string; label: string }> = { const map: Record<GroupState, { bg: string; label: string }> = {
[GroupState.OPEN]: { bg: 'success', label: 'Otevřeno' }, [GroupState.OPEN]: { bg: 'success', label: 'Otevřeno' },
@@ -39,6 +47,12 @@ export default function OrderGroupsPage() {
const socket = useContext(SocketContext); const socket = useContext(SocketContext);
const [data, setData] = useState<ClientData | undefined>(); const [data, setData] = useState<ClientData | undefined>();
const [failure, setFailure] = useState(false); const [failure, setFailure] = useState(false);
// Vybrané datum pro zobrazení historie (undefined = aktuální den)
const [selectedDate, setSelectedDate] = useState<string | undefined>();
// ISO datum dnešního dne dle serveru (horní hranice navigace), zjištěné při prvním načtení
const [todayIso, setTodayIso] = useState<string | undefined>();
// Ref pro socket handler aby věděl, zda se zobrazuje historie (na ní se živé aktualizace neaplikují)
const selectedDateRef = useRef<string | undefined>(undefined);
const [newGroupName, setNewGroupName] = useState(''); const [newGroupName, setNewGroupName] = useState('');
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [adminModalOpen, setAdminModalOpen] = useState(false); const [adminModalOpen, setAdminModalOpen] = useState(false);
@@ -51,22 +65,32 @@ export default function OrderGroupsPage() {
const [confirmOrderGroup, setConfirmOrderGroup] = useState<OrderGroup | null>(null); const [confirmOrderGroup, setConfirmOrderGroup] = useState<OrderGroup | null>(null);
const [pageError, setPageError] = useState<string | null>(null); const [pageError, setPageError] = useState<string | null>(null);
const fetchData = async () => { const fetchData = async (date?: string) => {
try { try {
const r = await getData({ query: { slot: SLOT } }); const r = await getData({ query: { slot: SLOT, date } });
if (r.data) setData(r.data); 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 { } catch {
setFailure(true); setFailure(true);
} }
}; };
useEffect(() => {
selectedDateRef.current = selectedDate;
}, [selectedDate]);
useEffect(() => { useEffect(() => {
if (!auth?.login) return; if (!auth?.login) return;
fetchData(); fetchData(selectedDate);
}, [auth?.login]); }, [auth?.login, selectedDate]);
useEffect(() => { useEffect(() => {
socket.on(EVENT_MESSAGE, (newData: ClientData) => { 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 => ({ if (newData.slot === SLOT) setData(prev => ({
...newData, ...newData,
stores: newData.stores ?? prev?.stores, 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 // 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) => { socket.on(EVENT_PENDING_QR, (pendingQr: PendingQr) => {
if (selectedDateRef.current) return;
setData(prev => prev ? { ...prev, pendingQrs: [...(prev.pendingQrs ?? []), pendingQr] } : prev); setData(prev => prev ? { ...prev, pendingQrs: [...(prev.pendingQrs ?? []), pendingQr] } : prev);
}); });
return () => { socket.off(EVENT_MESSAGE); socket.off(EVENT_PENDING_QR); }; return () => { socket.off(EVENT_MESSAGE); socket.off(EVENT_PENDING_QR); };
}, [socket]); }, [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<any>): Promise<boolean> => { const refresh = async (fn: () => Promise<any>): Promise<boolean> => {
setPageError(null); setPageError(null);
const result = await fn(); 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; }); 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) => { const canEditMember = (group: OrderGroup, targetLogin: string) => {
if (selectedDate) return false;
if (group.state === GroupState.ORDERED) return false; if (group.state === GroupState.ORDERED) return false;
if (auth?.login === group.creatorLogin) return true; if (auth?.login === group.creatorLogin) return true;
if (auth?.login === targetLogin && group.state === GroupState.OPEN) return true; if (auth?.login === targetLogin && group.state === GroupState.OPEN) return true;
@@ -178,6 +228,7 @@ export default function OrderGroupsPage() {
}; };
const canManageMembers = (group: OrderGroup) => { const canManageMembers = (group: OrderGroup) => {
if (selectedDate) return false;
if (group.state === GroupState.ORDERED) return false; if (group.state === GroupState.ORDERED) return false;
if (auth?.login === group.creatorLogin) return true; if (auth?.login === group.creatorLogin) return true;
return group.state === GroupState.OPEN; return group.state === GroupState.OPEN;
@@ -196,6 +247,24 @@ export default function OrderGroupsPage() {
const stores = data.stores ?? []; const stores = data.stores ?? [];
const groups = data.groups ?? []; 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 ( return (
<div className="app-container"> <div className="app-container">
<Header choices={data.choices} /> <Header choices={data.choices} />
@@ -208,6 +277,40 @@ export default function OrderGroupsPage() {
</div> </div>
<p style={{ color: 'var(--luncher-text-muted)' }}>Skupinové objednávky z obchodů a restaurací</p> <p style={{ color: 'var(--luncher-text-muted)' }}>Skupinové objednávky z obchodů a restaurací</p>
{/* Navigace mezi dny šipky kolem výběru data (i klávesami ←/→) */}
<div className="day-navigator order-day-navigator">
<span title="Předchozí den">
<FontAwesomeIcon icon={faChevronLeft} onClick={() => goToDay(-1)} />
</span>
<Form.Control
type="date"
value={displayedIso ?? ''}
max={todayIso}
onChange={e => handleDatePick(e.target.value)}
className={isReadOnly ? 'text-muted' : ''}
style={{ maxWidth: 200 }}
/>
<span title="Následující den">
<FontAwesomeIcon
icon={faChevronRight}
style={{ visibility: canGoNext ? 'visible' : 'hidden' }}
onClick={() => canGoNext && goToDay(1)}
/>
</span>
</div>
{isReadOnly && (
<Alert variant="secondary" className="d-flex align-items-center gap-2 py-2">
<FontAwesomeIcon icon={faClockRotateLeft} />
<span>
Prohlížíte historii ze dne <strong>{displayedIso ? formatDateString(displayedIso) : data.date}</strong> data jsou pouze pro čtení.
</span>
<Button variant="link" size="sm" className="p-0 ms-auto" onClick={() => setSelectedDate(undefined)}>
Zpět na dnešek
</Button>
</Alert>
)}
{pageError && ( {pageError && (
<Alert variant="danger" dismissible onClose={() => setPageError(null)} className="mt-2"> <Alert variant="danger" dismissible onClose={() => setPageError(null)} className="mt-2">
{pageError} {pageError}
@@ -216,7 +319,8 @@ export default function OrderGroupsPage() {
<div className="content-wrapper"> <div className="content-wrapper">
<div className="content" style={{ maxWidth: 1200 }}> <div className="content" style={{ maxWidth: 1200 }}>
{/* Vytvoření nové skupiny */} {/* Vytvoření nové skupiny pouze pro aktuální den */}
{!isReadOnly && (
<div className="choice-section fade-in mb-4"> <div className="choice-section fade-in mb-4">
<h5>Vytvořit skupinu</h5> <h5>Vytvořit skupinu</h5>
{stores.length === 0 ? ( {stores.length === 0 ? (
@@ -242,10 +346,13 @@ export default function OrderGroupsPage() {
</div> </div>
)} )}
</div> </div>
)}
{/* Seznam skupin */} {/* Seznam skupin */}
{groups.length === 0 && ( {groups.length === 0 && (
<p className="text-muted fade-in">Zatím žádné skupiny pro dnešní den.</p> <p className="text-muted fade-in">
{isReadOnly ? 'Pro tento den nejsou žádné skupiny.' : 'Zatím žádné skupiny pro dnešní den.'}
</p>
)} )}
{groups.map(group => { {groups.map(group => {
@@ -274,7 +381,7 @@ export default function OrderGroupsPage() {
<small className="text-muted">zakladatel: {group.creatorLogin}</small> <small className="text-muted">zakladatel: {group.creatorLogin}</small>
</div> </div>
<div className="d-flex gap-2"> <div className="d-flex gap-2">
{isCreator && !isOrdered && ( {!isReadOnly && isCreator && !isOrdered && (
<> <>
<Button variant="outline-info" size="sm" onClick={() => setFeesModal(group)} title="Upravit poplatky a slevu"> <Button variant="outline-info" size="sm" onClick={() => setFeesModal(group)} title="Upravit poplatky a slevu">
Poplatky Poplatky
@@ -292,7 +399,7 @@ export default function OrderGroupsPage() {
</Button> </Button>
</> </>
)} )}
{isCreator && isOrdered && ( {!isReadOnly && isCreator && isOrdered && (
<> <>
{settings?.bankAccount && settings?.holderName && !group.qrGenerated && ( {settings?.bankAccount && settings?.holderName && !group.qrGenerated && (
<Button variant="primary" size="sm" onClick={() => setPayModal(group)}> <Button variant="primary" size="sm" onClick={() => setPayModal(group)}>
@@ -305,7 +412,7 @@ export default function OrderGroupsPage() {
</Button> </Button>
</> </>
)} )}
{!isMember && !isOrdered && !isLocked && ( {!isReadOnly && !isMember && !isOrdered && !isLocked && (
<Button variant="outline-success" size="sm" onClick={() => handleJoin(group.id)}> <Button variant="outline-success" size="sm" onClick={() => handleJoin(group.id)}>
<FontAwesomeIcon icon={faUserPlus} className="me-1" /> <FontAwesomeIcon icon={faUserPlus} className="me-1" />
Přidat se Přidat se
@@ -493,7 +600,7 @@ export default function OrderGroupsPage() {
{/* Časy objednání a doručení */} {/* Časy objednání a doručení */}
{isOrdered && ( {isOrdered && (
<div className="px-3 py-2 border-top"> <div className="px-3 py-2 border-top">
{isCreator && editingTimes ? ( {!isReadOnly && isCreator && editingTimes ? (
<div className="d-flex align-items-center gap-3 flex-wrap"> <div className="d-flex align-items-center gap-3 flex-wrap">
<div className="d-flex align-items-center gap-1"> <div className="d-flex align-items-center gap-1">
<small className="text-muted text-nowrap">Objednáno v:</small> <small className="text-muted text-nowrap">Objednáno v:</small>
@@ -526,9 +633,9 @@ export default function OrderGroupsPage() {
) : ( ) : (
<div <div
className="d-flex align-items-center gap-3 flex-wrap" className="d-flex align-items-center gap-3 flex-wrap"
style={{ cursor: isCreator ? 'pointer' : undefined }} style={{ cursor: !isReadOnly && isCreator ? 'pointer' : undefined }}
onClick={() => isCreator && setEditTimes(prev => ({ ...prev, [group.id]: { orderedAt: group.orderedAt ?? '', deliveryAt: group.deliveryAt ?? '' } }))} onClick={() => !isReadOnly && isCreator && setEditTimes(prev => ({ ...prev, [group.id]: { orderedAt: group.orderedAt ?? '', deliveryAt: group.deliveryAt ?? '' } }))}
title={isCreator ? 'Klikněte pro úpravu časů' : undefined} title={!isReadOnly && isCreator ? 'Klikněte pro úpravu časů' : undefined}
> >
<small className="text-muted"> <small className="text-muted">
Objednáno v: <strong>{group.orderedAt ?? '—'}</strong> Objednáno v: <strong>{group.orderedAt ?? '—'}</strong>
@@ -545,12 +652,14 @@ export default function OrderGroupsPage() {
); );
})} })}
{/* Nevyřízené platby přihlášeného uživatele */} {/* Nevyřízené platby přihlášeného uživatele jen v režimu aktuálního dne */}
{!isReadOnly && (
<PendingPayments <PendingPayments
pendingQrs={data.pendingQrs} pendingQrs={data.pendingQrs}
login={auth.login} login={auth.login}
onDismissed={fetchData} onDismissed={() => fetchData()}
/> />
)}
</div> </div>
</div> </div>
</div> </div>
@@ -584,7 +693,7 @@ export default function OrderGroupsPage() {
<PayForGroupModal <PayForGroupModal
isOpen={!!payModal} isOpen={!!payModal}
onClose={() => setPayModal(null)} onClose={() => setPayModal(null)}
onSuccess={fetchData} onSuccess={() => fetchData()}
group={payModal} group={payModal}
groupId={payModal.id} groupId={payModal.id}
payerLogin={auth.login} payerLogin={auth.login}
+1
View File
@@ -1,4 +1,5 @@
[ [
"Možnost zobrazení objednávek z historie",
"Podpora neplatících osob u objednávání", "Podpora neplatících osob u objednávání",
"Zobrazení neuhrazených plateb i na stránce objednávek", "Zobrazení neuhrazených plateb i na stránce objednávek",
"Oprava duplicitního zobrazení QR kódu u Pizza day", "Oprava duplicitního zobrazení QR kódu u Pizza day",
+9 -1
View File
@@ -161,7 +161,15 @@ app.use("/api/", (req, res, next) => {
/** Vrátí data pro aktuální den. */ /** Vrátí data pro aktuální den. */
app.get("/api/data", async (req, res) => { app.get("/api/data", async (req, res) => {
let date = undefined; let date = undefined;
if (req.query.dayIndex != null && typeof req.query.dayIndex === 'string') { if (req.query.date != null && typeof req.query.date === 'string') {
// Konkrétní datum (YYYY-MM-DD) umožňuje načtení historie i mimo aktuální týden
const parsed = new Date(`${req.query.date}T00:00:00`);
if (isNaN(parsed.getTime())) {
return res.status(400).json({ error: 'Neplatné datum' });
}
// Budoucnost ořízneme na dnešek do budoucna historii nedává smysl zobrazovat
date = parsed.getTime() > getToday().getTime() ? getToday() : parsed;
} else if (req.query.dayIndex != null && typeof req.query.dayIndex === 'string') {
const index = parseInt(req.query.dayIndex); const index = parseInt(req.query.dayIndex);
if (!isNaN(index)) { if (!isNaN(index)) {
date = getDateForWeekIndex(parseInt(req.query.dayIndex)); date = getDateForWeekIndex(parseInt(req.query.dayIndex));
+2
View File
@@ -40,6 +40,7 @@ export function getEmptyData(date?: Date): ClientData {
return { return {
todayDayIndex: getDayOfWeekIndex(getToday()), todayDayIndex: getDayOfWeekIndex(getToday()),
date: getHumanDate(usedDate), date: getHumanDate(usedDate),
isoDate: formatDate(usedDate),
isWeekend: getIsWeekend(usedDate), isWeekend: getIsWeekend(usedDate),
dayIndex: getDayOfWeekIndex(usedDate), dayIndex: getDayOfWeekIndex(usedDate),
choices: {}, choices: {},
@@ -583,6 +584,7 @@ export async function getClientData(date?: Date, slot?: MealSlot): Promise<Clien
return { return {
...clientData, ...clientData,
todayDayIndex: getDayOfWeekIndex(getToday()), todayDayIndex: getDayOfWeekIndex(getToday()),
isoDate: formatDate(targetDate),
...(slot === MealSlot.EXTRA ? { slot: MealSlot.EXTRA } : {}), ...(slot === MealSlot.EXTRA ? { slot: MealSlot.EXTRA } : {}),
} }
} }
+9
View File
@@ -9,6 +9,15 @@ get:
type: integer type: integer
minimum: 0 minimum: 0
maximum: 4 maximum: 4
- in: query
name: date
description: >-
Konkrétní datum (YYYY-MM-DD), pro které se mají vrátit data. Má přednost
před dayIndex a umožňuje načtení historických dat i mimo aktuální týden.
Datum v budoucnosti je oříznuto na dnešek.
schema:
type: string
format: date
- in: query - in: query
name: slot name: slot
description: Slot jídla. Pokud není předán, je použit výchozí slot (oběd). description: Slot jídla. Pokud není předán, je použit výchozí slot (oběd).
+4
View File
@@ -32,6 +32,10 @@ ClientData:
date: date:
description: Human-readable datum dne description: Human-readable datum dne
type: string type: string
isoDate:
description: Datum zobrazeného dne ve formátu YYYY-MM-DD (pro navigaci mezi dny)
type: string
format: date
isWeekend: isWeekend:
description: Příznak, zda je tento den víkend description: Příznak, zda je tento den víkend
type: boolean type: boolean