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
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:
@@ -5,20 +5,20 @@ import { SnowOverlay } from 'react-snow-overlay';
|
|||||||
import { ToastContainer } from "react-toastify";
|
import { ToastContainer } from "react-toastify";
|
||||||
import { SocketContext, socket } from "./context/socket";
|
import { SocketContext, socket } from "./context/socket";
|
||||||
import StatsPage from "./pages/StatsPage";
|
import StatsPage from "./pages/StatsPage";
|
||||||
import ExtraPage from "./pages/ExtraPage";
|
import OrderGroupsPage from "./pages/OrderGroupsPage";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
|
||||||
export const STATS_URL = '/stats';
|
export const STATS_URL = '/stats';
|
||||||
export const VECERE_URL = '/vecere';
|
export const OBJEDNANI_URL = '/objednani';
|
||||||
|
|
||||||
export default function AppRoutes() {
|
export default function AppRoutes() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path={STATS_URL} element={<StatsPage />} />
|
<Route path={STATS_URL} element={<StatsPage />} />
|
||||||
<Route path={VECERE_URL} element={
|
<Route path={OBJEDNANI_URL} element={
|
||||||
<ProvideSettings>
|
<ProvideSettings>
|
||||||
<SocketContext.Provider value={socket}>
|
<SocketContext.Provider value={socket}>
|
||||||
<ExtraPage />
|
<OrderGroupsPage />
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
</SocketContext.Provider>
|
</SocketContext.Provider>
|
||||||
</ProvideSettings>
|
</ProvideSettings>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import GenerateQrModal from "./modals/GenerateQrModal";
|
|||||||
import GenerateMockDataModal from "./modals/GenerateMockDataModal";
|
import GenerateMockDataModal from "./modals/GenerateMockDataModal";
|
||||||
import ClearMockDataModal from "./modals/ClearMockDataModal";
|
import ClearMockDataModal from "./modals/ClearMockDataModal";
|
||||||
import { useNavigate } from "react-router";
|
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 { FeatureRequest, getVotes, updateVote, LunchChoices, getChangelogs } from "../../../types";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons";
|
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={() => setPizzaModalOpen(true)}>Pizza kalkulačka</NavDropdown.Item>
|
||||||
<NavDropdown.Item onClick={handleQrMenuClick}>Generování QR kódů</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(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={() => {
|
<NavDropdown.Item onClick={() => {
|
||||||
getChangelogs().then(response => {
|
getChangelogs().then(response => {
|
||||||
const entries = response.data ?? {};
|
const entries = response.data ?? {};
|
||||||
|
|||||||
@@ -225,35 +225,33 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
|
|||||||
</small>
|
</small>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{!isPayer && (
|
<div className="d-flex gap-1">
|
||||||
<div className="d-flex gap-1">
|
<Form.Control
|
||||||
<Form.Control
|
type="text"
|
||||||
type="text"
|
placeholder="popis"
|
||||||
placeholder="popis"
|
value={d.surchargeText}
|
||||||
value={d.surchargeText}
|
onChange={e => handleSurchargeText(d.login, e.target.value)}
|
||||||
onChange={e => handleSurchargeText(d.login, e.target.value)}
|
disabled={!isPayer && !d.included}
|
||||||
disabled={!d.included}
|
size="sm"
|
||||||
size="sm"
|
onKeyDown={e => e.stopPropagation()}
|
||||||
onKeyDown={e => e.stopPropagation()}
|
/>
|
||||||
/>
|
<Form.Control
|
||||||
<Form.Control
|
type="text"
|
||||||
type="text"
|
placeholder="Kč"
|
||||||
placeholder="Kč"
|
value={d.surchargeAmount}
|
||||||
value={d.surchargeAmount}
|
onChange={e => handleSurchargeAmount(d.login, e.target.value)}
|
||||||
onChange={e => handleSurchargeAmount(d.login, e.target.value)}
|
disabled={!isPayer && !d.included}
|
||||||
disabled={!d.included}
|
size="sm"
|
||||||
size="sm"
|
style={{ width: 70 }}
|
||||||
style={{ width: 70 }}
|
onKeyDown={e => e.stopPropagation()}
|
||||||
onKeyDown={e => e.stopPropagation()}
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="text-end">
|
<td className="text-end">
|
||||||
{!isPayer && d.included ? `${tipPerPerson} Kč` : '—'}
|
{!isPayer && d.included ? `${tipPerPerson} Kč` : '—'}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-end fw-bold">
|
<td className="text-end fw-bold">
|
||||||
{!isPayer ? `${total} Kč` : '—'}
|
{`${total} Kč`}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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="Kč"
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 (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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -47,4 +47,8 @@
|
|||||||
|
|
||||||
# Heslo pro bypass rate limitu na endpointu /api/food/refresh (pro skripty/admin).
|
# Heslo pro bypass rate limitu na endpointu /api/food/refresh (pro skripty/admin).
|
||||||
# Bez hesla může refresh volat každý přihlášený uživatel (podléhá rate limitu).
|
# Bez hesla může refresh volat každý přihlášený uživatel (podléhá rate limitu).
|
||||||
# REFRESH_BYPASS_PASSWORD=
|
# REFRESH_BYPASS_PASSWORD=
|
||||||
|
|
||||||
|
# Admin heslo pro správu seznamu obchodů na stránce /objednani.
|
||||||
|
# Bez hesla nelze přidávat ani odebírat obchody ze seznamu (POST/DELETE na /api/stores vrátí 403).
|
||||||
|
# ADMIN_PASSWORD=
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import crypto from "crypto";
|
||||||
|
import getStorage from "./storage";
|
||||||
|
import { getClientData, getToday, initIfNeeded } from "./service";
|
||||||
|
import { getStores } from "./stores";
|
||||||
|
import { ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember } from "../../types/gen/types.gen";
|
||||||
|
import { formatDate } from "./utils";
|
||||||
|
|
||||||
|
const storage = getStorage();
|
||||||
|
|
||||||
|
async function getExtraData(date?: Date): Promise<ClientData> {
|
||||||
|
await initIfNeeded(date, MealSlot.EXTRA);
|
||||||
|
return getClientData(date, MealSlot.EXTRA);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExtraKey(date?: Date): string {
|
||||||
|
return `${formatDate(date ?? getToday())}_extra`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveExtraData(data: ClientData, date?: Date): Promise<ClientData> {
|
||||||
|
await storage.setData(getExtraKey(date), data);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findGroup(data: ClientData, id: string): OrderGroup | undefined {
|
||||||
|
return data.groups?.find(g => g.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createGroup(creatorLogin: string, name: string, date?: Date): Promise<ClientData> {
|
||||||
|
const stores = await getStores();
|
||||||
|
if (!stores.some(s => s.toLowerCase() === name.trim().toLowerCase())) {
|
||||||
|
throw new Error('Obchod není v seznamu povolených obchodů');
|
||||||
|
}
|
||||||
|
const data = await getExtraData(date);
|
||||||
|
const group: OrderGroup = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name: name.trim(),
|
||||||
|
creatorLogin,
|
||||||
|
state: GroupState.OPEN,
|
||||||
|
members: { [creatorLogin]: {} },
|
||||||
|
};
|
||||||
|
data.groups = [...(data.groups ?? []), group];
|
||||||
|
return saveExtraData(data, date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteGroup(login: string, groupId: string, date?: Date): Promise<ClientData> {
|
||||||
|
const data = await getExtraData(date);
|
||||||
|
const group = findGroup(data, groupId);
|
||||||
|
if (!group) throw new Error('Skupina nebyla nalezena');
|
||||||
|
if (group.creatorLogin !== login) throw new Error('Skupinu může smazat pouze zakladatel');
|
||||||
|
data.groups = (data.groups ?? []).filter(g => g.id !== groupId);
|
||||||
|
return saveExtraData(data, date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addGroupMember(login: string, groupId: string, targetLogin: string, date?: Date): Promise<ClientData> {
|
||||||
|
const data = await getExtraData(date);
|
||||||
|
const group = findGroup(data, groupId);
|
||||||
|
if (!group) throw new Error('Skupina nebyla nalezena');
|
||||||
|
if (group.state === GroupState.ORDERED) throw new Error('Skupinu ve stavu "objednáno" nelze upravovat');
|
||||||
|
if (login !== group.creatorLogin && login !== targetLogin) {
|
||||||
|
throw new Error('Přidat jiného uživatele může pouze zakladatel');
|
||||||
|
}
|
||||||
|
if (group.state === GroupState.LOCKED && login !== group.creatorLogin) {
|
||||||
|
throw new Error('Skupinu ve stavu "uzamčeno" může upravovat pouze zakladatel');
|
||||||
|
}
|
||||||
|
if (group.members[targetLogin]) throw new Error('Uživatel je již ve skupině');
|
||||||
|
group.members[targetLogin] = {};
|
||||||
|
return saveExtraData(data, date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeGroupMember(login: string, groupId: string, targetLogin: string, date?: Date): Promise<ClientData> {
|
||||||
|
const data = await getExtraData(date);
|
||||||
|
const group = findGroup(data, groupId);
|
||||||
|
if (!group) throw new Error('Skupina nebyla nalezena');
|
||||||
|
if (group.state === GroupState.ORDERED) throw new Error('Skupinu ve stavu "objednáno" nelze upravovat');
|
||||||
|
if (login !== group.creatorLogin && login !== targetLogin) {
|
||||||
|
throw new Error('Odebrat jiného uživatele může pouze zakladatel');
|
||||||
|
}
|
||||||
|
if (group.state === GroupState.LOCKED && login !== group.creatorLogin) {
|
||||||
|
throw new Error('Skupinu ve stavu "uzamčeno" může upravovat pouze zakladatel');
|
||||||
|
}
|
||||||
|
if (targetLogin === group.creatorLogin) throw new Error('Zakladatel skupiny nemůže být odebrán');
|
||||||
|
if (!group.members[targetLogin]) throw new Error('Uživatel není ve skupině');
|
||||||
|
delete group.members[targetLogin];
|
||||||
|
return saveExtraData(data, date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateGroupMember(login: string, groupId: string, targetLogin: string, patch: Partial<OrderGroupMember>, date?: Date): Promise<ClientData> {
|
||||||
|
const data = await getExtraData(date);
|
||||||
|
const group = findGroup(data, groupId);
|
||||||
|
if (!group) throw new Error('Skupina nebyla nalezena');
|
||||||
|
if (group.state === GroupState.ORDERED) throw new Error('Skupinu ve stavu "objednáno" nelze upravovat');
|
||||||
|
const isSelf = login === targetLogin;
|
||||||
|
const isCreator = login === group.creatorLogin;
|
||||||
|
if (!isSelf && !isCreator) throw new Error('Upravit jiného uživatele může pouze zakladatel');
|
||||||
|
if (!isCreator && group.state === GroupState.LOCKED) {
|
||||||
|
throw new Error('Skupinu ve stavu "uzamčeno" může upravovat pouze zakladatel');
|
||||||
|
}
|
||||||
|
if (!group.members[targetLogin]) throw new Error('Uživatel není ve skupině');
|
||||||
|
group.members[targetLogin] = { ...group.members[targetLogin], ...patch };
|
||||||
|
return saveExtraData(data, date);
|
||||||
|
}
|
||||||
|
|
||||||
|
const VALID_TRANSITIONS: Record<GroupState, GroupState[]> = {
|
||||||
|
[GroupState.OPEN]: [GroupState.LOCKED],
|
||||||
|
[GroupState.LOCKED]: [GroupState.OPEN, GroupState.ORDERED],
|
||||||
|
[GroupState.ORDERED]: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function setGroupState(login: string, groupId: string, newState: GroupState, date?: Date): Promise<ClientData> {
|
||||||
|
const data = await getExtraData(date);
|
||||||
|
const group = findGroup(data, groupId);
|
||||||
|
if (!group) throw new Error('Skupina nebyla nalezena');
|
||||||
|
if (group.creatorLogin !== login) throw new Error('Stav může měnit pouze zakladatel');
|
||||||
|
if (!VALID_TRANSITIONS[group.state].includes(newState)) {
|
||||||
|
throw new Error(`Nelze přejít ze stavu "${group.state}" do stavu "${newState}"`);
|
||||||
|
}
|
||||||
|
group.state = newState;
|
||||||
|
return saveExtraData(data, date);
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ import notificationRoutes from "./routes/notificationRoutes";
|
|||||||
import qrRoutes from "./routes/qrRoutes";
|
import qrRoutes from "./routes/qrRoutes";
|
||||||
import devRoutes from "./routes/devRoutes";
|
import devRoutes from "./routes/devRoutes";
|
||||||
import changelogRoutes from "./routes/changelogRoutes";
|
import changelogRoutes from "./routes/changelogRoutes";
|
||||||
|
import groupRoutes from "./routes/groupRoutes";
|
||||||
|
import storeRoutes from "./routes/storeRoutes";
|
||||||
|
|
||||||
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
|
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
|
||||||
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
|
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
|
||||||
@@ -180,6 +182,8 @@ app.use("/api/notifications", notificationRoutes);
|
|||||||
app.use("/api/qr", qrRoutes);
|
app.use("/api/qr", qrRoutes);
|
||||||
app.use("/api/dev", devRoutes);
|
app.use("/api/dev", devRoutes);
|
||||||
app.use("/api/changelogs", changelogRoutes);
|
app.use("/api/changelogs", changelogRoutes);
|
||||||
|
app.use("/api/groups", groupRoutes);
|
||||||
|
app.use("/api/stores", storeRoutes);
|
||||||
|
|
||||||
app.use('/stats', express.static('public'));
|
app.use('/stats', express.static('public'));
|
||||||
app.use(express.static('public'));
|
app.use(express.static('public'));
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ const parseValidateFutureDayIndex = (req: Request<{}, any, AddChoiceData["body"]
|
|||||||
|
|
||||||
const parseSlot = (body: Record<string, any>): MealSlot | undefined => {
|
const parseSlot = (body: Record<string, any>): MealSlot | undefined => {
|
||||||
const slot = body?.slot;
|
const slot = body?.slot;
|
||||||
if (slot != null && slot !== MealSlot.OBED && slot !== MealSlot.EXTRA) {
|
if (slot != null && slot !== MealSlot.OBED) {
|
||||||
throw Error(`Neplatný slot: ${slot}`);
|
throw Error(`Neplatný slot: ${slot}`);
|
||||||
}
|
}
|
||||||
return slot ?? undefined;
|
return slot ?? undefined;
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import express, { Request } from "express";
|
||||||
|
import { getLogin } from "../auth";
|
||||||
|
import { parseToken } from "../utils";
|
||||||
|
import { getWebsocket } from "../websocket";
|
||||||
|
import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState } from "../groups";
|
||||||
|
import { GroupState } from "../../../types/gen/types.gen";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
function broadcastExtra(data: any) {
|
||||||
|
getWebsocket().emit("message", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post("/create", async (req: Request, res, next) => {
|
||||||
|
const login = getLogin(parseToken(req));
|
||||||
|
const { name } = req.body ?? {};
|
||||||
|
if (!name || typeof name !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'Nebyl předán název skupiny' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await createGroup(login, name);
|
||||||
|
broadcastExtra(data);
|
||||||
|
res.status(200).json(data);
|
||||||
|
} catch (e: any) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/delete", async (req: Request, res, next) => {
|
||||||
|
const login = getLogin(parseToken(req));
|
||||||
|
const { id } = req.body ?? {};
|
||||||
|
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
|
||||||
|
try {
|
||||||
|
const data = await deleteGroup(login, id);
|
||||||
|
broadcastExtra(data);
|
||||||
|
res.status(200).json(data);
|
||||||
|
} catch (e: any) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/addMember", async (req: Request, res, next) => {
|
||||||
|
const login = getLogin(parseToken(req));
|
||||||
|
const { id, login: targetLogin } = req.body ?? {};
|
||||||
|
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
|
||||||
|
const target = targetLogin ?? login;
|
||||||
|
try {
|
||||||
|
const data = await addGroupMember(login, id, target);
|
||||||
|
broadcastExtra(data);
|
||||||
|
res.status(200).json(data);
|
||||||
|
} catch (e: any) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/removeMember", async (req: Request, res, next) => {
|
||||||
|
const login = getLogin(parseToken(req));
|
||||||
|
const { id, login: targetLogin } = req.body ?? {};
|
||||||
|
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
|
||||||
|
if (!targetLogin) return res.status(400).json({ error: 'Nebyl předán login uživatele' });
|
||||||
|
try {
|
||||||
|
const data = await removeGroupMember(login, id, targetLogin);
|
||||||
|
broadcastExtra(data);
|
||||||
|
res.status(200).json(data);
|
||||||
|
} catch (e: any) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/updateMember", async (req: Request, res, next) => {
|
||||||
|
const login = getLogin(parseToken(req));
|
||||||
|
const { id, login: targetLogin, amount, note, surchargeText, surchargeAmount } = req.body ?? {};
|
||||||
|
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
|
||||||
|
if (!targetLogin) return res.status(400).json({ error: 'Nebyl předán login uživatele' });
|
||||||
|
const patch: Record<string, any> = {};
|
||||||
|
if (amount !== undefined) patch.amount = amount;
|
||||||
|
if (note !== undefined) patch.note = note;
|
||||||
|
if (surchargeText !== undefined) patch.surchargeText = surchargeText;
|
||||||
|
if (surchargeAmount !== undefined) patch.surchargeAmount = surchargeAmount;
|
||||||
|
try {
|
||||||
|
const data = await updateGroupMember(login, id, targetLogin, patch);
|
||||||
|
broadcastExtra(data);
|
||||||
|
res.status(200).json(data);
|
||||||
|
} catch (e: any) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/setState", async (req: Request, res, next) => {
|
||||||
|
const login = getLogin(parseToken(req));
|
||||||
|
const { id, state } = req.body ?? {};
|
||||||
|
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
|
||||||
|
if (!state || !Object.values(GroupState).includes(state)) {
|
||||||
|
return res.status(400).json({ error: 'Neplatný stav skupiny' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await setGroupState(login, id, state as GroupState);
|
||||||
|
broadcastExtra(data);
|
||||||
|
res.status(200).json(data);
|
||||||
|
} catch (e: any) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import express from "express";
|
||||||
|
import { getStores, addStore, removeStore } from "../stores";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get("/", async (_req, res, next) => {
|
||||||
|
try {
|
||||||
|
const stores = await getStores();
|
||||||
|
res.status(200).json(stores);
|
||||||
|
} catch (e: any) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/add", async (req, res, next) => {
|
||||||
|
const { name, heslo } = req.body ?? {};
|
||||||
|
if (!name || typeof name !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'Nebyl předán název obchodu' });
|
||||||
|
}
|
||||||
|
if (!heslo || typeof heslo !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'Nebylo předáno heslo' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const stores = await addStore(name, heslo);
|
||||||
|
res.status(200).json(stores);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.message === 'UNAUTHORIZED') {
|
||||||
|
return res.status(403).json({ error: 'Nesprávné heslo' });
|
||||||
|
}
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/delete", async (req, res, next) => {
|
||||||
|
const { name, heslo } = req.body ?? {};
|
||||||
|
if (!name || typeof name !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'Nebyl předán název obchodu' });
|
||||||
|
}
|
||||||
|
if (!heslo || typeof heslo !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'Nebylo předáno heslo' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const stores = await removeStore(name, heslo);
|
||||||
|
res.status(200).json(stores);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.message === 'UNAUTHORIZED') {
|
||||||
|
return res.status(403).json({ error: 'Nesprávné heslo' });
|
||||||
|
}
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -3,6 +3,7 @@ import getStorage from "./storage";
|
|||||||
import { getMenuSladovnicka, getMenuTechTower, getMenuZastavkaUmichala, getMenuSenkSerikova, StaleWeekError } from "./restaurants";
|
import { getMenuSladovnicka, getMenuTechTower, getMenuZastavkaUmichala, getMenuSenkSerikova, StaleWeekError } from "./restaurants";
|
||||||
import { getTodayMock } from "./mock";
|
import { getTodayMock } from "./mock";
|
||||||
import { removeAllUserPizzas } from "./pizza";
|
import { removeAllUserPizzas } from "./pizza";
|
||||||
|
import { getStores } from "./stores";
|
||||||
import { ClientData, DepartureTime, LunchChoice, MealSlot, PizzaDayState, Restaurant, RestaurantDayMenu, WeekMenu } from "../../types/gen/types.gen";
|
import { ClientData, DepartureTime, LunchChoice, MealSlot, PizzaDayState, Restaurant, RestaurantDayMenu, WeekMenu } from "../../types/gen/types.gen";
|
||||||
|
|
||||||
const storage = getStorage();
|
const storage = getStorage();
|
||||||
@@ -50,7 +51,9 @@ export function getEmptyData(date?: Date): ClientData {
|
|||||||
*/
|
*/
|
||||||
export async function getData(date?: Date, slot?: MealSlot): Promise<ClientData> {
|
export async function getData(date?: Date, slot?: MealSlot): Promise<ClientData> {
|
||||||
const clientData = await getClientData(date, slot);
|
const clientData = await getClientData(date, slot);
|
||||||
if (slot !== MealSlot.EXTRA) {
|
if (slot === MealSlot.EXTRA) {
|
||||||
|
clientData.stores = await getStores();
|
||||||
|
} else {
|
||||||
clientData.menus = {
|
clientData.menus = {
|
||||||
SLADOVNICKA: await getRestaurantMenu('SLADOVNICKA', date),
|
SLADOVNICKA: await getRestaurantMenu('SLADOVNICKA', date),
|
||||||
// UMOTLIKU: await getRestaurantMenu('UMOTLIKU', date),
|
// UMOTLIKU: await getRestaurantMenu('UMOTLIKU', date),
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import getStorage from "./storage";
|
||||||
|
|
||||||
|
const storage = getStorage();
|
||||||
|
const STORES_KEY = 'stores';
|
||||||
|
|
||||||
|
export async function getStores(): Promise<string[]> {
|
||||||
|
return (await storage.getData<string[]>(STORES_KEY)) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addStore(name: string, heslo: string): Promise<string[]> {
|
||||||
|
const adminPassword = process.env.ADMIN_PASSWORD;
|
||||||
|
if (!adminPassword || heslo !== adminPassword) {
|
||||||
|
throw new Error('UNAUTHORIZED');
|
||||||
|
}
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
throw new Error('Název obchodu nesmí být prázdný');
|
||||||
|
}
|
||||||
|
const stores = await getStores();
|
||||||
|
if (stores.some(s => s.toLowerCase() === trimmed.toLowerCase())) {
|
||||||
|
throw new Error('Obchod s tímto názvem již existuje');
|
||||||
|
}
|
||||||
|
const updated = [...stores, trimmed];
|
||||||
|
await storage.setData(STORES_KEY, updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeStore(name: string, heslo: string): Promise<string[]> {
|
||||||
|
const adminPassword = process.env.ADMIN_PASSWORD;
|
||||||
|
if (!adminPassword || heslo !== adminPassword) {
|
||||||
|
throw new Error('UNAUTHORIZED');
|
||||||
|
}
|
||||||
|
const stores = await getStores();
|
||||||
|
const updated = stores.filter(s => s.toLowerCase() !== name.trim().toLowerCase());
|
||||||
|
await storage.setData(STORES_KEY, updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { resetMemoryStorage } from '../storage/memory';
|
||||||
|
import { getStores, addStore } from '../stores';
|
||||||
|
import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState } from '../groups';
|
||||||
|
import { GroupState } from '../../../types/gen/types.gen';
|
||||||
|
|
||||||
|
const CREATOR = 'tomas';
|
||||||
|
const USER = 'petr';
|
||||||
|
const ADMIN_PW = 'testadmin';
|
||||||
|
const STORE = 'McDonald\'s';
|
||||||
|
const TODAY = new Date('2025-01-10');
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
resetMemoryStorage();
|
||||||
|
process.env.ADMIN_PASSWORD = ADMIN_PW;
|
||||||
|
await addStore(STORE, ADMIN_PW);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.ADMIN_PASSWORD;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createGroup', () => {
|
||||||
|
test('vytvoří skupinu, creator je člen', async () => {
|
||||||
|
const data = await createGroup(CREATOR, STORE, TODAY);
|
||||||
|
expect(data.groups).toHaveLength(1);
|
||||||
|
const group = data.groups![0];
|
||||||
|
expect(group.name).toBe(STORE);
|
||||||
|
expect(group.creatorLogin).toBe(CREATOR);
|
||||||
|
expect(group.state).toBe(GroupState.OPEN);
|
||||||
|
expect(group.members[CREATOR]).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('odmítne název mimo seznam obchodů', async () => {
|
||||||
|
await expect(createGroup(CREATOR, 'Neznámý obchod', TODAY)).rejects.toThrow('seznam');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('vygeneruje unikátní ID', async () => {
|
||||||
|
const d1 = await createGroup(CREATOR, STORE, TODAY);
|
||||||
|
const d2 = await createGroup(USER, STORE, TODAY);
|
||||||
|
expect(d2.groups).toHaveLength(2);
|
||||||
|
expect(d2.groups![1].id).not.toBe(d2.groups![0].id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteGroup', () => {
|
||||||
|
test('creator může smazat skupinu', async () => {
|
||||||
|
const d = await createGroup(CREATOR, STORE, TODAY);
|
||||||
|
const groupId = d.groups![0].id;
|
||||||
|
const result = await deleteGroup(CREATOR, groupId, TODAY);
|
||||||
|
expect(result.groups).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nečlen nemůže smazat skupinu', async () => {
|
||||||
|
const d = await createGroup(CREATOR, STORE, TODAY);
|
||||||
|
const groupId = d.groups![0].id;
|
||||||
|
await expect(deleteGroup(USER, groupId, TODAY)).rejects.toThrow('zakladatel');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('smazání neexistující skupiny vyhodí chybu', async () => {
|
||||||
|
await expect(deleteGroup(CREATOR, 'nonexistent', TODAY)).rejects.toThrow('nalezena');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addGroupMember', () => {
|
||||||
|
let groupId: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const d = await createGroup(CREATOR, STORE, TODAY);
|
||||||
|
groupId = d.groups![0].id;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uživatel se může přidat sám (open)', async () => {
|
||||||
|
const d = await addGroupMember(USER, groupId, USER, TODAY);
|
||||||
|
expect(d.groups![0].members[USER]).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creator může přidat jiného uživatele', async () => {
|
||||||
|
const d = await addGroupMember(CREATOR, groupId, USER, TODAY);
|
||||||
|
expect(d.groups![0].members[USER]).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nečlen nemůže přidat jiného uživatele', async () => {
|
||||||
|
await expect(addGroupMember(USER, groupId, 'dalsi', TODAY)).rejects.toThrow('zakladatel');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nelze přidat do skupiny ve stavu ordered', async () => {
|
||||||
|
await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY);
|
||||||
|
await setGroupState(CREATOR, groupId, GroupState.ORDERED, TODAY);
|
||||||
|
await expect(addGroupMember(USER, groupId, USER, TODAY)).rejects.toThrow('objednáno');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nelze přidat existujícího člena', async () => {
|
||||||
|
await expect(addGroupMember(CREATOR, groupId, CREATOR, TODAY)).rejects.toThrow('již');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removeGroupMember', () => {
|
||||||
|
let groupId: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const d = await createGroup(CREATOR, STORE, TODAY);
|
||||||
|
groupId = d.groups![0].id;
|
||||||
|
await addGroupMember(CREATOR, groupId, USER, TODAY);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('člen se může odhlásit sám', async () => {
|
||||||
|
const d = await removeGroupMember(USER, groupId, USER, TODAY);
|
||||||
|
expect(d.groups![0].members[USER]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creator může odebrat jiného člena', async () => {
|
||||||
|
const d = await removeGroupMember(CREATOR, groupId, USER, TODAY);
|
||||||
|
expect(d.groups![0].members[USER]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nelze odebrat zakladatele', async () => {
|
||||||
|
await expect(removeGroupMember(CREATOR, groupId, CREATOR, TODAY)).rejects.toThrow('Zakladatel');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nečlen nemůže odebrat jiného', async () => {
|
||||||
|
await expect(removeGroupMember(USER, groupId, CREATOR, TODAY)).rejects.toThrow('zakladatel');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateGroupMember', () => {
|
||||||
|
let groupId: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const d = await createGroup(CREATOR, STORE, TODAY);
|
||||||
|
groupId = d.groups![0].id;
|
||||||
|
await addGroupMember(CREATOR, groupId, USER, TODAY);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('člen může upravit svá data (open)', async () => {
|
||||||
|
const d = await updateGroupMember(USER, groupId, USER, { amount: 150, note: 'Big Mac' }, TODAY);
|
||||||
|
expect(d.groups![0].members[USER].amount).toBe(150);
|
||||||
|
expect(d.groups![0].members[USER].note).toBe('Big Mac');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creator může upravit data jiného člena', async () => {
|
||||||
|
const d = await updateGroupMember(CREATOR, groupId, USER, { amount: 200 }, TODAY);
|
||||||
|
expect(d.groups![0].members[USER].amount).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('člen nemůže upravit data jiného (locked)', async () => {
|
||||||
|
await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY);
|
||||||
|
await expect(updateGroupMember(USER, groupId, USER, { amount: 100 }, TODAY)).rejects.toThrow('uzamčeno');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nikdo nemůže upravit při stavu ordered', async () => {
|
||||||
|
await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY);
|
||||||
|
await setGroupState(CREATOR, groupId, GroupState.ORDERED, TODAY);
|
||||||
|
await expect(updateGroupMember(CREATOR, groupId, USER, { amount: 100 }, TODAY)).rejects.toThrow('objednáno');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setGroupState', () => {
|
||||||
|
let groupId: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const d = await createGroup(CREATOR, STORE, TODAY);
|
||||||
|
groupId = d.groups![0].id;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('open → locked', async () => {
|
||||||
|
const d = await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY);
|
||||||
|
expect(d.groups![0].state).toBe(GroupState.LOCKED);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('locked → open (odemčení)', async () => {
|
||||||
|
await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY);
|
||||||
|
const d = await setGroupState(CREATOR, groupId, GroupState.OPEN, TODAY);
|
||||||
|
expect(d.groups![0].state).toBe(GroupState.OPEN);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('locked → ordered', async () => {
|
||||||
|
await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY);
|
||||||
|
const d = await setGroupState(CREATOR, groupId, GroupState.ORDERED, TODAY);
|
||||||
|
expect(d.groups![0].state).toBe(GroupState.ORDERED);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('open → ordered není povoleno', async () => {
|
||||||
|
await expect(setGroupState(CREATOR, groupId, GroupState.ORDERED, TODAY)).rejects.toThrow('Nelze přejít');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ordered je terminální stav', async () => {
|
||||||
|
await setGroupState(CREATOR, groupId, GroupState.LOCKED, TODAY);
|
||||||
|
await setGroupState(CREATOR, groupId, GroupState.ORDERED, TODAY);
|
||||||
|
await expect(setGroupState(CREATOR, groupId, GroupState.OPEN, TODAY)).rejects.toThrow('Nelze přejít');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nečlen nemůže měnit stav', async () => {
|
||||||
|
await expect(setGroupState(USER, groupId, GroupState.LOCKED, TODAY)).rejects.toThrow('zakladatel');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { resetMemoryStorage } from '../storage/memory';
|
||||||
|
import { getStores, addStore, removeStore } from '../stores';
|
||||||
|
|
||||||
|
const ADMIN_PW = 'testadmin';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetMemoryStorage();
|
||||||
|
process.env.ADMIN_PASSWORD = ADMIN_PW;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.ADMIN_PASSWORD;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getStores', () => {
|
||||||
|
test('vrátí prázdné pole, pokud seznam neexistuje', async () => {
|
||||||
|
const stores = await getStores();
|
||||||
|
expect(stores).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addStore', () => {
|
||||||
|
test('přidá obchod se správným heslem', async () => {
|
||||||
|
const stores = await addStore('McDonald\'s', ADMIN_PW);
|
||||||
|
expect(stores).toContain('McDonald\'s');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('vyhodí UNAUTHORIZED s nesprávným heslem', async () => {
|
||||||
|
await expect(addStore('KFC', 'spatne')).rejects.toThrow('UNAUTHORIZED');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('vyhodí UNAUTHORIZED pokud ADMIN_PASSWORD není nastaven', async () => {
|
||||||
|
delete process.env.ADMIN_PASSWORD;
|
||||||
|
await expect(addStore('KFC', '')).rejects.toThrow('UNAUTHORIZED');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('odmítne prázdný název', async () => {
|
||||||
|
await expect(addStore(' ', ADMIN_PW)).rejects.toThrow('prázdný');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('odmítne duplikát (case-insensitive)', async () => {
|
||||||
|
await addStore('McDonald\'s', ADMIN_PW);
|
||||||
|
await expect(addStore('mcdonald\'s', ADMIN_PW)).rejects.toThrow('existuje');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('vrátí aktualizovaný seznam', async () => {
|
||||||
|
await addStore('McDonald\'s', ADMIN_PW);
|
||||||
|
const stores = await addStore('KFC', ADMIN_PW);
|
||||||
|
expect(stores).toHaveLength(2);
|
||||||
|
expect(stores).toContain('McDonald\'s');
|
||||||
|
expect(stores).toContain('KFC');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removeStore', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await addStore('McDonald\'s', ADMIN_PW);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('odebere obchod se správným heslem', async () => {
|
||||||
|
const stores = await removeStore('McDonald\'s', ADMIN_PW);
|
||||||
|
expect(stores).not.toContain('McDonald\'s');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('case-insensitive odebrání', async () => {
|
||||||
|
const stores = await removeStore('MCDONALD\'S', ADMIN_PW);
|
||||||
|
expect(stores).not.toContain('McDonald\'s');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('vyhodí UNAUTHORIZED s nesprávným heslem', async () => {
|
||||||
|
await expect(removeStore('McDonald\'s', 'spatne')).rejects.toThrow('UNAUTHORIZED');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('odebrání neexistujícího obchodu nic nerozbije', async () => {
|
||||||
|
const stores = await removeStore('Neexistuje', ADMIN_PW);
|
||||||
|
expect(stores).toContain('McDonald\'s');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -81,6 +81,28 @@ paths:
|
|||||||
/changelogs:
|
/changelogs:
|
||||||
$ref: "./paths/changelogs/getChangelogs.yml"
|
$ref: "./paths/changelogs/getChangelogs.yml"
|
||||||
|
|
||||||
|
# Skupiny objednávek (/api/groups)
|
||||||
|
/groups/create:
|
||||||
|
$ref: "./paths/groups/createGroup.yml"
|
||||||
|
/groups/delete:
|
||||||
|
$ref: "./paths/groups/deleteGroup.yml"
|
||||||
|
/groups/addMember:
|
||||||
|
$ref: "./paths/groups/addMember.yml"
|
||||||
|
/groups/removeMember:
|
||||||
|
$ref: "./paths/groups/removeMember.yml"
|
||||||
|
/groups/updateMember:
|
||||||
|
$ref: "./paths/groups/updateMember.yml"
|
||||||
|
/groups/setState:
|
||||||
|
$ref: "./paths/groups/setState.yml"
|
||||||
|
|
||||||
|
# Správa obchodů (/api/stores)
|
||||||
|
/stores:
|
||||||
|
$ref: "./paths/stores/listStores.yml"
|
||||||
|
/stores/add:
|
||||||
|
$ref: "./paths/stores/addStore.yml"
|
||||||
|
/stores/delete:
|
||||||
|
$ref: "./paths/stores/deleteStore.yml"
|
||||||
|
|
||||||
# DEV endpointy (/api/dev)
|
# DEV endpointy (/api/dev)
|
||||||
/dev/generate:
|
/dev/generate:
|
||||||
$ref: "./paths/dev/generate.yml"
|
$ref: "./paths/dev/generate.yml"
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
post:
|
||||||
|
operationId: addGroupMember
|
||||||
|
summary: Přidá uživatele do skupiny (sebe, nebo jiného jako zakladatel).
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
description: ID skupiny
|
||||||
|
type: string
|
||||||
|
login:
|
||||||
|
description: Login uživatele (volitelné — pokud není zadán, přidá přihlášeného uživatele)
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
$ref: "../../api.yml#/components/responses/ClientDataResponse"
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
post:
|
||||||
|
operationId: createGroup
|
||||||
|
summary: Vytvoří novou skupinu objednávky pro aktuální den.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
description: Název obchodu/restaurace (musí být v seznamu povolených obchodů)
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
$ref: "../../api.yml#/components/responses/ClientDataResponse"
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
post:
|
||||||
|
operationId: deleteGroup
|
||||||
|
summary: Smaže skupinu objednávky (pouze zakladatel).
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
description: ID skupiny
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
$ref: "../../api.yml#/components/responses/ClientDataResponse"
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
post:
|
||||||
|
operationId: removeGroupMember
|
||||||
|
summary: Odebere uživatele ze skupiny (sebe, nebo jiného jako zakladatel).
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- login
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
description: ID skupiny
|
||||||
|
type: string
|
||||||
|
login:
|
||||||
|
description: Login uživatele k odebrání
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
$ref: "../../api.yml#/components/responses/ClientDataResponse"
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
post:
|
||||||
|
operationId: setGroupState
|
||||||
|
summary: Změní stav skupiny (pouze zakladatel).
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- state
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
description: ID skupiny
|
||||||
|
type: string
|
||||||
|
state:
|
||||||
|
$ref: "../../schemas/_index.yml#/GroupState"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
$ref: "../../api.yml#/components/responses/ClientDataResponse"
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
post:
|
||||||
|
operationId: updateGroupMember
|
||||||
|
summary: Aktualizuje data člena skupiny (částka, poznámka, příplatek).
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- login
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
description: ID skupiny
|
||||||
|
type: string
|
||||||
|
login:
|
||||||
|
description: Login člena ke změně
|
||||||
|
type: string
|
||||||
|
amount:
|
||||||
|
description: Částka k úhradě v Kč
|
||||||
|
type: number
|
||||||
|
note:
|
||||||
|
description: Poznámka
|
||||||
|
type: string
|
||||||
|
surchargeText:
|
||||||
|
description: Popis příplatku
|
||||||
|
type: string
|
||||||
|
surchargeAmount:
|
||||||
|
description: Výše příplatku v Kč
|
||||||
|
type: number
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
$ref: "../../api.yml#/components/responses/ClientDataResponse"
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
post:
|
||||||
|
operationId: addStore
|
||||||
|
summary: Přidá obchod do seznamu povolených (vyžaduje admin heslo).
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
- heslo
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
description: Název obchodu/restaurace
|
||||||
|
type: string
|
||||||
|
heslo:
|
||||||
|
description: Admin heslo (ADMIN_PASSWORD)
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Obchod byl přidán
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
post:
|
||||||
|
operationId: deleteStore
|
||||||
|
summary: Odebere obchod ze seznamu povolených (vyžaduje admin heslo).
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
- heslo
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
description: Název obchodu/restaurace k odebrání
|
||||||
|
type: string
|
||||||
|
heslo:
|
||||||
|
description: Admin heslo (ADMIN_PASSWORD)
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Obchod byl odebrán
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
get:
|
||||||
|
operationId: listStores
|
||||||
|
summary: Vrátí seznam povolených obchodů/restaurací.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Seznam obchodů
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
@@ -66,6 +66,16 @@ ClientData:
|
|||||||
slot:
|
slot:
|
||||||
description: Slot jídla, ke kterému se tato data vztahují
|
description: Slot jídla, ke kterému se tato data vztahují
|
||||||
$ref: "#/MealSlot"
|
$ref: "#/MealSlot"
|
||||||
|
groups:
|
||||||
|
description: Skupiny objednávajících pro extra slot
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/OrderGroup"
|
||||||
|
stores:
|
||||||
|
description: Seznam povolených obchodů/restaurací pro extra objednávky
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
|
||||||
# --- OBĚDY ---
|
# --- OBĚDY ---
|
||||||
UserLunchChoice:
|
UserLunchChoice:
|
||||||
@@ -674,6 +684,68 @@ ClearMockDataRequest:
|
|||||||
description: Index dne v týdnu (0 = pondělí, 4 = pátek). Pokud není zadán, použije se aktuální den.
|
description: Index dne v týdnu (0 = pondělí, 4 = pátek). Pokud není zadán, použije se aktuální den.
|
||||||
$ref: "#/DayIndex"
|
$ref: "#/DayIndex"
|
||||||
|
|
||||||
|
# --- SKUPINOVÉ OBJEDNÁVKY ---
|
||||||
|
GroupState:
|
||||||
|
description: Stav skupiny objednávky
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- open
|
||||||
|
- locked
|
||||||
|
- ordered
|
||||||
|
x-enum-varnames:
|
||||||
|
- OPEN
|
||||||
|
- LOCKED
|
||||||
|
- ORDERED
|
||||||
|
|
||||||
|
OrderGroupMember:
|
||||||
|
description: Data člena skupiny objednávky
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
properties:
|
||||||
|
amount:
|
||||||
|
description: Částka k úhradě v Kč
|
||||||
|
type: number
|
||||||
|
note:
|
||||||
|
description: Volitelná poznámka (např. co si objednává)
|
||||||
|
type: string
|
||||||
|
surchargeText:
|
||||||
|
description: Popis příplatku
|
||||||
|
type: string
|
||||||
|
surchargeAmount:
|
||||||
|
description: Výše příplatku v Kč
|
||||||
|
type: number
|
||||||
|
|
||||||
|
OrderGroup:
|
||||||
|
description: Skupina uživatelů objednávajících z jednoho místa
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- name
|
||||||
|
- creatorLogin
|
||||||
|
- state
|
||||||
|
- members
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
description: Unikátní identifikátor skupiny
|
||||||
|
type: string
|
||||||
|
name:
|
||||||
|
description: Název obchodu/restaurace
|
||||||
|
type: string
|
||||||
|
creatorLogin:
|
||||||
|
description: Login zakladatele skupiny
|
||||||
|
type: string
|
||||||
|
state:
|
||||||
|
$ref: "#/GroupState"
|
||||||
|
members:
|
||||||
|
description: Členové skupiny
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
$ref: "#/OrderGroupMember"
|
||||||
|
tipTotal:
|
||||||
|
description: Celkové dýško (Kč), vyplněno při přechodu do stavu ordered
|
||||||
|
type: number
|
||||||
|
|
||||||
# --- NEVYŘÍZENÉ QR KÓDY ---
|
# --- NEVYŘÍZENÉ QR KÓDY ---
|
||||||
PendingQr:
|
PendingQr:
|
||||||
description: Nevyřízený QR kód pro platbu
|
description: Nevyřízený QR kód pro platbu
|
||||||
|
|||||||
Reference in New Issue
Block a user