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": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+9
-10
@@ -7,6 +7,7 @@ self.addEventListener('push', (event) => {
|
|||||||
body: data.body,
|
body: data.body,
|
||||||
icon: '/favicon.ico',
|
icon: '/favicon.ico',
|
||||||
tag: 'lunch-reminder',
|
tag: 'lunch-reminder',
|
||||||
|
data: { login: data.login, token: data.token },
|
||||||
actions: [
|
actions: [
|
||||||
{ action: 'neobedvam', title: 'Mám vlastní/neobědvám' },
|
{ action: 'neobedvam', title: 'Mám vlastní/neobědvám' },
|
||||||
],
|
],
|
||||||
@@ -18,28 +19,26 @@ self.addEventListener('notificationclick', (event) => {
|
|||||||
event.notification.close();
|
event.notification.close();
|
||||||
|
|
||||||
if (event.action === 'neobedvam') {
|
if (event.action === 'neobedvam') {
|
||||||
event.waitUntil(
|
const { login, token } = event.notification.data ?? {};
|
||||||
self.registration.pushManager.getSubscription().then((subscription) => {
|
if (login && token) {
|
||||||
if (!subscription) return;
|
event.waitUntil(
|
||||||
return fetch('/api/notifications/push/quickChoice', {
|
fetch('/api/notifications/push/quickChoice', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ endpoint: subscription.endpoint }),
|
body: JSON.stringify({ login, token }),
|
||||||
});
|
})
|
||||||
})
|
);
|
||||||
);
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
self.clients.matchAll({ type: 'window' }).then((clientList) => {
|
self.clients.matchAll({ type: 'window' }).then((clientList) => {
|
||||||
// Pokud je již otevřené okno, zaostříme na něj
|
|
||||||
for (const client of clientList) {
|
for (const client of clientList) {
|
||||||
if (client.url.includes(self.location.origin) && 'focus' in client) {
|
if (client.url.includes(self.location.origin) && 'focus' in client) {
|
||||||
return client.focus();
|
return client.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Jinak otevřeme nové
|
|
||||||
return self.clients.openWindow('/');
|
return self.clients.openWindow('/');
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
+14
-2
@@ -1,6 +1,6 @@
|
|||||||
import React, { useContext, useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
import React, { useContext, useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
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 { useAuth } from './context/auth';
|
||||||
import Login from './Login';
|
import Login from './Login';
|
||||||
import { Alert, Button, Col, Form, Row, Table } from 'react-bootstrap';
|
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 NoteModal from './components/modals/NoteModal';
|
||||||
import PayForAllModal from './components/modals/PayForAllModal';
|
import PayForAllModal from './components/modals/PayForAllModal';
|
||||||
import { useEasterEgg } from './context/eggs';
|
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 { getLunchChoiceName } from './enums';
|
||||||
// import FallingLeaves, { LEAF_PRESETS, LEAF_COLOR_THEMES } from './FallingLeaves';
|
// import FallingLeaves, { LEAF_PRESETS, LEAF_COLOR_THEMES } from './FallingLeaves';
|
||||||
// import './FallingLeaves.scss';
|
// import './FallingLeaves.scss';
|
||||||
@@ -126,19 +126,31 @@ function App() {
|
|||||||
});
|
});
|
||||||
socket.on(EVENT_MESSAGE, (newData: ClientData) => {
|
socket.on(EVENT_MESSAGE, (newData: ClientData) => {
|
||||||
// console.log("Přijata nová data ze socketu", newData);
|
// 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ý
|
// Aktualizujeme pouze, pokud jsme dostali data pro den, který máme aktuálně zobrazený
|
||||||
if (dayIndexRef.current == null || newData.dayIndex === dayIndexRef.current) {
|
if (dayIndexRef.current == null || newData.dayIndex === dayIndexRef.current) {
|
||||||
setData(newData);
|
setData(newData);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
socket.on(EVENT_PENDING_QR, (pendingQr: PendingQr) => {
|
||||||
|
setData(prev => prev ? { ...prev, pendingQrs: [...(prev.pendingQrs ?? []), pendingQr] } : prev);
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
socket.off(EVENT_CONNECT);
|
socket.off(EVENT_CONNECT);
|
||||||
socket.off(EVENT_DISCONNECT);
|
socket.off(EVENT_DISCONNECT);
|
||||||
socket.off(EVENT_MESSAGE);
|
socket.off(EVENT_MESSAGE);
|
||||||
|
socket.off(EVENT_PENDING_QR);
|
||||||
}
|
}
|
||||||
}, [socket]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!auth?.login || !data?.choices) {
|
if (!auth?.login || !data?.choices) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -5,14 +5,24 @@ import { SnowOverlay } from 'react-snow-overlay';
|
|||||||
import { ToastContainer } from "react-toastify";
|
import { ToastContainer } from "react-toastify";
|
||||||
import { SocketContext, socket } from "./context/socket";
|
import { SocketContext, socket } from "./context/socket";
|
||||||
import StatsPage from "./pages/StatsPage";
|
import StatsPage from "./pages/StatsPage";
|
||||||
|
import OrderGroupsPage from "./pages/OrderGroupsPage";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
|
||||||
export const STATS_URL = '/stats';
|
export const STATS_URL = '/stats';
|
||||||
|
export const OBJEDNANI_URL = '/objednani';
|
||||||
|
|
||||||
export default function AppRoutes() {
|
export default function AppRoutes() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path={STATS_URL} element={<StatsPage />} />
|
<Route path={STATS_URL} element={<StatsPage />} />
|
||||||
|
<Route path={OBJEDNANI_URL} element={
|
||||||
|
<ProvideSettings>
|
||||||
|
<SocketContext.Provider value={socket}>
|
||||||
|
<OrderGroupsPage />
|
||||||
|
<ToastContainer />
|
||||||
|
</SocketContext.Provider>
|
||||||
|
</ProvideSettings>
|
||||||
|
} />
|
||||||
<Route path="/" element={
|
<Route path="/" element={
|
||||||
<ProvideSettings>
|
<ProvideSettings>
|
||||||
<SocketContext.Provider value={socket}>
|
<SocketContext.Provider value={socket}>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import GenerateQrModal from "./modals/GenerateQrModal";
|
|||||||
import GenerateMockDataModal from "./modals/GenerateMockDataModal";
|
import GenerateMockDataModal from "./modals/GenerateMockDataModal";
|
||||||
import ClearMockDataModal from "./modals/ClearMockDataModal";
|
import ClearMockDataModal from "./modals/ClearMockDataModal";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { STATS_URL } from "../AppRoutes";
|
import { STATS_URL, OBJEDNANI_URL } from "../AppRoutes";
|
||||||
import { FeatureRequest, getVotes, updateVote, LunchChoices, getChangelogs } from "../../../types";
|
import { FeatureRequest, getVotes, updateVote, LunchChoices, getChangelogs } from "../../../types";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons";
|
import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons";
|
||||||
@@ -207,6 +207,7 @@ export default function Header({ choices, dayIndex }: Props) {
|
|||||||
<NavDropdown.Item onClick={() => setPizzaModalOpen(true)}>Pizza kalkulačka</NavDropdown.Item>
|
<NavDropdown.Item onClick={() => setPizzaModalOpen(true)}>Pizza kalkulačka</NavDropdown.Item>
|
||||||
<NavDropdown.Item onClick={handleQrMenuClick}>Generování QR kódů</NavDropdown.Item>
|
<NavDropdown.Item onClick={handleQrMenuClick}>Generování QR kódů</NavDropdown.Item>
|
||||||
<NavDropdown.Item onClick={() => navigate(STATS_URL)}>Statistiky</NavDropdown.Item>
|
<NavDropdown.Item onClick={() => navigate(STATS_URL)}>Statistiky</NavDropdown.Item>
|
||||||
|
<NavDropdown.Item onClick={() => navigate(OBJEDNANI_URL)}>Objednání</NavDropdown.Item>
|
||||||
<NavDropdown.Item onClick={() => {
|
<NavDropdown.Item onClick={() => {
|
||||||
getChangelogs().then(response => {
|
getChangelogs().then(response => {
|
||||||
const entries = response.data ?? {};
|
const entries = response.data ?? {};
|
||||||
|
|||||||
@@ -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;
|
if (includedDiners.length === 0) return 0;
|
||||||
const tip = parseAmount(tipTotal);
|
const tip = parseAmount(tipTotal);
|
||||||
if (tip === null || tip === 0) return 0;
|
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 getTotal = (d: DinerEntry): number => {
|
||||||
const surcharge = parseAmount(d.surchargeAmount) ?? 0;
|
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;
|
return Math.round((d.baseAmount + surcharge + tip) * 100) / 100;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -167,7 +173,7 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
|
|||||||
</Alert>
|
</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 && (
|
{!hasMenu && (
|
||||||
<Alert variant="info">
|
<Alert variant="info">
|
||||||
@@ -194,7 +200,7 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
|
|||||||
<th>Strávník</th>
|
<th>Strávník</th>
|
||||||
<th>Jídla</th>
|
<th>Jídla</th>
|
||||||
<th style={{ width: 220 }}>Příplatek</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>
|
<th style={{ width: 90 }}>Celkem</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -225,35 +231,33 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
|
|||||||
</small>
|
</small>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{!isPayer && (
|
<div className="d-flex gap-1">
|
||||||
<div className="d-flex gap-1">
|
<Form.Control
|
||||||
<Form.Control
|
type="text"
|
||||||
type="text"
|
placeholder="popis"
|
||||||
placeholder="popis"
|
value={d.surchargeText}
|
||||||
value={d.surchargeText}
|
onChange={e => handleSurchargeText(d.login, e.target.value)}
|
||||||
onChange={e => handleSurchargeText(d.login, e.target.value)}
|
disabled={!isPayer && !d.included}
|
||||||
disabled={!d.included}
|
size="sm"
|
||||||
size="sm"
|
onKeyDown={e => e.stopPropagation()}
|
||||||
onKeyDown={e => e.stopPropagation()}
|
/>
|
||||||
/>
|
<Form.Control
|
||||||
<Form.Control
|
type="text"
|
||||||
type="text"
|
placeholder="Kč"
|
||||||
placeholder="Kč"
|
value={d.surchargeAmount}
|
||||||
value={d.surchargeAmount}
|
onChange={e => handleSurchargeAmount(d.login, e.target.value)}
|
||||||
onChange={e => handleSurchargeAmount(d.login, e.target.value)}
|
disabled={!isPayer && !d.included}
|
||||||
disabled={!d.included}
|
size="sm"
|
||||||
size="sm"
|
style={{ width: 70 }}
|
||||||
style={{ width: 70 }}
|
onKeyDown={e => e.stopPropagation()}
|
||||||
onKeyDown={e => e.stopPropagation()}
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="text-end">
|
<td className="text-end">
|
||||||
{!isPayer && d.included ? `${tipPerPerson} Kč` : '—'}
|
{(() => { const s = isPayer ? payerTipShare : tipPerPerson; return s > 0 ? `${s} Kč` : '—'; })()}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-end fw-bold">
|
<td className="text-end fw-bold">
|
||||||
{!isPayer ? `${total} Kč` : '—'}
|
{`${total} Kč`}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
@@ -262,7 +266,7 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
|
|||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
<div className="d-flex align-items-center gap-2 mt-2">
|
<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
|
<Form.Control
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="0"
|
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_CONNECT = 'connect';
|
||||||
export const EVENT_DISCONNECT = 'disconnect';
|
export const EVENT_DISCONNECT = 'disconnect';
|
||||||
export const EVENT_MESSAGE = 'message';
|
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"
|
||||||
@@ -47,4 +47,8 @@
|
|||||||
|
|
||||||
# Heslo pro bypass rate limitu na endpointu /api/food/refresh (pro skripty/admin).
|
# Heslo pro bypass rate limitu na endpointu /api/food/refresh (pro skripty/admin).
|
||||||
# Bez hesla může refresh volat každý přihlášený uživatel (podléhá rate limitu).
|
# Bez hesla může refresh volat každý přihlášený uživatel (podléhá rate limitu).
|
||||||
# REFRESH_BYPASS_PASSWORD=
|
# REFRESH_BYPASS_PASSWORD=
|
||||||
|
|
||||||
|
# Admin heslo pro správu seznamu obchodů na stránce /objednani.
|
||||||
|
# Bez hesla nelze přidávat ani odebírat obchody ze seznamu (POST/DELETE na /api/stores vrátí 403).
|
||||||
|
# ADMIN_PASSWORD=
|
||||||
@@ -0,0 +1,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 express from "express";
|
||||||
import bodyParser from "body-parser";
|
import bodyParser from "body-parser";
|
||||||
import cors from 'cors';
|
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 dotenv from 'dotenv';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { getQr } from "./qr";
|
import { getQr } from "./qr";
|
||||||
import { generateToken, getLogin, verify } from "./auth";
|
import { generateToken, getLogin, verify } from "./auth";
|
||||||
import { getIsWeekend, InsufficientPermissions, PizzaDayConflictError, parseToken } from "./utils";
|
import { getIsWeekend, InsufficientPermissions, PizzaDayConflictError, parseToken } from "./utils";
|
||||||
import { getPendingQrs } from "./pizza";
|
import { getPendingQrs } from "./pizza";
|
||||||
import { initWebsocket } from "./websocket";
|
import { initWebsocket, getWebsocket } from "./websocket";
|
||||||
import { startReminderScheduler } from "./pushReminder";
|
import { startReminderScheduler, verifyQuickChoiceToken } from "./pushReminder";
|
||||||
import { storageReady } from "./storage";
|
import { storageReady } from "./storage";
|
||||||
import pizzaDayRoutes from "./routes/pizzaDayRoutes";
|
import pizzaDayRoutes from "./routes/pizzaDayRoutes";
|
||||||
import foodRoutes, { refreshMetoda } from "./routes/foodRoutes";
|
import foodRoutes, { refreshMetoda } from "./routes/foodRoutes";
|
||||||
@@ -20,6 +21,8 @@ import notificationRoutes from "./routes/notificationRoutes";
|
|||||||
import qrRoutes from "./routes/qrRoutes";
|
import qrRoutes from "./routes/qrRoutes";
|
||||||
import devRoutes from "./routes/devRoutes";
|
import devRoutes from "./routes/devRoutes";
|
||||||
import changelogRoutes from "./routes/changelogRoutes";
|
import changelogRoutes from "./routes/changelogRoutes";
|
||||||
|
import groupRoutes from "./routes/groupRoutes";
|
||||||
|
import storeRoutes from "./routes/storeRoutes";
|
||||||
|
|
||||||
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
|
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
|
||||||
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
|
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
|
||||||
@@ -113,6 +116,22 @@ app.get("/api/qr", async (req, res) => {
|
|||||||
// Přeskočení auth pro refresh dat xd
|
// Přeskočení auth pro refresh dat xd
|
||||||
app.use("/api/food/refresh", refreshMetoda);
|
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 */
|
/** Middleware ověřující JWT token */
|
||||||
app.use("/api/", (req, res, next) => {
|
app.use("/api/", (req, res, next) => {
|
||||||
if (HTTP_REMOTE_USER_ENABLED) {
|
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"
|
// Na víkendu zobrazíme pátek místo hlášky "Užívejte víkend"
|
||||||
date = getDateForWeekIndex(4);
|
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
|
// Připojíme nevyřízené QR kódy pro přihlášeného uživatele
|
||||||
try {
|
try {
|
||||||
const login = getLogin(parseToken(req));
|
const login = getLogin(parseToken(req));
|
||||||
@@ -175,6 +198,8 @@ app.use("/api/notifications", notificationRoutes);
|
|||||||
app.use("/api/qr", qrRoutes);
|
app.use("/api/qr", qrRoutes);
|
||||||
app.use("/api/dev", devRoutes);
|
app.use("/api/dev", devRoutes);
|
||||||
app.use("/api/changelogs", changelogRoutes);
|
app.use("/api/changelogs", changelogRoutes);
|
||||||
|
app.use("/api/groups", groupRoutes);
|
||||||
|
app.use("/api/stores", storeRoutes);
|
||||||
|
|
||||||
app.use('/stats', express.static('public'));
|
app.use('/stats', express.static('public'));
|
||||||
app.use(express.static('public'));
|
app.use(express.static('public'));
|
||||||
|
|||||||
+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).
|
* 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 key = getPendingQrKey(login);
|
||||||
const existing = await storage.getData<PendingQr[]>(key) ?? [];
|
const existing = await storage.getData<PendingQr[]>(key) ?? [];
|
||||||
|
const dismissed = existing.find(qr => qr.id === id);
|
||||||
const filtered = existing.filter(qr => qr.id !== id);
|
const filtered = existing.filter(qr => qr.id !== id);
|
||||||
await storage.setData(key, filtered);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+23
-20
@@ -1,4 +1,5 @@
|
|||||||
import webpush from 'web-push';
|
import webpush from 'web-push';
|
||||||
|
import crypto from 'crypto';
|
||||||
import getStorage from './storage';
|
import getStorage from './storage';
|
||||||
import { getClientData, getToday } from './service';
|
import { getClientData, getToday } from './service';
|
||||||
import { getIsWeekend } from './utils';
|
import { getIsWeekend } from './utils';
|
||||||
@@ -14,13 +15,10 @@ interface RegistryEntry {
|
|||||||
|
|
||||||
type Registry = Record<string, RegistryEntry>;
|
type Registry = Record<string, RegistryEntry>;
|
||||||
|
|
||||||
/** Mapa login → datum (YYYY-MM-DD), kdy byl uživatel naposledy upozorněn. */
|
/** Mapa login → timestamp (ms) posledního odeslání připomínky. */
|
||||||
const remindedToday = new Map<string, string>();
|
const lastReminded = new Map<string, number>();
|
||||||
|
|
||||||
function getTodayDateString(): string {
|
const REMINDER_COOLDOWN_MS = 60 * 60 * 1000; // 60 minut mezi připomínkami
|
||||||
const now = new Date();
|
|
||||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCurrentTimeHHMM(): string {
|
function getCurrentTimeHHMM(): string {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -59,7 +57,7 @@ export async function unsubscribePush(login: string): Promise<void> {
|
|||||||
const registry = await getRegistry();
|
const registry = await getRegistry();
|
||||||
delete registry[login];
|
delete registry[login];
|
||||||
await saveRegistry(registry);
|
await saveRegistry(registry);
|
||||||
remindedToday.delete(login);
|
lastReminded.delete(login);
|
||||||
console.log(`Push reminder: uživatel ${login} odhlášen z připomínek`);
|
console.log(`Push reminder: uživatel ${login} odhlášen z připomínek`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,17 +66,20 @@ export function getVapidPublicKey(): string | undefined {
|
|||||||
return process.env.VAPID_PUBLIC_KEY;
|
return process.env.VAPID_PUBLIC_KEY;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Najde login uživatele podle push subscription endpointu. */
|
function generateQuickChoiceToken(login: string): string {
|
||||||
export async function findLoginByEndpoint(endpoint: string): Promise<string | undefined> {
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
const registry = await getRegistry();
|
const secret = process.env.JWT_SECRET ?? '';
|
||||||
for (const [login, entry] of Object.entries(registry)) {
|
return crypto.createHmac('sha256', secret).update(`${login}:${today}`).digest('hex');
|
||||||
if (entry.subscription.endpoint === endpoint) {
|
|
||||||
return login;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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'));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/** Zkontroluje a odešle připomínky uživatelům, kteří si nezvolili oběd. */
|
/** Zkontroluje a odešle připomínky uživatelům, kteří si nezvolili oběd. */
|
||||||
async function checkAndSendReminders(): Promise<void> {
|
async function checkAndSendReminders(): Promise<void> {
|
||||||
// Přeskočit víkendy
|
// Přeskočit víkendy
|
||||||
@@ -93,7 +94,6 @@ async function checkAndSendReminders(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentTime = getCurrentTimeHHMM();
|
const currentTime = getCurrentTimeHHMM();
|
||||||
const todayStr = getTodayDateString();
|
|
||||||
|
|
||||||
// Získáme data pro dnešek jednou pro všechny uživatele
|
// Získáme data pro dnešek jednou pro všechny uživatele
|
||||||
let clientData;
|
let clientData;
|
||||||
@@ -110,8 +110,9 @@ async function checkAndSendReminders(): Promise<void> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Už jsme dnes připomenuli
|
// Cooldown — nepřipomínat častěji než jednou za hodinu
|
||||||
if (remindedToday.get(login) === todayStr) {
|
const last = lastReminded.get(login) ?? 0;
|
||||||
|
if (Date.now() - last < REMINDER_COOLDOWN_MS) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,9 +128,11 @@ async function checkAndSendReminders(): Promise<void> {
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
title: 'Luncher',
|
title: 'Luncher',
|
||||||
body: 'Ještě nemáte zvolený oběd!',
|
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}`);
|
console.log(`Push reminder: odeslána připomínka uživateli ${login}`);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.statusCode === 410 || error.statusCode === 404) {
|
if (error.statusCode === 410 || error.statusCode === 404) {
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ router.post("/testPush", async (req, res, next) => {
|
|||||||
webpush.setVapidDetails(subject, publicKey, privateKey);
|
webpush.setVapidDetails(subject, publicKey, privateKey);
|
||||||
await webpush.sendNotification(
|
await webpush.sendNotification(
|
||||||
entry.subscription,
|
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 });
|
res.status(200).json({ ok: true });
|
||||||
} catch (e: any) { next(e) }
|
} catch (e: any) { next(e) }
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { addChoice, getDateForWeekIndex, getToday, removeChoice, removeChoices,
|
|||||||
import { getDayOfWeekIndex, parseToken, getFirstWorkDayOfWeek } from "../utils";
|
import { getDayOfWeekIndex, parseToken, getFirstWorkDayOfWeek } from "../utils";
|
||||||
import { getWebsocket } from "../websocket";
|
import { getWebsocket } from "../websocket";
|
||||||
import { callNotifikace } from "../notifikace";
|
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
|
// RateLimit na refresh endpoint
|
||||||
@@ -69,11 +69,21 @@ const parseValidateFutureDayIndex = (req: Request<{}, any, AddChoiceData["body"]
|
|||||||
return dayIndex;
|
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();
|
const router = express.Router();
|
||||||
|
|
||||||
router.post("/addChoice", async (req: Request<{}, any, AddChoiceData["body"]>, res, next) => {
|
router.post("/addChoice", async (req: Request<{}, any, AddChoiceData["body"]>, res, next) => {
|
||||||
const login = getLogin(parseToken(req));
|
const login = getLogin(parseToken(req));
|
||||||
const trusted = getTrusted(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;
|
let date = undefined;
|
||||||
if (req.body.dayIndex != null) {
|
if (req.body.dayIndex != null) {
|
||||||
let dayIndex;
|
let dayIndex;
|
||||||
@@ -85,7 +95,7 @@ router.post("/addChoice", async (req: Request<{}, any, AddChoiceData["body"]>, r
|
|||||||
date = getDateForWeekIndex(dayIndex);
|
date = getDateForWeekIndex(dayIndex);
|
||||||
}
|
}
|
||||||
try {
|
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);
|
getWebsocket().emit("message", data);
|
||||||
return res.status(200).json(data);
|
return res.status(200).json(data);
|
||||||
} catch (e: any) { next(e) }
|
} 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) => {
|
router.post("/removeChoices", async (req: Request<{}, any, RemoveChoicesData["body"]>, res, next) => {
|
||||||
const login = getLogin(parseToken(req));
|
const login = getLogin(parseToken(req));
|
||||||
const trusted = getTrusted(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;
|
let date = undefined;
|
||||||
if (req.body.dayIndex != null) {
|
if (req.body.dayIndex != null) {
|
||||||
let dayIndex;
|
let dayIndex;
|
||||||
@@ -105,7 +117,7 @@ router.post("/removeChoices", async (req: Request<{}, any, RemoveChoicesData["bo
|
|||||||
date = getDateForWeekIndex(dayIndex);
|
date = getDateForWeekIndex(dayIndex);
|
||||||
}
|
}
|
||||||
try {
|
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);
|
getWebsocket().emit("message", data);
|
||||||
res.status(200).json(data);
|
res.status(200).json(data);
|
||||||
} catch (e: any) { next(e) }
|
} 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) => {
|
router.post("/removeChoice", async (req: Request<{}, any, RemoveChoiceData["body"]>, res, next) => {
|
||||||
const login = getLogin(parseToken(req));
|
const login = getLogin(parseToken(req));
|
||||||
const trusted = getTrusted(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;
|
let date = undefined;
|
||||||
if (req.body.dayIndex != null) {
|
if (req.body.dayIndex != null) {
|
||||||
let dayIndex;
|
let dayIndex;
|
||||||
@@ -125,7 +139,7 @@ router.post("/removeChoice", async (req: Request<{}, any, RemoveChoiceData["body
|
|||||||
date = getDateForWeekIndex(dayIndex);
|
date = getDateForWeekIndex(dayIndex);
|
||||||
}
|
}
|
||||||
try {
|
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);
|
getWebsocket().emit("message", data);
|
||||||
res.status(200).json(data);
|
res.status(200).json(data);
|
||||||
} catch (e: any) { next(e) }
|
} catch (e: any) { next(e) }
|
||||||
@@ -135,6 +149,8 @@ router.post("/updateNote", async (req: Request<{}, any, UpdateNoteData["body"]>,
|
|||||||
const login = getLogin(parseToken(req));
|
const login = getLogin(parseToken(req));
|
||||||
const trusted = getTrusted(parseToken(req));
|
const trusted = getTrusted(parseToken(req));
|
||||||
const note = req.body.note;
|
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 {
|
try {
|
||||||
if (note && note.length > 70) {
|
if (note && note.length > 70) {
|
||||||
throw Error("Poznámka může mít maximálně 70 znaků");
|
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);
|
date = getDateForWeekIndex(dayIndex);
|
||||||
}
|
}
|
||||||
const data = await updateNote(login, trusted, note, date);
|
const data = await updateNote(login, trusted, note, date, slot);
|
||||||
getWebsocket().emit("message", data);
|
getWebsocket().emit("message", data);
|
||||||
res.status(200).json(data);
|
res.status(200).json(data);
|
||||||
} catch (e: any) { next(e) }
|
} catch (e: any) { next(e) }
|
||||||
@@ -184,8 +200,10 @@ router.post("/jdemeObed", async (req, res, next) => {
|
|||||||
|
|
||||||
router.post("/updateBuyer", async (req, res, next) => {
|
router.post("/updateBuyer", async (req, res, next) => {
|
||||||
const login = getLogin(parseToken(req));
|
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 {
|
try {
|
||||||
const data = await updateBuyer(login);
|
const data = await updateBuyer(login, slot);
|
||||||
getWebsocket().emit("message", data);
|
getWebsocket().emit("message", data);
|
||||||
res.status(200).json({});
|
res.status(200).json({});
|
||||||
} catch (e: any) { next(e) }
|
} 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 { getLogin } from "../auth";
|
||||||
import { parseToken } from "../utils";
|
import { parseToken } from "../utils";
|
||||||
import { getNotificationSettings, saveNotificationSettings } from "../notifikace";
|
import { getNotificationSettings, saveNotificationSettings } from "../notifikace";
|
||||||
import { subscribePush, unsubscribePush, getVapidPublicKey, findLoginByEndpoint } from "../pushReminder";
|
import { subscribePush, unsubscribePush, getVapidPublicKey } from "../pushReminder";
|
||||||
import { addChoice } from "../service";
|
|
||||||
import { getWebsocket } from "../websocket";
|
|
||||||
import { UpdateNotificationSettingsData } from "../../../types";
|
import { UpdateNotificationSettingsData } from "../../../types";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -66,21 +64,4 @@ router.post("/push/unsubscribe", async (req, res, next) => {
|
|||||||
} catch (e: any) { next(e) }
|
} 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;
|
export default router;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import express, { Request } from "express";
|
import express, { Request } from "express";
|
||||||
import { getLogin } from "../auth";
|
import { getLogin } from "../auth";
|
||||||
import { createPizzaDay, deletePizzaDay, getPizzaList, getSalatList, addPizzaOrder, addSalatOrder, removePizzaOrder, lockPizzaDay, unlockPizzaDay, finishPizzaOrder, finishPizzaDelivery, updatePizzaDayNote, updatePizzaFee, dismissPendingQr } from "../pizza";
|
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 { parseToken } from "../utils";
|
||||||
import { getWebsocket } from "../websocket";
|
import { getWebsocket } from "../websocket";
|
||||||
import { AddPizzaData, DismissQrData, FinishDeliveryData, RemovePizzaData, UpdatePizzaDayNoteData, UpdatePizzaFeeData } from "../../../types";
|
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" });
|
return res.status(400).json({ error: "Nebyl předán identifikátor QR kódu" });
|
||||||
}
|
}
|
||||||
try {
|
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({});
|
res.status(200).json({});
|
||||||
} catch (e: any) { next(e) }
|
} catch (e: any) { next(e) }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { getLogin } from "../auth";
|
|||||||
import { parseToken, formatDate } from "../utils";
|
import { parseToken, formatDate } from "../utils";
|
||||||
import { generateQr } from "../qr";
|
import { generateQr } from "../qr";
|
||||||
import { addPendingQr } from "../pizza";
|
import { addPendingQr } from "../pizza";
|
||||||
|
import { emitToUser } from "../websocket";
|
||||||
import { GenerateQrData } from "../../../types";
|
import { GenerateQrData } from "../../../types";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
|
|
||||||
@@ -14,7 +15,7 @@ const router = express.Router();
|
|||||||
router.post("/generate", async (req: Request<{}, any, GenerateQrData["body"]>, res, next) => {
|
router.post("/generate", async (req: Request<{}, any, GenerateQrData["body"]>, res, next) => {
|
||||||
const login = getLogin(parseToken(req));
|
const login = getLogin(parseToken(req));
|
||||||
try {
|
try {
|
||||||
const { recipients, bankAccount, bankAccountHolder } = req.body;
|
const { recipients, bankAccount, bankAccountHolder, groupId } = req.body;
|
||||||
|
|
||||||
if (!recipients || !Array.isArray(recipients) || recipients.length === 0) {
|
if (!recipients || !Array.isArray(recipients) || recipients.length === 0) {
|
||||||
return res.status(400).json({ error: "Nebyl předán seznam příjemců" });
|
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();
|
const id = crypto.randomUUID();
|
||||||
await generateQr(recipient.login, bankAccount, bankAccountHolder, recipient.amount, recipient.purpose, id);
|
await generateQr(recipient.login, bankAccount, bankAccountHolder, recipient.amount, recipient.purpose, id);
|
||||||
|
|
||||||
// Uložit jako nevyřízený QR kód
|
// Uložit jako nevyřízený QR kód a okamžitě doručit příjemci
|
||||||
await addPendingQr(recipient.login, {
|
const pendingQr = {
|
||||||
id,
|
id,
|
||||||
date: today,
|
date: today,
|
||||||
creator: login,
|
creator: login,
|
||||||
totalPrice: recipient.amount,
|
totalPrice: recipient.amount,
|
||||||
purpose: recipient.purpose,
|
purpose: recipient.purpose,
|
||||||
});
|
...(groupId ? { groupId } : {}),
|
||||||
|
};
|
||||||
|
await addPendingQr(recipient.login, pendingQr);
|
||||||
|
emitToUser(recipient.login, 'pendingQr', pendingQr);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(200).json({ success: true, count: recipients.length });
|
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;
|
||||||
+67
-56
@@ -3,11 +3,17 @@ import getStorage from "./storage";
|
|||||||
import { getMenuSladovnicka, getMenuTechTower, getMenuZastavkaUmichala, getMenuSenkSerikova, StaleWeekError } from "./restaurants";
|
import { getMenuSladovnicka, getMenuTechTower, getMenuZastavkaUmichala, getMenuSenkSerikova, StaleWeekError } from "./restaurants";
|
||||||
import { getTodayMock } from "./mock";
|
import { getTodayMock } from "./mock";
|
||||||
import { removeAllUserPizzas } from "./pizza";
|
import { removeAllUserPizzas } from "./pizza";
|
||||||
import { 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 storage = getStorage();
|
||||||
const MENU_PREFIX = 'menu';
|
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í. */
|
/** Vrátí dnešní datum, případně fiktivní datum pro účely vývoje a testování. */
|
||||||
export function getToday(): Date {
|
export function getToday(): Date {
|
||||||
if (process.env.MOCK_DATA === 'true') {
|
if (process.env.MOCK_DATA === 'true') {
|
||||||
@@ -43,14 +49,18 @@ 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.
|
* 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> {
|
export async function getData(date?: Date, slot?: MealSlot): Promise<ClientData> {
|
||||||
const clientData = await getClientData(date);
|
const clientData = await getClientData(date, slot);
|
||||||
clientData.menus = {
|
if (slot === MealSlot.EXTRA) {
|
||||||
SLADOVNICKA: await getRestaurantMenu('SLADOVNICKA', date),
|
clientData.stores = await getStores();
|
||||||
// UMOTLIKU: await getRestaurantMenu('UMOTLIKU', date),
|
} else {
|
||||||
TECHTOWER: await getRestaurantMenu('TECHTOWER', date),
|
clientData.menus = {
|
||||||
ZASTAVKAUMICHALA: await getRestaurantMenu('ZASTAVKAUMICHALA', date),
|
SLADOVNICKA: await getRestaurantMenu('SLADOVNICKA', date),
|
||||||
SENKSERIKOVA: await getRestaurantMenu('SENKSERIKOVA', date),
|
// UMOTLIKU: await getRestaurantMenu('UMOTLIKU', date),
|
||||||
|
TECHTOWER: await getRestaurantMenu('TECHTOWER', date),
|
||||||
|
ZASTAVKAUMICHALA: await getRestaurantMenu('ZASTAVKAUMICHALA', date),
|
||||||
|
SENKSERIKOVA: await getRestaurantMenu('SENKSERIKOVA', date),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return clientData;
|
return clientData;
|
||||||
}
|
}
|
||||||
@@ -290,8 +300,8 @@ function generateMenuWarnings(menu: RestaurantDayMenu): string[] {
|
|||||||
*
|
*
|
||||||
* @param date datum
|
* @param date datum
|
||||||
*/
|
*/
|
||||||
export async function initIfNeeded(date?: Date) {
|
export async function initIfNeeded(date?: Date, slot?: MealSlot) {
|
||||||
const usedDate = formatDate(date ?? getToday());
|
const usedDate = getDataKey(date ?? getToday(), slot);
|
||||||
const hasData = await storage.hasData(usedDate);
|
const hasData = await storage.hasData(usedDate);
|
||||||
if (!hasData) {
|
if (!hasData) {
|
||||||
await storage.setData(usedDate, getEmptyData(date || getToday()));
|
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
|
* @param date datum, ke kterému se volba vztahuje
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
export async function removeChoices(login: string, trusted: boolean, locationKey: LunchChoice, date?: Date) {
|
export async function removeChoices(login: string, trusted: boolean, locationKey: LunchChoice, date?: Date, slot?: MealSlot) {
|
||||||
const selectedDay = formatDate(date ?? getToday());
|
const selectedDay = getDataKey(date ?? getToday(), slot);
|
||||||
let data = await getClientData(date);
|
let data = await getClientData(date, slot);
|
||||||
validateTrusted(data, login, trusted);
|
validateTrusted(data, login, trusted);
|
||||||
if (locationKey in data.choices) {
|
if (locationKey in data.choices) {
|
||||||
if (data.choices[locationKey] && login in data.choices[locationKey]) {
|
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
|
* @param date datum, ke kterému se volba vztahuje
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
export async function removeChoice(login: string, trusted: boolean, locationKey: LunchChoice, foodIndex: number, date?: Date) {
|
export async function removeChoice(login: string, trusted: boolean, locationKey: LunchChoice, foodIndex: number, date?: Date, slot?: MealSlot) {
|
||||||
const selectedDay = formatDate(date ?? getToday());
|
const selectedDay = getDataKey(date ?? getToday(), slot);
|
||||||
let data = await getClientData(date);
|
let data = await getClientData(date, slot);
|
||||||
validateTrusted(data, login, trusted);
|
validateTrusted(data, login, trusted);
|
||||||
if (locationKey in data.choices) {
|
if (locationKey in data.choices) {
|
||||||
if (data.choices[locationKey] && login in data.choices[locationKey]) {
|
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 date datum, ke kterému se volby vztahují
|
||||||
* @param ignoredLocationKey volba, která nebude odstraněna, pokud existuje
|
* @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();
|
const usedDate = date ?? getToday();
|
||||||
let data = await getClientData(usedDate);
|
let data = await getClientData(usedDate, slot);
|
||||||
for (const key of Object.keys(data.choices)) {
|
for (const key of Object.keys(data.choices)) {
|
||||||
const locationKey = key as LunchChoice;
|
const locationKey = key as LunchChoice;
|
||||||
if (ignoredLocationKey != null && ignoredLocationKey == locationKey) {
|
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) {
|
if (Object.keys(data.choices[locationKey]).length === 0) {
|
||||||
delete data.choices[locationKey];
|
delete data.choices[locationKey];
|
||||||
}
|
}
|
||||||
await storage.setData(formatDate(usedDate), data);
|
await storage.setData(getDataKey(usedDate, slot), data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
@@ -409,41 +419,43 @@ function validateTrusted(data: ClientData, login: string, trusted: boolean) {
|
|||||||
* @param date datum, ke kterému se volba vztahuje
|
* @param date datum, ke kterému se volba vztahuje
|
||||||
* @returns aktuální data
|
* @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();
|
const usedDate = date ?? getToday();
|
||||||
await initIfNeeded(usedDate);
|
await initIfNeeded(usedDate, slot);
|
||||||
let data = await getClientData(usedDate);
|
let data = await getClientData(usedDate, slot);
|
||||||
validateTrusted(data, login, trusted);
|
validateTrusted(data, login, trusted);
|
||||||
await validateFoodIndex(locationKey, foodIndex, date);
|
await validateFoodIndex(locationKey, foodIndex, date);
|
||||||
|
|
||||||
// Pokud uživatel měl vybranou PIZZA a mění na něco jiného
|
if (!slot || slot === MealSlot.OBED) {
|
||||||
const hadPizzaChoice = data.choices.PIZZA && login in data.choices.PIZZA;
|
// Pokud uživatel měl vybranou PIZZA a mění na něco jiného
|
||||||
if (hadPizzaChoice && locationKey !== LunchChoice.PIZZA && foodIndex == null) {
|
const hadPizzaChoice = data.choices.PIZZA && login in data.choices.PIZZA;
|
||||||
// Kontrola, zda existuje Pizza day a uživatel je jeho zakladatel
|
if (hadPizzaChoice && locationKey !== LunchChoice.PIZZA && foodIndex == null) {
|
||||||
if (data.pizzaDay && data.pizzaDay.creator === login) {
|
// Kontrola, zda existuje Pizza day a uživatel je jeho zakladatel
|
||||||
// Pokud Pizza day není ve stavu CREATED, nelze změnit volbu
|
if (data.pizzaDay && data.pizzaDay.creator === login) {
|
||||||
if (data.pizzaDay.state !== PizzaDayState.CREATED) {
|
// Pokud Pizza day není ve stavu CREATED, nelze změnit volbu
|
||||||
throw new PizzaDayConflictError(
|
if (data.pizzaDay.state !== PizzaDayState.CREATED) {
|
||||||
`Nelze změnit volbu. Pizza day je ve stavu "${data.pizzaDay.state}" a musí být nejprve dokončen nebo smazán.`
|
throw new PizzaDayConflictError(
|
||||||
);
|
`Nelze změnit volbu. Pizza day je ve stavu "${data.pizzaDay.state}" a musí být nejprve dokončen nebo smazán.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Pizza day je ve stavu CREATED - bude smazán frontendem po potvrzení uživatelem
|
||||||
|
// (frontend volá nejprve deletePizzaDay, pak teprve addChoice)
|
||||||
}
|
}
|
||||||
// Pizza day je ve stavu CREATED - bude smazán frontendem po potvrzení uživatelem
|
|
||||||
// (frontend volá nejprve deletePizzaDay, pak teprve addChoice)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Smažeme pizzy uživatele (pokud Pizza day nebyl založen tímto uživatelem,
|
// Smažeme pizzy uživatele (pokud Pizza day nebyl založen tímto uživatelem,
|
||||||
// nebo byl již smazán frontendem)
|
// nebo byl již smazán frontendem)
|
||||||
await removeAllUserPizzas(login, usedDate);
|
await removeAllUserPizzas(login, usedDate);
|
||||||
// Znovu načteme data, protože removeAllUserPizzas je upravila
|
// 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í
|
// Pokud měníme pouze lokaci, mažeme případné předchozí
|
||||||
if (foodIndex == null) {
|
if (foodIndex == null) {
|
||||||
data = await removeChoiceIfPresent(login, usedDate);
|
data = await removeChoiceIfPresent(login, usedDate, undefined, slot);
|
||||||
} else {
|
} else {
|
||||||
// Mažeme případné ostatní volby (měla by být maximálně jedna)
|
// 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
|
// TODO vytáhnout inicializaci "prázdné struktury" do vlastní funkce
|
||||||
data.choices[locationKey] ??= {};
|
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)) {
|
if (foodIndex != null && !data.choices[locationKey][login].selectedFoods?.includes(foodIndex)) {
|
||||||
data.choices[locationKey][login].selectedFoods?.push(foodIndex);
|
data.choices[locationKey][login].selectedFoods?.push(foodIndex);
|
||||||
}
|
}
|
||||||
const selectedDate = formatDate(usedDate);
|
await storage.setData(getDataKey(usedDate, slot), data);
|
||||||
await storage.setData(selectedDate, data);
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -498,10 +509,10 @@ async function validateFoodIndex(locationKey: LunchChoice, foodIndex?: number, d
|
|||||||
* @param note poznámka
|
* @param note poznámka
|
||||||
* @param date datum, ke kterému se volba vztahuje
|
* @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();
|
const usedDate = date ?? getToday();
|
||||||
await initIfNeeded(usedDate);
|
await initIfNeeded(usedDate, slot);
|
||||||
let data = await getClientData(usedDate);
|
let data = await getClientData(usedDate, slot);
|
||||||
validateTrusted(data, login, trusted);
|
validateTrusted(data, login, trusted);
|
||||||
const userEntry = data.choices != null && Object.entries(data.choices).find(entry => entry[1][login] != null);
|
const userEntry = data.choices != null && Object.entries(data.choices).find(entry => entry[1][login] != null);
|
||||||
if (userEntry) {
|
if (userEntry) {
|
||||||
@@ -510,8 +521,7 @@ export async function updateNote(login: string, trusted: boolean, note?: string,
|
|||||||
} else {
|
} else {
|
||||||
userEntry[1][login].note = note;
|
userEntry[1][login].note = note;
|
||||||
}
|
}
|
||||||
const selectedDate = formatDate(usedDate);
|
await storage.setData(getDataKey(usedDate, slot), data);
|
||||||
await storage.setData(selectedDate, data);
|
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
@@ -537,7 +547,7 @@ export async function updateDepartureTime(login: string, time?: string, date?: D
|
|||||||
}
|
}
|
||||||
found[login].departureTime = time;
|
found[login].departureTime = time;
|
||||||
}
|
}
|
||||||
await storage.setData(formatDate(usedDate), clientData);
|
await storage.setData(getDataKey(usedDate), clientData);
|
||||||
}
|
}
|
||||||
return 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
|
* @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();
|
const usedDate = getToday();
|
||||||
let clientData = await getClientData(usedDate);
|
let clientData = await getClientData(usedDate, slot);
|
||||||
const userEntry = clientData.choices?.['OBJEDNAVAM']?.[login];
|
const userEntry = clientData.choices?.['OBJEDNAVAM']?.[login];
|
||||||
if (!userEntry) {
|
if (!userEntry) {
|
||||||
throw new Error("Nelze nastavit objednatele pro uživatele s jinou volbou než \"Budu objednávat\"");
|
throw new Error("Nelze nastavit objednatele pro uživatele s jinou volbou než \"Budu objednávat\"");
|
||||||
}
|
}
|
||||||
userEntry.isBuyer = !(userEntry.isBuyer || false);
|
userEntry.isBuyer = !(userEntry.isBuyer || false);
|
||||||
await storage.setData(formatDate(usedDate), clientData);
|
await storage.setData(getDataKey(usedDate, slot), clientData);
|
||||||
return 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
|
* @param date datum pro který vrátit data, pokud není vyplněno, je použit dnešní den
|
||||||
* @returns data pro klienta
|
* @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 targetDate = date ?? getToday();
|
||||||
const dateString = formatDate(targetDate);
|
const dateString = getDataKey(targetDate, slot);
|
||||||
const clientData = await storage.getData<ClientData>(dateString) || getEmptyData(date);
|
const clientData = await storage.getData<ClientData>(dateString) || getEmptyData(date);
|
||||||
return {
|
return {
|
||||||
...clientData,
|
...clientData,
|
||||||
todayDayIndex: getDayOfWeekIndex(getToday()),
|
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) => {
|
io.on("connection", (socket) => {
|
||||||
console.log(`New client connected: ${socket.id}`);
|
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) => {
|
socket.on("message", (message) => {
|
||||||
io.emit("message", message);
|
io.emit("message", message);
|
||||||
});
|
});
|
||||||
@@ -22,6 +28,9 @@ export const initWebsocket = (server: any) => {
|
|||||||
return io;
|
return io;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getWebsocket = () => {
|
export const getWebsocket = () => io;
|
||||||
return 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:
|
/changelogs:
|
||||||
$ref: "./paths/changelogs/getChangelogs.yml"
|
$ref: "./paths/changelogs/getChangelogs.yml"
|
||||||
|
|
||||||
|
# Skupiny objednávek (/api/groups)
|
||||||
|
/groups/create:
|
||||||
|
$ref: "./paths/groups/createGroup.yml"
|
||||||
|
/groups/delete:
|
||||||
|
$ref: "./paths/groups/deleteGroup.yml"
|
||||||
|
/groups/addMember:
|
||||||
|
$ref: "./paths/groups/addMember.yml"
|
||||||
|
/groups/removeMember:
|
||||||
|
$ref: "./paths/groups/removeMember.yml"
|
||||||
|
/groups/updateMember:
|
||||||
|
$ref: "./paths/groups/updateMember.yml"
|
||||||
|
/groups/setState:
|
||||||
|
$ref: "./paths/groups/setState.yml"
|
||||||
|
/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 endpointy (/api/dev)
|
||||||
/dev/generate:
|
/dev/generate:
|
||||||
$ref: "./paths/dev/generate.yml"
|
$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"
|
$ref: "../../schemas/_index.yml#/DayIndex"
|
||||||
foodIndex:
|
foodIndex:
|
||||||
$ref: "../../schemas/_index.yml#/FoodIndex"
|
$ref: "../../schemas/_index.yml#/FoodIndex"
|
||||||
|
slot:
|
||||||
|
$ref: "../../schemas/_index.yml#/MealSlot"
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
$ref: "../../api.yml#/components/responses/ClientDataResponse"
|
$ref: "../../api.yml#/components/responses/ClientDataResponse"
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ post:
|
|||||||
$ref: "../../schemas/_index.yml#/LunchChoice"
|
$ref: "../../schemas/_index.yml#/LunchChoice"
|
||||||
dayIndex:
|
dayIndex:
|
||||||
$ref: "../../schemas/_index.yml#/DayIndex"
|
$ref: "../../schemas/_index.yml#/DayIndex"
|
||||||
|
slot:
|
||||||
|
$ref: "../../schemas/_index.yml#/MealSlot"
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
$ref: "../../api.yml#/components/responses/ClientDataResponse"
|
$ref: "../../api.yml#/components/responses/ClientDataResponse"
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ post:
|
|||||||
$ref: "../../schemas/_index.yml#/LunchChoice"
|
$ref: "../../schemas/_index.yml#/LunchChoice"
|
||||||
dayIndex:
|
dayIndex:
|
||||||
$ref: "../../schemas/_index.yml#/DayIndex"
|
$ref: "../../schemas/_index.yml#/DayIndex"
|
||||||
|
slot:
|
||||||
|
$ref: "../../schemas/_index.yml#/MealSlot"
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
$ref: "../../api.yml#/components/responses/ClientDataResponse"
|
$ref: "../../api.yml#/components/responses/ClientDataResponse"
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
post:
|
post:
|
||||||
operationId: setBuyer
|
operationId: setBuyer
|
||||||
summary: Nastavení/odnastavení aktuálně přihlášeného uživatele jako objednatele pro stav "Budu objednávat" pro aktuální den.
|
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:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Stav byl úspěšně změněn.
|
description: Stav byl úspěšně změněn.
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ post:
|
|||||||
$ref: "../../schemas/_index.yml#/DayIndex"
|
$ref: "../../schemas/_index.yml#/DayIndex"
|
||||||
note:
|
note:
|
||||||
type: string
|
type: string
|
||||||
|
slot:
|
||||||
|
$ref: "../../schemas/_index.yml#/MealSlot"
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
$ref: "../../api.yml#/components/responses/ClientDataResponse"
|
$ref: "../../api.yml#/components/responses/ClientDataResponse"
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ get:
|
|||||||
type: integer
|
type: integer
|
||||||
minimum: 0
|
minimum: 0
|
||||||
maximum: 4
|
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:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
$ref: "../api.yml#/components/responses/ClientDataResponse"
|
$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
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: "#/PendingQr"
|
$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 ---
|
# --- OBĚDY ---
|
||||||
UserLunchChoice:
|
UserLunchChoice:
|
||||||
@@ -135,6 +148,15 @@ LunchChoice:
|
|||||||
- OBJEDNAVAM
|
- OBJEDNAVAM
|
||||||
- NEOBEDVAM
|
- NEOBEDVAM
|
||||||
- ROZHODUJI
|
- 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:
|
DayIndex:
|
||||||
description: Index dne v týdnu (0 = pondělí, 4 = pátek)
|
description: Index dne v týdnu (0 = pondělí, 4 = pátek)
|
||||||
type: integer
|
type: integer
|
||||||
@@ -250,7 +272,6 @@ DepartureTime:
|
|||||||
FeatureRequest:
|
FeatureRequest:
|
||||||
type: string
|
type: string
|
||||||
enum:
|
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 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
|
- 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
|
- 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
|
- Celkové vylepšení UI/UX
|
||||||
- Zlepšení dokumentace/postupů pro ostatní vývojáře
|
- Zlepšení dokumentace/postupů pro ostatní vývojáře
|
||||||
x-enum-varnames:
|
x-enum-varnames:
|
||||||
- CUSTOM_QR
|
|
||||||
- FAVORITES
|
- FAVORITES
|
||||||
- SINGLE_PAYMENT
|
- SINGLE_PAYMENT
|
||||||
- NO_WEEKENDS
|
- NO_WEEKENDS
|
||||||
@@ -638,6 +658,9 @@ GenerateQrRequest:
|
|||||||
bankAccountHolder:
|
bankAccountHolder:
|
||||||
description: Jméno držitele bankovního účtu
|
description: Jméno držitele bankovního účtu
|
||||||
type: string
|
type: string
|
||||||
|
groupId:
|
||||||
|
description: ID skupiny objednávky (pro propojení QR kódů se skupinou)
|
||||||
|
type: string
|
||||||
|
|
||||||
# --- DEV MOCK DATA ---
|
# --- DEV MOCK DATA ---
|
||||||
GenerateMockDataRequest:
|
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.
|
description: Index dne v týdnu (0 = pondělí, 4 = pátek). Pokud není zadán, použije se aktuální den.
|
||||||
$ref: "#/DayIndex"
|
$ref: "#/DayIndex"
|
||||||
|
|
||||||
|
# --- SKUPINOVÉ OBJEDNÁVKY ---
|
||||||
|
GroupState:
|
||||||
|
description: Stav skupiny objednávky
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- open
|
||||||
|
- locked
|
||||||
|
- ordered
|
||||||
|
x-enum-varnames:
|
||||||
|
- OPEN
|
||||||
|
- LOCKED
|
||||||
|
- ORDERED
|
||||||
|
|
||||||
|
OrderGroupMember:
|
||||||
|
description: Data člena skupiny objednávky
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
properties:
|
||||||
|
amount:
|
||||||
|
description: Částka k úhradě v Kč
|
||||||
|
type: number
|
||||||
|
note:
|
||||||
|
description: Volitelná poznámka (např. co si objednává)
|
||||||
|
type: string
|
||||||
|
surchargeText:
|
||||||
|
description: Popis příplatku
|
||||||
|
type: string
|
||||||
|
surchargeAmount:
|
||||||
|
description: Výše příplatku v Kč
|
||||||
|
type: number
|
||||||
|
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 ---
|
# --- NEVYŘÍZENÉ QR KÓDY ---
|
||||||
PendingQr:
|
PendingQr:
|
||||||
description: Nevyřízený QR kód pro platbu
|
description: Nevyřízený QR kód pro platbu
|
||||||
@@ -688,3 +795,6 @@ PendingQr:
|
|||||||
purpose:
|
purpose:
|
||||||
description: Účel platby (např. "Pizza prosciutto")
|
description: Účel platby (např. "Pizza prosciutto")
|
||||||
type: string
|
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":
|
"@hey-api/client-fetch@^0.8.2":
|
||||||
version "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==
|
integrity sha512-61T4UGfAzY5345vMxWDX8qnSTNRJcOpWuZyvNu3vNebCTLPwMQAM85mhEuBoACdWeRtLhNoUjU0UR5liRyD1bA==
|
||||||
|
|
||||||
"@hey-api/json-schema-ref-parser@1.0.2":
|
"@hey-api/json-schema-ref-parser@1.0.2":
|
||||||
version "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==
|
integrity sha512-F6LSkttZcT/XiX3ydeDqTY3uRN3BLJMwyMTk4kg/ichZlKUp3+3Odv0WokSmXGSoZGTW/N66FROMYAm5NPdJlA==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@jsdevtools/ono" "^7.1.3"
|
"@jsdevtools/ono" "^7.1.3"
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
"@hey-api/openapi-ts@^0.64.7":
|
"@hey-api/openapi-ts@^0.64.7":
|
||||||
version "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==
|
integrity sha512-xpaBzdGAKz7cPuGah1GZWl3zTZquOXRnwmpVCQKEUpvDtSYWBLjSxtAYIqDBjwJkuNHcakZBIWLcappx7slc2g==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@hey-api/json-schema-ref-parser" "1.0.2"
|
"@hey-api/json-schema-ref-parser" "1.0.2"
|
||||||
@@ -28,27 +28,27 @@
|
|||||||
|
|
||||||
"@jsdevtools/ono@^7.1.3":
|
"@jsdevtools/ono@^7.1.3":
|
||||||
version "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==
|
integrity sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==
|
||||||
|
|
||||||
"@types/json-schema@^7.0.15":
|
"@types/json-schema@^7.0.15":
|
||||||
version "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==
|
integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==
|
||||||
|
|
||||||
acorn@^8.14.0:
|
acorn@^8.14.0:
|
||||||
version "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==
|
integrity sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==
|
||||||
|
|
||||||
argparse@^2.0.1:
|
argparse@^2.0.1:
|
||||||
version "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==
|
integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
|
||||||
|
|
||||||
c12@2.0.1:
|
c12@2.0.1:
|
||||||
version "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==
|
integrity sha512-Z4JgsKXHG37C6PYUtIxCfLJZvo6FyhHJoClwwb9ftUkLpPSkuYqn6Tr+vnaN8hymm0kIbcg6Ey3kv/Q71k5w/A==
|
||||||
dependencies:
|
dependencies:
|
||||||
chokidar "^4.0.1"
|
chokidar "^4.0.1"
|
||||||
@@ -66,63 +66,63 @@ c12@2.0.1:
|
|||||||
|
|
||||||
chokidar@^4.0.1:
|
chokidar@^4.0.1:
|
||||||
version "4.0.3"
|
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==
|
integrity sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==
|
||||||
dependencies:
|
dependencies:
|
||||||
readdirp "^4.0.1"
|
readdirp "^4.0.1"
|
||||||
|
|
||||||
chownr@^2.0.0:
|
chownr@^2.0.0:
|
||||||
version "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==
|
integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==
|
||||||
|
|
||||||
citty@^0.1.6:
|
citty@^0.1.6:
|
||||||
version "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==
|
integrity sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
consola "^3.2.3"
|
consola "^3.2.3"
|
||||||
|
|
||||||
commander@13.0.0:
|
commander@13.0.0:
|
||||||
version "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==
|
integrity sha512-oPYleIY8wmTVzkvQq10AEok6YcTC4sRUBl8F9gVuwchGVUCTbl/vhLTaQqutuuySYOsu8YTgV+OxKc/8Yvx+mQ==
|
||||||
|
|
||||||
confbox@^0.1.7, confbox@^0.1.8:
|
confbox@^0.1.7, confbox@^0.1.8:
|
||||||
version "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==
|
integrity sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==
|
||||||
|
|
||||||
consola@^3.2.3, consola@^3.4.0:
|
consola@^3.2.3, consola@^3.4.0:
|
||||||
version "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==
|
integrity sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA==
|
||||||
|
|
||||||
defu@^6.1.4:
|
defu@^6.1.4:
|
||||||
version "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==
|
integrity sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==
|
||||||
|
|
||||||
destr@^2.0.3:
|
destr@^2.0.3:
|
||||||
version "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==
|
integrity sha512-2N3BOUU4gYMpTP24s5rF5iP7BDr7uNTCs4ozw3kf/eKfvWSIu93GEBi5m427YoyJoeOzQ5smuu4nNAPGb8idSQ==
|
||||||
|
|
||||||
dotenv@^16.4.5:
|
dotenv@^16.4.5:
|
||||||
version "16.4.7"
|
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==
|
integrity sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==
|
||||||
|
|
||||||
fs-minipass@^2.0.0:
|
fs-minipass@^2.0.0:
|
||||||
version "2.1.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==
|
integrity sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==
|
||||||
dependencies:
|
dependencies:
|
||||||
minipass "^3.0.0"
|
minipass "^3.0.0"
|
||||||
|
|
||||||
giget@^1.2.3:
|
giget@^1.2.3:
|
||||||
version "1.2.5"
|
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==
|
integrity sha512-r1ekGw/Bgpi3HLV3h1MRBIlSAdHoIMklpaQ3OQLFcRw9PwAj2rqigvIbg+dBUI51OxVI2jsEtDywDBjSiuf7Ug==
|
||||||
dependencies:
|
dependencies:
|
||||||
citty "^0.1.6"
|
citty "^0.1.6"
|
||||||
@@ -135,7 +135,7 @@ giget@^1.2.3:
|
|||||||
|
|
||||||
handlebars@4.7.8:
|
handlebars@4.7.8:
|
||||||
version "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==
|
integrity sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
minimist "^1.2.5"
|
minimist "^1.2.5"
|
||||||
@@ -147,36 +147,36 @@ handlebars@4.7.8:
|
|||||||
|
|
||||||
jiti@^2.3.0:
|
jiti@^2.3.0:
|
||||||
version "2.4.2"
|
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==
|
integrity sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==
|
||||||
|
|
||||||
js-yaml@^4.1.0:
|
js-yaml@^4.1.0:
|
||||||
version "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==
|
integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==
|
||||||
dependencies:
|
dependencies:
|
||||||
argparse "^2.0.1"
|
argparse "^2.0.1"
|
||||||
|
|
||||||
minimist@^1.2.5:
|
minimist@^1.2.5:
|
||||||
version "1.2.8"
|
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==
|
integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
|
||||||
|
|
||||||
minipass@^3.0.0:
|
minipass@^3.0.0:
|
||||||
version "3.3.6"
|
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==
|
integrity sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==
|
||||||
dependencies:
|
dependencies:
|
||||||
yallist "^4.0.0"
|
yallist "^4.0.0"
|
||||||
|
|
||||||
minipass@^5.0.0:
|
minipass@^5.0.0:
|
||||||
version "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==
|
integrity sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==
|
||||||
|
|
||||||
minizlib@^2.1.1:
|
minizlib@^2.1.1:
|
||||||
version "2.1.2"
|
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==
|
integrity sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==
|
||||||
dependencies:
|
dependencies:
|
||||||
minipass "^3.0.0"
|
minipass "^3.0.0"
|
||||||
@@ -184,12 +184,12 @@ minizlib@^2.1.1:
|
|||||||
|
|
||||||
mkdirp@^1.0.3:
|
mkdirp@^1.0.3:
|
||||||
version "1.0.4"
|
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==
|
integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
|
||||||
|
|
||||||
mlly@^1.7.1, mlly@^1.7.4:
|
mlly@^1.7.1, mlly@^1.7.4:
|
||||||
version "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==
|
integrity sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==
|
||||||
dependencies:
|
dependencies:
|
||||||
acorn "^8.14.0"
|
acorn "^8.14.0"
|
||||||
@@ -199,17 +199,17 @@ mlly@^1.7.1, mlly@^1.7.4:
|
|||||||
|
|
||||||
neo-async@^2.6.2:
|
neo-async@^2.6.2:
|
||||||
version "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==
|
integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==
|
||||||
|
|
||||||
node-fetch-native@^1.6.6:
|
node-fetch-native@^1.6.6:
|
||||||
version "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==
|
integrity sha512-8Mc2HhqPdlIfedsuZoc3yioPuzp6b+L5jRCRY1QzuWZh2EGJVQrGppC6V6cF0bLdbW0+O2YpqCA25aF/1lvipQ==
|
||||||
|
|
||||||
nypm@^0.5.4:
|
nypm@^0.5.4:
|
||||||
version "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==
|
integrity sha512-X0SNNrZiGU8/e/zAB7sCTtdxWTMSIO73q+xuKgglm2Yvzwlo8UoC5FNySQFCvl84uPaeADkqHUZUkWy4aH4xOA==
|
||||||
dependencies:
|
dependencies:
|
||||||
citty "^0.1.6"
|
citty "^0.1.6"
|
||||||
@@ -221,27 +221,32 @@ nypm@^0.5.4:
|
|||||||
|
|
||||||
ohash@^1.1.4:
|
ohash@^1.1.4:
|
||||||
version "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==
|
integrity sha512-FlDryZAahJmEF3VR3w1KogSEdWX3WhA5GPakFx4J81kEAiHyLMpdLLElS8n8dfNadMgAne/MywcvmogzscVt4g==
|
||||||
|
|
||||||
pathe@^1.1.2:
|
pathe@^1.1.2:
|
||||||
version "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==
|
integrity sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==
|
||||||
|
|
||||||
pathe@^2.0.1, pathe@^2.0.3:
|
pathe@^2.0.1:
|
||||||
version "2.0.3"
|
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==
|
integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==
|
||||||
|
|
||||||
perfect-debounce@^1.0.0:
|
perfect-debounce@^1.0.0:
|
||||||
version "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==
|
integrity sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==
|
||||||
|
|
||||||
pkg-types@^1.2.0, pkg-types@^1.3.0, pkg-types@^1.3.1:
|
pkg-types@^1.2.0, pkg-types@^1.3.0, pkg-types@^1.3.1:
|
||||||
version "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==
|
integrity sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
confbox "^0.1.8"
|
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:
|
rc9@^2.1.2:
|
||||||
version "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==
|
integrity sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==
|
||||||
dependencies:
|
dependencies:
|
||||||
defu "^6.1.4"
|
defu "^6.1.4"
|
||||||
@@ -258,17 +263,17 @@ rc9@^2.1.2:
|
|||||||
|
|
||||||
readdirp@^4.0.1:
|
readdirp@^4.0.1:
|
||||||
version "4.1.2"
|
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==
|
integrity sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==
|
||||||
|
|
||||||
source-map@^0.6.1:
|
source-map@^0.6.1:
|
||||||
version "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==
|
integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
|
||||||
|
|
||||||
tar@^6.2.1:
|
tar@^6.2.1:
|
||||||
version "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==
|
integrity sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==
|
||||||
dependencies:
|
dependencies:
|
||||||
chownr "^2.0.0"
|
chownr "^2.0.0"
|
||||||
@@ -280,30 +285,30 @@ tar@^6.2.1:
|
|||||||
|
|
||||||
tinyexec@^0.3.2:
|
tinyexec@^0.3.2:
|
||||||
version "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==
|
integrity sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==
|
||||||
|
|
||||||
typescript@^5.9.3:
|
typescript@^5.5.3, typescript@^5.9.3:
|
||||||
version "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==
|
integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==
|
||||||
|
|
||||||
ufo@^1.5.4:
|
ufo@^1.5.4:
|
||||||
version "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==
|
integrity sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==
|
||||||
|
|
||||||
uglify-js@^3.1.4:
|
uglify-js@^3.1.4:
|
||||||
version "3.19.3"
|
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==
|
integrity sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==
|
||||||
|
|
||||||
wordwrap@^1.0.0:
|
wordwrap@^1.0.0:
|
||||||
version "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==
|
integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==
|
||||||
|
|
||||||
yallist@^4.0.0:
|
yallist@^4.0.0:
|
||||||
version "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==
|
integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==
|
||||||
|
|||||||
Reference in New Issue
Block a user