936b33cc80
CI / Generate TypeScript types (push) Successful in 18s
CI / Generate TypeScript types (pull_request) Successful in 9s
CI / Server unit tests (push) Successful in 21s
CI / Build client (push) Successful in 40s
CI / Server unit tests (pull_request) Successful in 21s
CI / Build server (pull_request) Successful in 24s
CI / Build server (push) Has been cancelled
CI / Playwright E2E tests (push) Has been cancelled
CI / Build and push Docker image (push) Has been cancelled
CI / Notify (push) Has been cancelled
CI / Build client (pull_request) Has been cancelled
CI / Playwright E2E tests (pull_request) Has been cancelled
CI / Build and push Docker image (pull_request) Has been cancelled
CI / Notify (pull_request) Has been cancelled
Nahrazuje /vecere novou stránkou /objednani. Místo jednoho OBJEDNAVAM bucketu umožňuje vytvářet více skupin, kde každá objednává z jiného obchodu. - Skupiny mají stavový automat: open → locked → ordered - Obchody spravuje admin heslem (ADMIN_PASSWORD env var) přes modal „Správa obchodů" - Při stavu ordered zakladatel generuje QR kódy platby (nový PayForGroupModal – volné částky bez menu) - PayForAllModal (oběd) upraven: plátce nyní vidí svůj vlastní díl jako informační řádek - Nové testy: stores.test.ts + groups.test.ts (36 testů)
351 lines
20 KiB
TypeScript
351 lines
20 KiB
TypeScript
import { useContext, useEffect, useRef, useState } from 'react';
|
|
import { Badge, Button, Card, Form, Table } from 'react-bootstrap';
|
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
import { faNoteSticky, faTrashCan } from '@fortawesome/free-regular-svg-icons';
|
|
import { faBasketShopping, faGear, faLock, faLockOpen, faSearch, faUserPlus } from '@fortawesome/free-solid-svg-icons';
|
|
import {
|
|
ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember,
|
|
getData, createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState,
|
|
} from '../../../types';
|
|
import { EVENT_MESSAGE, SocketContext } from '../context/socket';
|
|
import { useAuth } from '../context/auth';
|
|
import { useSettings } from '../context/settings';
|
|
import Login from '../Login';
|
|
import Header from '../components/Header';
|
|
import Footer from '../components/Footer';
|
|
import Loader from '../components/Loader';
|
|
import NoteModal from '../components/modals/NoteModal';
|
|
import StoreAdminModal from '../components/modals/StoreAdminModal';
|
|
import PayForGroupModal from '../components/modals/PayForGroupModal';
|
|
|
|
const SLOT = MealSlot.EXTRA;
|
|
|
|
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);
|
|
const [newGroupName, setNewGroupName] = useState('');
|
|
const [creating, setCreating] = useState(false);
|
|
const [adminModalOpen, setAdminModalOpen] = useState(false);
|
|
const [noteModal, setNoteModal] = useState<{ groupId: string; login: string } | null>(null);
|
|
const [editAmounts, setEditAmounts] = useState<Record<string, string>>({});
|
|
const [payModal, setPayModal] = useState<OrderGroup | null>(null);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const fetchData = async () => {
|
|
try {
|
|
const r = await getData({ query: { slot: SLOT } });
|
|
if (r.data) setData(r.data);
|
|
} catch {
|
|
setFailure(true);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!auth?.login) return;
|
|
fetchData();
|
|
}, [auth?.login]);
|
|
|
|
useEffect(() => {
|
|
socket.on(EVENT_MESSAGE, (newData: ClientData) => {
|
|
if (newData.slot === SLOT) setData(newData);
|
|
});
|
|
return () => { socket.off(EVENT_MESSAGE); };
|
|
}, [socket]);
|
|
|
|
const refresh = async (fn: () => Promise<any>) => {
|
|
const result = await fn();
|
|
if (result?.data) {
|
|
setData(result.data);
|
|
const ws = result.data as ClientData;
|
|
socket.emit?.('message', ws);
|
|
}
|
|
await fetchData();
|
|
};
|
|
|
|
const handleCreate = async () => {
|
|
if (!newGroupName || !auth?.login) return;
|
|
setCreating(true);
|
|
try {
|
|
await refresh(() => createGroup({ body: { name: newGroupName } }));
|
|
setNewGroupName('');
|
|
} catch { /* swallow */ }
|
|
setCreating(false);
|
|
};
|
|
|
|
const handleJoin = (groupId: string) =>
|
|
refresh(() => addGroupMember({ body: { id: groupId } }));
|
|
|
|
const handleLeave = (groupId: string) =>
|
|
refresh(() => removeGroupMember({ body: { id: groupId, login: auth?.login ?? '' } }));
|
|
|
|
const handleToggleLock = (group: OrderGroup) => {
|
|
const next = group.state === GroupState.OPEN ? GroupState.LOCKED : GroupState.OPEN;
|
|
return refresh(() => setGroupState({ body: { id: group.id, state: next } }));
|
|
};
|
|
|
|
const handleMarkOrdered = (group: OrderGroup) =>
|
|
refresh(() => setGroupState({ body: { id: group.id, state: GroupState.ORDERED } }));
|
|
|
|
const handleDelete = (groupId: string) =>
|
|
refresh(() => deleteGroup({ body: { id: groupId } }));
|
|
|
|
const handleSaveNote = async (note?: string) => {
|
|
if (!noteModal || !auth?.login) return;
|
|
await refresh(() => updateGroupMember({ body: { id: noteModal.groupId, login: noteModal.login, note } }));
|
|
setNoteModal(null);
|
|
};
|
|
|
|
const handleSaveAmount = async (groupId: string, login: string) => {
|
|
const key = `${groupId}:${login}`;
|
|
const raw = editAmounts[key];
|
|
const n = parseFloat(raw ?? '');
|
|
const amount = isNaN(n) || n < 0 ? undefined : n;
|
|
await refresh(() => updateGroupMember({ body: { id: groupId, login, amount } }));
|
|
setEditAmounts(prev => { const next = { ...prev }; delete next[key]; return next; });
|
|
};
|
|
|
|
const canEditMember = (group: OrderGroup, targetLogin: string) => {
|
|
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 (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 ?? [];
|
|
|
|
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-secondary" size="sm" onClick={() => setAdminModalOpen(true)} title="Správa obchodů">
|
|
<FontAwesomeIcon icon={faGear} />
|
|
</Button>
|
|
</div>
|
|
<p style={{ color: 'var(--luncher-text-muted)' }}>Skupinové objednávky z obchodů a restaurací</p>
|
|
|
|
<div className="content-wrapper">
|
|
<div className="content">
|
|
{/* Vytvoření nové skupiny */}
|
|
<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-end 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} value={s}>{s}</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">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][];
|
|
|
|
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">
|
|
<strong>{group.name}</strong>
|
|
{stateBadge(group.state)}
|
|
<small className="text-muted">zakladatel: {group.creatorLogin}</small>
|
|
</div>
|
|
<div className="d-flex gap-2">
|
|
{isCreator && !isOrdered && (
|
|
<>
|
|
<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={() => handleMarkOrdered(group)}>
|
|
Objednáno
|
|
</Button>
|
|
)}
|
|
<Button variant="outline-danger" size="sm" onClick={() => handleDelete(group.id)} title="Smazat skupinu">
|
|
<FontAwesomeIcon icon={faTrashCan} />
|
|
</Button>
|
|
</>
|
|
)}
|
|
{isCreator && isOrdered && settings?.bankAccount && settings?.holderName && (
|
|
<Button variant="primary" size="sm" onClick={() => setPayModal(group)}>
|
|
<FontAwesomeIcon icon={faBasketShopping} className="me-1" />
|
|
Generovat QR
|
|
</Button>
|
|
)}
|
|
{!isMember && !isOrdered && (
|
|
<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: 130 }}>Částka (Kč)</th>
|
|
<th>Poznámka</th>
|
|
<th style={{ width: 80 }}></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{memberEntries.map(([memberLogin, member]) => {
|
|
const amountKey = `${group.id}:${memberLogin}`;
|
|
const editingAmount = amountKey in editAmounts;
|
|
const canEdit = canEditMember(group, memberLogin);
|
|
return (
|
|
<tr key={memberLogin}>
|
|
<td>
|
|
<span className="user-info">
|
|
<strong>{memberLogin}</strong>
|
|
{memberLogin === group.creatorLogin && (
|
|
<FontAwesomeIcon icon={faBasketShopping} className="ms-1 buyer-icon" title="Zakladatel / objednávající" />
|
|
)}
|
|
</span>
|
|
</td>
|
|
<td>
|
|
{canEdit && editingAmount ? (
|
|
<div className="d-flex gap-1">
|
|
<Form.Control
|
|
ref={memberLogin === login ? inputRef : undefined}
|
|
type="number"
|
|
size="sm"
|
|
value={editAmounts[amountKey]}
|
|
onChange={e => setEditAmounts(prev => ({ ...prev, [amountKey]: e.target.value }))}
|
|
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveAmount(group.id, memberLogin); }}
|
|
style={{ width: 80 }}
|
|
autoFocus={memberLogin === login}
|
|
/>
|
|
<Button size="sm" variant="outline-success" onClick={() => handleSaveAmount(group.id, memberLogin)}>✓</Button>
|
|
</div>
|
|
) : (
|
|
<span
|
|
style={{ cursor: canEdit ? 'pointer' : undefined }}
|
|
onClick={() => canEdit && setEditAmounts(prev => ({ ...prev, [amountKey]: String(member.amount ?? '') }))}
|
|
title={canEdit ? 'Klikněte pro úpravu' : undefined}
|
|
>
|
|
{member.amount != null ? `${member.amount} Kč` : <span className="text-muted">—</span>}
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td>
|
|
<small className="text-muted">{member.note || '—'}</small>
|
|
</td>
|
|
<td>
|
|
<div className="d-flex gap-1 justify-content-end">
|
|
{memberLogin === login && (
|
|
<FontAwesomeIcon
|
|
icon={faNoteSticky}
|
|
className="action-icon"
|
|
title="Upravit poznámku"
|
|
onClick={() => setNoteModal({ groupId: group.id, login: memberLogin })}
|
|
/>
|
|
)}
|
|
{canManageMembers(group) && (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>
|
|
</Table>
|
|
</Card.Body>
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<Footer />
|
|
|
|
<NoteModal
|
|
isOpen={!!noteModal}
|
|
onClose={() => setNoteModal(null)}
|
|
onSave={handleSaveNote}
|
|
/>
|
|
|
|
<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)}
|
|
group={payModal}
|
|
payerLogin={auth.login}
|
|
bankAccount={settings.bankAccount}
|
|
bankAccountHolder={settings.holderName}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|