CI / Generate TypeScript types (push) Successful in 13s
CI / Server unit tests (push) Successful in 25s
CI / Build server (push) Successful in 28s
CI / Build client (push) Successful in 41s
CI / Playwright E2E tests (push) Successful in 1m47s
CI / Build and push Docker image (push) Successful in 53s
CI / Notify (push) Successful in 2s
Vedle Bolt Food jde nově sledovat i objednávka z Woltu — stačí u objednané skupiny vložit odkaz z track.wolt.com. Logika sledování je zobecněná do registru rozvozových služeb (trackingProviders.ts): každá služba umí vytáhnout kód ze sdílecího odkazu a dotázat se svého API, scheduler i stepper jsou společné. - pole skupiny bolt* přejmenována na tracking* + nové trackingProvider - endpoint /groups/setBoltTracking → /groups/setTracking - Wolt: čas doručení z delivery_eta v časové zóně objednávky, 404 ukončí sledování, stav kurýra odvozen z is_delivering/is_delivering_other_order - DEV simulace zůstává jen pro Bolt Food
937 lines
61 KiB
TypeScript
937 lines
61 KiB
TypeScript
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, 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, MealSlot, OrderGroup, OrderGroupMember, PendingQr, TrackingProvider,
|
||
getData, createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, setTracking, getOrderDates,
|
||
} from '../../../types';
|
||
import { computeFeeShare, computeMemberTotal, countActiveMembers } from '../utils/groupFees';
|
||
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 PendingPayments from '../components/PendingPayments';
|
||
import OrderProgress, { PROVIDER_LABEL } from '../components/OrderProgress';
|
||
|
||
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;
|
||
}
|
||
|
||
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' },
|
||
};
|
||
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('');
|
||
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 [confirmOrderGroup, setConfirmOrderGroup] = useState<OrderGroup | null>(null);
|
||
const [pageError, setPageError] = useState<string | null>(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
|
||
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();
|
||
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;
|
||
};
|
||
|
||
const handleCreate = async () => {
|
||
if (!newGroupName || !auth?.login) return;
|
||
setCreating(true);
|
||
const ok = await refresh(() => createGroup({ body: { name: newGroupName } }));
|
||
if (ok) setNewGroupName('');
|
||
setCreating(false);
|
||
};
|
||
|
||
const handleJoin = (groupId: string) =>
|
||
refresh(() => addGroupMember({ body: { id: groupId } }));
|
||
|
||
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 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; });
|
||
};
|
||
|
||
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 (group.state === GroupState.ORDERED) 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 (group.state === GroupState.ORDERED) 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 ?? [];
|
||
|
||
// 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>
|
||
<Button variant="outline-primary" size="sm" onClick={() => setAdminModalOpen(true)} title="Správa obchodů">
|
||
<FontAwesomeIcon icon={faGear} className="me-1" />
|
||
Obchody
|
||
</Button>
|
||
</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 => setNewGroupName(e.target.value)}
|
||
style={{ maxWidth: 260 }}
|
||
>
|
||
<option value="">— vyberte obchod —</option>
|
||
{stores.map(s => <option key={s.name} value={s.name}>{s.name}</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 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 (pokud ji má dohledatelný obchod vyplněnou).
|
||
// Povolíme jen http(s), aby odkaz nemohl být zneužit (např. javascript:).
|
||
const rawStoreUrl = stores.find(s => s.name === group.name)?.url;
|
||
const storeUrl = rawStoreUrl && /^https?:\/\//i.test(rawStoreUrl) ? rawStoreUrl : 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 && !isOrdered && (
|
||
<>
|
||
<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 && isOrdered && (
|
||
<>
|
||
{settings?.bankAccount && settings?.holderName && !group.qrGenerated && (
|
||
<Button variant="primary" size="sm" onClick={() => setPayModal(group)}>
|
||
<FontAwesomeIcon icon={faBasketShopping} className="me-1" />
|
||
Generovat QR
|
||
</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 && !isOrdered && !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>
|
||
<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">
|
||
<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>
|
||
<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={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í */}
|
||
{isOrdered && (
|
||
<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 && 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>
|
||
);
|
||
}
|