fix: opravy po review
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 21s
CI / Generate TypeScript types (pull_request) Successful in 47s
CI / Build server (push) Successful in 27s
CI / Server unit tests (pull_request) Successful in 20s
CI / Build server (pull_request) Successful in 27s
CI / Build client (pull_request) Successful in 40s
CI / Playwright E2E tests (pull_request) Successful in 1m20s
CI / Build and push Docker image (pull_request) Has been skipped
CI / Notify (pull_request) Has been skipped
CI / Build client (push) Successful in 4m13s
CI / Playwright E2E tests (push) Successful in 6m7s
CI / Build and push Docker image (push) Has been skipped
CI / Notify (push) Successful in 6s
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 21s
CI / Generate TypeScript types (pull_request) Successful in 47s
CI / Build server (push) Successful in 27s
CI / Server unit tests (pull_request) Successful in 20s
CI / Build server (pull_request) Successful in 27s
CI / Build client (pull_request) Successful in 40s
CI / Playwright E2E tests (pull_request) Successful in 1m20s
CI / Build and push Docker image (pull_request) Has been skipped
CI / Notify (pull_request) Has been skipped
CI / Build client (push) Successful in 4m13s
CI / Playwright E2E tests (push) Successful in 6m7s
CI / Build and push Docker image (push) Has been skipped
CI / Notify (push) Successful in 6s
This commit is contained in:
+4
-4
@@ -7,7 +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 },
|
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' },
|
||||||
],
|
],
|
||||||
@@ -19,13 +19,13 @@ self.addEventListener('notificationclick', (event) => {
|
|||||||
event.notification.close();
|
event.notification.close();
|
||||||
|
|
||||||
if (event.action === 'neobedvam') {
|
if (event.action === 'neobedvam') {
|
||||||
const login = event.notification.data?.login;
|
const { login, token } = event.notification.data ?? {};
|
||||||
if (login) {
|
if (login && token) {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
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({ login }),
|
body: JSON.stringify({ login, token }),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,10 +87,16 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
|
|||||||
const totalPeople = includedDiners.length + 1; // +1 for payer
|
const totalPeople = includedDiners.length + 1; // +1 for payer
|
||||||
return Math.round((tip / totalPeople) * 100) / 100;
|
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;
|
||||||
return Math.round((d.baseAmount + surcharge + tipPerPerson) * 100) / 100;
|
const tip = d.login === payerLogin ? payerTipShare : tipPerPerson;
|
||||||
|
return Math.round((d.baseAmount + surcharge + tip) * 100) / 100;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInclude = useCallback((login: string, checked: boolean) => {
|
const handleInclude = useCallback((login: string, checked: boolean) => {
|
||||||
@@ -248,7 +254,7 @@ export default function PayForAllModal({ isOpen, onClose, locationName, location
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="text-end">
|
<td className="text-end">
|
||||||
{tipPerPerson > 0 ? `${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">
|
||||||
{`${total} Kč`}
|
{`${total} Kč`}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type Props = {
|
|||||||
payerLogin: string;
|
payerLogin: string;
|
||||||
bankAccount: string;
|
bankAccount: string;
|
||||||
bankAccountHolder: string;
|
bankAccountHolder: string;
|
||||||
|
groupId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function sanitizeAmount(value: string): string {
|
function sanitizeAmount(value: string): string {
|
||||||
@@ -32,7 +33,7 @@ function parseAmount(s: string): number | null {
|
|||||||
return Math.round(n * 100) / 100;
|
return Math.round(n * 100) / 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PayForGroupModal({ isOpen, onClose, group, payerLogin, bankAccount, bankAccountHolder }: Readonly<Props>) {
|
export default function PayForGroupModal({ isOpen, onClose, group, payerLogin, bankAccount, bankAccountHolder, groupId }: Readonly<Props>) {
|
||||||
const [diners, setDiners] = useState<DinerEntry[]>([]);
|
const [diners, setDiners] = useState<DinerEntry[]>([]);
|
||||||
const [tipTotal, setTipTotal] = useState('');
|
const [tipTotal, setTipTotal] = useState('');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -63,10 +64,16 @@ export default function PayForGroupModal({ isOpen, onClose, group, payerLogin, b
|
|||||||
const totalPeople = includedNonPayers.length + 1; // +1 for payer
|
const totalPeople = includedNonPayers.length + 1; // +1 for payer
|
||||||
return Math.round((tip / totalPeople) * 100) / 100;
|
return Math.round((tip / totalPeople) * 100) / 100;
|
||||||
})();
|
})();
|
||||||
|
const payerTipShare = (() => {
|
||||||
|
const tip = parseAmount(tipTotal);
|
||||||
|
if (!tip) return 0;
|
||||||
|
return Math.round((tip - tipPerPerson * includedNonPayers.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;
|
||||||
return Math.round((d.baseAmount + surcharge + tipPerPerson) * 100) / 100;
|
const tip = d.login === payerLogin ? payerTipShare : tipPerPerson;
|
||||||
|
return Math.round((d.baseAmount + surcharge + tip) * 100) / 100;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInclude = useCallback((login: string, checked: boolean) => {
|
const handleInclude = useCallback((login: string, checked: boolean) => {
|
||||||
@@ -112,7 +119,7 @@ export default function PayForGroupModal({ isOpen, onClose, group, payerLogin, b
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await generateQr({
|
const response = await generateQr({
|
||||||
body: { recipients, bankAccount, bankAccountHolder },
|
body: { recipients, bankAccount, bankAccountHolder, ...(groupId ? { groupId } : {}) },
|
||||||
});
|
});
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
setError((response.error as any).error || 'Nastala chyba při generování QR kódů');
|
setError((response.error as any).error || 'Nastala chyba při generování QR kódů');
|
||||||
@@ -203,7 +210,7 @@ export default function PayForGroupModal({ isOpen, onClose, group, payerLogin, b
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="text-end">
|
<td className="text-end">
|
||||||
{tipPerPerson > 0 ? `${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">
|
||||||
{`${total} Kč`}
|
{`${total} Kč`}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useContext, useEffect, useRef, useState } from 'react';
|
import { useContext, useEffect, useRef, useState } from 'react';
|
||||||
import { Badge, Button, Card, Form, Table } from 'react-bootstrap';
|
import { Alert, Badge, Button, Card, Form, Modal, Table } from 'react-bootstrap';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faNoteSticky, faTrashCan } from '@fortawesome/free-regular-svg-icons';
|
import { faTrashCan } from '@fortawesome/free-regular-svg-icons';
|
||||||
import { faBasketShopping, faGear, faLock, faLockOpen, faSearch, faUserPlus } from '@fortawesome/free-solid-svg-icons';
|
import { faBasketShopping, faGear, faLock, faLockOpen, faSearch, faUserPlus } from '@fortawesome/free-solid-svg-icons';
|
||||||
import {
|
import {
|
||||||
ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember,
|
ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember,
|
||||||
getData, createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState,
|
getData, createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes,
|
||||||
} from '../../../types';
|
} from '../../../types';
|
||||||
import { EVENT_MESSAGE, SocketContext } from '../context/socket';
|
import { EVENT_MESSAGE, SocketContext } from '../context/socket';
|
||||||
import { useAuth } from '../context/auth';
|
import { useAuth } from '../context/auth';
|
||||||
@@ -14,11 +14,11 @@ import Login from '../Login';
|
|||||||
import Header from '../components/Header';
|
import Header from '../components/Header';
|
||||||
import Footer from '../components/Footer';
|
import Footer from '../components/Footer';
|
||||||
import Loader from '../components/Loader';
|
import Loader from '../components/Loader';
|
||||||
import NoteModal from '../components/modals/NoteModal';
|
|
||||||
import StoreAdminModal from '../components/modals/StoreAdminModal';
|
import StoreAdminModal from '../components/modals/StoreAdminModal';
|
||||||
import PayForGroupModal from '../components/modals/PayForGroupModal';
|
import PayForGroupModal from '../components/modals/PayForGroupModal';
|
||||||
|
|
||||||
const SLOT = MealSlot.EXTRA;
|
const SLOT = MealSlot.EXTRA;
|
||||||
|
const TIME_REGEX = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||||
|
|
||||||
function stateBadge(state: GroupState) {
|
function stateBadge(state: GroupState) {
|
||||||
const map: Record<GroupState, { bg: string; label: string }> = {
|
const map: Record<GroupState, { bg: string; label: string }> = {
|
||||||
@@ -39,9 +39,12 @@ export default function OrderGroupsPage() {
|
|||||||
const [newGroupName, setNewGroupName] = useState('');
|
const [newGroupName, setNewGroupName] = useState('');
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [adminModalOpen, setAdminModalOpen] = useState(false);
|
const [adminModalOpen, setAdminModalOpen] = useState(false);
|
||||||
const [noteModal, setNoteModal] = useState<{ groupId: string; login: string } | null>(null);
|
|
||||||
const [editAmounts, setEditAmounts] = useState<Record<string, string>>({});
|
const [editAmounts, setEditAmounts] = useState<Record<string, string>>({});
|
||||||
|
const [editNotes, setEditNotes] = useState<Record<string, string>>({});
|
||||||
|
const [editTimes, setEditTimes] = useState<Record<string, { orderedAt: string; deliveryAt: string }>>({});
|
||||||
const [payModal, setPayModal] = useState<OrderGroup | null>(null);
|
const [payModal, setPayModal] = useState<OrderGroup | null>(null);
|
||||||
|
const [confirmOrderGroup, setConfirmOrderGroup] = useState<OrderGroup | null>(null);
|
||||||
|
const [pageError, setPageError] = useState<string | null>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
@@ -60,61 +63,90 @@ export default function OrderGroupsPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
socket.on(EVENT_MESSAGE, (newData: ClientData) => {
|
socket.on(EVENT_MESSAGE, (newData: ClientData) => {
|
||||||
if (newData.slot === SLOT) setData(newData);
|
if (newData.slot === SLOT) setData(prev => ({
|
||||||
|
...newData,
|
||||||
|
stores: newData.stores ?? prev?.stores,
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
return () => { socket.off(EVENT_MESSAGE); };
|
return () => { socket.off(EVENT_MESSAGE); };
|
||||||
}, [socket]);
|
}, [socket]);
|
||||||
|
|
||||||
const refresh = async (fn: () => Promise<any>) => {
|
const refresh = async (fn: () => Promise<any>): Promise<boolean> => {
|
||||||
|
setPageError(null);
|
||||||
const result = await fn();
|
const result = await fn();
|
||||||
|
if (result?.error) {
|
||||||
|
setPageError((result.error as any).error || 'Nastala chyba');
|
||||||
|
await fetchData();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (result?.data) {
|
if (result?.data) {
|
||||||
setData(result.data);
|
setData(result.data);
|
||||||
const ws = result.data as ClientData;
|
socket.emit?.('message', result.data as ClientData);
|
||||||
socket.emit?.('message', ws);
|
|
||||||
}
|
}
|
||||||
await fetchData();
|
await fetchData();
|
||||||
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!newGroupName || !auth?.login) return;
|
if (!newGroupName || !auth?.login) return;
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
try {
|
const ok = await refresh(() => createGroup({ body: { name: newGroupName } }));
|
||||||
await refresh(() => createGroup({ body: { name: newGroupName } }));
|
if (ok) setNewGroupName('');
|
||||||
setNewGroupName('');
|
|
||||||
} catch { /* swallow */ }
|
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleJoin = (groupId: string) =>
|
const handleJoin = (groupId: string) =>
|
||||||
refresh(() => addGroupMember({ body: { id: groupId } }));
|
refresh(() => addGroupMember({ body: { id: groupId } }));
|
||||||
|
|
||||||
const handleLeave = (groupId: string) =>
|
|
||||||
refresh(() => removeGroupMember({ body: { id: groupId, login: auth?.login ?? '' } }));
|
|
||||||
|
|
||||||
const handleToggleLock = (group: OrderGroup) => {
|
const handleToggleLock = (group: OrderGroup) => {
|
||||||
const next = group.state === GroupState.OPEN ? GroupState.LOCKED : GroupState.OPEN;
|
const next = group.state === GroupState.OPEN ? GroupState.LOCKED : GroupState.OPEN;
|
||||||
return refresh(() => setGroupState({ body: { id: group.id, state: next } }));
|
return refresh(() => setGroupState({ body: { id: group.id, state: next } }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMarkOrdered = (group: OrderGroup) =>
|
const handleConfirmOrdered = async (group: OrderGroup) => {
|
||||||
refresh(() => setGroupState({ body: { id: group.id, state: GroupState.ORDERED } }));
|
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) =>
|
const handleDelete = (groupId: string) =>
|
||||||
refresh(() => deleteGroup({ body: { id: groupId } }));
|
refresh(() => deleteGroup({ body: { id: groupId } }));
|
||||||
|
|
||||||
const handleSaveNote = async (note?: string) => {
|
|
||||||
if (!noteModal || !auth?.login) return;
|
|
||||||
await refresh(() => updateGroupMember({ body: { id: noteModal.groupId, login: noteModal.login, note } }));
|
|
||||||
setNoteModal(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveAmount = async (groupId: string, login: string) => {
|
const handleSaveAmount = async (groupId: string, login: string) => {
|
||||||
const key = `${groupId}:${login}`;
|
const key = `${groupId}:${login}`;
|
||||||
const raw = editAmounts[key];
|
const raw = editAmounts[key];
|
||||||
const n = parseFloat(raw ?? '');
|
const n = parseFloat(raw ?? '');
|
||||||
const amount = isNaN(n) || n < 0 ? undefined : n;
|
if (!raw || isNaN(n) || n < 0) {
|
||||||
await refresh(() => updateGroupMember({ body: { id: groupId, login, amount } }));
|
setPageError('Zadejte platnou kladnou částku');
|
||||||
setEditAmounts(prev => { const next = { ...prev }; delete next[key]; return next; });
|
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 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) => {
|
const canEditMember = (group: OrderGroup, targetLogin: string) => {
|
||||||
@@ -155,6 +187,12 @@ export default function OrderGroupsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<p style={{ color: 'var(--luncher-text-muted)' }}>Skupinové objednávky z obchodů a restaurací</p>
|
<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-wrapper">
|
||||||
<div className="content">
|
<div className="content">
|
||||||
{/* Vytvoření nové skupiny */}
|
{/* Vytvoření nové skupiny */}
|
||||||
@@ -196,6 +234,7 @@ export default function OrderGroupsPage() {
|
|||||||
const isOrdered = group.state === GroupState.ORDERED;
|
const isOrdered = group.state === GroupState.ORDERED;
|
||||||
const isLocked = group.state === GroupState.LOCKED;
|
const isLocked = group.state === GroupState.LOCKED;
|
||||||
const memberEntries = Object.entries(group.members) as [string, OrderGroupMember][];
|
const memberEntries = Object.entries(group.members) as [string, OrderGroupMember][];
|
||||||
|
const editingTimes = group.id in editTimes;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={group.id} className="mb-3 fade-in">
|
<Card key={group.id} className="mb-3 fade-in">
|
||||||
@@ -212,7 +251,7 @@ export default function OrderGroupsPage() {
|
|||||||
<FontAwesomeIcon icon={isLocked ? faLockOpen : faLock} />
|
<FontAwesomeIcon icon={isLocked ? faLockOpen : faLock} />
|
||||||
</Button>
|
</Button>
|
||||||
{isLocked && (
|
{isLocked && (
|
||||||
<Button variant="outline-primary" size="sm" onClick={() => handleMarkOrdered(group)}>
|
<Button variant="outline-primary" size="sm" onClick={() => setConfirmOrderGroup(group)}>
|
||||||
Objednáno
|
Objednáno
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -221,13 +260,20 @@ export default function OrderGroupsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{isCreator && isOrdered && settings?.bankAccount && settings?.holderName && (
|
{isCreator && isOrdered && (
|
||||||
<Button variant="primary" size="sm" onClick={() => setPayModal(group)}>
|
<>
|
||||||
<FontAwesomeIcon icon={faBasketShopping} className="me-1" />
|
{settings?.bankAccount && settings?.holderName && (
|
||||||
Generovat QR
|
<Button variant="primary" size="sm" onClick={() => setPayModal(group)}>
|
||||||
</Button>
|
<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 && (
|
{!isMember && !isOrdered && !isLocked && (
|
||||||
<Button variant="outline-success" size="sm" onClick={() => handleJoin(group.id)}>
|
<Button variant="outline-success" size="sm" onClick={() => handleJoin(group.id)}>
|
||||||
<FontAwesomeIcon icon={faUserPlus} className="me-1" />
|
<FontAwesomeIcon icon={faUserPlus} className="me-1" />
|
||||||
Přidat se
|
Přidat se
|
||||||
@@ -242,13 +288,15 @@ export default function OrderGroupsPage() {
|
|||||||
<th>Člen</th>
|
<th>Člen</th>
|
||||||
<th style={{ width: 130 }}>Částka (Kč)</th>
|
<th style={{ width: 130 }}>Částka (Kč)</th>
|
||||||
<th>Poznámka</th>
|
<th>Poznámka</th>
|
||||||
<th style={{ width: 80 }}></th>
|
<th style={{ width: 40 }}></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{memberEntries.map(([memberLogin, member]) => {
|
{memberEntries.map(([memberLogin, member]) => {
|
||||||
const amountKey = `${group.id}:${memberLogin}`;
|
const amountKey = `${group.id}:${memberLogin}`;
|
||||||
|
const noteKey = `${group.id}:${memberLogin}`;
|
||||||
const editingAmount = amountKey in editAmounts;
|
const editingAmount = amountKey in editAmounts;
|
||||||
|
const editingNote = noteKey in editNotes;
|
||||||
const canEdit = canEditMember(group, memberLogin);
|
const canEdit = canEditMember(group, memberLogin);
|
||||||
return (
|
return (
|
||||||
<tr key={memberLogin}>
|
<tr key={memberLogin}>
|
||||||
@@ -269,7 +317,7 @@ export default function OrderGroupsPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
value={editAmounts[amountKey]}
|
value={editAmounts[amountKey]}
|
||||||
onChange={e => setEditAmounts(prev => ({ ...prev, [amountKey]: e.target.value }))}
|
onChange={e => setEditAmounts(prev => ({ ...prev, [amountKey]: e.target.value }))}
|
||||||
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveAmount(group.id, memberLogin); }}
|
onKeyDown={e => { e.stopPropagation(); if (e.key === 'Enter') handleSaveAmount(group.id, memberLogin); if (e.key === 'Escape') setEditAmounts(prev => { const n = { ...prev }; delete n[amountKey]; return n; }); }}
|
||||||
style={{ width: 80 }}
|
style={{ width: 80 }}
|
||||||
autoFocus={memberLogin === login}
|
autoFocus={memberLogin === login}
|
||||||
/>
|
/>
|
||||||
@@ -286,19 +334,31 @@ export default function OrderGroupsPage() {
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<small className="text-muted">{member.note || '—'}</small>
|
{canEdit && editingNote ? (
|
||||||
|
<div className="d-flex gap-1">
|
||||||
|
<Form.Control
|
||||||
|
type="text"
|
||||||
|
size="sm"
|
||||||
|
value={editNotes[noteKey]}
|
||||||
|
onChange={e => setEditNotes(prev => ({ ...prev, [noteKey]: 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[noteKey]; 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, [noteKey]: member.note ?? '' }))}
|
||||||
|
title={canEdit ? 'Klikněte pro úpravu poznámky' : undefined}
|
||||||
|
>
|
||||||
|
<small className="text-muted">{member.note || '—'}</small>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="d-flex gap-1 justify-content-end">
|
<div className="d-flex gap-1 justify-content-end">
|
||||||
{memberLogin === login && (
|
{canManageMembers(group) && (isCreator || memberLogin === login) && (memberLogin !== group.creatorLogin) && (
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={faNoteSticky}
|
|
||||||
className="action-icon"
|
|
||||||
title="Upravit poznámku"
|
|
||||||
onClick={() => setNoteModal({ groupId: group.id, login: memberLogin })}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{canManageMembers(group) && (memberLogin !== group.creatorLogin) && (
|
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon={faTrashCan}
|
icon={faTrashCan}
|
||||||
className="action-icon"
|
className="action-icon"
|
||||||
@@ -313,6 +373,58 @@ export default function OrderGroupsPage() {
|
|||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
|
{/* Č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.Body>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -322,11 +434,22 @@ export default function OrderGroupsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|
||||||
<NoteModal
|
{/* Potvrzovací dialog pro přechod do stavu Objednáno */}
|
||||||
isOpen={!!noteModal}
|
<Modal show={!!confirmOrderGroup} onHide={() => setConfirmOrderGroup(null)} centered>
|
||||||
onClose={() => setNoteModal(null)}
|
<Modal.Header closeButton>
|
||||||
onSave={handleSaveNote}
|
<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
|
<StoreAdminModal
|
||||||
isOpen={adminModalOpen}
|
isOpen={adminModalOpen}
|
||||||
@@ -340,6 +463,7 @@ export default function OrderGroupsPage() {
|
|||||||
isOpen={!!payModal}
|
isOpen={!!payModal}
|
||||||
onClose={() => setPayModal(null)}
|
onClose={() => setPayModal(null)}
|
||||||
group={payModal}
|
group={payModal}
|
||||||
|
groupId={payModal.id}
|
||||||
payerLogin={auth.login}
|
payerLogin={auth.login}
|
||||||
bankAccount={settings.bankAccount}
|
bankAccount={settings.bankAccount}
|
||||||
bankAccountHolder={settings.holderName}
|
bankAccountHolder={settings.holderName}
|
||||||
|
|||||||
+31
-3
@@ -2,6 +2,7 @@ import crypto from "crypto";
|
|||||||
import getStorage from "./storage";
|
import getStorage from "./storage";
|
||||||
import { getClientData, getToday, initIfNeeded } from "./service";
|
import { getClientData, getToday, initIfNeeded } from "./service";
|
||||||
import { getStores } from "./stores";
|
import { getStores } from "./stores";
|
||||||
|
import { removePendingQrsByGroupId } from "./pizza";
|
||||||
import { ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember } from "../../types/gen/types.gen";
|
import { ClientData, GroupState, MealSlot, OrderGroup, OrderGroupMember } from "../../types/gen/types.gen";
|
||||||
import { formatDate } from "./utils";
|
import { formatDate } from "./utils";
|
||||||
|
|
||||||
@@ -9,7 +10,9 @@ const storage = getStorage();
|
|||||||
|
|
||||||
async function getExtraData(date?: Date): Promise<ClientData> {
|
async function getExtraData(date?: Date): Promise<ClientData> {
|
||||||
await initIfNeeded(date, MealSlot.EXTRA);
|
await initIfNeeded(date, MealSlot.EXTRA);
|
||||||
return getClientData(date, MealSlot.EXTRA);
|
const data = await getClientData(date, MealSlot.EXTRA);
|
||||||
|
data.stores = await getStores();
|
||||||
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getExtraKey(date?: Date): string {
|
function getExtraKey(date?: Date): string {
|
||||||
@@ -31,9 +34,10 @@ export async function createGroup(creatorLogin: string, name: string, date?: Dat
|
|||||||
throw new Error('Obchod není v seznamu povolených obchodů');
|
throw new Error('Obchod není v seznamu povolených obchodů');
|
||||||
}
|
}
|
||||||
const data = await getExtraData(date);
|
const data = await getExtraData(date);
|
||||||
|
const canonical = stores.find(s => s.toLowerCase() === name.trim().toLowerCase())!;
|
||||||
const group: OrderGroup = {
|
const group: OrderGroup = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
name: name.trim(),
|
name: canonical,
|
||||||
creatorLogin,
|
creatorLogin,
|
||||||
state: GroupState.OPEN,
|
state: GroupState.OPEN,
|
||||||
members: { [creatorLogin]: {} },
|
members: { [creatorLogin]: {} },
|
||||||
@@ -103,9 +107,14 @@ export async function updateGroupMember(login: string, groupId: string, targetLo
|
|||||||
const VALID_TRANSITIONS: Record<GroupState, GroupState[]> = {
|
const VALID_TRANSITIONS: Record<GroupState, GroupState[]> = {
|
||||||
[GroupState.OPEN]: [GroupState.LOCKED],
|
[GroupState.OPEN]: [GroupState.LOCKED],
|
||||||
[GroupState.LOCKED]: [GroupState.OPEN, GroupState.ORDERED],
|
[GroupState.LOCKED]: [GroupState.OPEN, GroupState.ORDERED],
|
||||||
[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> {
|
export async function setGroupState(login: string, groupId: string, newState: GroupState, date?: Date): Promise<ClientData> {
|
||||||
const data = await getExtraData(date);
|
const data = await getExtraData(date);
|
||||||
const group = findGroup(data, groupId);
|
const group = findGroup(data, groupId);
|
||||||
@@ -114,6 +123,25 @@ export async function setGroupState(login: string, groupId: string, newState: Gr
|
|||||||
if (!VALID_TRANSITIONS[group.state].includes(newState)) {
|
if (!VALID_TRANSITIONS[group.state].includes(newState)) {
|
||||||
throw new Error(`Nelze přejít ze stavu "${group.state}" do stavu "${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;
|
||||||
|
}
|
||||||
group.state = newState;
|
group.state = newState;
|
||||||
return saveExtraData(data, date);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
+19
-3
@@ -1,7 +1,7 @@
|
|||||||
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 { MealSlot } from "../../types/gen/types.gen";
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@@ -9,8 +9,8 @@ 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";
|
||||||
@@ -116,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) {
|
||||||
|
|||||||
@@ -456,3 +456,17 @@ export async function dismissPendingQr(login: string, id: string): Promise<void>
|
|||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -65,6 +66,19 @@ export function getVapidPublicKey(): string | undefined {
|
|||||||
return process.env.VAPID_PUBLIC_KEY;
|
return process.env.VAPID_PUBLIC_KEY;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function generateQuickChoiceToken(login: string): string {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const secret = process.env.JWT_SECRET ?? '';
|
||||||
|
return crypto.createHmac('sha256', secret).update(`${login}:${today}`).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ověří jednorázový token z push notifikace. */
|
||||||
|
export function verifyQuickChoiceToken(login: string, token: string): boolean {
|
||||||
|
if (!login || !token || token.length !== 64) return false;
|
||||||
|
const expected = generateQuickChoiceToken(login);
|
||||||
|
return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(token, 'hex'));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/** 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> {
|
||||||
@@ -115,6 +129,7 @@ async function checkAndSendReminders(): Promise<void> {
|
|||||||
title: 'Luncher',
|
title: 'Luncher',
|
||||||
body: 'Ještě nemáte zvolený oběd!',
|
body: 'Ještě nemáte zvolený oběd!',
|
||||||
login,
|
login,
|
||||||
|
token: generateQuickChoiceToken(login),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
lastReminded.set(login, Date.now());
|
lastReminded.set(login, Date.now());
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import express, { Request } from "express";
|
|||||||
import { getLogin } from "../auth";
|
import { getLogin } from "../auth";
|
||||||
import { parseToken } from "../utils";
|
import { parseToken } from "../utils";
|
||||||
import { getWebsocket } from "../websocket";
|
import { getWebsocket } from "../websocket";
|
||||||
import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState } from "../groups";
|
import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes } from "../groups";
|
||||||
import { GroupState } from "../../../types/gen/types.gen";
|
import { GroupState } from "../../../types/gen/types.gen";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -39,6 +39,9 @@ router.post("/addMember", async (req: Request, res, next) => {
|
|||||||
const login = getLogin(parseToken(req));
|
const login = getLogin(parseToken(req));
|
||||||
const { id, login: targetLogin } = req.body ?? {};
|
const { id, login: targetLogin } = req.body ?? {};
|
||||||
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
|
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;
|
const target = targetLogin ?? login;
|
||||||
try {
|
try {
|
||||||
const data = await addGroupMember(login, id, target);
|
const data = await addGroupMember(login, id, target);
|
||||||
@@ -65,10 +68,26 @@ router.post("/updateMember", async (req: Request, res, next) => {
|
|||||||
if (!id) return res.status(400).json({ error: 'Nebylo předáno ID skupiny' });
|
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' });
|
if (!targetLogin) return res.status(400).json({ error: 'Nebyl předán login uživatele' });
|
||||||
const patch: Record<string, any> = {};
|
const patch: Record<string, any> = {};
|
||||||
if (amount !== undefined) patch.amount = amount;
|
if (amount !== undefined) {
|
||||||
if (note !== undefined) patch.note = note;
|
if (typeof amount !== 'number' || !Number.isFinite(amount) || amount < 0) {
|
||||||
if (surchargeText !== undefined) patch.surchargeText = surchargeText;
|
return res.status(400).json({ error: 'Neplatná částka' });
|
||||||
if (surchargeAmount !== undefined) patch.surchargeAmount = surchargeAmount;
|
}
|
||||||
|
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 {
|
try {
|
||||||
const data = await updateGroupMember(login, id, targetLogin, patch);
|
const data = await updateGroupMember(login, id, targetLogin, patch);
|
||||||
broadcastExtra(data);
|
broadcastExtra(data);
|
||||||
@@ -90,4 +109,22 @@ router.post("/setState", async (req: Request, res, next) => {
|
|||||||
} catch (e: any) { next(e); }
|
} 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;
|
export default router;
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ 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 } 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,14 +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 NEOBEDVAM pro přihlášeného uživatele. */
|
|
||||||
router.post("/push/quickChoice", async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const login = getLogin(parseToken(req));
|
|
||||||
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;
|
||||||
|
|||||||
@@ -14,7 +14,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ů" });
|
||||||
@@ -55,6 +55,7 @@ router.post("/generate", async (req: Request<{}, any, GenerateQrData["body"]>, r
|
|||||||
creator: login,
|
creator: login,
|
||||||
totalPrice: recipient.amount,
|
totalPrice: recipient.amount,
|
||||||
purpose: recipient.purpose,
|
purpose: recipient.purpose,
|
||||||
|
...(groupId ? { groupId } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,8 @@ paths:
|
|||||||
$ref: "./paths/groups/updateMember.yml"
|
$ref: "./paths/groups/updateMember.yml"
|
||||||
/groups/setState:
|
/groups/setState:
|
||||||
$ref: "./paths/groups/setState.yml"
|
$ref: "./paths/groups/setState.yml"
|
||||||
|
/groups/updateTimes:
|
||||||
|
$ref: "./paths/groups/updateTimes.yml"
|
||||||
|
|
||||||
# Správa obchodů (/api/stores)
|
# Správa obchodů (/api/stores)
|
||||||
/stores:
|
/stores:
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -658,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:
|
||||||
@@ -740,9 +743,12 @@ OrderGroup:
|
|||||||
type: object
|
type: object
|
||||||
additionalProperties:
|
additionalProperties:
|
||||||
$ref: "#/OrderGroupMember"
|
$ref: "#/OrderGroupMember"
|
||||||
tipTotal:
|
orderedAt:
|
||||||
description: Celkové dýško (Kč), vyplněno při přechodu do stavu ordered
|
description: Čas objednání ve formátu HH:MM
|
||||||
type: number
|
type: string
|
||||||
|
deliveryAt:
|
||||||
|
description: Očekávaný čas doručení ve formátu HH:MM
|
||||||
|
type: string
|
||||||
|
|
||||||
# --- NEVYŘÍZENÉ QR KÓDY ---
|
# --- NEVYŘÍZENÉ QR KÓDY ---
|
||||||
PendingQr:
|
PendingQr:
|
||||||
@@ -770,3 +776,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
|
||||||
|
|||||||
Reference in New Issue
Block a user