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
// ============================================
+132 -23
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 { 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, { bg: string; label: string }> = {
[GroupState.OPEN]: { bg: 'success', label: 'Otevřeno' },
@@ -39,6 +47,12 @@ export default function OrderGroupsPage() {
const socket = useContext(SocketContext);
const [data, setData] = useState<ClientData | undefined>();
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 [creating, setCreating] = useState(false);
const [adminModalOpen, setAdminModalOpen] = useState(false);
@@ -51,22 +65,32 @@ export default function OrderGroupsPage() {
const [confirmOrderGroup, setConfirmOrderGroup] = useState<OrderGroup | null>(null);
const [pageError, setPageError] = useState<string | null>(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<any>): Promise<boolean> => {
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 (
<div className="app-container">
<Header choices={data.choices} />
@@ -208,6 +277,40 @@ export default function OrderGroupsPage() {
</div>
<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 && (
<Alert variant="danger" dismissible onClose={() => setPageError(null)} className="mt-2">
{pageError}
@@ -216,7 +319,8 @@ export default function OrderGroupsPage() {
<div className="content-wrapper">
<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">
<h5>Vytvořit skupinu</h5>
{stores.length === 0 ? (
@@ -242,10 +346,13 @@ export default function OrderGroupsPage() {
</div>
)}
</div>
)}
{/* Seznam skupin */}
{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 => {
@@ -274,7 +381,7 @@ export default function OrderGroupsPage() {
<small className="text-muted">zakladatel: {group.creatorLogin}</small>
</div>
<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">
Poplatky
@@ -292,7 +399,7 @@ export default function OrderGroupsPage() {
</Button>
</>
)}
{isCreator && isOrdered && (
{!isReadOnly && isCreator && isOrdered && (
<>
{settings?.bankAccount && settings?.holderName && !group.qrGenerated && (
<Button variant="primary" size="sm" onClick={() => setPayModal(group)}>
@@ -305,7 +412,7 @@ export default function OrderGroupsPage() {
</Button>
</>
)}
{!isMember && !isOrdered && !isLocked && (
{!isReadOnly && !isMember && !isOrdered && !isLocked && (
<Button variant="outline-success" size="sm" onClick={() => handleJoin(group.id)}>
<FontAwesomeIcon icon={faUserPlus} className="me-1" />
Přidat se
@@ -493,7 +600,7 @@ export default function OrderGroupsPage() {
{/* Časy objednání a doručení */}
{isOrdered && (
<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-1">
<small className="text-muted text-nowrap">Objednáno v:</small>
@@ -526,9 +633,9 @@ export default function OrderGroupsPage() {
) : (
<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}
style={{ cursor: !isReadOnly && isCreator ? 'pointer' : undefined }}
onClick={() => !isReadOnly && isCreator && setEditTimes(prev => ({ ...prev, [group.id]: { orderedAt: group.orderedAt ?? '', deliveryAt: group.deliveryAt ?? '' } }))}
title={!isReadOnly && isCreator ? 'Klikněte pro úpravu časů' : undefined}
>
<small className="text-muted">
Objednáno v: <strong>{group.orderedAt ?? '—'}</strong>
@@ -545,12 +652,14 @@ export default function OrderGroupsPage() {
);
})}
{/* Nevyřízené platby přihlášeného uživatele */}
<PendingPayments
pendingQrs={data.pendingQrs}
login={auth.login}
onDismissed={fetchData}
/>
{/* Nevyřízené platby přihlášeného uživatele jen v režimu aktuálního dne */}
{!isReadOnly && (
<PendingPayments
pendingQrs={data.pendingQrs}
login={auth.login}
onDismissed={() => fetchData()}
/>
)}
</div>
</div>
</div>
@@ -584,7 +693,7 @@ export default function OrderGroupsPage() {
<PayForGroupModal
isOpen={!!payModal}
onClose={() => setPayModal(null)}
onSuccess={fetchData}
onSuccess={() => fetchData()}
group={payModal}
groupId={payModal.id}
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í",
"Zobrazení neuhrazených plateb i na stránce objednávek",
"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. */
app.get("/api/data", async (req, res) => {
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);
if (!isNaN(index)) {
date = getDateForWeekIndex(parseInt(req.query.dayIndex));
+2
View File
@@ -40,6 +40,7 @@ export function getEmptyData(date?: Date): ClientData {
return {
todayDayIndex: getDayOfWeekIndex(getToday()),
date: getHumanDate(usedDate),
isoDate: formatDate(usedDate),
isWeekend: getIsWeekend(usedDate),
dayIndex: getDayOfWeekIndex(usedDate),
choices: {},
@@ -583,6 +584,7 @@ export async function getClientData(date?: Date, slot?: MealSlot): Promise<Clien
return {
...clientData,
todayDayIndex: getDayOfWeekIndex(getToday()),
isoDate: formatDate(targetDate),
...(slot === MealSlot.EXTRA ? { slot: MealSlot.EXTRA } : {}),
}
}
+9
View File
@@ -9,6 +9,15 @@ get:
type: integer
minimum: 0
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
name: slot
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:
description: Human-readable datum dne
type: string
isoDate:
description: Datum zobrazeného dne ve formátu YYYY-MM-DD (pro navigaci mezi dny)
type: string
format: date
isWeekend:
description: Příznak, zda je tento den víkend
type: boolean