Compare commits
9 Commits
5f903797f1
...
c7f78cf2c9
| Author | SHA1 | Date | |
|---|---|---|---|
| c7f78cf2c9 | |||
| 1efe2b8f7d | |||
| 5f03471541 | |||
| 21d7224fb4 | |||
| abc3d070cc | |||
| cca751752d | |||
| d2f45be2d3 | |||
| 936b33cc80 | |||
| 774be3df6d |
Vendored
+67
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "types: openapi-ts",
|
||||
"type": "shell",
|
||||
"command": "yarn openapi-ts",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/types"
|
||||
},
|
||||
"presentation": {
|
||||
"reveal": "silent",
|
||||
"panel": "dedicated"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "server: startReload",
|
||||
"type": "shell",
|
||||
"command": "yarn startReload",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/server",
|
||||
"env": {
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
},
|
||||
"isBackground": true,
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "dedicated",
|
||||
"group": "dev"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "client: vite",
|
||||
"type": "shell",
|
||||
"command": "yarn start",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/client"
|
||||
},
|
||||
"isBackground": true,
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "dedicated",
|
||||
"group": "dev"
|
||||
},
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "dev: server+client",
|
||||
"dependsOn": [
|
||||
"server: startReload",
|
||||
"client: vite"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "dev: all",
|
||||
"dependsOrder": "sequence",
|
||||
"dependsOn": [
|
||||
"types: openapi-ts",
|
||||
"dev: server+client"
|
||||
],
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
||||
+6
-7
@@ -7,6 +7,7 @@ self.addEventListener('push', (event) => {
|
||||
body: data.body,
|
||||
icon: '/favicon.ico',
|
||||
tag: 'lunch-reminder',
|
||||
data: { login: data.login, token: data.token },
|
||||
actions: [
|
||||
{ action: 'neobedvam', title: 'Mám vlastní/neobědvám' },
|
||||
],
|
||||
@@ -18,28 +19,26 @@ self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
|
||||
if (event.action === 'neobedvam') {
|
||||
const { login, token } = event.notification.data ?? {};
|
||||
if (login && token) {
|
||||
event.waitUntil(
|
||||
self.registration.pushManager.getSubscription().then((subscription) => {
|
||||
if (!subscription) return;
|
||||
return fetch('/api/notifications/push/quickChoice', {
|
||||
fetch('/api/notifications/push/quickChoice', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ endpoint: subscription.endpoint }),
|
||||
});
|
||||
body: JSON.stringify({ login, token }),
|
||||
})
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: 'window' }).then((clientList) => {
|
||||
// Pokud je již otevřené okno, zaostříme na něj
|
||||
for (const client of clientList) {
|
||||
if (client.url.includes(self.location.origin) && 'focus' in client) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
// Jinak otevřeme nové
|
||||
return self.clients.openWindow('/');
|
||||
})
|
||||
);
|
||||
|
||||
+14
-2
@@ -1,6 +1,6 @@
|
||||
import React, { useContext, useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import { EVENT_DISCONNECT, EVENT_MESSAGE, SocketContext } from './context/socket';
|
||||
import { EVENT_DISCONNECT, EVENT_MESSAGE, EVENT_PENDING_QR, SocketContext } from './context/socket';
|
||||
import { useAuth } from './context/auth';
|
||||
import Login from './Login';
|
||||
import { Alert, Button, Col, Form, Row, Table } from 'react-bootstrap';
|
||||
@@ -19,7 +19,7 @@ import { getHumanDateTime, isInTheFuture, formatDateString } from './Utils';
|
||||
import NoteModal from './components/modals/NoteModal';
|
||||
import PayForAllModal from './components/modals/PayForAllModal';
|
||||
import { useEasterEgg } from './context/eggs';
|
||||
import { ClientData, Food, PizzaOrder, DepartureTime, PizzaDayState, Restaurant, RestaurantDayMenu, RestaurantDayMenuMap, LunchChoice, LocationLunchChoicesMap, UserLunchChoice, PizzaVariant, getData, getEasterEggImage, addPizza, removePizza, updatePizzaDayNote, createPizzaDay, deletePizzaDay, lockPizzaDay, unlockPizzaDay, finishOrder, finishDelivery, addChoice, jdemeObed, removeChoices, removeChoice, updateNote, changeDepartureTime, setBuyer, dismissQr, generateQr } from '../../types';
|
||||
import { ClientData, Food, MealSlot, PendingQr, PizzaOrder, DepartureTime, PizzaDayState, Restaurant, RestaurantDayMenu, RestaurantDayMenuMap, LunchChoice, LocationLunchChoicesMap, UserLunchChoice, PizzaVariant, getData, getEasterEggImage, addPizza, removePizza, updatePizzaDayNote, createPizzaDay, deletePizzaDay, lockPizzaDay, unlockPizzaDay, finishOrder, finishDelivery, addChoice, jdemeObed, removeChoices, removeChoice, updateNote, changeDepartureTime, setBuyer, dismissQr, generateQr } from '../../types';
|
||||
import { getLunchChoiceName } from './enums';
|
||||
// import FallingLeaves, { LEAF_PRESETS, LEAF_COLOR_THEMES } from './FallingLeaves';
|
||||
// import './FallingLeaves.scss';
|
||||
@@ -126,19 +126,31 @@ function App() {
|
||||
});
|
||||
socket.on(EVENT_MESSAGE, (newData: ClientData) => {
|
||||
// console.log("Přijata nová data ze socketu", newData);
|
||||
if (newData.slot === MealSlot.EXTRA) return;
|
||||
// Aktualizujeme pouze, pokud jsme dostali data pro den, který máme aktuálně zobrazený
|
||||
if (dayIndexRef.current == null || newData.dayIndex === dayIndexRef.current) {
|
||||
setData(newData);
|
||||
}
|
||||
});
|
||||
socket.on(EVENT_PENDING_QR, (pendingQr: PendingQr) => {
|
||||
setData(prev => prev ? { ...prev, pendingQrs: [...(prev.pendingQrs ?? []), pendingQr] } : prev);
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.off(EVENT_CONNECT);
|
||||
socket.off(EVENT_DISCONNECT);
|
||||
socket.off(EVENT_MESSAGE);
|
||||
socket.off(EVENT_PENDING_QR);
|
||||
}
|
||||
}, [socket]);
|
||||
|
||||
// Připojení do osobní socket místnosti po přihlášení
|
||||
useEffect(() => {
|
||||
if (auth?.login) {
|
||||
socket.emit('join', auth.login);
|
||||
}
|
||||
}, [auth?.login, socket]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!auth?.login || !data?.choices) {
|
||||
return
|
||||
|
||||
@@ -5,14 +5,24 @@ import { SnowOverlay } from 'react-snow-overlay';
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import { SocketContext, socket } from "./context/socket";
|
||||
import StatsPage from "./pages/StatsPage";
|
||||
import OrderGroupsPage from "./pages/OrderGroupsPage";
|
||||
import App from "./App";
|
||||
|
||||
export const STATS_URL = '/stats';
|
||||
export const OBJEDNANI_URL = '/objednani';
|
||||
|
||||
export default function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path={STATS_URL} element={<StatsPage />} />
|
||||
<Route path={OBJEDNANI_URL} element={
|
||||
<ProvideSettings>
|
||||
<SocketContext.Provider value={socket}>
|
||||
<OrderGroupsPage />
|
||||
<ToastContainer />
|
||||
</SocketContext.Provider>
|
||||
</ProvideSettings>
|
||||
} />
|
||||
<Route path="/" element={
|
||||
<ProvideSettings>
|
||||
<SocketContext.Provider value={socket}>
|
||||
|
||||
@@ -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 } 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,6 +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(OBJEDNANI_URL)}>Objednání</NavDropdown.Item>
|
||||
<NavDropdown.Item onClick={() => {
|
||||
getChangelogs().then(response => {
|
||||
const entries = response.data ?? {};
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Modal, Button, Form, Table, Alert } from "react-bootstrap";
|
||||
import { updateGroupFees, OrderGroup, OrderGroupMember } from "../../../../types";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
group: OrderGroup;
|
||||
onSaved: (data: any) => void;
|
||||
};
|
||||
|
||||
function parseNum(s: string): number {
|
||||
const n = parseFloat(s.replace(',', '.'));
|
||||
return isNaN(n) || n < 0 ? 0 : Math.round(n * 100) / 100;
|
||||
}
|
||||
|
||||
function computeMemberTotal(member: OrderGroupMember, feeShare: number, discountType: string, discountValue: number, memberCount: number): number {
|
||||
const base = member.amount ?? 0;
|
||||
const surcharge = member.surchargeAmount ?? 0;
|
||||
const discount = discountType === 'percent'
|
||||
? Math.round((base + surcharge) * discountValue / 100 * 100) / 100
|
||||
: Math.round(discountValue / memberCount * 100) / 100;
|
||||
return Math.round((base + surcharge + feeShare - discount) * 100) / 100;
|
||||
}
|
||||
|
||||
export default function EditGroupFeesModal({ isOpen, onClose, group, onSaved }: Readonly<Props>) {
|
||||
const [fees, setFees] = useState('');
|
||||
const [shipping, setShipping] = useState('');
|
||||
const [tip, setTip] = useState('');
|
||||
const [discountType, setDiscountType] = useState<'percent' | 'fixed'>('percent');
|
||||
const [discountValue, setDiscountValue] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setFees(group.fees ? String(group.fees) : '');
|
||||
setShipping(group.shipping ? String(group.shipping) : '');
|
||||
setTip(group.tip ? String(group.tip) : '');
|
||||
setDiscountType((group.discountType as 'percent' | 'fixed') ?? 'percent');
|
||||
setDiscountValue(group.discountValue ? String(group.discountValue) : '');
|
||||
setError(null);
|
||||
}, [isOpen, group]);
|
||||
|
||||
const memberEntries = Object.entries(group.members) as [string, OrderGroupMember][];
|
||||
const memberCount = memberEntries.length;
|
||||
|
||||
const feesNum = parseNum(fees);
|
||||
const shippingNum = parseNum(shipping);
|
||||
const tipNum = parseNum(tip);
|
||||
const discountNum = parseNum(discountValue);
|
||||
const totalFees = feesNum + shippingNum + tipNum;
|
||||
const feeShare = memberCount > 0 ? Math.round(totalFees / memberCount * 100) / 100 : 0;
|
||||
|
||||
const handleSave = async () => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const body: Record<string, any> = { id: group.id };
|
||||
body.fees = feesNum;
|
||||
body.shipping = shippingNum;
|
||||
body.tip = tipNum;
|
||||
if (discountNum > 0) {
|
||||
body.discountType = discountType;
|
||||
body.discountValue = discountNum;
|
||||
} else {
|
||||
body.discountType = '';
|
||||
body.discountValue = 0;
|
||||
}
|
||||
const res = await updateGroupFees({ body });
|
||||
if (res.error) {
|
||||
setError((res.error as any).error || 'Nastala chyba');
|
||||
} else {
|
||||
onSaved(res.data);
|
||||
onClose();
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e.message || 'Nastala chyba');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal show={isOpen} onHide={onClose} size="lg">
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title><h2>Poplatky skupiny — {group.name}</h2></Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
{error && (
|
||||
<Alert variant="danger" onClose={() => setError(null)} dismissible>{error}</Alert>
|
||||
)}
|
||||
|
||||
<div className="d-flex gap-3 flex-wrap mb-3">
|
||||
<Form.Group>
|
||||
<Form.Label>Poplatky (Kč)</Form.Label>
|
||||
<Form.Control
|
||||
type="number" min={0} step={0.01}
|
||||
value={fees} onChange={e => setFees(e.target.value)}
|
||||
placeholder="0" style={{ width: 110 }}
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group>
|
||||
<Form.Label>Doprava (Kč)</Form.Label>
|
||||
<Form.Control
|
||||
type="number" min={0} step={0.01}
|
||||
value={shipping} onChange={e => setShipping(e.target.value)}
|
||||
placeholder="0" style={{ width: 110 }}
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group>
|
||||
<Form.Label>Spropitné (Kč)</Form.Label>
|
||||
<Form.Control
|
||||
type="number" min={0} step={0.01}
|
||||
value={tip} onChange={e => setTip(e.target.value)}
|
||||
placeholder="0" style={{ width: 110 }}
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
/>
|
||||
</Form.Group>
|
||||
</div>
|
||||
|
||||
<div className="d-flex gap-3 align-items-end flex-wrap mb-3">
|
||||
<Form.Group>
|
||||
<Form.Label>Sleva</Form.Label>
|
||||
<div className="d-flex gap-2 align-items-center">
|
||||
<Form.Select
|
||||
value={discountType}
|
||||
onChange={e => setDiscountType(e.target.value as 'percent' | 'fixed')}
|
||||
style={{ width: 160 }}
|
||||
>
|
||||
<option value="percent">Procentuální (%)</option>
|
||||
<option value="fixed">Pevná částka (Kč)</option>
|
||||
</Form.Select>
|
||||
<Form.Control
|
||||
type="number" min={0} step={discountType === 'percent' ? 1 : 0.01}
|
||||
value={discountValue} onChange={e => setDiscountValue(e.target.value)}
|
||||
placeholder="0" style={{ width: 100 }}
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
/>
|
||||
<span className="text-muted">{discountType === 'percent' ? '%' : 'Kč'}</span>
|
||||
</div>
|
||||
</Form.Group>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
<h6>Náhled celkových částek ({memberCount} členů, {feeShare > 0 ? `poplatek ${feeShare} Kč/os.` : 'bez poplatku'})</h6>
|
||||
<Table size="sm" bordered>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Člen</th>
|
||||
<th className="text-end">Základ</th>
|
||||
<th className="text-end">Příplatek</th>
|
||||
<th className="text-end">Poplatek</th>
|
||||
<th className="text-end">Sleva</th>
|
||||
<th className="text-end fw-bold">Celkem</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{memberEntries.map(([login, member]) => {
|
||||
const base = member.amount ?? 0;
|
||||
const surcharge = member.surchargeAmount ?? 0;
|
||||
const discount = discountNum > 0
|
||||
? (discountType === 'percent'
|
||||
? Math.round((base + surcharge) * discountNum / 100 * 100) / 100
|
||||
: Math.round(discountNum / memberCount * 100) / 100)
|
||||
: 0;
|
||||
const total = computeMemberTotal(member, feeShare, discountType, discountNum, memberCount);
|
||||
return (
|
||||
<tr key={login}>
|
||||
<td><strong>{login}</strong></td>
|
||||
<td className="text-end">{base > 0 ? `${base} Kč` : '—'}</td>
|
||||
<td className="text-end">{surcharge > 0 ? `${surcharge} Kč` : '—'}</td>
|
||||
<td className="text-end">{feeShare > 0 ? `${feeShare} Kč` : '—'}</td>
|
||||
<td className="text-end text-danger">{discount > 0 ? `-${discount} Kč` : '—'}</td>
|
||||
<td className="text-end fw-bold">{total > 0 ? `${total} Kč` : '—'}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="secondary" onClick={onClose} disabled={loading}>Storno</Button>
|
||||
<Button variant="primary" onClick={handleSave} disabled={loading}>
|
||||
{loading ? 'Ukládám...' : 'Uložit'}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -84,12 +84,18 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
|
||||
if (includedDiners.length === 0) return 0;
|
||||
const tip = parseAmount(tipTotal);
|
||||
if (tip === null || tip === 0) return 0;
|
||||
return Math.round((tip / includedDiners.length) * 100) / 100;
|
||||
const totalPeople = includedDiners.length + 1; // +1 for payer
|
||||
return Math.round((tip / totalPeople) * 100) / 100;
|
||||
})();
|
||||
const payerTipShare = (() => {
|
||||
const tip = parseAmount(tipTotal);
|
||||
if (!tip) return 0;
|
||||
return Math.round((tip - tipPerPerson * includedDiners.length) * 100) / 100;
|
||||
})();
|
||||
|
||||
const getTotal = (d: DinerEntry): number => {
|
||||
const surcharge = parseAmount(d.surchargeAmount) ?? 0;
|
||||
const tip = d.included && d.login !== payerLogin ? tipPerPerson : 0;
|
||||
const tip = d.login === payerLogin ? payerTipShare : tipPerPerson;
|
||||
return Math.round((d.baseAmount + surcharge + tip) * 100) / 100;
|
||||
};
|
||||
|
||||
@@ -167,7 +173,7 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<p>Zaplatili jste za skupinu v restauraci. Nastavte příplatky a dýško, poté vygenerujte QR kódy pro ostatní.</p>
|
||||
<p>Zaplatili jste za skupinu v restauraci. Nastavte příplatky a společné poplatky, poté vygenerujte QR kódy pro ostatní.</p>
|
||||
|
||||
{!hasMenu && (
|
||||
<Alert variant="info">
|
||||
@@ -194,7 +200,7 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
|
||||
<th>Strávník</th>
|
||||
<th>Jídla</th>
|
||||
<th style={{ width: 220 }}>Příplatek</th>
|
||||
<th style={{ width: 90 }}>Dýško</th>
|
||||
<th style={{ width: 90 }}>Poplatek</th>
|
||||
<th style={{ width: 90 }}>Celkem</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -225,14 +231,13 @@ 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}
|
||||
disabled={!isPayer && !d.included}
|
||||
size="sm"
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
/>
|
||||
@@ -241,19 +246,18 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
|
||||
placeholder="Kč"
|
||||
value={d.surchargeAmount}
|
||||
onChange={e => handleSurchargeAmount(d.login, e.target.value)}
|
||||
disabled={!d.included}
|
||||
disabled={!isPayer && !d.included}
|
||||
size="sm"
|
||||
style={{ width: 70 }}
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-end">
|
||||
{!isPayer && d.included ? `${tipPerPerson} Kč` : '—'}
|
||||
{(() => { const s = isPayer ? payerTipShare : tipPerPerson; return s > 0 ? `${s} Kč` : '—'; })()}
|
||||
</td>
|
||||
<td className="text-end fw-bold">
|
||||
{!isPayer ? `${total} Kč` : '—'}
|
||||
{`${total} Kč`}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
@@ -262,7 +266,7 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
|
||||
</Table>
|
||||
|
||||
<div className="d-flex align-items-center gap-2 mt-2">
|
||||
<label className="mb-0 text-nowrap">Dýško celkem (Kč):</label>
|
||||
<label className="mb-0 text-nowrap">Poplatky celkem (Kč):</label>
|
||||
<Form.Control
|
||||
type="text"
|
||||
placeholder="0"
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Modal, Button, Form, Table, Alert } from "react-bootstrap";
|
||||
import { generateQr, OrderGroup, OrderGroupMember, QrRecipient } from "../../../../types";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
group: OrderGroup;
|
||||
payerLogin: string;
|
||||
bankAccount: string;
|
||||
bankAccountHolder: string;
|
||||
groupId?: string;
|
||||
};
|
||||
|
||||
type DinerEntry = {
|
||||
login: string;
|
||||
member: OrderGroupMember;
|
||||
included: boolean;
|
||||
};
|
||||
|
||||
export default function PayForGroupModal({ isOpen, onClose, group, payerLogin, bankAccount, bankAccountHolder, groupId }: Readonly<Props>) {
|
||||
const [diners, setDiners] = useState<DinerEntry[]>([]);
|
||||
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,
|
||||
member,
|
||||
included: login !== payerLogin,
|
||||
}));
|
||||
setDiners(entries);
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
}, [isOpen, group, payerLogin]);
|
||||
|
||||
const memberCount = diners.length;
|
||||
const fees = group.fees ?? 0;
|
||||
const shipping = group.shipping ?? 0;
|
||||
const tip = group.tip ?? 0;
|
||||
const totalFees = fees + shipping + tip;
|
||||
const feeShare = memberCount > 0 ? Math.round(totalFees / memberCount * 100) / 100 : 0;
|
||||
|
||||
const getMemberTotal = (entry: DinerEntry): number => {
|
||||
const base = entry.member.amount ?? 0;
|
||||
const surcharge = entry.member.surchargeAmount ?? 0;
|
||||
const discountType = group.discountType;
|
||||
const discountValue = group.discountValue ?? 0;
|
||||
const discount = discountValue > 0
|
||||
? (discountType === 'percent'
|
||||
? Math.round((base + surcharge) * discountValue / 100 * 100) / 100
|
||||
: Math.round(discountValue / memberCount * 100) / 100)
|
||||
: 0;
|
||||
return Math.round((base + surcharge + feeShare - discount) * 100) / 100;
|
||||
};
|
||||
|
||||
const includedNonPayers = diners.filter(d => d.included && d.login !== payerLogin);
|
||||
|
||||
const handleInclude = (login: string, checked: boolean) => {
|
||||
setDiners(prev => prev.map(d => d.login === login ? { ...d, included: checked } : d));
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setError(null);
|
||||
const recipients: QrRecipient[] = [];
|
||||
|
||||
for (const d of diners) {
|
||||
if (!d.included || d.login === payerLogin) continue;
|
||||
const total = getMemberTotal(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, ...(groupId ? { groupId } : {}) },
|
||||
});
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const hasFees = totalFees > 0;
|
||||
|
||||
return (
|
||||
<Modal show={isOpen} onHide={onClose} size="lg">
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title><h2>Generovat QR — {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. Vyberte, komu vygenerovat QR kód k úhradě.</p>
|
||||
|
||||
{error && (
|
||||
<Alert variant="danger" onClose={() => setError(null)} dismissible>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{hasFees && (
|
||||
<div className="d-flex gap-3 mb-2 text-muted" style={{ fontSize: '0.9em' }}>
|
||||
{fees > 0 && <span>Poplatky: <strong>{fees} Kč</strong></span>}
|
||||
{shipping > 0 && <span>Doprava: <strong>{shipping} Kč</strong></span>}
|
||||
{tip > 0 && <span>Spropitné: <strong>{tip} Kč</strong></span>}
|
||||
<span>→ {feeShare} Kč/os.</span>
|
||||
</div>
|
||||
)}
|
||||
{group.discountValue != null && group.discountValue > 0 && (
|
||||
<div className="mb-2 text-success" style={{ fontSize: '0.9em' }}>
|
||||
Sleva: {group.discountType === 'percent' ? `${group.discountValue}%` : `${group.discountValue} Kč`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Table striped bordered hover responsive size="sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 40 }}></th>
|
||||
<th>Člen</th>
|
||||
<th style={{ width: 90 }} className="text-end">Základ</th>
|
||||
<th style={{ width: 90 }} className="text-end">Příplatek</th>
|
||||
{hasFees && <th style={{ width: 90 }} className="text-end">Poplatek</th>}
|
||||
<th style={{ width: 90 }} className="text-end fw-bold">Celkem</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{diners.map(d => {
|
||||
const isPayer = d.login === payerLogin;
|
||||
const total = getMemberTotal(d);
|
||||
const surcharge = d.member.surchargeAmount ?? 0;
|
||||
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>
|
||||
{d.member.surchargeText && (
|
||||
<small className="text-muted ms-1">({d.member.surchargeText})</small>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-end">
|
||||
{(d.member.amount ?? 0) > 0 ? `${d.member.amount} Kč` : <span className="text-muted">—</span>}
|
||||
</td>
|
||||
<td className="text-end">
|
||||
{surcharge > 0 ? `${surcharge} Kč` : <span className="text-muted">—</span>}
|
||||
</td>
|
||||
{hasFees && (
|
||||
<td className="text-end">
|
||||
{feeShare > 0 ? `${feeShare} Kč` : '—'}
|
||||
</td>
|
||||
)}
|
||||
<td className="text-end fw-bold">
|
||||
{total > 0 ? `${total} Kč` : <span className="text-muted">—</span>}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -18,3 +18,4 @@ export const SocketContext = React.createContext();
|
||||
export const EVENT_CONNECT = 'connect';
|
||||
export const EVENT_DISCONNECT = 'disconnect';
|
||||
export const EVENT_MESSAGE = 'message';
|
||||
export const EVENT_PENDING_QR = 'pendingQr';
|
||||
|
||||
@@ -0,0 +1,591 @@
|
||||
import { useContext, useEffect, useRef, useState } from 'react';
|
||||
import { Alert, Badge, Button, Card, Form, Modal, OverlayTrigger, Table, Tooltip } from 'react-bootstrap';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faTrashCan } from '@fortawesome/free-regular-svg-icons';
|
||||
import { faBasketShopping, faCircleCheck, faGear, faLock, faLockOpen, faSearch, faUserPlus } from '@fortawesome/free-solid-svg-icons';
|
||||
import {
|
||||
ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember,
|
||||
getData, createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes,
|
||||
} 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 StoreAdminModal from '../components/modals/StoreAdminModal';
|
||||
import PayForGroupModal from '../components/modals/PayForGroupModal';
|
||||
import EditGroupFeesModal from '../components/modals/EditGroupFeesModal';
|
||||
|
||||
const SLOT = MealSlot.EXTRA;
|
||||
const TIME_REGEX = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||
|
||||
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 [editAmounts, setEditAmounts] = useState<Record<string, string>>({});
|
||||
const [editNotes, setEditNotes] = useState<Record<string, string>>({});
|
||||
const [editSurcharges, setEditSurcharges] = useState<Record<string, { text: string; amount: string }>>({});
|
||||
const [editTimes, setEditTimes] = useState<Record<string, { orderedAt: string; deliveryAt: string }>>({});
|
||||
const [payModal, setPayModal] = useState<OrderGroup | null>(null);
|
||||
const [feesModal, setFeesModal] = useState<OrderGroup | null>(null);
|
||||
const [confirmOrderGroup, setConfirmOrderGroup] = useState<OrderGroup | null>(null);
|
||||
const [pageError, setPageError] = useState<string | 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(prev => ({
|
||||
...newData,
|
||||
stores: newData.stores ?? prev?.stores,
|
||||
}));
|
||||
});
|
||||
return () => { socket.off(EVENT_MESSAGE); };
|
||||
}, [socket]);
|
||||
|
||||
const refresh = async (fn: () => Promise<any>): Promise<boolean> => {
|
||||
setPageError(null);
|
||||
const result = await fn();
|
||||
if (result?.error) {
|
||||
setPageError((result.error as any).error || 'Nastala chyba');
|
||||
await fetchData();
|
||||
return false;
|
||||
}
|
||||
if (result?.data) {
|
||||
setData(result.data);
|
||||
socket.emit?.('message', result.data as ClientData);
|
||||
}
|
||||
await fetchData();
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newGroupName || !auth?.login) return;
|
||||
setCreating(true);
|
||||
const ok = await refresh(() => createGroup({ body: { name: newGroupName } }));
|
||||
if (ok) setNewGroupName('');
|
||||
setCreating(false);
|
||||
};
|
||||
|
||||
const handleJoin = (groupId: string) =>
|
||||
refresh(() => addGroupMember({ body: { id: groupId } }));
|
||||
|
||||
const handleToggleLock = (group: OrderGroup) => {
|
||||
const next = group.state === GroupState.OPEN ? GroupState.LOCKED : GroupState.OPEN;
|
||||
return refresh(() => setGroupState({ body: { id: group.id, state: next } }));
|
||||
};
|
||||
|
||||
const handleConfirmOrdered = async (group: OrderGroup) => {
|
||||
setConfirmOrderGroup(null);
|
||||
await refresh(() => setGroupState({ body: { id: group.id, state: GroupState.ORDERED } }));
|
||||
};
|
||||
|
||||
const handleRevertOrdered = (group: OrderGroup) =>
|
||||
refresh(() => setGroupState({ body: { id: group.id, state: GroupState.LOCKED } }));
|
||||
|
||||
const handleDelete = (groupId: string) =>
|
||||
refresh(() => deleteGroup({ body: { id: groupId } }));
|
||||
|
||||
const handleSaveAmount = async (groupId: string, login: string) => {
|
||||
const key = `${groupId}:${login}`;
|
||||
const raw = editAmounts[key];
|
||||
const n = parseFloat(raw ?? '');
|
||||
if (!raw || isNaN(n) || n < 0) {
|
||||
setPageError('Zadejte platnou kladnou částku');
|
||||
return;
|
||||
}
|
||||
const ok = await refresh(() => updateGroupMember({ body: { id: groupId, login, amount: n } }));
|
||||
if (ok) setEditAmounts(prev => { const next = { ...prev }; delete next[key]; return next; });
|
||||
};
|
||||
|
||||
const handleSaveNote = async (groupId: string, login: string) => {
|
||||
const key = `${groupId}:${login}`;
|
||||
const note = editNotes[key] ?? '';
|
||||
const ok = await refresh(() => updateGroupMember({ body: { id: groupId, login, note } }));
|
||||
if (ok) setEditNotes(prev => { const next = { ...prev }; delete next[key]; return next; });
|
||||
};
|
||||
|
||||
const handleSaveSurcharge = async (groupId: string, login: string) => {
|
||||
const key = `${groupId}:${login}`;
|
||||
const surchargeText = editSurcharges[key]?.text ?? '';
|
||||
const rawAmount = editSurcharges[key]?.amount ?? '';
|
||||
const surchargeAmount = rawAmount === '' ? 0 : parseFloat(rawAmount.replace(',', '.'));
|
||||
if (rawAmount !== '' && (isNaN(surchargeAmount) || surchargeAmount < 0)) {
|
||||
setPageError('Zadejte platnou výši příplatku');
|
||||
return;
|
||||
}
|
||||
const ok = await refresh(() => updateGroupMember({ body: { id: groupId, login, surchargeText, surchargeAmount: rawAmount === '' ? 0 : surchargeAmount } }));
|
||||
if (ok) setEditSurcharges(prev => { const next = { ...prev }; delete next[key]; return next; });
|
||||
};
|
||||
|
||||
const handleSaveTimes = async (group: OrderGroup) => {
|
||||
const times = editTimes[group.id];
|
||||
if (!times) return;
|
||||
const { orderedAt, deliveryAt } = times;
|
||||
if (orderedAt && !TIME_REGEX.test(orderedAt)) {
|
||||
setPageError('Čas objednání musí být ve formátu HH:MM');
|
||||
return;
|
||||
}
|
||||
if (deliveryAt && !TIME_REGEX.test(deliveryAt)) {
|
||||
setPageError('Čas doručení musí být ve formátu HH:MM');
|
||||
return;
|
||||
}
|
||||
const ok = await refresh(() => updateGroupTimes({ body: { id: group.id, orderedAt, deliveryAt } }));
|
||||
if (ok) setEditTimes(prev => { const next = { ...prev }; delete next[group.id]; 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>
|
||||
|
||||
{pageError && (
|
||||
<Alert variant="danger" dismissible onClose={() => setPageError(null)} className="mt-2">
|
||||
{pageError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<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][];
|
||||
const memberCount = memberEntries.length;
|
||||
const editingTimes = group.id in editTimes;
|
||||
|
||||
const totalFees = (group.fees ?? 0) + (group.shipping ?? 0) + (group.tip ?? 0);
|
||||
const feeShare = memberCount > 0 ? Math.round(totalFees / memberCount * 100) / 100 : 0;
|
||||
const getMemberTotal = (m: OrderGroupMember) => {
|
||||
const base = m.amount ?? 0;
|
||||
const surcharge = m.surchargeAmount ?? 0;
|
||||
const dv = group.discountValue ?? 0;
|
||||
const discount = dv > 0
|
||||
? (group.discountType === 'percent'
|
||||
? Math.round((base + surcharge) * dv / 100 * 100) / 100
|
||||
: Math.round(dv / memberCount * 100) / 100)
|
||||
: 0;
|
||||
return Math.round((base + surcharge + feeShare - discount) * 100) / 100;
|
||||
};
|
||||
|
||||
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-info" size="sm" onClick={() => setFeesModal(group)} title="Upravit poplatky a slevu">
|
||||
Poplatky
|
||||
</Button>
|
||||
<Button variant="outline-secondary" size="sm" onClick={() => handleToggleLock(group)} title={isLocked ? 'Odemknout' : 'Uzamknout'}>
|
||||
<FontAwesomeIcon icon={isLocked ? faLockOpen : faLock} />
|
||||
</Button>
|
||||
{isLocked && (
|
||||
<Button variant="outline-primary" size="sm" onClick={() => setConfirmOrderGroup(group)}>
|
||||
Objednáno
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline-danger" size="sm" onClick={() => handleDelete(group.id)} title="Smazat skupinu">
|
||||
<FontAwesomeIcon icon={faTrashCan} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{isCreator && isOrdered && (
|
||||
<>
|
||||
{settings?.bankAccount && settings?.holderName && (
|
||||
<Button variant="primary" size="sm" onClick={() => setPayModal(group)}>
|
||||
<FontAwesomeIcon icon={faBasketShopping} className="me-1" />
|
||||
Generovat QR
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline-warning" size="sm" onClick={() => handleRevertOrdered(group)} title="Vrátit na Uzamčeno (smaže QR kódy)">
|
||||
<FontAwesomeIcon icon={faLockOpen} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!isMember && !isOrdered && !isLocked && (
|
||||
<Button variant="outline-success" size="sm" onClick={() => handleJoin(group.id)}>
|
||||
<FontAwesomeIcon icon={faUserPlus} className="me-1" />
|
||||
Přidat se
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Body className="p-0">
|
||||
<Table className="mb-0" size="sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Člen</th>
|
||||
<th style={{ width: 120 }}>Částka (Kč)</th>
|
||||
<th style={{ width: 180 }}>Příplatek</th>
|
||||
<th>Poznámka</th>
|
||||
<th style={{ width: 90 }}>Celkem</th>
|
||||
<th style={{ width: 40 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{memberEntries.map(([memberLogin, member]) => {
|
||||
const key = `${group.id}:${memberLogin}`;
|
||||
const editingAmount = key in editAmounts;
|
||||
const editingNote = key in editNotes;
|
||||
const editingSurcharge = key in editSurcharges;
|
||||
const canEdit = canEditMember(group, memberLogin);
|
||||
const memberTotal = getMemberTotal(member);
|
||||
return (
|
||||
<tr key={memberLogin}>
|
||||
<td>
|
||||
<span className="user-info">
|
||||
<strong>{memberLogin}</strong>
|
||||
{memberLogin === group.creatorLogin && (
|
||||
<OverlayTrigger placement="top" overlay={<Tooltip>Zakladatel / objednávající</Tooltip>}>
|
||||
<span className="ms-1"><FontAwesomeIcon icon={faBasketShopping} className="buyer-icon" /></span>
|
||||
</OverlayTrigger>
|
||||
)}
|
||||
{member.paid && (
|
||||
<OverlayTrigger placement="top" overlay={<Tooltip>Zaplaceno</Tooltip>}>
|
||||
<span className="ms-1"><FontAwesomeIcon icon={faCircleCheck} className="text-success" /></span>
|
||||
</OverlayTrigger>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{canEdit && editingAmount ? (
|
||||
<div className="d-flex gap-1">
|
||||
<Form.Control
|
||||
ref={memberLogin === login ? inputRef : undefined}
|
||||
type="number"
|
||||
size="sm"
|
||||
value={editAmounts[key]}
|
||||
onChange={e => setEditAmounts(prev => ({ ...prev, [key]: e.target.value }))}
|
||||
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveAmount(group.id, memberLogin); if (e.key === 'Escape') setEditAmounts(prev => { const n = { ...prev }; delete n[key]; return n; }); }}
|
||||
style={{ width: 75 }}
|
||||
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, [key]: String(member.amount ?? '') }))}
|
||||
title={canEdit ? 'Klikněte pro úpravu' : undefined}
|
||||
>
|
||||
{member.amount != null ? `${member.amount} Kč` : <span className="text-muted">—</span>}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{canEdit && editingSurcharge ? (
|
||||
<div className="d-flex gap-1">
|
||||
<Form.Control
|
||||
type="text"
|
||||
size="sm"
|
||||
placeholder="popis"
|
||||
value={editSurcharges[key]?.text ?? ''}
|
||||
onChange={e => setEditSurcharges(prev => ({ ...prev, [key]: { ...prev[key], text: e.target.value } }))}
|
||||
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveSurcharge(group.id, memberLogin); if (e.key === 'Escape') setEditSurcharges(prev => { const n = { ...prev }; delete n[key]; return n; }); }}
|
||||
style={{ width: 80 }}
|
||||
autoFocus
|
||||
/>
|
||||
<Form.Control
|
||||
type="number"
|
||||
size="sm"
|
||||
placeholder="Kč"
|
||||
value={editSurcharges[key]?.amount ?? ''}
|
||||
onChange={e => setEditSurcharges(prev => ({ ...prev, [key]: { ...prev[key], amount: e.target.value } }))}
|
||||
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveSurcharge(group.id, memberLogin); if (e.key === 'Escape') setEditSurcharges(prev => { const n = { ...prev }; delete n[key]; return n; }); }}
|
||||
style={{ width: 60 }}
|
||||
/>
|
||||
<Button size="sm" variant="outline-success" onClick={() => handleSaveSurcharge(group.id, memberLogin)}>✓</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span
|
||||
style={{ cursor: canEdit ? 'pointer' : undefined }}
|
||||
onClick={() => canEdit && setEditSurcharges(prev => ({ ...prev, [key]: { text: member.surchargeText ?? '', amount: member.surchargeAmount != null ? String(member.surchargeAmount) : '' } }))}
|
||||
title={canEdit ? 'Klikněte pro úpravu příplatku' : undefined}
|
||||
>
|
||||
{member.surchargeAmount != null && member.surchargeAmount > 0 ? (
|
||||
<small>{member.surchargeText ? `${member.surchargeText}: ` : ''}<strong>{member.surchargeAmount} Kč</strong></small>
|
||||
) : (
|
||||
<small className="text-muted">—</small>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{canEdit && editingNote ? (
|
||||
<div className="d-flex gap-1">
|
||||
<Form.Control
|
||||
type="text"
|
||||
size="sm"
|
||||
value={editNotes[key]}
|
||||
onChange={e => setEditNotes(prev => ({ ...prev, [key]: e.target.value }))}
|
||||
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveNote(group.id, memberLogin); if (e.key === 'Escape') setEditNotes(prev => { const n = { ...prev }; delete n[key]; return n; }); }}
|
||||
autoFocus
|
||||
/>
|
||||
<Button size="sm" variant="outline-success" onClick={() => handleSaveNote(group.id, memberLogin)}>✓</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span
|
||||
style={{ cursor: canEdit ? 'pointer' : undefined }}
|
||||
onClick={() => canEdit && setEditNotes(prev => ({ ...prev, [key]: member.note ?? '' }))}
|
||||
title={canEdit ? 'Klikněte pro úpravu poznámky' : undefined}
|
||||
>
|
||||
<small className="text-muted">{member.note || '—'}</small>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-end">
|
||||
<small className={memberTotal > 0 ? 'fw-bold' : 'text-muted'}>
|
||||
{memberTotal > 0 ? `${memberTotal} Kč` : '—'}
|
||||
</small>
|
||||
</td>
|
||||
<td>
|
||||
<div className="d-flex gap-1 justify-content-end">
|
||||
{canManageMembers(group) && (isCreator || memberLogin === login) && (memberLogin !== group.creatorLogin) && (
|
||||
<FontAwesomeIcon
|
||||
icon={faTrashCan}
|
||||
className="action-icon"
|
||||
title={memberLogin === login ? 'Odhlásit se' : 'Odebrat z skupiny'}
|
||||
onClick={() => refresh(() => removeGroupMember({ body: { id: group.id, login: memberLogin } }))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
|
||||
{/* Souhrn poplatků a slevy */}
|
||||
{(totalFees > 0 || (group.discountValue != null && group.discountValue > 0)) && (
|
||||
<div className="px-3 py-2 border-top d-flex gap-3 flex-wrap" style={{ fontSize: '0.85em', color: 'var(--luncher-text-muted)' }}>
|
||||
{group.fees != null && group.fees > 0 && <span>Poplatky: <strong>{group.fees} Kč</strong></span>}
|
||||
{group.shipping != null && group.shipping > 0 && <span>Doprava: <strong>{group.shipping} Kč</strong></span>}
|
||||
{group.tip != null && group.tip > 0 && <span>Spropitné: <strong>{group.tip} Kč</strong></span>}
|
||||
{feeShare > 0 && <span>→ <strong>{feeShare} Kč</strong>/os.</span>}
|
||||
{group.discountValue != null && group.discountValue > 0 && (
|
||||
<span className="text-success">
|
||||
Sleva: <strong>{group.discountType === 'percent' ? `${group.discountValue}%` : `${group.discountValue} Kč`}</strong>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Časy objednání a doručení */}
|
||||
{isOrdered && (
|
||||
<div className="px-3 py-2 border-top">
|
||||
{isCreator && editingTimes ? (
|
||||
<div className="d-flex align-items-center gap-3 flex-wrap">
|
||||
<div className="d-flex align-items-center gap-1">
|
||||
<small className="text-muted text-nowrap">Objednáno v:</small>
|
||||
<Form.Control
|
||||
type="text"
|
||||
size="sm"
|
||||
placeholder="HH:MM"
|
||||
value={editTimes[group.id]?.orderedAt ?? ''}
|
||||
onChange={e => setEditTimes(prev => ({ ...prev, [group.id]: { ...prev[group.id], orderedAt: e.target.value } }))}
|
||||
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveTimes(group); }}
|
||||
style={{ width: 75 }}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="d-flex align-items-center gap-1">
|
||||
<small className="text-muted text-nowrap">Doručení v:</small>
|
||||
<Form.Control
|
||||
type="text"
|
||||
size="sm"
|
||||
placeholder="HH:MM"
|
||||
value={editTimes[group.id]?.deliveryAt ?? ''}
|
||||
onChange={e => setEditTimes(prev => ({ ...prev, [group.id]: { ...prev[group.id], deliveryAt: e.target.value } }))}
|
||||
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveTimes(group); }}
|
||||
style={{ width: 75 }}
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" variant="outline-success" onClick={() => handleSaveTimes(group)}>Uložit</Button>
|
||||
<Button size="sm" variant="outline-secondary" onClick={() => setEditTimes(prev => { const n = { ...prev }; delete n[group.id]; return n; })}>Zrušit</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="d-flex align-items-center gap-3 flex-wrap"
|
||||
style={{ cursor: isCreator ? 'pointer' : undefined }}
|
||||
onClick={() => isCreator && setEditTimes(prev => ({ ...prev, [group.id]: { orderedAt: group.orderedAt ?? '', deliveryAt: group.deliveryAt ?? '' } }))}
|
||||
title={isCreator ? 'Klikněte pro úpravu časů' : undefined}
|
||||
>
|
||||
<small className="text-muted">
|
||||
Objednáno v: <strong>{group.orderedAt ?? '—'}</strong>
|
||||
</small>
|
||||
<small className="text-muted">
|
||||
Doručení v: <strong>{group.deliveryAt ?? '—'}</strong>
|
||||
</small>
|
||||
{isCreator && <small className="text-muted fst-italic">(upravit)</small>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
|
||||
{/* Potvrzovací dialog pro přechod do stavu Objednáno */}
|
||||
<Modal show={!!confirmOrderGroup} onHide={() => setConfirmOrderGroup(null)} centered>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>Potvrdit objednání</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
Opravdu chcete označit skupinu <strong>{confirmOrderGroup?.name}</strong> jako objednanou?
|
||||
Tato akce uzavře skupinu a zaznamená čas objednání.
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="secondary" onClick={() => setConfirmOrderGroup(null)}>Zrušit</Button>
|
||||
<Button variant="primary" onClick={() => confirmOrderGroup && handleConfirmOrdered(confirmOrderGroup)}>
|
||||
Objednáno
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
|
||||
<StoreAdminModal
|
||||
isOpen={adminModalOpen}
|
||||
onClose={() => setAdminModalOpen(false)}
|
||||
stores={stores}
|
||||
onStoresChanged={updated => setData(prev => prev ? { ...prev, stores: updated } : prev)}
|
||||
/>
|
||||
|
||||
{payModal && settings?.bankAccount && settings?.holderName && (
|
||||
<PayForGroupModal
|
||||
isOpen={!!payModal}
|
||||
onClose={() => setPayModal(null)}
|
||||
group={payModal}
|
||||
groupId={payModal.id}
|
||||
payerLogin={auth.login}
|
||||
bankAccount={settings.bankAccount}
|
||||
bankAccountHolder={settings.holderName}
|
||||
/>
|
||||
)}
|
||||
|
||||
{feesModal && (
|
||||
<EditGroupFeesModal
|
||||
isOpen={!!feesModal}
|
||||
onClose={() => setFeesModal(null)}
|
||||
group={feesModal}
|
||||
onSaved={newData => {
|
||||
if (newData) {
|
||||
setData(newData);
|
||||
socket.emit?.('message', newData as ClientData);
|
||||
}
|
||||
setFeesModal(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
# Spustí server a klienta v samostatných panelech jednoho okna Windows Terminalu.
|
||||
# Vyžaduje Windows Terminal (wt.exe) — výchozí součást Windows 11.
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ScriptDir = $PSScriptRoot
|
||||
|
||||
Push-Location (Join-Path $ScriptDir 'types')
|
||||
try { yarn openapi-ts } finally { Pop-Location }
|
||||
|
||||
if (-not (Get-Command wt.exe -ErrorAction SilentlyContinue)) {
|
||||
Write-Error "wt.exe (Windows Terminal) nebyl nalezen. Nainstalujte z Microsoft Store nebo použijte run_dev.sh v WSL."
|
||||
exit 1
|
||||
}
|
||||
|
||||
$serverDir = Join-Path $ScriptDir 'server'
|
||||
$clientDir = Join-Path $ScriptDir 'client'
|
||||
|
||||
# wt splits on ';' before respecting quoting, so encode the compound server command to avoid it
|
||||
$serverCmd = '$env:NODE_ENV = ''development''; yarn startReload'
|
||||
$serverCmdB64 = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($serverCmd))
|
||||
|
||||
wt -w 0 new-tab --title 'luncher-server' -d $serverDir pwsh -NoExit -EncodedCommand $serverCmdB64 `; `
|
||||
split-pane -H --title 'luncher-client' -d $clientDir pwsh -NoExit -Command "yarn start"
|
||||
@@ -48,3 +48,7 @@
|
||||
# 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).
|
||||
# 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,3 @@
|
||||
[
|
||||
"Zobrazení nabídky salátů z Pizza Chefie"
|
||||
]
|
||||
@@ -0,0 +1,3 @@
|
||||
[
|
||||
"Možnost úhrady celého účtu jednou osobou s rozesláním QR kódů ostatním (na Pizza day)"
|
||||
]
|
||||
@@ -0,0 +1,3 @@
|
||||
[
|
||||
"Skupinové objednávky s QR platbou — stránka /objednani (více skupin, každá z jiného obchodu, stavový automat open/locked/ordered)"
|
||||
]
|
||||
@@ -0,0 +1,172 @@
|
||||
import crypto from "crypto";
|
||||
import getStorage from "./storage";
|
||||
import { getClientData, getToday, initIfNeeded } from "./service";
|
||||
import { getStores } from "./stores";
|
||||
import { removePendingQrsByGroupId } from "./pizza";
|
||||
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);
|
||||
const data = await getClientData(date, MealSlot.EXTRA);
|
||||
data.stores = await getStores();
|
||||
return data;
|
||||
}
|
||||
|
||||
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 canonical = stores.find(s => s.toLowerCase() === name.trim().toLowerCase())!;
|
||||
const group: OrderGroup = {
|
||||
id: crypto.randomUUID(),
|
||||
name: canonical,
|
||||
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]: [GroupState.LOCKED],
|
||||
};
|
||||
|
||||
function getCurrentHHMM(): string {
|
||||
const now = new Date();
|
||||
return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
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}"`);
|
||||
}
|
||||
if (newState === GroupState.ORDERED) {
|
||||
group.orderedAt = getCurrentHHMM();
|
||||
}
|
||||
if (group.state === GroupState.ORDERED && newState === GroupState.LOCKED) {
|
||||
const memberLogins = Object.keys(group.members);
|
||||
await removePendingQrsByGroupId(memberLogins, groupId);
|
||||
group.orderedAt = undefined;
|
||||
group.deliveryAt = undefined;
|
||||
for (const ml of memberLogins) {
|
||||
group.members[ml] = { ...group.members[ml], paid: undefined };
|
||||
}
|
||||
}
|
||||
group.state = newState;
|
||||
return saveExtraData(data, date);
|
||||
}
|
||||
|
||||
export async function markGroupMemberPaid(login: string, groupId: string, date?: Date): Promise<ClientData | null> {
|
||||
const data = await getExtraData(date);
|
||||
const group = findGroup(data, groupId);
|
||||
if (!group || !group.members[login]) return null;
|
||||
group.members[login] = { ...group.members[login], paid: true };
|
||||
return saveExtraData(data, date);
|
||||
}
|
||||
|
||||
export async function updateGroupFees(login: string, groupId: string, fees?: number, shipping?: number, tip?: number, discountType?: string, discountValue?: number, 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('Poplatky může měnit pouze zakladatel');
|
||||
if (group.state === GroupState.ORDERED) throw new Error('Skupinu ve stavu "objednáno" nelze upravovat');
|
||||
if (fees !== undefined) group.fees = fees > 0 ? fees : undefined;
|
||||
if (shipping !== undefined) group.shipping = shipping > 0 ? shipping : undefined;
|
||||
if (tip !== undefined) group.tip = tip > 0 ? tip : undefined;
|
||||
if (discountType !== undefined) group.discountType = (discountType as any) || undefined;
|
||||
if (discountValue !== undefined) group.discountValue = discountValue > 0 ? discountValue : undefined;
|
||||
return saveExtraData(data, date);
|
||||
}
|
||||
|
||||
export async function updateGroupTimes(login: string, groupId: string, orderedAt?: string, deliveryAt?: 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('Časy může měnit pouze zakladatel');
|
||||
if (orderedAt !== undefined) group.orderedAt = orderedAt || undefined;
|
||||
if (deliveryAt !== undefined) group.deliveryAt = deliveryAt || undefined;
|
||||
return saveExtraData(data, date);
|
||||
}
|
||||
+29
-4
@@ -1,15 +1,16 @@
|
||||
import express from "express";
|
||||
import bodyParser from "body-parser";
|
||||
import cors from 'cors';
|
||||
import { getData, getDateForWeekIndex, getToday } from "./service";
|
||||
import { getData, addChoice, getDateForWeekIndex, getToday } from "./service";
|
||||
import { MealSlot } from "../../types/gen/types.gen";
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
import { getQr } from "./qr";
|
||||
import { generateToken, getLogin, verify } from "./auth";
|
||||
import { getIsWeekend, InsufficientPermissions, PizzaDayConflictError, parseToken } from "./utils";
|
||||
import { getPendingQrs } from "./pizza";
|
||||
import { initWebsocket } from "./websocket";
|
||||
import { startReminderScheduler } from "./pushReminder";
|
||||
import { initWebsocket, getWebsocket } from "./websocket";
|
||||
import { startReminderScheduler, verifyQuickChoiceToken } from "./pushReminder";
|
||||
import { storageReady } from "./storage";
|
||||
import pizzaDayRoutes from "./routes/pizzaDayRoutes";
|
||||
import foodRoutes, { refreshMetoda } from "./routes/foodRoutes";
|
||||
@@ -20,6 +21,8 @@ import notificationRoutes from "./routes/notificationRoutes";
|
||||
import qrRoutes from "./routes/qrRoutes";
|
||||
import devRoutes from "./routes/devRoutes";
|
||||
import changelogRoutes from "./routes/changelogRoutes";
|
||||
import groupRoutes from "./routes/groupRoutes";
|
||||
import storeRoutes from "./routes/storeRoutes";
|
||||
|
||||
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
|
||||
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
|
||||
@@ -113,6 +116,22 @@ app.get("/api/qr", async (req, res) => {
|
||||
// Přeskočení auth pro refresh dat xd
|
||||
app.use("/api/food/refresh", refreshMetoda);
|
||||
|
||||
// Rychlá akce z push notifikace — autentizace pomocí HMAC tokenu z push payloadu (SW nemá přístup k JWT)
|
||||
app.post("/api/notifications/push/quickChoice", async (req, res, next) => {
|
||||
try {
|
||||
const { login, token } = req.body ?? {};
|
||||
if (!login || typeof login !== 'string' || !token || typeof token !== 'string') {
|
||||
return res.status(400).json({ error: 'Chybí login nebo token' });
|
||||
}
|
||||
if (!verifyQuickChoiceToken(login, token)) {
|
||||
return res.status(403).json({ error: 'Neplatný token' });
|
||||
}
|
||||
const updatedData = await addChoice(login, false, 'NEOBEDVAM', undefined, undefined);
|
||||
getWebsocket().emit("message", updatedData);
|
||||
res.status(200).json({});
|
||||
} catch (e: any) { next(e); }
|
||||
});
|
||||
|
||||
/** Middleware ověřující JWT token */
|
||||
app.use("/api/", (req, res, next) => {
|
||||
if (HTTP_REMOTE_USER_ENABLED) {
|
||||
@@ -151,7 +170,11 @@ app.get("/api/data", async (req, res) => {
|
||||
// Na víkendu zobrazíme pátek místo hlášky "Užívejte víkend"
|
||||
date = getDateForWeekIndex(4);
|
||||
}
|
||||
const data = await getData(date);
|
||||
const slotParam = typeof req.query.slot === 'string' ? req.query.slot as MealSlot : undefined;
|
||||
if (slotParam && slotParam !== MealSlot.OBED && slotParam !== MealSlot.EXTRA) {
|
||||
return res.status(400).json({ error: 'Neplatný slot' });
|
||||
}
|
||||
const data = await getData(date, slotParam);
|
||||
// Připojíme nevyřízené QR kódy pro přihlášeného uživatele
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
@@ -175,6 +198,8 @@ app.use("/api/notifications", notificationRoutes);
|
||||
app.use("/api/qr", qrRoutes);
|
||||
app.use("/api/dev", devRoutes);
|
||||
app.use("/api/changelogs", changelogRoutes);
|
||||
app.use("/api/groups", groupRoutes);
|
||||
app.use("/api/stores", storeRoutes);
|
||||
|
||||
app.use('/stats', express.static('public'));
|
||||
app.use(express.static('public'));
|
||||
|
||||
+18
-1
@@ -449,10 +449,27 @@ export async function getPendingQrs(login: string): Promise<PendingQr[]> {
|
||||
|
||||
/**
|
||||
* Označí QR kód jako uhrazený (odstraní ho ze seznamu nevyřízených).
|
||||
* Vrátí odstraněný QR kód, pokud byl nalezen.
|
||||
*/
|
||||
export async function dismissPendingQr(login: string, id: string): Promise<void> {
|
||||
export async function dismissPendingQr(login: string, id: string): Promise<PendingQr | undefined> {
|
||||
const key = getPendingQrKey(login);
|
||||
const existing = await storage.getData<PendingQr[]>(key) ?? [];
|
||||
const dismissed = existing.find(qr => qr.id === id);
|
||||
const filtered = existing.filter(qr => qr.id !== id);
|
||||
await storage.setData(key, filtered);
|
||||
return dismissed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Odstraní všechny nevyřízené QR kódy patřící dané skupině objednávky.
|
||||
*/
|
||||
export async function removePendingQrsByGroupId(logins: string[], groupId: string): Promise<void> {
|
||||
for (const login of logins) {
|
||||
const key = getPendingQrKey(login);
|
||||
const existing = await storage.getData<PendingQr[]>(key) ?? [];
|
||||
const filtered = existing.filter(qr => qr.groupId !== groupId);
|
||||
if (filtered.length !== existing.length) {
|
||||
await storage.setData(key, filtered);
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
-19
@@ -1,4 +1,5 @@
|
||||
import webpush from 'web-push';
|
||||
import crypto from 'crypto';
|
||||
import getStorage from './storage';
|
||||
import { getClientData, getToday } from './service';
|
||||
import { getIsWeekend } from './utils';
|
||||
@@ -14,13 +15,10 @@ interface RegistryEntry {
|
||||
|
||||
type Registry = Record<string, RegistryEntry>;
|
||||
|
||||
/** Mapa login → datum (YYYY-MM-DD), kdy byl uživatel naposledy upozorněn. */
|
||||
const remindedToday = new Map<string, string>();
|
||||
/** Mapa login → timestamp (ms) posledního odeslání připomínky. */
|
||||
const lastReminded = new Map<string, number>();
|
||||
|
||||
function getTodayDateString(): string {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
const REMINDER_COOLDOWN_MS = 60 * 60 * 1000; // 60 minut mezi připomínkami
|
||||
|
||||
function getCurrentTimeHHMM(): string {
|
||||
const now = new Date();
|
||||
@@ -59,7 +57,7 @@ export async function unsubscribePush(login: string): Promise<void> {
|
||||
const registry = await getRegistry();
|
||||
delete registry[login];
|
||||
await saveRegistry(registry);
|
||||
remindedToday.delete(login);
|
||||
lastReminded.delete(login);
|
||||
console.log(`Push reminder: uživatel ${login} odhlášen z připomínek`);
|
||||
}
|
||||
|
||||
@@ -68,16 +66,19 @@ export function getVapidPublicKey(): string | undefined {
|
||||
return process.env.VAPID_PUBLIC_KEY;
|
||||
}
|
||||
|
||||
/** Najde login uživatele podle push subscription endpointu. */
|
||||
export async function findLoginByEndpoint(endpoint: string): Promise<string | undefined> {
|
||||
const registry = await getRegistry();
|
||||
for (const [login, entry] of Object.entries(registry)) {
|
||||
if (entry.subscription.endpoint === endpoint) {
|
||||
return login;
|
||||
function generateQuickChoiceToken(login: string): string {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const secret = process.env.JWT_SECRET ?? '';
|
||||
return crypto.createHmac('sha256', secret).update(`${login}:${today}`).digest('hex');
|
||||
}
|
||||
|
||||
/** Ověří jednorázový token z push notifikace. */
|
||||
export function verifyQuickChoiceToken(login: string, token: string): boolean {
|
||||
if (!login || !token || token.length !== 64) return false;
|
||||
const expected = generateQuickChoiceToken(login);
|
||||
return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(token, 'hex'));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
/** Zkontroluje a odešle připomínky uživatelům, kteří si nezvolili oběd. */
|
||||
async function checkAndSendReminders(): Promise<void> {
|
||||
@@ -93,7 +94,6 @@ async function checkAndSendReminders(): Promise<void> {
|
||||
}
|
||||
|
||||
const currentTime = getCurrentTimeHHMM();
|
||||
const todayStr = getTodayDateString();
|
||||
|
||||
// Získáme data pro dnešek jednou pro všechny uživatele
|
||||
let clientData;
|
||||
@@ -110,8 +110,9 @@ async function checkAndSendReminders(): Promise<void> {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Už jsme dnes připomenuli
|
||||
if (remindedToday.get(login) === todayStr) {
|
||||
// Cooldown — nepřipomínat častěji než jednou za hodinu
|
||||
const last = lastReminded.get(login) ?? 0;
|
||||
if (Date.now() - last < REMINDER_COOLDOWN_MS) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -127,9 +128,11 @@ async function checkAndSendReminders(): Promise<void> {
|
||||
JSON.stringify({
|
||||
title: 'Luncher',
|
||||
body: 'Ještě nemáte zvolený oběd!',
|
||||
login,
|
||||
token: generateQuickChoiceToken(login),
|
||||
})
|
||||
);
|
||||
remindedToday.set(login, todayStr);
|
||||
lastReminded.set(login, Date.now());
|
||||
console.log(`Push reminder: odeslána připomínka uživateli ${login}`);
|
||||
} catch (error: any) {
|
||||
if (error.statusCode === 410 || error.statusCode === 404) {
|
||||
|
||||
@@ -189,7 +189,7 @@ router.post("/testPush", async (req, res, next) => {
|
||||
webpush.setVapidDetails(subject, publicKey, privateKey);
|
||||
await webpush.sendNotification(
|
||||
entry.subscription,
|
||||
JSON.stringify({ title: 'Luncher test', body: 'Push notifikace fungují!' })
|
||||
JSON.stringify({ title: 'Luncher test', body: 'Push notifikace fungují!', login })
|
||||
);
|
||||
res.status(200).json({ ok: true });
|
||||
} catch (e: any) { next(e) }
|
||||
|
||||
@@ -4,7 +4,7 @@ import { addChoice, getDateForWeekIndex, getToday, removeChoice, removeChoices,
|
||||
import { getDayOfWeekIndex, parseToken, getFirstWorkDayOfWeek } from "../utils";
|
||||
import { getWebsocket } from "../websocket";
|
||||
import { callNotifikace } from "../notifikace";
|
||||
import { AddChoiceData, ChangeDepartureTimeData, RemoveChoiceData, RemoveChoicesData, UdalostEnum, UpdateNoteData } from "../../../types/gen/types.gen";
|
||||
import { AddChoiceData, ChangeDepartureTimeData, MealSlot, RemoveChoiceData, RemoveChoicesData, UdalostEnum, UpdateNoteData } from "../../../types/gen/types.gen";
|
||||
|
||||
|
||||
// RateLimit na refresh endpoint
|
||||
@@ -69,11 +69,21 @@ const parseValidateFutureDayIndex = (req: Request<{}, any, AddChoiceData["body"]
|
||||
return dayIndex;
|
||||
}
|
||||
|
||||
const parseSlot = (body: Record<string, any>): MealSlot | undefined => {
|
||||
const slot = body?.slot;
|
||||
if (slot != null && slot !== MealSlot.OBED) {
|
||||
throw Error(`Neplatný slot: ${slot}`);
|
||||
}
|
||||
return slot ?? undefined;
|
||||
};
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post("/addChoice", async (req: Request<{}, any, AddChoiceData["body"]>, res, next) => {
|
||||
const login = getLogin(parseToken(req));
|
||||
const trusted = getTrusted(parseToken(req));
|
||||
let slot: MealSlot | undefined;
|
||||
try { slot = parseSlot(req.body); } catch (e: any) { return res.status(400).json({ error: e.message }); }
|
||||
let date = undefined;
|
||||
if (req.body.dayIndex != null) {
|
||||
let dayIndex;
|
||||
@@ -85,7 +95,7 @@ router.post("/addChoice", async (req: Request<{}, any, AddChoiceData["body"]>, r
|
||||
date = getDateForWeekIndex(dayIndex);
|
||||
}
|
||||
try {
|
||||
const data = await addChoice(login, trusted, req.body.locationKey, req.body.foodIndex, date);
|
||||
const data = await addChoice(login, trusted, req.body.locationKey, req.body.foodIndex, date, slot);
|
||||
getWebsocket().emit("message", data);
|
||||
return res.status(200).json(data);
|
||||
} catch (e: any) { next(e) }
|
||||
@@ -94,6 +104,8 @@ router.post("/addChoice", async (req: Request<{}, any, AddChoiceData["body"]>, r
|
||||
router.post("/removeChoices", async (req: Request<{}, any, RemoveChoicesData["body"]>, res, next) => {
|
||||
const login = getLogin(parseToken(req));
|
||||
const trusted = getTrusted(parseToken(req));
|
||||
let slot: MealSlot | undefined;
|
||||
try { slot = parseSlot(req.body); } catch (e: any) { return res.status(400).json({ error: e.message }); }
|
||||
let date = undefined;
|
||||
if (req.body.dayIndex != null) {
|
||||
let dayIndex;
|
||||
@@ -105,7 +117,7 @@ router.post("/removeChoices", async (req: Request<{}, any, RemoveChoicesData["bo
|
||||
date = getDateForWeekIndex(dayIndex);
|
||||
}
|
||||
try {
|
||||
const data = await removeChoices(login, trusted, req.body.locationKey, date);
|
||||
const data = await removeChoices(login, trusted, req.body.locationKey, date, slot);
|
||||
getWebsocket().emit("message", data);
|
||||
res.status(200).json(data);
|
||||
} catch (e: any) { next(e) }
|
||||
@@ -114,6 +126,8 @@ router.post("/removeChoices", async (req: Request<{}, any, RemoveChoicesData["bo
|
||||
router.post("/removeChoice", async (req: Request<{}, any, RemoveChoiceData["body"]>, res, next) => {
|
||||
const login = getLogin(parseToken(req));
|
||||
const trusted = getTrusted(parseToken(req));
|
||||
let slot: MealSlot | undefined;
|
||||
try { slot = parseSlot(req.body); } catch (e: any) { return res.status(400).json({ error: e.message }); }
|
||||
let date = undefined;
|
||||
if (req.body.dayIndex != null) {
|
||||
let dayIndex;
|
||||
@@ -125,7 +139,7 @@ router.post("/removeChoice", async (req: Request<{}, any, RemoveChoiceData["body
|
||||
date = getDateForWeekIndex(dayIndex);
|
||||
}
|
||||
try {
|
||||
const data = await removeChoice(login, trusted, req.body.locationKey, req.body.foodIndex, date);
|
||||
const data = await removeChoice(login, trusted, req.body.locationKey, req.body.foodIndex, date, slot);
|
||||
getWebsocket().emit("message", data);
|
||||
res.status(200).json(data);
|
||||
} catch (e: any) { next(e) }
|
||||
@@ -135,6 +149,8 @@ router.post("/updateNote", async (req: Request<{}, any, UpdateNoteData["body"]>,
|
||||
const login = getLogin(parseToken(req));
|
||||
const trusted = getTrusted(parseToken(req));
|
||||
const note = req.body.note;
|
||||
let slot: MealSlot | undefined;
|
||||
try { slot = parseSlot(req.body); } catch (e: any) { return res.status(400).json({ error: e.message }); }
|
||||
try {
|
||||
if (note && note.length > 70) {
|
||||
throw Error("Poznámka může mít maximálně 70 znaků");
|
||||
@@ -149,7 +165,7 @@ router.post("/updateNote", async (req: Request<{}, any, UpdateNoteData["body"]>,
|
||||
}
|
||||
date = getDateForWeekIndex(dayIndex);
|
||||
}
|
||||
const data = await updateNote(login, trusted, note, date);
|
||||
const data = await updateNote(login, trusted, note, date, slot);
|
||||
getWebsocket().emit("message", data);
|
||||
res.status(200).json(data);
|
||||
} catch (e: any) { next(e) }
|
||||
@@ -184,8 +200,10 @@ router.post("/jdemeObed", async (req, res, next) => {
|
||||
|
||||
router.post("/updateBuyer", async (req, res, next) => {
|
||||
const login = getLogin(parseToken(req));
|
||||
let slot: MealSlot | undefined;
|
||||
try { slot = parseSlot(req.body ?? {}); } catch (e: any) { return res.status(400).json({ error: e.message }); }
|
||||
try {
|
||||
const data = await updateBuyer(login);
|
||||
const data = await updateBuyer(login, slot);
|
||||
getWebsocket().emit("message", data);
|
||||
res.status(200).json({});
|
||||
} catch (e: any) { next(e) }
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import express, { Request } from "express";
|
||||
import { getLogin } from "../auth";
|
||||
import { parseToken } from "../utils";
|
||||
import { getWebsocket } from "../websocket";
|
||||
import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, updateGroupFees } 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' });
|
||||
if (targetLogin !== undefined && (typeof targetLogin !== 'string' || targetLogin.trim() === '')) {
|
||||
return res.status(400).json({ error: 'Neplatný login uživatele' });
|
||||
}
|
||||
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) {
|
||||
if (typeof amount !== 'number' || !Number.isFinite(amount) || amount < 0) {
|
||||
return res.status(400).json({ error: 'Neplatná částka' });
|
||||
}
|
||||
patch.amount = amount;
|
||||
}
|
||||
if (note !== undefined) {
|
||||
if (typeof note !== 'string') return res.status(400).json({ error: 'Neplatná poznámka' });
|
||||
patch.note = note;
|
||||
}
|
||||
if (surchargeText !== undefined) {
|
||||
if (typeof surchargeText !== 'string') return res.status(400).json({ error: 'Neplatný text příplatku' });
|
||||
patch.surchargeText = surchargeText;
|
||||
}
|
||||
if (surchargeAmount !== undefined) {
|
||||
if (typeof surchargeAmount !== 'number' || !Number.isFinite(surchargeAmount) || surchargeAmount < 0) {
|
||||
return res.status(400).json({ error: 'Neplatná výše příplatku' });
|
||||
}
|
||||
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); }
|
||||
});
|
||||
|
||||
router.post("/updateFees", async (req: Request, res, next) => {
|
||||
const login = getLogin(parseToken(req));
|
||||
const { id, fees, shipping, tip, discountType, discountValue } = req.body ?? {};
|
||||
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
|
||||
if (fees !== undefined && (typeof fees !== 'number' || !Number.isFinite(fees) || fees < 0)) {
|
||||
return res.status(400).json({ error: 'Neplatná výše poplatků' });
|
||||
}
|
||||
if (shipping !== undefined && (typeof shipping !== 'number' || !Number.isFinite(shipping) || shipping < 0)) {
|
||||
return res.status(400).json({ error: 'Neplatná výše dopravy' });
|
||||
}
|
||||
if (tip !== undefined && (typeof tip !== 'number' || !Number.isFinite(tip) || tip < 0)) {
|
||||
return res.status(400).json({ error: 'Neplatná výše spropitného' });
|
||||
}
|
||||
if (discountType !== undefined && discountType !== '' && !['percent', 'fixed'].includes(discountType)) {
|
||||
return res.status(400).json({ error: 'Neplatný typ slevy' });
|
||||
}
|
||||
if (discountValue !== undefined && (typeof discountValue !== 'number' || !Number.isFinite(discountValue) || discountValue < 0)) {
|
||||
return res.status(400).json({ error: 'Neplatná výše slevy' });
|
||||
}
|
||||
try {
|
||||
const data = await updateGroupFees(login, id, fees, shipping, tip, discountType, discountValue);
|
||||
broadcastExtra(data);
|
||||
res.status(200).json(data);
|
||||
} catch (e: any) { next(e); }
|
||||
});
|
||||
|
||||
router.post("/updateTimes", async (req: Request, res, next) => {
|
||||
const login = getLogin(parseToken(req));
|
||||
const { id, orderedAt, deliveryAt } = req.body ?? {};
|
||||
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
|
||||
const timeRegex = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||
if (orderedAt !== undefined && orderedAt !== '' && !timeRegex.test(orderedAt)) {
|
||||
return res.status(400).json({ error: 'Neplatný formát času objednání (očekáváno HH:MM)' });
|
||||
}
|
||||
if (deliveryAt !== undefined && deliveryAt !== '' && !timeRegex.test(deliveryAt)) {
|
||||
return res.status(400).json({ error: 'Neplatný formát času doručení (očekáváno HH:MM)' });
|
||||
}
|
||||
try {
|
||||
const data = await updateGroupTimes(login, id, orderedAt, deliveryAt);
|
||||
broadcastExtra(data);
|
||||
res.status(200).json(data);
|
||||
} catch (e: any) { next(e); }
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -2,9 +2,7 @@ import express, { Request } from "express";
|
||||
import { getLogin } from "../auth";
|
||||
import { parseToken } from "../utils";
|
||||
import { getNotificationSettings, saveNotificationSettings } from "../notifikace";
|
||||
import { subscribePush, unsubscribePush, getVapidPublicKey, findLoginByEndpoint } from "../pushReminder";
|
||||
import { addChoice } from "../service";
|
||||
import { getWebsocket } from "../websocket";
|
||||
import { subscribePush, unsubscribePush, getVapidPublicKey } from "../pushReminder";
|
||||
import { UpdateNotificationSettingsData } from "../../../types";
|
||||
|
||||
const router = express.Router();
|
||||
@@ -66,21 +64,4 @@ router.post("/push/unsubscribe", async (req, res, next) => {
|
||||
} catch (e: any) { next(e) }
|
||||
});
|
||||
|
||||
/** Rychlá akce z push notifikace — nastaví volbu bez JWT (identita přes subscription endpoint). */
|
||||
router.post("/push/quickChoice", async (req, res, next) => {
|
||||
try {
|
||||
const { endpoint } = req.body;
|
||||
if (!endpoint) {
|
||||
return res.status(400).json({ error: "Nebyl předán endpoint" });
|
||||
}
|
||||
const login = await findLoginByEndpoint(endpoint);
|
||||
if (!login) {
|
||||
return res.status(404).json({ error: "Subscription nenalezena" });
|
||||
}
|
||||
const data = await addChoice(login, false, 'NEOBEDVAM', undefined, undefined);
|
||||
getWebsocket().emit("message", data);
|
||||
res.status(200).json({});
|
||||
} catch (e: any) { next(e) }
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import express, { Request } from "express";
|
||||
import { getLogin } from "../auth";
|
||||
import { createPizzaDay, deletePizzaDay, getPizzaList, getSalatList, addPizzaOrder, addSalatOrder, removePizzaOrder, lockPizzaDay, unlockPizzaDay, finishPizzaOrder, finishPizzaDelivery, updatePizzaDayNote, updatePizzaFee, dismissPendingQr } from "../pizza";
|
||||
import { markGroupMemberPaid } from "../groups";
|
||||
import { parseToken } from "../utils";
|
||||
import { getWebsocket } from "../websocket";
|
||||
import { AddPizzaData, DismissQrData, FinishDeliveryData, RemovePizzaData, UpdatePizzaDayNoteData, UpdatePizzaFeeData } from "../../../types";
|
||||
@@ -132,7 +133,11 @@ router.post("/dismissQr", async (req: Request<{}, any, DismissQrData["body"]>, r
|
||||
return res.status(400).json({ error: "Nebyl předán identifikátor QR kódu" });
|
||||
}
|
||||
try {
|
||||
await dismissPendingQr(login, req.body.id);
|
||||
const dismissed = await dismissPendingQr(login, req.body.id);
|
||||
if (dismissed?.groupId) {
|
||||
const updatedExtra = await markGroupMemberPaid(login, dismissed.groupId);
|
||||
if (updatedExtra) getWebsocket().emit("message", updatedExtra);
|
||||
}
|
||||
res.status(200).json({});
|
||||
} catch (e: any) { next(e) }
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getLogin } from "../auth";
|
||||
import { parseToken, formatDate } from "../utils";
|
||||
import { generateQr } from "../qr";
|
||||
import { addPendingQr } from "../pizza";
|
||||
import { emitToUser } from "../websocket";
|
||||
import { GenerateQrData } from "../../../types";
|
||||
import crypto from "crypto";
|
||||
|
||||
@@ -14,7 +15,7 @@ const router = express.Router();
|
||||
router.post("/generate", async (req: Request<{}, any, GenerateQrData["body"]>, res, next) => {
|
||||
const login = getLogin(parseToken(req));
|
||||
try {
|
||||
const { recipients, bankAccount, bankAccountHolder } = req.body;
|
||||
const { recipients, bankAccount, bankAccountHolder, groupId } = req.body;
|
||||
|
||||
if (!recipients || !Array.isArray(recipients) || recipients.length === 0) {
|
||||
return res.status(400).json({ error: "Nebyl předán seznam příjemců" });
|
||||
@@ -48,14 +49,17 @@ router.post("/generate", async (req: Request<{}, any, GenerateQrData["body"]>, r
|
||||
const id = crypto.randomUUID();
|
||||
await generateQr(recipient.login, bankAccount, bankAccountHolder, recipient.amount, recipient.purpose, id);
|
||||
|
||||
// Uložit jako nevyřízený QR kód
|
||||
await addPendingQr(recipient.login, {
|
||||
// Uložit jako nevyřízený QR kód a okamžitě doručit příjemci
|
||||
const pendingQr = {
|
||||
id,
|
||||
date: today,
|
||||
creator: login,
|
||||
totalPrice: recipient.amount,
|
||||
purpose: recipient.purpose,
|
||||
});
|
||||
...(groupId ? { groupId } : {}),
|
||||
};
|
||||
await addPendingQr(recipient.login, pendingQr);
|
||||
emitToUser(recipient.login, 'pendingQr', pendingQr);
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, count: recipients.length });
|
||||
|
||||
@@ -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;
|
||||
+44
-33
@@ -3,11 +3,17 @@ import getStorage from "./storage";
|
||||
import { getMenuSladovnicka, getMenuTechTower, getMenuZastavkaUmichala, getMenuSenkSerikova, StaleWeekError } from "./restaurants";
|
||||
import { getTodayMock } from "./mock";
|
||||
import { removeAllUserPizzas } from "./pizza";
|
||||
import { ClientData, DepartureTime, LunchChoice, PizzaDayState, Restaurant, RestaurantDayMenu, WeekMenu } from "../../types/gen/types.gen";
|
||||
import { getStores } from "./stores";
|
||||
import { ClientData, DepartureTime, LunchChoice, MealSlot, PizzaDayState, Restaurant, RestaurantDayMenu, WeekMenu } from "../../types/gen/types.gen";
|
||||
|
||||
const storage = getStorage();
|
||||
const MENU_PREFIX = 'menu';
|
||||
|
||||
function getDataKey(date: Date, slot?: MealSlot): string {
|
||||
const base = formatDate(date);
|
||||
return slot === MealSlot.EXTRA ? `${base}_extra` : base;
|
||||
}
|
||||
|
||||
/** Vrátí dnešní datum, případně fiktivní datum pro účely vývoje a testování. */
|
||||
export function getToday(): Date {
|
||||
if (process.env.MOCK_DATA === 'true') {
|
||||
@@ -43,8 +49,11 @@ export function getEmptyData(date?: Date): ClientData {
|
||||
/**
|
||||
* Vrátí veškerá klientská data pro předaný den, nebo aktuální den, pokud není předán.
|
||||
*/
|
||||
export async function getData(date?: Date): Promise<ClientData> {
|
||||
const clientData = await getClientData(date);
|
||||
export async function getData(date?: Date, slot?: MealSlot): Promise<ClientData> {
|
||||
const clientData = await getClientData(date, slot);
|
||||
if (slot === MealSlot.EXTRA) {
|
||||
clientData.stores = await getStores();
|
||||
} else {
|
||||
clientData.menus = {
|
||||
SLADOVNICKA: await getRestaurantMenu('SLADOVNICKA', date),
|
||||
// UMOTLIKU: await getRestaurantMenu('UMOTLIKU', date),
|
||||
@@ -52,6 +61,7 @@ export async function getData(date?: Date): Promise<ClientData> {
|
||||
ZASTAVKAUMICHALA: await getRestaurantMenu('ZASTAVKAUMICHALA', date),
|
||||
SENKSERIKOVA: await getRestaurantMenu('SENKSERIKOVA', date),
|
||||
}
|
||||
}
|
||||
return clientData;
|
||||
}
|
||||
|
||||
@@ -290,8 +300,8 @@ function generateMenuWarnings(menu: RestaurantDayMenu): string[] {
|
||||
*
|
||||
* @param date datum
|
||||
*/
|
||||
export async function initIfNeeded(date?: Date) {
|
||||
const usedDate = formatDate(date ?? getToday());
|
||||
export async function initIfNeeded(date?: Date, slot?: MealSlot) {
|
||||
const usedDate = getDataKey(date ?? getToday(), slot);
|
||||
const hasData = await storage.hasData(usedDate);
|
||||
if (!hasData) {
|
||||
await storage.setData(usedDate, getEmptyData(date || getToday()));
|
||||
@@ -307,9 +317,9 @@ export async function initIfNeeded(date?: Date) {
|
||||
* @param date datum, ke kterému se volba vztahuje
|
||||
* @returns
|
||||
*/
|
||||
export async function removeChoices(login: string, trusted: boolean, locationKey: LunchChoice, date?: Date) {
|
||||
const selectedDay = formatDate(date ?? getToday());
|
||||
let data = await getClientData(date);
|
||||
export async function removeChoices(login: string, trusted: boolean, locationKey: LunchChoice, date?: Date, slot?: MealSlot) {
|
||||
const selectedDay = getDataKey(date ?? getToday(), slot);
|
||||
let data = await getClientData(date, slot);
|
||||
validateTrusted(data, login, trusted);
|
||||
if (locationKey in data.choices) {
|
||||
if (data.choices[locationKey] && login in data.choices[locationKey]) {
|
||||
@@ -334,9 +344,9 @@ export async function removeChoices(login: string, trusted: boolean, locationKey
|
||||
* @param date datum, ke kterému se volba vztahuje
|
||||
* @returns
|
||||
*/
|
||||
export async function removeChoice(login: string, trusted: boolean, locationKey: LunchChoice, foodIndex: number, date?: Date) {
|
||||
const selectedDay = formatDate(date ?? getToday());
|
||||
let data = await getClientData(date);
|
||||
export async function removeChoice(login: string, trusted: boolean, locationKey: LunchChoice, foodIndex: number, date?: Date, slot?: MealSlot) {
|
||||
const selectedDay = getDataKey(date ?? getToday(), slot);
|
||||
let data = await getClientData(date, slot);
|
||||
validateTrusted(data, login, trusted);
|
||||
if (locationKey in data.choices) {
|
||||
if (data.choices[locationKey] && login in data.choices[locationKey]) {
|
||||
@@ -357,9 +367,9 @@ export async function removeChoice(login: string, trusted: boolean, locationKey:
|
||||
* @param date datum, ke kterému se volby vztahují
|
||||
* @param ignoredLocationKey volba, která nebude odstraněna, pokud existuje
|
||||
*/
|
||||
async function removeChoiceIfPresent(login: string, date?: Date, ignoredLocationKey?: LunchChoice) {
|
||||
async function removeChoiceIfPresent(login: string, date?: Date, ignoredLocationKey?: LunchChoice, slot?: MealSlot) {
|
||||
const usedDate = date ?? getToday();
|
||||
let data = await getClientData(usedDate);
|
||||
let data = await getClientData(usedDate, slot);
|
||||
for (const key of Object.keys(data.choices)) {
|
||||
const locationKey = key as LunchChoice;
|
||||
if (ignoredLocationKey != null && ignoredLocationKey == locationKey) {
|
||||
@@ -370,7 +380,7 @@ async function removeChoiceIfPresent(login: string, date?: Date, ignoredLocation
|
||||
if (Object.keys(data.choices[locationKey]).length === 0) {
|
||||
delete data.choices[locationKey];
|
||||
}
|
||||
await storage.setData(formatDate(usedDate), data);
|
||||
await storage.setData(getDataKey(usedDate, slot), data);
|
||||
}
|
||||
}
|
||||
return data;
|
||||
@@ -409,13 +419,14 @@ function validateTrusted(data: ClientData, login: string, trusted: boolean) {
|
||||
* @param date datum, ke kterému se volba vztahuje
|
||||
* @returns aktuální data
|
||||
*/
|
||||
export async function addChoice(login: string, trusted: boolean, locationKey: LunchChoice, foodIndex?: number, date?: Date) {
|
||||
export async function addChoice(login: string, trusted: boolean, locationKey: LunchChoice, foodIndex?: number, date?: Date, slot?: MealSlot) {
|
||||
const usedDate = date ?? getToday();
|
||||
await initIfNeeded(usedDate);
|
||||
let data = await getClientData(usedDate);
|
||||
await initIfNeeded(usedDate, slot);
|
||||
let data = await getClientData(usedDate, slot);
|
||||
validateTrusted(data, login, trusted);
|
||||
await validateFoodIndex(locationKey, foodIndex, date);
|
||||
|
||||
if (!slot || slot === MealSlot.OBED) {
|
||||
// Pokud uživatel měl vybranou PIZZA a mění na něco jiného
|
||||
const hadPizzaChoice = data.choices.PIZZA && login in data.choices.PIZZA;
|
||||
if (hadPizzaChoice && locationKey !== LunchChoice.PIZZA && foodIndex == null) {
|
||||
@@ -435,15 +446,16 @@ export async function addChoice(login: string, trusted: boolean, locationKey: Lu
|
||||
// nebo byl již smazán frontendem)
|
||||
await removeAllUserPizzas(login, usedDate);
|
||||
// Znovu načteme data, protože removeAllUserPizzas je upravila
|
||||
data = await getClientData(usedDate);
|
||||
data = await getClientData(usedDate, slot);
|
||||
}
|
||||
}
|
||||
|
||||
// Pokud měníme pouze lokaci, mažeme případné předchozí
|
||||
if (foodIndex == null) {
|
||||
data = await removeChoiceIfPresent(login, usedDate);
|
||||
data = await removeChoiceIfPresent(login, usedDate, undefined, slot);
|
||||
} else {
|
||||
// Mažeme případné ostatní volby (měla by být maximálně jedna)
|
||||
data = await removeChoiceIfPresent(login, usedDate, locationKey);
|
||||
data = await removeChoiceIfPresent(login, usedDate, locationKey, slot);
|
||||
}
|
||||
// TODO vytáhnout inicializaci "prázdné struktury" do vlastní funkce
|
||||
data.choices[locationKey] ??= {};
|
||||
@@ -459,8 +471,7 @@ export async function addChoice(login: string, trusted: boolean, locationKey: Lu
|
||||
if (foodIndex != null && !data.choices[locationKey][login].selectedFoods?.includes(foodIndex)) {
|
||||
data.choices[locationKey][login].selectedFoods?.push(foodIndex);
|
||||
}
|
||||
const selectedDate = formatDate(usedDate);
|
||||
await storage.setData(selectedDate, data);
|
||||
await storage.setData(getDataKey(usedDate, slot), data);
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -498,10 +509,10 @@ async function validateFoodIndex(locationKey: LunchChoice, foodIndex?: number, d
|
||||
* @param note poznámka
|
||||
* @param date datum, ke kterému se volba vztahuje
|
||||
*/
|
||||
export async function updateNote(login: string, trusted: boolean, note?: string, date?: Date) {
|
||||
export async function updateNote(login: string, trusted: boolean, note?: string, date?: Date, slot?: MealSlot) {
|
||||
const usedDate = date ?? getToday();
|
||||
await initIfNeeded(usedDate);
|
||||
let data = await getClientData(usedDate);
|
||||
await initIfNeeded(usedDate, slot);
|
||||
let data = await getClientData(usedDate, slot);
|
||||
validateTrusted(data, login, trusted);
|
||||
const userEntry = data.choices != null && Object.entries(data.choices).find(entry => entry[1][login] != null);
|
||||
if (userEntry) {
|
||||
@@ -510,8 +521,7 @@ export async function updateNote(login: string, trusted: boolean, note?: string,
|
||||
} else {
|
||||
userEntry[1][login].note = note;
|
||||
}
|
||||
const selectedDate = formatDate(usedDate);
|
||||
await storage.setData(selectedDate, data);
|
||||
await storage.setData(getDataKey(usedDate, slot), data);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
@@ -537,7 +547,7 @@ export async function updateDepartureTime(login: string, time?: string, date?: D
|
||||
}
|
||||
found[login].departureTime = time;
|
||||
}
|
||||
await storage.setData(formatDate(usedDate), clientData);
|
||||
await storage.setData(getDataKey(usedDate), clientData);
|
||||
}
|
||||
return clientData;
|
||||
}
|
||||
@@ -548,15 +558,15 @@ export async function updateDepartureTime(login: string, time?: string, date?: D
|
||||
*
|
||||
* @param login přihlašovací jméno uživatele
|
||||
*/
|
||||
export async function updateBuyer(login: string) {
|
||||
export async function updateBuyer(login: string, slot?: MealSlot) {
|
||||
const usedDate = getToday();
|
||||
let clientData = await getClientData(usedDate);
|
||||
let clientData = await getClientData(usedDate, slot);
|
||||
const userEntry = clientData.choices?.['OBJEDNAVAM']?.[login];
|
||||
if (!userEntry) {
|
||||
throw new Error("Nelze nastavit objednatele pro uživatele s jinou volbou než \"Budu objednávat\"");
|
||||
}
|
||||
userEntry.isBuyer = !(userEntry.isBuyer || false);
|
||||
await storage.setData(formatDate(usedDate), clientData);
|
||||
await storage.setData(getDataKey(usedDate, slot), clientData);
|
||||
return clientData;
|
||||
}
|
||||
|
||||
@@ -566,12 +576,13 @@ export async function updateBuyer(login: string) {
|
||||
* @param date datum pro který vrátit data, pokud není vyplněno, je použit dnešní den
|
||||
* @returns data pro klienta
|
||||
*/
|
||||
export async function getClientData(date?: Date): Promise<ClientData> {
|
||||
export async function getClientData(date?: Date, slot?: MealSlot): Promise<ClientData> {
|
||||
const targetDate = date ?? getToday();
|
||||
const dateString = formatDate(targetDate);
|
||||
const dateString = getDataKey(targetDate, slot);
|
||||
const clientData = await storage.getData<ClientData>(dateString) || getEmptyData(date);
|
||||
return {
|
||||
...clientData,
|
||||
todayDayIndex: getDayOfWeekIndex(getToday()),
|
||||
...(slot === MealSlot.EXTRA ? { slot: MealSlot.EXTRA } : {}),
|
||||
}
|
||||
}
|
||||
@@ -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,60 @@
|
||||
const mockStorageData = new Map<string, any>();
|
||||
jest.mock('../storage', () => ({
|
||||
__esModule: true,
|
||||
default: () => ({
|
||||
hasData: async (key: string) => mockStorageData.has(key),
|
||||
getData: async <T>(key: string) => mockStorageData.get(key) as T,
|
||||
setData: async <T>(key: string, val: T) => void mockStorageData.set(key, val),
|
||||
}),
|
||||
storageReady: Promise.resolve(),
|
||||
}));
|
||||
|
||||
import { addChoice, getData } from '../service';
|
||||
import { LunchChoice, MealSlot } from '../../../types/gen/types.gen';
|
||||
|
||||
const TODAY = new Date('2025-01-10');
|
||||
const TODAY_STR = '2025-01-10';
|
||||
const TODAY_EXTRA_STR = '2025-01-10_extra';
|
||||
|
||||
describe('MealSlot storage isolation', () => {
|
||||
beforeEach(() => {
|
||||
mockStorageData.clear();
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(TODAY);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('addChoice slot=extra writes only to _extra key, not to obed key, and returns slot=EXTRA', async () => {
|
||||
const result = await addChoice('user1', false, LunchChoice.OBJEDNAVAM, undefined, TODAY, MealSlot.EXTRA);
|
||||
expect(result.slot).toBe(MealSlot.EXTRA);
|
||||
expect(mockStorageData.has(TODAY_EXTRA_STR)).toBe(true);
|
||||
expect(mockStorageData.has(TODAY_STR)).toBe(false);
|
||||
const extraData = mockStorageData.get(TODAY_EXTRA_STR);
|
||||
expect(extraData.choices.OBJEDNAVAM?.['user1']).toBeDefined();
|
||||
});
|
||||
|
||||
test('getData slot=extra returns slot===MealSlot.EXTRA and no menus', async () => {
|
||||
await addChoice('user1', false, LunchChoice.OBJEDNAVAM, undefined, TODAY, MealSlot.EXTRA);
|
||||
const result = await getData(TODAY, MealSlot.EXTRA);
|
||||
expect(result.slot).toBe(MealSlot.EXTRA);
|
||||
expect(result.menus).toBeUndefined();
|
||||
});
|
||||
|
||||
test('addChoice slot=extra does not modify obed data even when obed has PIZZA choice', async () => {
|
||||
mockStorageData.set(TODAY_STR, {
|
||||
choices: { PIZZA: { user1: { selectedFoods: [0], trusted: false } } },
|
||||
todayDayIndex: 4,
|
||||
date: '10. 1. 2025',
|
||||
isWeekend: false,
|
||||
dayIndex: 4,
|
||||
});
|
||||
|
||||
await addChoice('user1', false, LunchChoice.OBJEDNAVAM, undefined, TODAY, MealSlot.EXTRA);
|
||||
|
||||
const obed = mockStorageData.get(TODAY_STR);
|
||||
expect(obed.choices.PIZZA?.['user1']).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
+11
-2
@@ -11,6 +11,12 @@ export const initWebsocket = (server: any) => {
|
||||
io.on("connection", (socket) => {
|
||||
console.log(`New client connected: ${socket.id}`);
|
||||
|
||||
socket.on("join", (login: string) => {
|
||||
if (login && typeof login === "string") {
|
||||
socket.join(`user:${login}`);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("message", (message) => {
|
||||
io.emit("message", message);
|
||||
});
|
||||
@@ -22,6 +28,9 @@ export const initWebsocket = (server: any) => {
|
||||
return io;
|
||||
}
|
||||
|
||||
export const getWebsocket = () => {
|
||||
return io;
|
||||
export const getWebsocket = () => io;
|
||||
|
||||
/** Pošle event konkrétnímu přihlášenému uživateli (pokud je připojen). */
|
||||
export const emitToUser = (login: string, event: string, data: unknown) => {
|
||||
io.to(`user:${login}`).emit(event, data);
|
||||
}
|
||||
@@ -81,6 +81,32 @@ paths:
|
||||
/changelogs:
|
||||
$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"
|
||||
/groups/updateTimes:
|
||||
$ref: "./paths/groups/updateTimes.yml"
|
||||
/groups/updateFees:
|
||||
$ref: "./paths/groups/updateFees.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/generate:
|
||||
$ref: "./paths/dev/generate.yml"
|
||||
|
||||
Generated
+594
@@ -0,0 +1,594 @@
|
||||
{
|
||||
"name": "@luncher/types",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@luncher/types",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@hey-api/client-fetch": "^0.8.2",
|
||||
"@hey-api/openapi-ts": "^0.64.7",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@hey-api/client-fetch": {
|
||||
"version": "0.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@hey-api/client-fetch/-/client-fetch-0.8.2.tgz",
|
||||
"integrity": "sha512-61T4UGfAzY5345vMxWDX8qnSTNRJcOpWuZyvNu3vNebCTLPwMQAM85mhEuBoACdWeRtLhNoUjU0UR5liRyD1bA==",
|
||||
"deprecated": "Starting with v0.73.0, this package is bundled directly inside @hey-api/openapi-ts.",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/hey-api"
|
||||
}
|
||||
},
|
||||
"node_modules/@hey-api/json-schema-ref-parser": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.0.2.tgz",
|
||||
"integrity": "sha512-F6LSkttZcT/XiX3ydeDqTY3uRN3BLJMwyMTk4kg/ichZlKUp3+3Odv0WokSmXGSoZGTW/N66FROMYAm5NPdJlA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jsdevtools/ono": "^7.1.3",
|
||||
"@types/json-schema": "^7.0.15",
|
||||
"js-yaml": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/hey-api"
|
||||
}
|
||||
},
|
||||
"node_modules/@hey-api/openapi-ts": {
|
||||
"version": "0.64.7",
|
||||
"resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.64.7.tgz",
|
||||
"integrity": "sha512-xpaBzdGAKz7cPuGah1GZWl3zTZquOXRnwmpVCQKEUpvDtSYWBLjSxtAYIqDBjwJkuNHcakZBIWLcappx7slc2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@hey-api/json-schema-ref-parser": "1.0.2",
|
||||
"c12": "2.0.1",
|
||||
"commander": "13.0.0",
|
||||
"handlebars": "4.7.8"
|
||||
},
|
||||
"bin": {
|
||||
"openapi-ts": "bin/index.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=22.11.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/hey-api"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.5.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@jsdevtools/ono": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz",
|
||||
"integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.14.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
|
||||
"integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/c12": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/c12/-/c12-2.0.1.tgz",
|
||||
"integrity": "sha512-Z4JgsKXHG37C6PYUtIxCfLJZvo6FyhHJoClwwb9ftUkLpPSkuYqn6Tr+vnaN8hymm0kIbcg6Ey3kv/Q71k5w/A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.1",
|
||||
"confbox": "^0.1.7",
|
||||
"defu": "^6.1.4",
|
||||
"dotenv": "^16.4.5",
|
||||
"giget": "^1.2.3",
|
||||
"jiti": "^2.3.0",
|
||||
"mlly": "^1.7.1",
|
||||
"ohash": "^1.1.4",
|
||||
"pathe": "^1.1.2",
|
||||
"perfect-debounce": "^1.0.0",
|
||||
"pkg-types": "^1.2.0",
|
||||
"rc9": "^2.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"magicast": "^0.3.5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"magicast": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"readdirp": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
|
||||
"integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/citty": {
|
||||
"version": "0.1.6",
|
||||
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
|
||||
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"consola": "^3.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-13.0.0.tgz",
|
||||
"integrity": "sha512-oPYleIY8wmTVzkvQq10AEok6YcTC4sRUBl8F9gVuwchGVUCTbl/vhLTaQqutuuySYOsu8YTgV+OxKc/8Yvx+mQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/confbox": {
|
||||
"version": "0.1.8",
|
||||
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz",
|
||||
"integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/consola": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.0.tgz",
|
||||
"integrity": "sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.18.0 || >=16.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/defu": {
|
||||
"version": "6.1.4",
|
||||
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
|
||||
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/destr": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.3.tgz",
|
||||
"integrity": "sha512-2N3BOUU4gYMpTP24s5rF5iP7BDr7uNTCs4ozw3kf/eKfvWSIu93GEBi5m427YoyJoeOzQ5smuu4nNAPGb8idSQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.4.7",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
|
||||
"integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-minipass": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
|
||||
"integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"minipass": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-minipass/node_modules/minipass": {
|
||||
"version": "3.3.6",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
|
||||
"integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"yallist": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/giget": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/giget/-/giget-1.2.5.tgz",
|
||||
"integrity": "sha512-r1ekGw/Bgpi3HLV3h1MRBIlSAdHoIMklpaQ3OQLFcRw9PwAj2rqigvIbg+dBUI51OxVI2jsEtDywDBjSiuf7Ug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"citty": "^0.1.6",
|
||||
"consola": "^3.4.0",
|
||||
"defu": "^6.1.4",
|
||||
"node-fetch-native": "^1.6.6",
|
||||
"nypm": "^0.5.4",
|
||||
"pathe": "^2.0.3",
|
||||
"tar": "^6.2.1"
|
||||
},
|
||||
"bin": {
|
||||
"giget": "dist/cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/giget/node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/handlebars": {
|
||||
"version": "4.7.8",
|
||||
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz",
|
||||
"integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.5",
|
||||
"neo-async": "^2.6.2",
|
||||
"source-map": "^0.6.1",
|
||||
"wordwrap": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"handlebars": "bin/handlebars"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.7"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"uglify-js": "^3.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
|
||||
"integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
||||
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
|
||||
"integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/minizlib": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
|
||||
"integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minipass": "^3.0.0",
|
||||
"yallist": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/minizlib/node_modules/minipass": {
|
||||
"version": "3.3.6",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
|
||||
"integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"yallist": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
|
||||
"integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mkdirp": "bin/cmd.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/mlly": {
|
||||
"version": "1.7.4",
|
||||
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.4.tgz",
|
||||
"integrity": "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.14.0",
|
||||
"pathe": "^2.0.1",
|
||||
"pkg-types": "^1.3.0",
|
||||
"ufo": "^1.5.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mlly/node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/neo-async": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
|
||||
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-fetch-native": {
|
||||
"version": "1.6.6",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.6.tgz",
|
||||
"integrity": "sha512-8Mc2HhqPdlIfedsuZoc3yioPuzp6b+L5jRCRY1QzuWZh2EGJVQrGppC6V6cF0bLdbW0+O2YpqCA25aF/1lvipQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nypm": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.5.4.tgz",
|
||||
"integrity": "sha512-X0SNNrZiGU8/e/zAB7sCTtdxWTMSIO73q+xuKgglm2Yvzwlo8UoC5FNySQFCvl84uPaeADkqHUZUkWy4aH4xOA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"citty": "^0.1.6",
|
||||
"consola": "^3.4.0",
|
||||
"pathe": "^2.0.3",
|
||||
"pkg-types": "^1.3.1",
|
||||
"tinyexec": "^0.3.2",
|
||||
"ufo": "^1.5.4"
|
||||
},
|
||||
"bin": {
|
||||
"nypm": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.16.0 || >=16.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nypm/node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ohash": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/ohash/-/ohash-1.1.4.tgz",
|
||||
"integrity": "sha512-FlDryZAahJmEF3VR3w1KogSEdWX3WhA5GPakFx4J81kEAiHyLMpdLLElS8n8dfNadMgAne/MywcvmogzscVt4g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
|
||||
"integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/perfect-debounce": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
||||
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pkg-types": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz",
|
||||
"integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"confbox": "^0.1.8",
|
||||
"mlly": "^1.7.4",
|
||||
"pathe": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/pkg-types/node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rc9": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
|
||||
"integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"defu": "^6.1.4",
|
||||
"destr": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14.18.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
|
||||
"integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==",
|
||||
"deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"chownr": "^2.0.0",
|
||||
"fs-minipass": "^2.0.0",
|
||||
"minipass": "^5.0.0",
|
||||
"minizlib": "^2.1.1",
|
||||
"mkdirp": "^1.0.3",
|
||||
"yallist": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
|
||||
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/ufo": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.4.tgz",
|
||||
"integrity": "sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uglify-js": {
|
||||
"version": "3.19.3",
|
||||
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
|
||||
"integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"uglifyjs": "bin/uglifyjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wordwrap": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
|
||||
"integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ post:
|
||||
$ref: "../../schemas/_index.yml#/DayIndex"
|
||||
foodIndex:
|
||||
$ref: "../../schemas/_index.yml#/FoodIndex"
|
||||
slot:
|
||||
$ref: "../../schemas/_index.yml#/MealSlot"
|
||||
responses:
|
||||
"200":
|
||||
$ref: "../../api.yml#/components/responses/ClientDataResponse"
|
||||
|
||||
@@ -16,6 +16,8 @@ post:
|
||||
$ref: "../../schemas/_index.yml#/LunchChoice"
|
||||
dayIndex:
|
||||
$ref: "../../schemas/_index.yml#/DayIndex"
|
||||
slot:
|
||||
$ref: "../../schemas/_index.yml#/MealSlot"
|
||||
responses:
|
||||
"200":
|
||||
$ref: "../../api.yml#/components/responses/ClientDataResponse"
|
||||
|
||||
@@ -13,6 +13,8 @@ post:
|
||||
$ref: "../../schemas/_index.yml#/LunchChoice"
|
||||
dayIndex:
|
||||
$ref: "../../schemas/_index.yml#/DayIndex"
|
||||
slot:
|
||||
$ref: "../../schemas/_index.yml#/MealSlot"
|
||||
responses:
|
||||
"200":
|
||||
$ref: "../../api.yml#/components/responses/ClientDataResponse"
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
post:
|
||||
operationId: setBuyer
|
||||
summary: Nastavení/odnastavení aktuálně přihlášeného uživatele jako objednatele pro stav "Budu objednávat" pro aktuální den.
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
slot:
|
||||
$ref: "../../schemas/_index.yml#/MealSlot"
|
||||
responses:
|
||||
"200":
|
||||
description: Stav byl úspěšně změněn.
|
||||
|
||||
@@ -11,6 +11,8 @@ post:
|
||||
$ref: "../../schemas/_index.yml#/DayIndex"
|
||||
note:
|
||||
type: string
|
||||
slot:
|
||||
$ref: "../../schemas/_index.yml#/MealSlot"
|
||||
responses:
|
||||
"200":
|
||||
$ref: "../../api.yml#/components/responses/ClientDataResponse"
|
||||
|
||||
@@ -9,6 +9,11 @@ get:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 4
|
||||
- in: query
|
||||
name: slot
|
||||
description: Slot jídla. Pokud není předán, je použit výchozí slot (oběd).
|
||||
schema:
|
||||
$ref: "../schemas/_index.yml#/MealSlot"
|
||||
responses:
|
||||
"200":
|
||||
$ref: "../api.yml#/components/responses/ClientDataResponse"
|
||||
|
||||
@@ -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: updateGroupFees
|
||||
summary: Aktualizuje skupinové poplatky a slevu (pouze zakladatel, pouze otevřená skupina).
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
properties:
|
||||
id:
|
||||
description: ID skupiny
|
||||
type: string
|
||||
fees:
|
||||
description: Poplatky (Kč)
|
||||
type: number
|
||||
shipping:
|
||||
description: Doprava (Kč)
|
||||
type: number
|
||||
tip:
|
||||
description: Spropitné (Kč)
|
||||
type: number
|
||||
discountType:
|
||||
description: Typ slevy
|
||||
type: string
|
||||
enum: [percent, fixed]
|
||||
discountValue:
|
||||
description: Hodnota slevy
|
||||
type: number
|
||||
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,24 @@
|
||||
post:
|
||||
operationId: updateGroupTimes
|
||||
summary: Aktualizuje časy objednání a doručení skupiny (pouze zakladatel).
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
properties:
|
||||
id:
|
||||
description: ID skupiny
|
||||
type: string
|
||||
orderedAt:
|
||||
description: Čas objednání ve formátu HH:MM
|
||||
type: string
|
||||
deliveryAt:
|
||||
description: Očekávaný čas doručení ve formátu HH:MM
|
||||
type: string
|
||||
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
|
||||
+112
-2
@@ -63,6 +63,19 @@ ClientData:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/PendingQr"
|
||||
slot:
|
||||
description: Slot jídla, ke kterému se tato data vztahují
|
||||
$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 ---
|
||||
UserLunchChoice:
|
||||
@@ -135,6 +148,15 @@ LunchChoice:
|
||||
- OBJEDNAVAM
|
||||
- NEOBEDVAM
|
||||
- ROZHODUJI
|
||||
MealSlot:
|
||||
description: Slot jídla - oběd nebo extra jídlo (večeře, pozdní oběd)
|
||||
type: string
|
||||
enum:
|
||||
- obed
|
||||
- extra
|
||||
x-enum-varnames:
|
||||
- OBED
|
||||
- EXTRA
|
||||
DayIndex:
|
||||
description: Index dne v týdnu (0 = pondělí, 4 = pátek)
|
||||
type: integer
|
||||
@@ -250,7 +272,6 @@ DepartureTime:
|
||||
FeatureRequest:
|
||||
type: string
|
||||
enum:
|
||||
- Ruční generování QR kódů mimo Pizza day (např. při objednávání)
|
||||
- Možnost označovat si jídla jako oblíbená (taková jídla by se uživateli následně zvýrazňovala)
|
||||
- Možnost úhrady v podniku za všechny jednou osobou a následné generování QR ostatním
|
||||
- Zrušení \"užívejte víkend\", místo toho umožnit zpětně náhled na uplynulý týden
|
||||
@@ -263,7 +284,6 @@ FeatureRequest:
|
||||
- Celkové vylepšení UI/UX
|
||||
- Zlepšení dokumentace/postupů pro ostatní vývojáře
|
||||
x-enum-varnames:
|
||||
- CUSTOM_QR
|
||||
- FAVORITES
|
||||
- SINGLE_PAYMENT
|
||||
- NO_WEEKENDS
|
||||
@@ -638,6 +658,9 @@ GenerateQrRequest:
|
||||
bankAccountHolder:
|
||||
description: Jméno držitele bankovního účtu
|
||||
type: string
|
||||
groupId:
|
||||
description: ID skupiny objednávky (pro propojení QR kódů se skupinou)
|
||||
type: string
|
||||
|
||||
# --- DEV MOCK DATA ---
|
||||
GenerateMockDataRequest:
|
||||
@@ -662,6 +685,90 @@ ClearMockDataRequest:
|
||||
description: Index dne v týdnu (0 = pondělí, 4 = pátek). Pokud není zadán, použije se aktuální den.
|
||||
$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
|
||||
paid:
|
||||
description: Příznak, zda člen uhradil svůj podíl objednávky
|
||||
type: boolean
|
||||
|
||||
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"
|
||||
orderedAt:
|
||||
description: Čas objednání ve formátu HH:MM
|
||||
type: string
|
||||
deliveryAt:
|
||||
description: Očekávaný čas doručení ve formátu HH:MM
|
||||
type: string
|
||||
fees:
|
||||
description: Poplatky (balení apod.) celkem v Kč
|
||||
type: number
|
||||
shipping:
|
||||
description: Doprava v Kč
|
||||
type: number
|
||||
tip:
|
||||
description: Spropitné v Kč
|
||||
type: number
|
||||
discountType:
|
||||
description: Typ slevy aplikované na objednávku
|
||||
type: string
|
||||
enum: [percent, fixed]
|
||||
discountValue:
|
||||
description: Hodnota slevy (procenta nebo Kč)
|
||||
type: number
|
||||
|
||||
# --- NEVYŘÍZENÉ QR KÓDY ---
|
||||
PendingQr:
|
||||
description: Nevyřízený QR kód pro platbu
|
||||
@@ -688,3 +795,6 @@ PendingQr:
|
||||
purpose:
|
||||
description: Účel platby (např. "Pizza prosciutto")
|
||||
type: string
|
||||
groupId:
|
||||
description: ID skupiny objednávky, ke které QR patří
|
||||
type: string
|
||||
|
||||
+53
-48
@@ -4,12 +4,12 @@
|
||||
|
||||
"@hey-api/client-fetch@^0.8.2":
|
||||
version "0.8.2"
|
||||
resolved "https://registry.yarnpkg.com/@hey-api/client-fetch/-/client-fetch-0.8.2.tgz#675aadfbc9478bb8eef5679f11a9334258dff4c8"
|
||||
resolved "https://registry.npmjs.org/@hey-api/client-fetch/-/client-fetch-0.8.2.tgz"
|
||||
integrity sha512-61T4UGfAzY5345vMxWDX8qnSTNRJcOpWuZyvNu3vNebCTLPwMQAM85mhEuBoACdWeRtLhNoUjU0UR5liRyD1bA==
|
||||
|
||||
"@hey-api/json-schema-ref-parser@1.0.2":
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.0.2.tgz#c3824c5d9d531eeb5c2b2557857a8ad20b5c75a7"
|
||||
resolved "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.0.2.tgz"
|
||||
integrity sha512-F6LSkttZcT/XiX3ydeDqTY3uRN3BLJMwyMTk4kg/ichZlKUp3+3Odv0WokSmXGSoZGTW/N66FROMYAm5NPdJlA==
|
||||
dependencies:
|
||||
"@jsdevtools/ono" "^7.1.3"
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
"@hey-api/openapi-ts@^0.64.7":
|
||||
version "0.64.7"
|
||||
resolved "https://registry.yarnpkg.com/@hey-api/openapi-ts/-/openapi-ts-0.64.7.tgz#f239d268b4a35b91f5ff25479d15578feb01f365"
|
||||
resolved "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.64.7.tgz"
|
||||
integrity sha512-xpaBzdGAKz7cPuGah1GZWl3zTZquOXRnwmpVCQKEUpvDtSYWBLjSxtAYIqDBjwJkuNHcakZBIWLcappx7slc2g==
|
||||
dependencies:
|
||||
"@hey-api/json-schema-ref-parser" "1.0.2"
|
||||
@@ -28,27 +28,27 @@
|
||||
|
||||
"@jsdevtools/ono@^7.1.3":
|
||||
version "7.1.3"
|
||||
resolved "https://registry.yarnpkg.com/@jsdevtools/ono/-/ono-7.1.3.tgz#9df03bbd7c696a5c58885c34aa06da41c8543796"
|
||||
resolved "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz"
|
||||
integrity sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==
|
||||
|
||||
"@types/json-schema@^7.0.15":
|
||||
version "7.0.15"
|
||||
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841"
|
||||
resolved "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz"
|
||||
integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==
|
||||
|
||||
acorn@^8.14.0:
|
||||
version "8.14.0"
|
||||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.14.0.tgz#063e2c70cac5fb4f6467f0b11152e04c682795b0"
|
||||
resolved "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz"
|
||||
integrity sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==
|
||||
|
||||
argparse@^2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
|
||||
resolved "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz"
|
||||
integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
|
||||
|
||||
c12@2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/c12/-/c12-2.0.1.tgz#5702d280b31a08abba39833494c9b1202f0f5aec"
|
||||
resolved "https://registry.npmjs.org/c12/-/c12-2.0.1.tgz"
|
||||
integrity sha512-Z4JgsKXHG37C6PYUtIxCfLJZvo6FyhHJoClwwb9ftUkLpPSkuYqn6Tr+vnaN8hymm0kIbcg6Ey3kv/Q71k5w/A==
|
||||
dependencies:
|
||||
chokidar "^4.0.1"
|
||||
@@ -66,63 +66,63 @@ c12@2.0.1:
|
||||
|
||||
chokidar@^4.0.1:
|
||||
version "4.0.3"
|
||||
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-4.0.3.tgz#7be37a4c03c9aee1ecfe862a4a23b2c70c205d30"
|
||||
resolved "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz"
|
||||
integrity sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==
|
||||
dependencies:
|
||||
readdirp "^4.0.1"
|
||||
|
||||
chownr@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/chownr/-/chownr-2.0.0.tgz#15bfbe53d2eab4cf70f18a8cd68ebe5b3cb1dece"
|
||||
resolved "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz"
|
||||
integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==
|
||||
|
||||
citty@^0.1.6:
|
||||
version "0.1.6"
|
||||
resolved "https://registry.yarnpkg.com/citty/-/citty-0.1.6.tgz#0f7904da1ed4625e1a9ea7e0fa780981aab7c5e4"
|
||||
resolved "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz"
|
||||
integrity sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==
|
||||
dependencies:
|
||||
consola "^3.2.3"
|
||||
|
||||
commander@13.0.0:
|
||||
version "13.0.0"
|
||||
resolved "https://registry.yarnpkg.com/commander/-/commander-13.0.0.tgz#1b161f60ee3ceb8074583a0f95359a4f8701845c"
|
||||
resolved "https://registry.npmjs.org/commander/-/commander-13.0.0.tgz"
|
||||
integrity sha512-oPYleIY8wmTVzkvQq10AEok6YcTC4sRUBl8F9gVuwchGVUCTbl/vhLTaQqutuuySYOsu8YTgV+OxKc/8Yvx+mQ==
|
||||
|
||||
confbox@^0.1.7, confbox@^0.1.8:
|
||||
version "0.1.8"
|
||||
resolved "https://registry.yarnpkg.com/confbox/-/confbox-0.1.8.tgz#820d73d3b3c82d9bd910652c5d4d599ef8ff8b06"
|
||||
resolved "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz"
|
||||
integrity sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==
|
||||
|
||||
consola@^3.2.3, consola@^3.4.0:
|
||||
version "3.4.0"
|
||||
resolved "https://registry.yarnpkg.com/consola/-/consola-3.4.0.tgz#4cfc9348fd85ed16a17940b3032765e31061ab88"
|
||||
resolved "https://registry.npmjs.org/consola/-/consola-3.4.0.tgz"
|
||||
integrity sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA==
|
||||
|
||||
defu@^6.1.4:
|
||||
version "6.1.4"
|
||||
resolved "https://registry.yarnpkg.com/defu/-/defu-6.1.4.tgz#4e0c9cf9ff68fe5f3d7f2765cc1a012dfdcb0479"
|
||||
resolved "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz"
|
||||
integrity sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==
|
||||
|
||||
destr@^2.0.3:
|
||||
version "2.0.3"
|
||||
resolved "https://registry.yarnpkg.com/destr/-/destr-2.0.3.tgz#7f9e97cb3d16dbdca7be52aca1644ce402cfe449"
|
||||
resolved "https://registry.npmjs.org/destr/-/destr-2.0.3.tgz"
|
||||
integrity sha512-2N3BOUU4gYMpTP24s5rF5iP7BDr7uNTCs4ozw3kf/eKfvWSIu93GEBi5m427YoyJoeOzQ5smuu4nNAPGb8idSQ==
|
||||
|
||||
dotenv@^16.4.5:
|
||||
version "16.4.7"
|
||||
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.4.7.tgz#0e20c5b82950140aa99be360a8a5f52335f53c26"
|
||||
resolved "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz"
|
||||
integrity sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==
|
||||
|
||||
fs-minipass@^2.0.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-2.1.0.tgz#7f5036fdbf12c63c169190cbe4199c852271f9fb"
|
||||
resolved "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz"
|
||||
integrity sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==
|
||||
dependencies:
|
||||
minipass "^3.0.0"
|
||||
|
||||
giget@^1.2.3:
|
||||
version "1.2.5"
|
||||
resolved "https://registry.yarnpkg.com/giget/-/giget-1.2.5.tgz#0bd4909356a0da75cc1f2b33538f93adec0d202f"
|
||||
resolved "https://registry.npmjs.org/giget/-/giget-1.2.5.tgz"
|
||||
integrity sha512-r1ekGw/Bgpi3HLV3h1MRBIlSAdHoIMklpaQ3OQLFcRw9PwAj2rqigvIbg+dBUI51OxVI2jsEtDywDBjSiuf7Ug==
|
||||
dependencies:
|
||||
citty "^0.1.6"
|
||||
@@ -135,7 +135,7 @@ giget@^1.2.3:
|
||||
|
||||
handlebars@4.7.8:
|
||||
version "4.7.8"
|
||||
resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.8.tgz#41c42c18b1be2365439188c77c6afae71c0cd9e9"
|
||||
resolved "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz"
|
||||
integrity sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==
|
||||
dependencies:
|
||||
minimist "^1.2.5"
|
||||
@@ -147,36 +147,36 @@ handlebars@4.7.8:
|
||||
|
||||
jiti@^2.3.0:
|
||||
version "2.4.2"
|
||||
resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.4.2.tgz#d19b7732ebb6116b06e2038da74a55366faef560"
|
||||
resolved "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz"
|
||||
integrity sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==
|
||||
|
||||
js-yaml@^4.1.0:
|
||||
version "4.1.0"
|
||||
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602"
|
||||
resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz"
|
||||
integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==
|
||||
dependencies:
|
||||
argparse "^2.0.1"
|
||||
|
||||
minimist@^1.2.5:
|
||||
version "1.2.8"
|
||||
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c"
|
||||
resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz"
|
||||
integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
|
||||
|
||||
minipass@^3.0.0:
|
||||
version "3.3.6"
|
||||
resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.3.6.tgz#7bba384db3a1520d18c9c0e5251c3444e95dd94a"
|
||||
resolved "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz"
|
||||
integrity sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==
|
||||
dependencies:
|
||||
yallist "^4.0.0"
|
||||
|
||||
minipass@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/minipass/-/minipass-5.0.0.tgz#3e9788ffb90b694a5d0ec94479a45b5d8738133d"
|
||||
resolved "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz"
|
||||
integrity sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==
|
||||
|
||||
minizlib@^2.1.1:
|
||||
version "2.1.2"
|
||||
resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931"
|
||||
resolved "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz"
|
||||
integrity sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==
|
||||
dependencies:
|
||||
minipass "^3.0.0"
|
||||
@@ -184,12 +184,12 @@ minizlib@^2.1.1:
|
||||
|
||||
mkdirp@^1.0.3:
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
|
||||
resolved "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz"
|
||||
integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
|
||||
|
||||
mlly@^1.7.1, mlly@^1.7.4:
|
||||
version "1.7.4"
|
||||
resolved "https://registry.yarnpkg.com/mlly/-/mlly-1.7.4.tgz#3d7295ea2358ec7a271eaa5d000a0f84febe100f"
|
||||
resolved "https://registry.npmjs.org/mlly/-/mlly-1.7.4.tgz"
|
||||
integrity sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==
|
||||
dependencies:
|
||||
acorn "^8.14.0"
|
||||
@@ -199,17 +199,17 @@ mlly@^1.7.1, mlly@^1.7.4:
|
||||
|
||||
neo-async@^2.6.2:
|
||||
version "2.6.2"
|
||||
resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f"
|
||||
resolved "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz"
|
||||
integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==
|
||||
|
||||
node-fetch-native@^1.6.6:
|
||||
version "1.6.6"
|
||||
resolved "https://registry.yarnpkg.com/node-fetch-native/-/node-fetch-native-1.6.6.tgz#ae1d0e537af35c2c0b0de81cbff37eedd410aa37"
|
||||
resolved "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.6.tgz"
|
||||
integrity sha512-8Mc2HhqPdlIfedsuZoc3yioPuzp6b+L5jRCRY1QzuWZh2EGJVQrGppC6V6cF0bLdbW0+O2YpqCA25aF/1lvipQ==
|
||||
|
||||
nypm@^0.5.4:
|
||||
version "0.5.4"
|
||||
resolved "https://registry.yarnpkg.com/nypm/-/nypm-0.5.4.tgz#a5ab0d8d37f96342328479f88ef58699f29b3051"
|
||||
resolved "https://registry.npmjs.org/nypm/-/nypm-0.5.4.tgz"
|
||||
integrity sha512-X0SNNrZiGU8/e/zAB7sCTtdxWTMSIO73q+xuKgglm2Yvzwlo8UoC5FNySQFCvl84uPaeADkqHUZUkWy4aH4xOA==
|
||||
dependencies:
|
||||
citty "^0.1.6"
|
||||
@@ -221,27 +221,32 @@ nypm@^0.5.4:
|
||||
|
||||
ohash@^1.1.4:
|
||||
version "1.1.4"
|
||||
resolved "https://registry.yarnpkg.com/ohash/-/ohash-1.1.4.tgz#ae8d83014ab81157d2c285abf7792e2995fadd72"
|
||||
resolved "https://registry.npmjs.org/ohash/-/ohash-1.1.4.tgz"
|
||||
integrity sha512-FlDryZAahJmEF3VR3w1KogSEdWX3WhA5GPakFx4J81kEAiHyLMpdLLElS8n8dfNadMgAne/MywcvmogzscVt4g==
|
||||
|
||||
pathe@^1.1.2:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/pathe/-/pathe-1.1.2.tgz#6c4cb47a945692e48a1ddd6e4094d170516437ec"
|
||||
resolved "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz"
|
||||
integrity sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==
|
||||
|
||||
pathe@^2.0.1, pathe@^2.0.3:
|
||||
pathe@^2.0.1:
|
||||
version "2.0.3"
|
||||
resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716"
|
||||
resolved "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz"
|
||||
integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==
|
||||
|
||||
pathe@^2.0.3:
|
||||
version "2.0.3"
|
||||
resolved "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz"
|
||||
integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==
|
||||
|
||||
perfect-debounce@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/perfect-debounce/-/perfect-debounce-1.0.0.tgz#9c2e8bc30b169cc984a58b7d5b28049839591d2a"
|
||||
resolved "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz"
|
||||
integrity sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==
|
||||
|
||||
pkg-types@^1.2.0, pkg-types@^1.3.0, pkg-types@^1.3.1:
|
||||
version "1.3.1"
|
||||
resolved "https://registry.yarnpkg.com/pkg-types/-/pkg-types-1.3.1.tgz#bd7cc70881192777eef5326c19deb46e890917df"
|
||||
resolved "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz"
|
||||
integrity sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==
|
||||
dependencies:
|
||||
confbox "^0.1.8"
|
||||
@@ -250,7 +255,7 @@ pkg-types@^1.2.0, pkg-types@^1.3.0, pkg-types@^1.3.1:
|
||||
|
||||
rc9@^2.1.2:
|
||||
version "2.1.2"
|
||||
resolved "https://registry.yarnpkg.com/rc9/-/rc9-2.1.2.tgz#6282ff638a50caa0a91a31d76af4a0b9cbd1080d"
|
||||
resolved "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz"
|
||||
integrity sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==
|
||||
dependencies:
|
||||
defu "^6.1.4"
|
||||
@@ -258,17 +263,17 @@ rc9@^2.1.2:
|
||||
|
||||
readdirp@^4.0.1:
|
||||
version "4.1.2"
|
||||
resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d"
|
||||
resolved "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz"
|
||||
integrity sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==
|
||||
|
||||
source-map@^0.6.1:
|
||||
version "0.6.1"
|
||||
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
|
||||
resolved "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz"
|
||||
integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
|
||||
|
||||
tar@^6.2.1:
|
||||
version "6.2.1"
|
||||
resolved "https://registry.yarnpkg.com/tar/-/tar-6.2.1.tgz#717549c541bc3c2af15751bea94b1dd068d4b03a"
|
||||
resolved "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz"
|
||||
integrity sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==
|
||||
dependencies:
|
||||
chownr "^2.0.0"
|
||||
@@ -280,30 +285,30 @@ tar@^6.2.1:
|
||||
|
||||
tinyexec@^0.3.2:
|
||||
version "0.3.2"
|
||||
resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-0.3.2.tgz#941794e657a85e496577995c6eef66f53f42b3d2"
|
||||
resolved "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz"
|
||||
integrity sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==
|
||||
|
||||
typescript@^5.9.3:
|
||||
typescript@^5.5.3, typescript@^5.9.3:
|
||||
version "5.9.3"
|
||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f"
|
||||
resolved "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz"
|
||||
integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==
|
||||
|
||||
ufo@^1.5.4:
|
||||
version "1.5.4"
|
||||
resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.5.4.tgz#16d6949674ca0c9e0fbbae1fa20a71d7b1ded754"
|
||||
resolved "https://registry.npmjs.org/ufo/-/ufo-1.5.4.tgz"
|
||||
integrity sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==
|
||||
|
||||
uglify-js@^3.1.4:
|
||||
version "3.19.3"
|
||||
resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.19.3.tgz#82315e9bbc6f2b25888858acd1fff8441035b77f"
|
||||
resolved "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz"
|
||||
integrity sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==
|
||||
|
||||
wordwrap@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
|
||||
resolved "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz"
|
||||
integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==
|
||||
|
||||
yallist@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"
|
||||
resolved "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz"
|
||||
integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==
|
||||
|
||||
Reference in New Issue
Block a user