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
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:
@@ -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
|
||||
// ============================================
|
||||
|
||||
@@ -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,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
@@ -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));
|
||||
|
||||
@@ -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,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).
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user