Files
Luncher/client/src/pages/OrderGroupsPage.tsx
T
mates fe12098ed0
CI / Generate TypeScript types (push) Successful in 11s
CI / Server unit tests (push) Successful in 27s
CI / Build server (push) Successful in 32s
CI / Build client (push) Successful in 42s
CI / Playwright E2E tests (push) Successful in 2m38s
CI / Build and push Docker image (push) Successful in 45s
CI / Notify (push) Successful in 2s
fix: nepřepínání dnů/týdnů při otevřené minihře
2026-10-19 21:09:39 +02:00

1101 lines
72 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, faChevronLeft, faChevronRight, faCircleCheck, faClockRotateLeft, faGear, faLock, faLockOpen, faPen, faSearch, faTruck, faUserPlus } from '@fortawesome/free-solid-svg-icons';
import DatePicker, { registerLocale } from 'react-datepicker';
import { cs } from 'date-fns/locale';
import 'react-datepicker/dist/react-datepicker.css';
import {
ClientData, GroupState, LunchChoice, MealSlot, OrderGroup, OrderGroupMember, PendingQr, TrackingProvider,
StoreMenuDish, getData, createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, setTracking, getOrderDates, getStoreMenu, addChoice,
} from '../../../types';
import MenuHintCell from '../components/MenuHintCell';
import EaterBadge from '../components/EaterBadge';
import { addItemLimitError, addNoteItem, countNoteItems, removeNoteItem } from '../utils/menuMatch';
import { computeFeeShare, computeMemberTotal, countActiveMembers } from '../utils/groupFees';
import { getStoreUrls, isHttpUrl, storeUrlLabel } from '../utils/storeUrls';
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';
import Loader from '../components/Loader';
import StoreAdminModal from '../components/modals/StoreAdminModal';
import PayForGroupModal from '../components/modals/PayForGroupModal';
import EditGroupFeesModal from '../components/modals/EditGroupFeesModal';
import BoltSimulationModal from '../components/modals/BoltSimulationModal';
import GenerateMockOrdersModal from '../components/modals/GenerateMockOrdersModal';
import PendingPayments from '../components/PendingPayments';
import OrderProgress, { PROVIDER_LABEL } from '../components/OrderProgress';
import { isArrowNavigationBlocked } from '../utils/arrowNavigation';
const SLOT = MealSlot.EXTRA;
const TIME_REGEX = /^([01]\d|2[0-3]):[0-5]\d$/;
const IS_DEV = process.env.NODE_ENV === 'development';
/** Předpony sdílecích odkazů jednotlivých služeb (pro sestavení odkazu z uloženého kódu). */
const TRACKING_URL_PREFIX: Record<TrackingProvider, string> = {
bolt: 'https://food.bolt.eu/sharedActiveOrder/',
wolt: 'https://track.wolt.com/',
};
/** Tvar kódu sledování u jednotlivých služeb — musí odpovídat serveru (trackingProviders.ts). */
const TRACKING_CODE_REGEX: Record<TrackingProvider, RegExp> = {
bolt: /^[0-9a-f]{64}$/i,
wolt: /^[A-Za-z0-9_-]{20,32}$/,
};
const TRACKING_HOST: Record<TrackingProvider, string> = {
bolt: 'bolt.eu',
wolt: 'wolt.com',
};
/** Sestaví odkaz na sledování objednávky ze služby a kódu. */
function trackingUrlFor(provider: TrackingProvider, code: string): string {
return `${TRACKING_URL_PREFIX[provider] ?? ''}${code}`;
}
/**
* Rozpozná rozvozovou službu podle odkazu na sledování (nebo podle samotného kódu)
* a vytáhne z něj kód sledování. Null = vstup nepatří žádné podporované službě.
*/
function extractTracking(input: string): { provider: TrackingProvider; code: string } | null {
const trimmed = input.trim();
if (!trimmed) return null;
let hostname = '';
let lastSegment = '';
try {
const url = new URL(trimmed);
hostname = url.hostname.toLowerCase();
const segments = url.pathname.split('/').filter(Boolean);
lastSegment = segments[segments.length - 1] ?? '';
} catch {
// Vstup není URL — zkusíme ho vzít jako samotný kód
}
for (const provider of Object.values(TrackingProvider)) {
const regex = TRACKING_CODE_REGEX[provider];
if (!hostname && regex.test(trimmed)) return { provider, code: trimmed };
const host = TRACKING_HOST[provider];
const hostMatches = hostname === host || hostname.endsWith(`.${host}`);
if (hostMatches && regex.test(lastSegment)) return { provider, code: lastSegment };
}
return null;
}
/** Zkrátí dlouhý odkaz pro zobrazení v řádku (zachová začátek i konec). */
function shortenUrl(url: string, max = 48): string {
if (url.length <= max) return url;
const head = Math.ceil((max - 1) / 2);
const tail = Math.floor((max - 1) / 2);
return `${url.slice(0, head)}…${url.slice(url.length - tail)}`;
}
// Český lokál pro date picker (názvy měsíců/dnů, pondělí jako první den)
registerLocale('cs', cs);
/** 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);
}
/** Převede ISO datum (YYYY-MM-DD) na lokální Date (půlnoc), nebo null. */
function isoToDate(iso?: string): Date | null {
return iso ? new Date(`${iso}T00:00:00`) : null;
}
/** Stavy, ve kterých je objednávka již odeslána do podniku (objednáno/doručeno) — skupina se nesmí upravovat. */
function isSubmittedState(state: GroupState): boolean {
return state === GroupState.ORDERED || state === GroupState.DELIVERED;
}
function stateBadge(state: GroupState) {
const map: Record<GroupState, { bg: string; label: string }> = {
[GroupState.OPEN]: { bg: 'success', label: 'Otevřeno' },
[GroupState.LOCKED]: { bg: 'warning', label: 'Uzamčeno' },
[GroupState.ORDERED]: { bg: 'secondary', label: 'Objednáno' },
[GroupState.DELIVERED]: { bg: 'info', label: 'Doručeno' },
};
const { bg, label } = map[state] ?? { bg: 'light', label: state };
return <Badge bg={bg}>{label}</Badge>;
}
export default function OrderGroupsPage() {
const auth = useAuth();
const settings = useSettings();
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);
// ISO data dnů, ve kterých existuje aspoň jedna objednávka (pro zvýraznění v date pickeru)
const [orderDates, setOrderDates] = useState<string[]>([]);
const [newGroupName, setNewGroupName] = useState('');
// Vybraná URL na nabídku — podnik může být dostupný přes více dovozových služeb
const [newGroupUrl, setNewGroupUrl] = useState('');
const [creating, setCreating] = useState(false);
const [adminModalOpen, setAdminModalOpen] = useState(false);
const [editAmounts, setEditAmounts] = useState<Record<string, string>>({});
const [editNotes, setEditNotes] = useState<Record<string, string>>({});
const [editSurcharges, setEditSurcharges] = useState<Record<string, { text: string; amount: string }>>({});
const [editTimes, setEditTimes] = useState<Record<string, { orderedAt: string; deliveryAt: string; trackingUrl: string }>>({});
const [payModal, setPayModal] = useState<OrderGroup | null>(null);
const [feesModal, setFeesModal] = useState<OrderGroup | null>(null);
const [boltSimModal, setBoltSimModal] = useState<OrderGroup | null>(null);
const [mockOrdersModalOpen, setMockOrdersModalOpen] = useState(false);
const [confirmOrderGroup, setConfirmOrderGroup] = useState<OrderGroup | null>(null);
const [pageError, setPageError] = useState<string | null>(null);
// Stažené nabídky podniků (dle názvu obchodu) pro sloupec „Z nabídky"
const [storeMenus, setStoreMenus] = useState<Record<string, StoreMenuDish[] | null>>({});
const fetchData = async (date?: string) => {
try {
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);
}
};
// Načte dny s objednávkou pro zvýraznění v date pickeru
const fetchOrderDates = async () => {
const r = await getOrderDates();
if (r.data?.dates) setOrderDates(r.data.dates);
};
useEffect(() => {
selectedDateRef.current = selectedDate;
}, [selectedDate]);
useEffect(() => {
if (!auth?.login) return;
fetchData(selectedDate);
}, [auth?.login, selectedDate]);
useEffect(() => {
if (!auth?.login) return;
fetchOrderDates();
}, [auth?.login]);
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,
}));
});
// 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]);
// Připojení do osobní socket místnosti po přihlášení – bez toho nechodí události
// o nových nevyřízených platbách (QR kódy se posílají do místnosti user:<login>)
useEffect(() => {
if (auth?.login) {
socket.emit('join', auth.login);
}
}, [auth?.login, socket]);
useEffect(() => {
// Po znovupřipojení socketu znovu vstoupíme do osobní místnosti a načteme aktuálně
// zobrazený den (mohli jsme přijít o živé aktualizace)
const onReconnect = () => {
if (auth?.login) socket.emit('join', auth.login);
fetchData(selectedDateRef.current);
};
socket.io.on('reconnect', onReconnect);
return () => { socket.io.off('reconnect', onReconnect); };
}, [socket, auth?.login]);
// 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 nebo je otevřený dialog (např. minihra)
if (isArrowNavigationBlocked(e)) 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();
if (result?.error) {
setPageError((result.error as any).error || 'Nastala chyba');
await fetchData();
return false;
}
if (result?.data) {
setData(result.data);
socket.emit?.('message', result.data as ClientData);
}
// Sada dnů s objednávkou se mohla změnit (vytvoření/smazání skupiny)
fetchOrderDates();
return true;
};
/**
* Pokud to má uživatel zapnuté v nastavení, přepne jeho dnešní volbu stravování na "Budu objednávat".
* Volbu nemění, pokud již objednává, nebo je přihlášen na Pizza day (změnou by přišel o objednané pizzy).
*/
const switchChoiceToOrdering = async () => {
if (!settings?.autoOrderChoice || !auth?.login) return;
const lunch = await getData({ query: { slot: MealSlot.OBED } });
const choices = lunch.data?.choices;
if (!choices) return;
if (auth.login in (choices[LunchChoice.OBJEDNAVAM] ?? {}) || auth.login in (choices[LunchChoice.PIZZA] ?? {})) return;
const result = await addChoice({ body: { locationKey: LunchChoice.OBJEDNAVAM, slot: MealSlot.OBED } });
if (result.error) {
setPageError(`Nepodařilo se přepnout volbu stravování: ${(result.error as any).error || 'Nastala chyba'}`);
}
};
const handleCreate = async () => {
if (!newGroupName || !auth?.login) return;
setCreating(true);
// Pojistka, kdyby se seznam obchodů mezitím změnil (socket) — vezmeme první URL podniku
const urls = getStoreUrls((data?.stores ?? []).find(s => s.name === newGroupName));
const url = urls.includes(newGroupUrl) ? newGroupUrl : urls[0];
const ok = await refresh(() => createGroup({ body: { name: newGroupName, url } }));
if (ok) {
setNewGroupName('');
setNewGroupUrl('');
await switchChoiceToOrdering();
}
setCreating(false);
};
const handleJoin = async (groupId: string) => {
if (await refresh(() => addGroupMember({ body: { id: groupId } }))) {
await switchChoiceToOrdering();
}
};
const handleToggleLock = (group: OrderGroup) => {
const next = group.state === GroupState.OPEN ? GroupState.LOCKED : GroupState.OPEN;
return refresh(() => setGroupState({ body: { id: group.id, state: next } }));
};
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 handleSetDelivered = (group: OrderGroup) =>
refresh(() => setGroupState({ body: { id: group.id, state: GroupState.DELIVERED } }));
const handleRevertDelivered = (group: OrderGroup) =>
refresh(() => setGroupState({ body: { id: group.id, state: GroupState.ORDERED } }));
const handleDelete = (groupId: string) =>
refresh(() => deleteGroup({ body: { id: groupId } }));
const handleSaveAmount = async (groupId: string, login: string) => {
const key = `${groupId}:${login}`;
const raw = editAmounts[key];
const n = parseFloat(raw ?? '');
if (!raw || isNaN(n) || n < 0) {
setPageError('Zadejte platnou kladnou částku');
return;
}
const ok = await refresh(() => updateGroupMember({ body: { id: groupId, login, amount: Math.round(n * 100) } }));
if (ok) setEditAmounts(prev => { const next = { ...prev }; delete next[key]; return next; });
};
// Nabídky podniků zobrazených skupin — jen pro obchody ze seznamu, každou jednou
const groupStoreNames = (data?.groups ?? [])
.map(g => (data?.stores ?? []).find(s => s.name.toLowerCase() === g.name.toLowerCase())?.name)
.filter((n): n is string => !!n);
const groupStoreKey = [...new Set(groupStoreNames)].sort().join('|');
useEffect(() => {
for (const name of groupStoreKey ? groupStoreKey.split('|') : []) {
if (name in storeMenus) continue;
setStoreMenus(prev => ({ ...prev, [name]: null }));
getStoreMenu({ query: { name } })
.then(r => setStoreMenus(prev => ({ ...prev, [name]: r.data?.dishes ?? [] })))
.catch(() => setStoreMenus(prev => ({ ...prev, [name]: [] })));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [groupStoreKey]);
const getGroupMenu = (group: OrderGroup): StoreMenuDish[] | undefined =>
Object.entries(storeMenus).find(([name]) => name.toLowerCase() === group.name.toLowerCase())?.[1] ?? undefined;
/** Výběr položky z nabídky — připojí ji k poznámce (opakovaný výběr zvýší počet „2x") a cenu přičte k částce. */
const handlePickDish = async (group: OrderGroup, login: string, member: OrderGroupMember, dish: StoreMenuDish) => {
const limitError = addItemLimitError(member.note, dish.name, getGroupMenu(group) ?? []);
if (limitError) {
setPageError(limitError);
return false;
}
const note = addNoteItem(member.note, dish.name, getGroupMenu(group) ?? []);
const amount = dish.price != null ? (member.amount ?? 0) + Math.round(dish.price * 100) : member.amount;
return refresh(() => updateGroupMember({ body: { id: group.id, login, note, ...(amount != null && { amount }) } }));
};
/** Odebere jeden kus položky z poznámky a odečte její cenu z částky (nikdy pod nulu). */
const handleRemoveDish = (group: OrderGroup, login: string, member: OrderGroupMember, index: number) => {
const { note, removedPrice } = removeNoteItem(member.note, index, getGroupMenu(group) ?? []);
const amount = removedPrice != null && member.amount != null
? Math.max(0, member.amount - Math.round(removedPrice * 100))
: member.amount;
return refresh(() => updateGroupMember({ body: { id: group.id, login, note, ...(amount != null && { amount }) } }));
};
const handleUseMenuPrice = (group: OrderGroup, login: string, priceKc: number) =>
refresh(() => updateGroupMember({ body: { id: group.id, login, amount: Math.round(priceKc * 100) } }));
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 handleSaveSurcharge = async (groupId: string, login: string) => {
const key = `${groupId}:${login}`;
const surchargeText = editSurcharges[key]?.text ?? '';
const rawAmount = editSurcharges[key]?.amount ?? '';
const surchargeAmount = rawAmount === '' ? 0 : parseFloat(rawAmount.replace(',', '.'));
if (rawAmount !== '' && (isNaN(surchargeAmount) || surchargeAmount < 0)) {
setPageError('Zadejte platnou výši příplatku');
return;
}
const ok = await refresh(() => updateGroupMember({ body: { id: groupId, login, surchargeText, surchargeAmount: rawAmount === '' ? 0 : Math.round(surchargeAmount * 100) } }));
if (ok) setEditSurcharges(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, trackingUrl } = 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;
}
// Odkaz na sledování se odesílá jen při změně oproti aktuálnímu kódu skupiny
const tracking = trackingUrl.trim() ? extractTracking(trackingUrl) : null;
if (trackingUrl.trim() && !tracking) {
setPageError('Neplatný odkaz pro sledování (očekáván odkaz Bolt Food nebo Wolt)');
return;
}
const trackingChanged = tracking?.code !== group.trackingCode || tracking?.provider !== group.trackingProvider;
let ok = await refresh(() => updateGroupTimes({ body: { id: group.id, orderedAt, deliveryAt } }));
if (ok && trackingChanged) {
ok = await refresh(() => setTracking({ body: { id: group.id, shareUrl: trackingUrl.trim() } }));
}
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 (isSubmittedState(group.state)) return false;
if (auth?.login === group.creatorLogin) return true;
if (auth?.login === targetLogin && group.state === GroupState.OPEN) return true;
return false;
};
const canManageMembers = (group: OrderGroup) => {
if (selectedDate) return false;
if (isSubmittedState(group.state)) return false;
if (auth?.login === group.creatorLogin) return true;
return group.state === GroupState.OPEN;
};
if (!auth?.login) return <Login />;
if (failure) return (
<Loader icon={faSearch} description="Nepodařilo se načíst data" animation="fa-beat" />
);
if (!data) return (
<Loader icon={faSearch} description="Načítám..." animation="fa-bounce" />
);
const stores = data.stores ?? [];
const groups = data.groups ?? [];
// URL na nabídku vybíraného podniku — výběr nabízíme, jen když jich je víc
const newStoreUrls = getStoreUrls(stores.find(s => s.name === newGroupName));
// 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);
};
// Dny s objednávkou jako Date objekty pro zvýraznění v kalendáři
const highlightedOrderDates = orderDates
.map(d => isoToDate(d))
.filter((d): d is Date => d != null);
return (
<div className="app-container">
<Header choices={data.choices} />
<div className="wrapper">
<div className="d-flex align-items-center justify-content-between mb-1">
<h1 className="title mb-0">Objednání</h1>
<div className="d-flex gap-2">
{IS_DEV && !isReadOnly && (
<Button variant="outline-warning" size="sm" onClick={() => setMockOrdersModalOpen(true)} title="Generovat mock objednávky (DEV)">
🔧 Mock objednávky
</Button>
)}
<Button variant="outline-primary" size="sm" onClick={() => setAdminModalOpen(true)} title="Správa obchodů">
<FontAwesomeIcon icon={faGear} className="me-1" />
Obchody
</Button>
</div>
</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>
<DatePicker
selected={isoToDate(displayedIso)}
onChange={(d: Date | null) => handleDatePick(d ? formatDate(d) : '')}
maxDate={isoToDate(todayIso) ?? undefined}
highlightDates={[{ 'luncher-order-day': highlightedOrderDates }]}
locale="cs"
dateFormat="d. M. yyyy"
calendarStartDay={1}
popperPlacement="bottom"
className={`form-control text-center fw-semibold order-date-input ${isReadOnly ? 'text-muted' : ''}`}
/>
<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}
</Alert>
)}
<div className="content-wrapper">
<div className="content" style={{ maxWidth: 1200 }}>
{/* 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 ? (
<p className="text-muted">
Nejsou přidány žádné obchody.{' '}
<Button variant="link" size="sm" className="p-0" onClick={() => setAdminModalOpen(true)}>
Přidat obchod
</Button>
</p>
) : (
<div className="d-flex gap-2 align-items-center flex-wrap">
<Form.Select
value={newGroupName}
onChange={e => {
const name = e.target.value;
setNewGroupName(name);
// Výběr nabídky se resetuje na první URL nově vybraného podniku
setNewGroupUrl(getStoreUrls(stores.find(s => s.name === name))[0] ?? '');
}}
style={{ maxWidth: 260 }}
>
<option value="">— vyberte obchod —</option>
{stores.map(s => <option key={s.name} value={s.name}>{s.name}</option>)}
</Form.Select>
{newStoreUrls.length > 1 && (
<Form.Select
value={newStoreUrls.includes(newGroupUrl) ? newGroupUrl : newStoreUrls[0]}
onChange={e => setNewGroupUrl(e.target.value)}
style={{ maxWidth: 220 }}
title="Odkud se bude objednávat"
>
{newStoreUrls.map(url => (
<option key={url} value={url}>{storeUrlLabel(url)}</option>
))}
</Form.Select>
)}
<Button variant="primary" onClick={handleCreate} disabled={creating || !newGroupName}>
Vytvořit skupinu
</Button>
</div>
)}
</div>
)}
{/* Seznam skupin */}
{groups.length === 0 && (
<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 => {
const login = auth!.login ?? '';
const isCreator = login === group.creatorLogin;
const isMember = login in group.members;
const isOrdered = group.state === GroupState.ORDERED;
const isDelivered = group.state === GroupState.DELIVERED;
// Objednáno i Doručeno = objednávka je odeslána do podniku, skupina se už needituje
const isSubmitted = isOrdered || isDelivered;
const isLocked = group.state === GroupState.LOCKED;
const memberEntries = Object.entries(group.members) as [string, OrderGroupMember][];
const editingTimes = group.id in editTimes;
// URL na nabídku podniku — přednostně ta, kterou zakladatel vybral při vytvoření
// skupiny. Skupiny z doby před výběrem URL ji nemají, pak zkusíme jedinou URL obchodu.
// Povolíme jen http(s), aby odkaz nemohl být zneužit (např. javascript:).
const groupStoreUrls = getStoreUrls(stores.find(s => s.name === group.name));
const storeUrl = isHttpUrl(group.url)
? group.url
: (groupStoreUrls.length === 1 ? groupStoreUrls[0] : undefined);
const totalFees = (group.fees ?? 0) + (group.shipping ?? 0) + (group.tip ?? 0);
// Poplatky se dělí jen mezi aktivní strávníky (kdo si reálně něco objednal).
const activeCount = countActiveMembers(group.members);
const feeShare = computeFeeShare(totalFees, activeCount);
const feeParams = { totalFees, discountType: group.discountType, discountValue: group.discountValue ?? 0 };
const getMemberTotal = (m: OrderGroupMember) =>
computeMemberTotal(m, feeParams, feeShare, activeCount);
return (
<Card key={group.id} className="mb-3 fade-in">
<Card.Header className="d-flex justify-content-between align-items-center">
<div className="d-flex align-items-center gap-2">
{storeUrl ? (
<strong>
<a href={storeUrl} target="_blank" rel="noopener noreferrer" title="Otevřít nabídku v nové záložce">
{group.name}
</a>
</strong>
) : (
<strong>{group.name}</strong>
)}
{stateBadge(group.state)}
<small className="text-muted">zakladatel: {group.creatorLogin}</small>
</div>
<div className="d-flex gap-2">
{!isReadOnly && isCreator && !isSubmitted && (
<>
<Button variant="outline-info" size="sm" onClick={() => setFeesModal(group)} title="Upravit poplatky a slevu">
Poplatky
</Button>
<Button variant="outline-secondary" size="sm" onClick={() => handleToggleLock(group)} title={isLocked ? 'Odemknout' : 'Uzamknout'}>
<FontAwesomeIcon icon={isLocked ? faLockOpen : faLock} />
</Button>
{isLocked && (
<Button variant="outline-primary" size="sm" onClick={() => setConfirmOrderGroup(group)}>
Objednáno
</Button>
)}
<Button variant="outline-danger" size="sm" onClick={() => handleDelete(group.id)} title="Smazat skupinu">
<FontAwesomeIcon icon={faTrashCan} />
</Button>
</>
)}
{!isReadOnly && isCreator && isSubmitted && (
<>
{isOrdered && (
<Button variant="outline-success" size="sm" onClick={() => handleSetDelivered(group)} title="Označit objednávku jako doručenou">
<FontAwesomeIcon icon={faTruck} className="me-1" />
Doručeno
</Button>
)}
{settings?.bankAccount && settings?.holderName && !group.qrGenerated && (
<Button variant="primary" size="sm" onClick={() => setPayModal(group)}>
<FontAwesomeIcon icon={faBasketShopping} className="me-1" />
Generovat QR
</Button>
)}
{isDelivered ? (
<Button variant="outline-warning" size="sm" onClick={() => handleRevertDelivered(group)} title="Vrátit na Objednáno">
<FontAwesomeIcon icon={faClockRotateLeft} />
</Button>
) : (
<Button variant="outline-warning" size="sm" onClick={() => handleRevertOrdered(group)} title="Vrátit na Uzamčeno (smaže QR kódy)">
<FontAwesomeIcon icon={faLockOpen} />
</Button>
)}
</>
)}
{!isReadOnly && !isMember && !isSubmitted && !isLocked && (
<Button variant="outline-success" size="sm" onClick={() => handleJoin(group.id)}>
<FontAwesomeIcon icon={faUserPlus} className="me-1" />
Přidat se
</Button>
)}
</div>
</Card.Header>
<Card.Body className="p-0">
<Table className="mb-0" size="sm">
<thead>
<tr>
<th>Člen</th>
<th style={{ width: 180 }}>Částka (bez slev)</th>
<th style={{ width: 220 }}>Příplatek</th>
<th>Poznámka</th>
{getGroupMenu(group)?.length ? <th style={{ width: 240 }}>Z nabídky</th> : null}
<th style={{ width: 160 }}>Celkem (s poplatky)</th>
<th style={{ width: 40 }}></th>
</tr>
</thead>
<tbody>
{memberEntries.map(([memberLogin, member]) => {
const key = `${group.id}:${memberLogin}`;
const editingAmount = key in editAmounts;
const editingNote = key in editNotes;
const editingSurcharge = key in editSurcharges;
const canEdit = canEditMember(group, memberLogin);
const memberTotal = getMemberTotal(member);
return (
<tr key={memberLogin}>
<td>
<span className="user-info">
<EaterBadge items={countNoteItems(member.note, getGroupMenu(group) ?? [])} />
<strong>{memberLogin}</strong>
{memberLogin === group.creatorLogin && (
<OverlayTrigger placement="top" overlay={<Tooltip>Zakladatel / objednávající</Tooltip>}>
<span className="ms-1"><FontAwesomeIcon icon={faBasketShopping} className="buyer-icon" /></span>
</OverlayTrigger>
)}
{member.paid && (
<OverlayTrigger placement="top" overlay={<Tooltip>Zaplaceno</Tooltip>}>
<span className="ms-1"><FontAwesomeIcon icon={faCircleCheck} className="text-success" /></span>
</OverlayTrigger>
)}
</span>
</td>
<td>
{canEdit && editingAmount ? (
<div className="d-flex gap-1">
<Form.Control
type="number"
size="sm"
value={editAmounts[key]}
onChange={e => setEditAmounts(prev => ({ ...prev, [key]: e.target.value }))}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveAmount(group.id, memberLogin); if (e.key === 'Escape') setEditAmounts(prev => { const n = { ...prev }; delete n[key]; return n; }); }}
style={{ width: 95 }}
autoFocus
/>
<Button size="sm" variant="outline-success" onClick={() => handleSaveAmount(group.id, memberLogin)}>✓</Button>
</div>
) : (
<span
style={{ cursor: canEdit ? 'pointer' : undefined }}
onClick={() => canEdit && setEditAmounts(prev => ({ ...prev, [key]: member.amount != null ? String(member.amount / 100) : '' }))}
title={canEdit ? 'Klikněte pro úpravu' : undefined}
>
{member.amount != null ? `${member.amount / 100} Kč` : <span className="text-muted">—</span>}
</span>
)}
</td>
<td>
{canEdit && editingSurcharge ? (
<div className="d-flex gap-1">
<Form.Control
type="text"
size="sm"
placeholder="popis"
value={editSurcharges[key]?.text ?? ''}
onChange={e => setEditSurcharges(prev => ({ ...prev, [key]: { ...prev[key], text: e.target.value } }))}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveSurcharge(group.id, memberLogin); if (e.key === 'Escape') setEditSurcharges(prev => { const n = { ...prev }; delete n[key]; return n; }); }}
style={{ width: 80 }}
autoFocus
/>
<Form.Control
type="number"
size="sm"
placeholder="Kč"
value={editSurcharges[key]?.amount ?? ''}
onChange={e => setEditSurcharges(prev => ({ ...prev, [key]: { ...prev[key], amount: e.target.value } }))}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveSurcharge(group.id, memberLogin); if (e.key === 'Escape') setEditSurcharges(prev => { const n = { ...prev }; delete n[key]; return n; }); }}
style={{ width: 60 }}
/>
<Button size="sm" variant="outline-success" onClick={() => handleSaveSurcharge(group.id, memberLogin)}>✓</Button>
</div>
) : (
<span
style={{ cursor: canEdit ? 'pointer' : undefined }}
onClick={() => canEdit && setEditSurcharges(prev => ({ ...prev, [key]: { text: member.surchargeText ?? '', amount: member.surchargeAmount != null ? String(member.surchargeAmount / 100) : '' } }))}
title={canEdit ? 'Klikněte pro úpravu příplatku' : undefined}
>
{member.surchargeAmount != null && member.surchargeAmount > 0 ? (
<small>{member.surchargeText ? `${member.surchargeText}: ` : ''}<strong>{member.surchargeAmount / 100} Kč</strong></small>
) : (
<small className="text-muted">—</small>
)}
</span>
)}
</td>
<td>
{canEdit && editingNote ? (
<div className="d-flex gap-1">
<Form.Control
type="text"
size="sm"
value={editNotes[key]}
onChange={e => setEditNotes(prev => ({ ...prev, [key]: e.target.value }))}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveNote(group.id, memberLogin); if (e.key === 'Escape') setEditNotes(prev => { const n = { ...prev }; delete n[key]; return n; }); }}
autoFocus
/>
<Button size="sm" variant="outline-success" onClick={() => handleSaveNote(group.id, memberLogin)}>✓</Button>
</div>
) : (
<span
style={{ cursor: canEdit ? 'pointer' : undefined }}
onClick={() => canEdit && setEditNotes(prev => ({ ...prev, [key]: member.note ?? '' }))}
title={canEdit ? 'Klikněte pro úpravu poznámky' : undefined}
>
<small className="text-muted">{member.note || '—'}</small>
</span>
)}
</td>
{getGroupMenu(group)?.length ? (
<td>
<MenuHintCell
menu={getGroupMenu(group)}
note={member.note}
amount={member.amount}
canEdit={canEdit}
onPick={dish => handlePickDish(group, memberLogin, member, dish)}
onUsePrice={price => handleUseMenuPrice(group, memberLogin, price)}
onRemoveItem={index => handleRemoveDish(group, memberLogin, member, index)}
/>
</td>
) : null}
<td className="text-end">
<small className={memberTotal > 0 ? 'fw-bold' : 'text-muted'}>
{memberTotal > 0 ? `${memberTotal / 100} Kč` : '—'}
</small>
</td>
<td className="align-middle">
<div className="d-flex gap-1 justify-content-end align-items-center">
{canManageMembers(group) && (isCreator || memberLogin === login) && (memberLogin !== group.creatorLogin) && (
<FontAwesomeIcon
icon={faTrashCan}
className="action-icon"
title={memberLogin === login ? 'Odhlásit se' : 'Odebrat z skupiny'}
onClick={() => refresh(() => removeGroupMember({ body: { id: group.id, login: memberLogin } }))}
/>
)}
</div>
</td>
</tr>
);
})}
</tbody>
{(() => {
const sumBase = memberEntries.reduce((sum, [, m]) => sum + (m.amount ?? 0) + (m.surchargeAmount ?? 0), 0);
const dv = group.discountValue ?? 0;
const totalDiscount = dv > 0
? (group.discountType === 'percent' ? Math.round(sumBase * dv / 100) : dv)
: 0;
const groupTotal = sumBase + totalFees - totalDiscount;
return groupTotal > 0 ? (
<tfoot>
<tr style={{ fontWeight: 700, borderTop: '2px solid var(--luncher-border)' }}>
<td colSpan={getGroupMenu(group)?.length ? 5 : 4} className="text-end" style={{ fontSize: '0.9em' }}>Celkem za skupinu:</td>
<td className="text-end">{groupTotal / 100} Kč</td>
<td></td>
</tr>
</tfoot>
) : null;
})()}
</Table>
{/* Souhrn poplatků a slevy */}
{(totalFees > 0 || (group.discountValue != null && group.discountValue > 0)) && (
<div className="px-3 py-2 border-top d-flex gap-3 flex-wrap" style={{ fontSize: '0.85em', color: 'var(--luncher-text-muted)' }}>
{group.fees != null && group.fees > 0 && <span>Poplatky: <strong>{group.fees / 100} Kč</strong></span>}
{group.shipping != null && group.shipping > 0 && <span>Doprava: <strong>{group.shipping / 100} Kč</strong></span>}
{group.tip != null && group.tip > 0 && <span>Spropitné: <strong>{group.tip / 100} Kč</strong></span>}
{feeShare > 0 && <span>→ <strong>{feeShare / 100} Kč</strong>/os.</span>}
{group.discountValue != null && group.discountValue > 0 && (
<span className="text-success">
Sleva: <strong>{group.discountType === 'percent' ? `${group.discountValue}%` : `${group.discountValue / 100} Kč`}</strong>
</span>
)}
</div>
)}
{/* Časy objednání a doručení */}
{isSubmitted && (
<div className="px-3 py-2 border-top">
{!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>
<Form.Control
type="text"
size="sm"
placeholder="HH:MM"
value={editTimes[group.id]?.orderedAt ?? ''}
onChange={e => 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
/>
</div>
<div className="d-flex align-items-center gap-1">
<small className="text-muted text-nowrap">Doručení v:</small>
<Form.Control
type="text"
size="sm"
placeholder="HH:MM"
value={editTimes[group.id]?.deliveryAt ?? ''}
onChange={e => 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 }}
/>
</div>
<div className="d-flex align-items-center gap-1">
<small className="text-muted text-nowrap">Odkaz pro sledování (Bolt Food / Wolt):</small>
<Form.Control
type="text"
size="sm"
placeholder={`${TRACKING_URL_PREFIX.wolt}…`}
value={editTimes[group.id]?.trackingUrl ?? ''}
onChange={e => setEditTimes(prev => ({ ...prev, [group.id]: { ...prev[group.id], trackingUrl: e.target.value } }))}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveTimes(group); }}
style={{ width: 260 }}
/>
</div>
<Button size="sm" variant="outline-success" onClick={() => handleSaveTimes(group)}>Uložit</Button>
<Button size="sm" variant="outline-secondary" onClick={() => setEditTimes(prev => { const n = { ...prev }; delete n[group.id]; return n; })}>Zrušit</Button>
</div>
) : (
<div className="d-flex align-items-center gap-3 flex-wrap">
{(() => {
const canEdit = !isReadOnly && isCreator;
// Aktivace editačního režimu – stejné chování jako tlačítko s tužkou
const trackingUrl = group.trackingProvider && group.trackingCode
? trackingUrlFor(group.trackingProvider, group.trackingCode)
: null;
const startEdit = () => canEdit && setEditTimes(prev => ({ ...prev, [group.id]: { orderedAt: group.orderedAt ?? '', deliveryAt: group.deliveryAt ?? '', trackingUrl: trackingUrl ?? '' } }));
return (
<>
{canEdit && (
<FontAwesomeIcon
icon={faPen}
className="action-icon ms-0"
title="Upravit časy a odkaz pro sledování"
onClick={startEdit}
/>
)}
<small
className="text-muted"
style={{ cursor: canEdit ? 'pointer' : undefined }}
onClick={startEdit}
>
Objednáno v: <strong>{group.orderedAt ?? '—'}</strong>
</small>
<small
className="text-muted"
style={{ cursor: canEdit ? 'pointer' : undefined }}
onClick={startEdit}
>
Doručení v: <strong>{group.deliveryAt ?? '—'}</strong>
{group.trackingCode && group.trackingProvider && (
<OverlayTrigger overlay={<Tooltip>Čas doručení se aktualizuje automaticky z {PROVIDER_LABEL[group.trackingProvider]}</Tooltip>}>
<Badge bg="success" className="ms-1">{PROVIDER_LABEL[group.trackingProvider]}</Badge>
</OverlayTrigger>
)}
</small>
<small className="text-muted text-nowrap">
URL pro sledování:{' '}
{trackingUrl ? (
<a
href={trackingUrl}
target="_blank"
rel="noopener noreferrer"
title={trackingUrl}
onClick={e => e.stopPropagation()}
>
{shortenUrl(trackingUrl)}
</a>
) : (
<strong>—</strong>
)}
</small>
</>
);
})()}
</div>
)}
{group.trackingOrderState && group.trackingProvider && (
<div className="mt-2">
<OrderProgress
provider={group.trackingProvider}
state={group.trackingOrderState}
courierState={group.trackingCourierState}
tracking={!!group.trackingCode}
/>
</div>
)}
{IS_DEV && (
<div className="mt-2">
<Button variant="outline-warning" size="sm" onClick={() => setBoltSimModal(group)} title="Simulovat sledování Bolt (DEV)">
🔧 Simulace Bolt
</Button>
</div>
)}
</div>
)}
</Card.Body>
</Card>
);
})}
{/* 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>
<Footer />
{/* Potvrzovací dialog pro přechod do stavu Objednáno */}
<Modal show={!!confirmOrderGroup} onHide={() => setConfirmOrderGroup(null)} centered>
<Modal.Header closeButton>
<Modal.Title>Potvrdit objednání</Modal.Title>
</Modal.Header>
<Modal.Body>
Opravdu chcete označit skupinu <strong>{confirmOrderGroup?.name}</strong> jako objednanou?
Tato akce uzavře skupinu a zaznamená čas objednání.
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={() => setConfirmOrderGroup(null)}>Zrušit</Button>
<Button variant="primary" onClick={() => confirmOrderGroup && handleConfirmOrdered(confirmOrderGroup)}>
Objednáno
</Button>
</Modal.Footer>
</Modal>
<StoreAdminModal
isOpen={adminModalOpen}
onClose={() => setAdminModalOpen(false)}
stores={stores}
onStoresChanged={updated => setData(prev => prev ? { ...prev, stores: updated } : prev)}
/>
{payModal && settings?.bankAccount && settings?.holderName && (
<PayForGroupModal
isOpen={!!payModal}
onClose={() => setPayModal(null)}
onSuccess={() => fetchData()}
group={payModal}
groupId={payModal.id}
payerLogin={auth.login}
bankAccount={settings.bankAccount}
bankAccountHolder={settings.holderName}
/>
)}
{feesModal && (
<EditGroupFeesModal
isOpen={!!feesModal}
onClose={() => setFeesModal(null)}
group={feesModal}
onSaved={newData => {
if (newData) {
setData(newData);
socket.emit?.('message', newData as ClientData);
}
setFeesModal(null);
}}
/>
)}
{IS_DEV && (
<GenerateMockOrdersModal
isOpen={mockOrdersModalOpen}
onClose={() => {
setMockOrdersModalOpen(false);
// Data přijdou i websocketem, po založení skupiny se ale mění i dny s objednávkou
fetchData();
fetchOrderDates();
}}
hasGroups={groups.length > 0}
hasStores={stores.length > 0}
/>
)}
{IS_DEV && boltSimModal && (
<BoltSimulationModal
isOpen={!!boltSimModal}
onClose={() => setBoltSimModal(null)}
// živá skupina z dat (aktualizuje se přes websocket), fallback na snapshot
group={groups.find(g => g.id === boltSimModal.id) ?? boltSimModal}
/>
)}
</div>
);
}