feat: /objednani – skupinové objednávky s QR platbou
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ů)
This commit is contained in:
2026-05-07 07:05:01 +02:00
parent 774be3df6d
commit 936b33cc80
28 changed files with 1641 additions and 242 deletions
+4 -4
View File
@@ -5,20 +5,20 @@ import { SnowOverlay } from 'react-snow-overlay';
import { ToastContainer } from "react-toastify";
import { SocketContext, socket } from "./context/socket";
import StatsPage from "./pages/StatsPage";
import ExtraPage from "./pages/ExtraPage";
import OrderGroupsPage from "./pages/OrderGroupsPage";
import App from "./App";
export const STATS_URL = '/stats';
export const VECERE_URL = '/vecere';
export const OBJEDNANI_URL = '/objednani';
export default function AppRoutes() {
return (
<Routes>
<Route path={STATS_URL} element={<StatsPage />} />
<Route path={VECERE_URL} element={
<Route path={OBJEDNANI_URL} element={
<ProvideSettings>
<SocketContext.Provider value={socket}>
<ExtraPage />
<OrderGroupsPage />
<ToastContainer />
</SocketContext.Provider>
</ProvideSettings>
+2 -2
View File
@@ -10,7 +10,7 @@ import GenerateQrModal from "./modals/GenerateQrModal";
import GenerateMockDataModal from "./modals/GenerateMockDataModal";
import ClearMockDataModal from "./modals/ClearMockDataModal";
import { useNavigate } from "react-router";
import { STATS_URL, VECERE_URL } from "../AppRoutes";
import { STATS_URL, OBJEDNANI_URL } from "../AppRoutes";
import { FeatureRequest, getVotes, updateVote, LunchChoices, getChangelogs } from "../../../types";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons";
@@ -207,7 +207,7 @@ export default function Header({ choices, dayIndex }: Props) {
<NavDropdown.Item onClick={() => setPizzaModalOpen(true)}>Pizza kalkulačka</NavDropdown.Item>
<NavDropdown.Item onClick={handleQrMenuClick}>Generování QR kódů</NavDropdown.Item>
<NavDropdown.Item onClick={() => navigate(STATS_URL)}>Statistiky</NavDropdown.Item>
<NavDropdown.Item onClick={() => navigate(VECERE_URL)}>Večeře</NavDropdown.Item>
<NavDropdown.Item onClick={() => navigate(OBJEDNANI_URL)}>Objednání</NavDropdown.Item>
<NavDropdown.Item onClick={() => {
getChangelogs().then(response => {
const entries = response.data ?? {};
+22 -24
View File
@@ -225,35 +225,33 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
</small>
</td>
<td>
{!isPayer && (
<div className="d-flex gap-1">
<Form.Control
type="text"
placeholder="popis"
value={d.surchargeText}
onChange={e => handleSurchargeText(d.login, e.target.value)}
disabled={!d.included}
size="sm"
onKeyDown={e => e.stopPropagation()}
/>
<Form.Control
type="text"
placeholder=""
value={d.surchargeAmount}
onChange={e => handleSurchargeAmount(d.login, e.target.value)}
disabled={!d.included}
size="sm"
style={{ width: 70 }}
onKeyDown={e => e.stopPropagation()}
/>
</div>
)}
<div className="d-flex gap-1">
<Form.Control
type="text"
placeholder="popis"
value={d.surchargeText}
onChange={e => handleSurchargeText(d.login, e.target.value)}
disabled={!isPayer && !d.included}
size="sm"
onKeyDown={e => e.stopPropagation()}
/>
<Form.Control
type="text"
placeholder=""
value={d.surchargeAmount}
onChange={e => handleSurchargeAmount(d.login, e.target.value)}
disabled={!isPayer && !d.included}
size="sm"
style={{ width: 70 }}
onKeyDown={e => e.stopPropagation()}
/>
</div>
</td>
<td className="text-end">
{!isPayer && d.included ? `${tipPerPerson} Kč` : '—'}
</td>
<td className="text-end fw-bold">
{!isPayer ? `${total} Kč` : '—'}
{`${total} Kč`}
</td>
</tr>
);
@@ -0,0 +1,261 @@
import { useState, useEffect, useCallback } from "react";
import { Modal, Button, Form, Table, Alert } from "react-bootstrap";
import { generateQr, OrderGroup, OrderGroupMember, QrRecipient } from "../../../../types";
type DinerEntry = {
login: string;
baseAmount: number;
surchargeText: string;
surchargeAmount: string;
included: boolean;
};
type Props = {
isOpen: boolean;
onClose: () => void;
group: OrderGroup;
payerLogin: string;
bankAccount: string;
bankAccountHolder: string;
};
function sanitizeAmount(value: string): string {
return value.replace(/[^0-9.,]/g, '').replace(',', '.');
}
function parseAmount(s: string): number | null {
if (!s || s.trim().length === 0) return null;
const n = parseFloat(s);
if (isNaN(n) || n < 0) return null;
const parts = s.split('.');
if (parts.length === 2 && parts[1].length > 2) return null;
return Math.round(n * 100) / 100;
}
export default function PayForGroupModal({ isOpen, onClose, group, payerLogin, bankAccount, bankAccountHolder }: Readonly<Props>) {
const [diners, setDiners] = useState<DinerEntry[]>([]);
const [tipTotal, setTipTotal] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
useEffect(() => {
if (!isOpen) return;
const entries: DinerEntry[] = (Object.entries(group.members) as [string, OrderGroupMember][]).map(([login, member]) => ({
login,
baseAmount: member.amount ?? 0,
surchargeText: member.surchargeText ?? '',
surchargeAmount: member.surchargeAmount != null ? String(member.surchargeAmount) : '',
included: login !== payerLogin,
}));
setDiners(entries);
setTipTotal('');
setError(null);
setSuccess(false);
}, [isOpen, group, payerLogin]);
const includedNonPayers = diners.filter(d => d.included && d.login !== payerLogin);
const tipPerPerson = (() => {
if (includedNonPayers.length === 0) return 0;
const tip = parseAmount(tipTotal);
if (tip === null || tip === 0) return 0;
return Math.round((tip / includedNonPayers.length) * 100) / 100;
})();
const getTotal = (d: DinerEntry): number => {
const surcharge = parseAmount(d.surchargeAmount) ?? 0;
const tip = d.included && d.login !== payerLogin ? tipPerPerson : 0;
return Math.round((d.baseAmount + surcharge + tip) * 100) / 100;
};
const handleInclude = useCallback((login: string, checked: boolean) => {
setDiners(prev => prev.map(d => d.login === login ? { ...d, included: checked } : d));
}, []);
const handleSurchargeText = useCallback((login: string, value: string) => {
setDiners(prev => prev.map(d => d.login === login ? { ...d, surchargeText: value } : d));
}, []);
const handleSurchargeAmount = useCallback((login: string, value: string) => {
setDiners(prev => prev.map(d => d.login === login ? { ...d, surchargeAmount: sanitizeAmount(value) } : d));
}, []);
const handleGenerate = async () => {
setError(null);
const recipients: QrRecipient[] = [];
for (const d of diners) {
if (!d.included || d.login === payerLogin) continue;
const total = getTotal(d);
if (total <= 0) {
setError(`Celková částka pro ${d.login} musí být kladná`);
return;
}
const amountStr = total.toString();
if (amountStr.includes('.') && amountStr.split('.')[1].length > 2) {
setError(`Částka pro ${d.login} má více než 2 desetinná místa`);
return;
}
recipients.push({
login: d.login,
purpose: `Objednávka ${group.name}`.substring(0, 60),
amount: total,
});
}
if (recipients.length === 0) {
setError("Nebyl vybrán žádný příjemce");
return;
}
setLoading(true);
try {
const response = await generateQr({
body: { recipients, bankAccount, bankAccountHolder },
});
if (response.error) {
setError((response.error as any).error || 'Nastala chyba při generování QR kódů');
} else {
setSuccess(true);
setTimeout(() => onClose(), 2000);
}
} catch (e: any) {
setError(e.message || 'Nastala chyba při generování QR kódů');
} finally {
setLoading(false);
}
};
return (
<Modal show={isOpen} onHide={onClose} size="lg">
<Modal.Header closeButton>
<Modal.Title><h2>Zaplatit za skupinu {group.name}</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
{success ? (
<Alert variant="success">
QR kódy byly úspěšně vygenerovány! Uživatelé je uvidí v sekci Nevyřízené platby".
</Alert>
) : (
<>
<p>Zaplatili jste za skupinu. Nastavte příplatky a dýško, poté vygenerujte QR kódy pro ostatní.</p>
{error && (
<Alert variant="danger" onClose={() => setError(null)} dismissible>
{error}
</Alert>
)}
<Table striped bordered hover responsive size="sm">
<thead>
<tr>
<th style={{ width: 40 }}></th>
<th>Člen</th>
<th style={{ width: 90 }}>Základ (Kč)</th>
<th style={{ width: 220 }}>Příplatek</th>
<th style={{ width: 90 }}>Dýško</th>
<th style={{ width: 90 }}>Celkem</th>
</tr>
</thead>
<tbody>
{diners.map(d => {
const isPayer = d.login === payerLogin;
const total = getTotal(d);
return (
<tr key={d.login} className={!d.included && !isPayer ? 'text-muted' : ''}>
<td className="text-center">
{isPayer ? (
<small className="text-muted">plátce</small>
) : (
<Form.Check
type="checkbox"
checked={d.included}
onChange={e => handleInclude(d.login, e.target.checked)}
/>
)}
</td>
<td><strong>{d.login}</strong></td>
<td className="text-end">
{d.baseAmount > 0 ? `${d.baseAmount} Kč` : <span className="text-muted">—</span>}
</td>
<td>
<div className="d-flex gap-1">
<Form.Control
type="text"
placeholder="popis"
value={d.surchargeText}
onChange={e => handleSurchargeText(d.login, e.target.value)}
disabled={!isPayer && !d.included}
size="sm"
onKeyDown={e => e.stopPropagation()}
/>
<Form.Control
type="text"
placeholder=""
value={d.surchargeAmount}
onChange={e => handleSurchargeAmount(d.login, e.target.value)}
disabled={!isPayer && !d.included}
size="sm"
style={{ width: 70 }}
onKeyDown={e => e.stopPropagation()}
/>
</div>
</td>
<td className="text-end">
{!isPayer && d.included ? `${tipPerPerson} Kč` : '—'}
</td>
<td className="text-end fw-bold">
{`${total} Kč`}
</td>
</tr>
);
})}
</tbody>
</Table>
<div className="d-flex align-items-center gap-2 mt-2">
<label className="mb-0 text-nowrap">Dýško celkem (Kč):</label>
<Form.Control
type="text"
placeholder="0"
value={tipTotal}
onChange={e => setTipTotal(sanitizeAmount(e.target.value))}
size="sm"
style={{ width: 100 }}
onKeyDown={e => e.stopPropagation()}
/>
<small className="text-muted">
{includedNonPayers.length > 0 && tipPerPerson > 0
? `(${tipPerPerson} Kč / osoba)`
: ''}
</small>
</div>
</>
)}
</Modal.Body>
<Modal.Footer>
{!success && (
<>
<span className="me-auto text-muted">
Příjemci: {includedNonPayers.length}
</span>
<Button variant="secondary" onClick={onClose} disabled={loading}>
Storno
</Button>
<Button
variant="primary"
onClick={handleGenerate}
disabled={loading || includedNonPayers.length === 0}
>
{loading ? 'Generuji...' : 'Vygenerovat QR'}
</Button>
</>
)}
{success && (
<Button variant="secondary" onClick={onClose}>Zavřít</Button>
)}
</Modal.Footer>
</Modal>
);
}
@@ -0,0 +1,119 @@
import { useState } from "react";
import { Modal, Button, Form, ListGroup, Alert } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faTrashCan } from "@fortawesome/free-regular-svg-icons";
import { addStore, deleteStore } from "../../../../types";
type Props = {
isOpen: boolean;
onClose: () => void;
stores: string[];
onStoresChanged: (stores: string[]) => void;
};
export default function StoreAdminModal({ isOpen, onClose, stores, onStoresChanged }: Readonly<Props>) {
const [newName, setNewName] = useState('');
const [heslo, setHeslo] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleAdd = async () => {
if (!newName.trim()) return;
setError(null);
setLoading(true);
try {
const res = await addStore({ body: { name: newName.trim(), heslo } });
if (res.error) {
setError((res.error as any).error || 'Nastala chyba');
} else if (res.data) {
onStoresChanged(res.data as string[]);
setNewName('');
}
} catch (e: any) {
setError(e.message || 'Nastala chyba');
} finally {
setLoading(false);
}
};
const handleRemove = async (name: string) => {
setError(null);
setLoading(true);
try {
const res = await deleteStore({ body: { name, heslo } });
if (res.error) {
setError((res.error as any).error || 'Nastala chyba');
} else if (res.data) {
onStoresChanged(res.data as string[]);
}
} catch (e: any) {
setError(e.message || 'Nastala chyba');
} finally {
setLoading(false);
}
};
return (
<Modal show={isOpen} onHide={onClose}>
<Modal.Header closeButton>
<Modal.Title><h2>Správa obchodů</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
{error && (
<Alert variant="danger" onClose={() => setError(null)} dismissible>
{error}
</Alert>
)}
<Form.Group className="mb-3">
<Form.Label>Admin heslo</Form.Label>
<Form.Control
type="password"
placeholder="Heslo"
value={heslo}
onChange={e => setHeslo(e.target.value)}
onKeyDown={e => e.stopPropagation()}
/>
</Form.Group>
<hr />
<h6>Přidat obchod</h6>
<div className="d-flex gap-2 mb-3">
<Form.Control
type="text"
placeholder="Název obchodu"
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleAdd(); }}
/>
<Button variant="primary" onClick={handleAdd} disabled={loading || !newName.trim() || !heslo}>
Přidat
</Button>
</div>
<h6>Aktuální seznam</h6>
{stores.length === 0 ? (
<p className="text-muted">Žádné obchody v seznamu</p>
) : (
<ListGroup>
{stores.map(s => (
<ListGroup.Item key={s} className="d-flex justify-content-between align-items-center">
{s}
<FontAwesomeIcon
icon={faTrashCan}
className="action-icon"
title="Odebrat"
onClick={() => handleRemove(s)}
style={{ cursor: 'pointer' }}
/>
</ListGroup.Item>
))}
</ListGroup>
)}
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={onClose}>Zavřít</Button>
</Modal.Footer>
</Modal>
);
}
-209
View File
@@ -1,209 +0,0 @@
import { useContext, useEffect, useRef, useState } from 'react';
import { Button, Table } from 'react-bootstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCircleCheck, faNoteSticky, faTrashCan } from '@fortawesome/free-regular-svg-icons';
import { faBasketShopping, faSearch } from '@fortawesome/free-solid-svg-icons';
import {
ClientData, LunchChoice, MealSlot, UserLunchChoice,
addChoice, removeChoices, updateNote, setBuyer, getData,
} from '../../../types';
import { EVENT_MESSAGE, SocketContext } from '../context/socket';
import { useAuth } from '../context/auth';
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';
const SLOT = MealSlot.EXTRA;
export default function ExtraPage() {
const auth = useAuth();
const socket = useContext(SocketContext);
const [data, setData] = useState<ClientData | undefined>();
const [failure, setFailure] = useState(false);
const [noteModalOpen, setNoteModalOpen] = useState(false);
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 myChoice = data?.choices?.OBJEDNAVAM?.[auth?.login ?? ''];
const isIn = !!myChoice;
const isBuyer = myChoice?.isBuyer ?? false;
const joinOrder = async () => {
if (!auth?.login) return;
await addChoice({ body: { locationKey: LunchChoice.OBJEDNAVAM, slot: SLOT } });
await fetchData();
};
const joinAndBuy = async () => {
if (!auth?.login) return;
await addChoice({ body: { locationKey: LunchChoice.OBJEDNAVAM, slot: SLOT } });
await setBuyer({ body: { slot: SLOT } });
await fetchData();
};
const leaveOrder = async () => {
if (!auth?.login) return;
await removeChoices({ body: { locationKey: LunchChoice.OBJEDNAVAM, slot: SLOT } });
await fetchData();
};
const toggleBuyer = async () => {
if (!auth?.login) return;
await setBuyer({ body: { slot: SLOT } });
await fetchData();
};
const saveNote = async (note?: string) => {
if (!auth?.login) return;
await updateNote({ body: { note, slot: SLOT } });
setNoteModalOpen(false);
await fetchData();
};
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 orderEntries = Object.entries(data.choices?.OBJEDNAVAM ?? {}) as [string, UserLunchChoice][];
return (
<div className="app-container">
<Header choices={data.choices} />
<div className="wrapper">
<h1 className="title">Večeře</h1>
<p style={{ color: 'var(--luncher-text-muted)' }}>Extra jídlo pro ty, kdo zůstávají déle</p>
<div className="content-wrapper">
<div className="content">
<div className="choice-section fade-in">
{!isIn ? (
<div className="d-flex gap-2 flex-wrap">
<Button variant="primary" onClick={joinOrder}>
Přidám se
</Button>
<Button variant="outline-primary" onClick={joinAndBuy}>
<FontAwesomeIcon icon={faBasketShopping} className="me-2" />
Budu objednávat
</Button>
</div>
) : (
<div className="d-flex gap-2 flex-wrap align-items-center">
<span style={{ color: 'var(--luncher-text-secondary)' }}>
{isBuyer ? 'Objednáváš.' : 'Jsi přidán/a k objednávce.'}
</span>
<Button variant="outline-secondary" size="sm" onClick={toggleBuyer}>
<FontAwesomeIcon icon={faBasketShopping} className="me-1" />
{isBuyer ? 'Odebrat roli objednávajícího' : 'Označit se jako objednávající'}
</Button>
<Button variant="outline-secondary" size="sm" onClick={() => setNoteModalOpen(true)}>
<FontAwesomeIcon icon={faNoteSticky} className="me-1" />
Poznámka
</Button>
<Button variant="outline-danger" size="sm" onClick={leaveOrder}>
<FontAwesomeIcon icon={faTrashCan} className="me-1" />
Odhlásit se
</Button>
</div>
)}
</div>
{orderEntries.length > 0 && (
<Table className="choices-table mt-4 fade-in">
<tbody>
<tr>
<td>Budu objednávat / Přidám se</td>
<td className="p-0">
<Table className="nested-table">
<tbody>
{orderEntries.map(([login, payload]) => (
<tr key={login}>
<td>
<div className="user-row">
<div className="user-info">
{payload.trusted && (
<span className="trusted-icon" title="Ověřený uživatel">
<FontAwesomeIcon icon={faCircleCheck} style={{ cursor: 'help' }} />
</span>
)}
<strong>{login}</strong>
{payload.note && (
<span className="ms-2" style={{ fontSize: 'small', color: 'var(--luncher-text-secondary)' }}>
({payload.note})
</span>
)}
</div>
<div className="user-actions">
{payload.isBuyer && (
<span title="Objednávající">
<FontAwesomeIcon icon={faBasketShopping} className="buyer-icon" />
</span>
)}
{login === auth.login && (
<>
<span title="Upravit poznámku">
<FontAwesomeIcon
onClick={() => setNoteModalOpen(true)}
className="action-icon"
icon={faNoteSticky}
/>
</span>
<span title="Odhlásit se z objednávky">
<FontAwesomeIcon
onClick={leaveOrder}
className="action-icon"
icon={faTrashCan}
/>
</span>
</>
)}
</div>
</div>
</td>
</tr>
))}
</tbody>
</Table>
</td>
</tr>
</tbody>
</Table>
)}
</div>
</div>
</div>
<Footer />
<NoteModal
isOpen={noteModalOpen}
onClose={() => setNoteModalOpen(false)}
onSave={saveNote}
/>
</div>
);
}
+350
View File
@@ -0,0 +1,350 @@
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 ()</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}` : <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>
);
}