feat: vylepšení funkce objednávání
CI / Generate TypeScript types (push) Successful in 1m24s
CI / Build server (push) Successful in 26s
CI / Build client (push) Successful in 41s
CI / Server unit tests (push) Successful in 3m25s
CI / Playwright E2E tests (push) Has been cancelled
CI / Build and push Docker image (push) Has been cancelled
CI / Notify (push) Has been cancelled

This commit is contained in:
2026-05-10 08:24:01 +02:00
parent 03f4e438a3
commit 3ba5fdd086
8 changed files with 88 additions and 28 deletions
+31 -17
View File
@@ -18,6 +18,7 @@ import { useNavigate } from 'react-router-dom';
import Loader from './components/Loader'; import Loader from './components/Loader';
import { getHumanDateTime, isInTheFuture, formatDateString } from './Utils'; import { getHumanDateTime, isInTheFuture, formatDateString } from './Utils';
import NoteModal from './components/modals/NoteModal'; import NoteModal from './components/modals/NoteModal';
import ConfirmModal from './components/modals/ConfirmModal';
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, 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 { 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';
@@ -77,6 +78,7 @@ function App() {
const [dayIndex, setDayIndex] = useState<number>(); const [dayIndex, setDayIndex] = useState<number>();
const [loadingPizzaDay, setLoadingPizzaDay] = useState<boolean>(false); const [loadingPizzaDay, setLoadingPizzaDay] = useState<boolean>(false);
const [noteModalOpen, setNoteModalOpen] = useState<boolean>(false); const [noteModalOpen, setNoteModalOpen] = useState<boolean>(false);
const [dismissQrId, setDismissQrId] = useState<string | null>(null);
const [payForAllLocationKey, setPayForAllLocationKey] = useState<LunchChoice | null>(null); const [payForAllLocationKey, setPayForAllLocationKey] = useState<LunchChoice | null>(null);
const [eggImage, setEggImage] = useState<Blob>(); const [eggImage, setEggImage] = useState<Blob>();
const eggRef = useRef<HTMLImageElement>(null); const eggRef = useRef<HTMLImageElement>(null);
@@ -698,15 +700,15 @@ function App() {
{locationPickCount >= 2 && auth.login && loginObject[auth.login] !== undefined {locationPickCount >= 2 && auth.login && loginObject[auth.login] !== undefined
&& locationKey !== LunchChoice.PIZZA && locationKey !== LunchChoice.NEOBEDVAM && locationKey !== LunchChoice.ROZHODUJI && locationKey !== LunchChoice.PIZZA && locationKey !== LunchChoice.NEOBEDVAM && locationKey !== LunchChoice.ROZHODUJI
&& settings?.bankAccount && settings?.holderName && ( && settings?.bankAccount && settings?.holderName && (
<span title='Zaplatit za všechny a vygenerovat QR kódy ostatním' className="ms-2"> <span title='Zaplatit za všechny a vygenerovat QR kódy ostatním' className="ms-2">
<FontAwesomeIcon <FontAwesomeIcon
icon={faMoneyBillTransfer} icon={faMoneyBillTransfer}
onClick={() => setPayForAllLocationKey(locationKey)} onClick={() => setPayForAllLocationKey(locationKey)}
className='action-icon' className='action-icon'
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
/> />
</span> </span>
)} )}
</td> </td>
<td className='p-0'> <td className='p-0'>
<Table className="nested-table"> <Table className="nested-table">
@@ -909,18 +911,12 @@ function App() {
{data.pendingQrs.map(qr => ( {data.pendingQrs.map(qr => (
<div key={qr.id} className='qr-code mb-3'> <div key={qr.id} className='qr-code mb-3'>
<p> <p>
<strong>{formatDateString(qr.date)}</strong> {qr.creator} ({qr.totalPrice} ) <strong>{formatDateString(qr.date)}</strong> {qr.creator} ({qr.totalPrice / 100} )
{qr.purpose && <><br /><span className="text-muted">{qr.purpose}</span></>} {qr.purpose && <><br /><span className="text-muted">{qr.purpose}</span></>}
</p> </p>
<img src={`/api/qr?login=${auth.login}&id=${qr.id}`} alt='QR kód' /> <img src={`/api/qr?login=${auth.login}&id=${qr.id}`} alt='QR kód' />
<div className='mt-2'> <div className='mt-2'>
<Button variant="success" onClick={async () => { <Button variant="success" onClick={() => setDismissQrId(qr.id)}>
await dismissQr({ body: { id: qr.id } });
const response = await getData({ query: { dayIndex } });
if (response.data) {
setData(response.data);
}
}}>
Zaplatil jsem Zaplatil jsem
</Button> </Button>
</div> </div>
@@ -936,6 +932,24 @@ function App() {
/> */} /> */}
<Footer /> <Footer />
<NoteModal isOpen={noteModalOpen} onClose={() => setNoteModalOpen(false)} onSave={saveNote} /> <NoteModal isOpen={noteModalOpen} onClose={() => setNoteModalOpen(false)} onSave={saveNote} />
<ConfirmModal
isOpen={dismissQrId !== null}
title="Potvrzení platby"
message="Opravdu jste zaplatili? QR kód bude odstraněn."
confirmLabel="Zaplatil jsem"
confirmVariant="success"
onClose={() => setDismissQrId(null)}
onConfirm={async () => {
if (!dismissQrId) return;
const id = dismissQrId;
setDismissQrId(null);
await dismissQr({ body: { id } });
const response = await getData({ query: { dayIndex } });
if (response.data) {
setData(response.data);
}
}}
/>
{payForAllLocationKey && data && ( {payForAllLocationKey && data && (
<PayForAllModal <PayForAllModal
isOpen isOpen
@@ -0,0 +1,26 @@
import { Modal, Button } from "react-bootstrap";
type Props = {
isOpen: boolean;
title: string;
message: string;
confirmLabel?: string;
confirmVariant?: string;
onConfirm: () => void;
onClose: () => void;
};
export default function ConfirmModal({ isOpen, title, message, confirmLabel = "Potvrdit", confirmVariant = "primary", onConfirm, onClose }: Readonly<Props>) {
return (
<Modal show={isOpen} onHide={onClose}>
<Modal.Header closeButton>
<Modal.Title>{title}</Modal.Title>
</Modal.Header>
<Modal.Body>{message}</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={onClose}>Zrušit</Button>
<Button variant={confirmVariant} onClick={onConfirm}>{confirmLabel}</Button>
</Modal.Footer>
</Modal>
);
}
@@ -5,6 +5,7 @@ import { generateQr, OrderGroup, OrderGroupMember, QrRecipient } from "../../../
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onSuccess?: () => void;
group: OrderGroup; group: OrderGroup;
payerLogin: string; payerLogin: string;
bankAccount: string; bankAccount: string;
@@ -18,7 +19,7 @@ type DinerEntry = {
included: boolean; included: boolean;
}; };
export default function PayForGroupModal({ isOpen, onClose, group, payerLogin, bankAccount, bankAccountHolder, groupId }: Readonly<Props>) { export default function PayForGroupModal({ isOpen, onClose, onSuccess, group, payerLogin, bankAccount, bankAccountHolder, groupId }: Readonly<Props>) {
const [diners, setDiners] = useState<DinerEntry[]>([]); const [diners, setDiners] = useState<DinerEntry[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -73,9 +74,10 @@ export default function PayForGroupModal({ isOpen, onClose, group, payerLogin, b
setError(`Celková částka pro ${d.login} musí být kladná`); setError(`Celková částka pro ${d.login} musí být kladná`);
return; return;
} }
const note = d.member.note?.trim();
recipients.push({ recipients.push({
login: d.login, login: d.login,
purpose: `Objednávka ${group.name}`.substring(0, 60), purpose: note ? note.replace(/[^\x00-\xff*]/g, '').replace(/\*/g, '').substring(0, 60) : `Objednávka ${group.name}`.substring(0, 60),
amount: total, amount: total,
}); });
} }
@@ -94,6 +96,7 @@ export default function PayForGroupModal({ isOpen, onClose, group, payerLogin, b
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ů');
} else { } else {
setSuccess(true); setSuccess(true);
onSuccess?.();
setTimeout(() => onClose(), 2000); setTimeout(() => onClose(), 2000);
} }
} catch (e: any) { } catch (e: any) {
+5 -5
View File
@@ -209,7 +209,7 @@ export default function OrderGroupsPage() {
)} )}
<div className="content-wrapper"> <div className="content-wrapper">
<div className="content" style={{ maxWidth: 960 }}> <div className="content" style={{ maxWidth: 1200 }}>
{/* Vytvoření nové skupiny */} {/* Vytvoření nové skupiny */}
<div className="choice-section fade-in mb-4"> <div className="choice-section fade-in mb-4">
<h5>Vytvořit skupinu</h5> <h5>Vytvořit skupinu</h5>
@@ -295,7 +295,7 @@ export default function OrderGroupsPage() {
)} )}
{isCreator && isOrdered && ( {isCreator && isOrdered && (
<> <>
{settings?.bankAccount && settings?.holderName && ( {settings?.bankAccount && settings?.holderName && !group.qrGenerated && (
<Button variant="primary" size="sm" onClick={() => setPayModal(group)}> <Button variant="primary" size="sm" onClick={() => setPayModal(group)}>
<FontAwesomeIcon icon={faBasketShopping} className="me-1" /> <FontAwesomeIcon icon={faBasketShopping} className="me-1" />
Generovat QR Generovat QR
@@ -319,10 +319,10 @@ export default function OrderGroupsPage() {
<thead> <thead>
<tr> <tr>
<th>Člen</th> <th>Člen</th>
<th style={{ width: 140 }}>Částka ()</th> <th style={{ width: 180 }}>Částka (bez slev)</th>
<th style={{ width: 220 }}>Příplatek</th> <th style={{ width: 220 }}>Příplatek</th>
<th>Poznámka</th> <th>Poznámka</th>
<th style={{ width: 100 }}>Celkem</th> <th style={{ width: 160 }}>Celkem (s poplatky)</th>
<th style={{ width: 40 }}></th> <th style={{ width: 40 }}></th>
</tr> </tr>
</thead> </thead>
@@ -537,7 +537,6 @@ export default function OrderGroupsPage() {
<small className="text-muted"> <small className="text-muted">
Doručení v: <strong>{group.deliveryAt ?? '—'}</strong> Doručení v: <strong>{group.deliveryAt ?? '—'}</strong>
</small> </small>
{isCreator && <small className="text-muted fst-italic">(upravit)</small>}
</div> </div>
)} )}
</div> </div>
@@ -579,6 +578,7 @@ export default function OrderGroupsPage() {
<PayForGroupModal <PayForGroupModal
isOpen={!!payModal} isOpen={!!payModal}
onClose={() => setPayModal(null)} onClose={() => setPayModal(null)}
onSuccess={fetchData}
group={payModal} group={payModal}
groupId={payModal.id} groupId={payModal.id}
payerLogin={auth.login} payerLogin={auth.login}
+11
View File
@@ -131,6 +131,7 @@ export async function setGroupState(login: string, groupId: string, newState: Gr
await removePendingQrsByGroupId(memberLogins, groupId); await removePendingQrsByGroupId(memberLogins, groupId);
group.orderedAt = undefined; group.orderedAt = undefined;
group.deliveryAt = undefined; group.deliveryAt = undefined;
group.qrGenerated = undefined;
for (const ml of memberLogins) { for (const ml of memberLogins) {
group.members[ml] = { ...group.members[ml], paid: undefined }; group.members[ml] = { ...group.members[ml], paid: undefined };
} }
@@ -139,6 +140,16 @@ export async function setGroupState(login: string, groupId: string, newState: Gr
return saveExtraData(data, date); return saveExtraData(data, date);
} }
export async function markGroupQrGenerated(login: string, groupId: string, date?: Date): Promise<void> {
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('QR kódy může generovat pouze zakladatel');
if (group.state !== GroupState.ORDERED) throw new Error('QR kódy lze generovat pouze ve stavu "objednáno"');
group.qrGenerated = true;
await saveExtraData(data, date);
}
export async function markGroupMemberPaid(login: string, groupId: string, date?: Date): Promise<ClientData | null> { export async function markGroupMemberPaid(login: string, groupId: string, date?: Date): Promise<ClientData | null> {
const data = await getExtraData(date); const data = await getExtraData(date);
const group = findGroup(data, groupId); const group = findGroup(data, groupId);
+2 -4
View File
@@ -56,10 +56,8 @@ function createStorageKey(customerName: string, id: string): string {
* @param id unikátní identifikátor (UUID) tohoto QR kódu * @param id unikátní identifikátor (UUID) tohoto QR kódu
*/ */
export async function generateQr(customerName: string, bankAccountNumber: string, bankAccountHolder: string, amount: number, message: string, id: string): Promise<void> { export async function generateQr(customerName: string, bankAccountNumber: string, bankAccountHolder: string, amount: number, message: string, id: string): Promise<void> {
// Zpráva pro příjemce nesmí dle standardu obsahovat '*' a být delší než 60 znaků // Zpráva nesmí obsahovat '*' a znaky mimo ISO 8859-1; délka max. 60 znaků
if (message.indexOf('*') >= 0) { message = message.replace(/[^\x00-\xff]/g, '').replace(/\*/g, '');
message = message.replace(/\*/g, '');
}
if (message.length > 60) { if (message.length > 60) {
message = message.substring(0, 60); message = message.substring(0, 60);
} }
+5
View File
@@ -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 { markGroupQrGenerated } from "../groups";
import { emitToUser } from "../websocket"; import { emitToUser } from "../websocket";
import { GenerateQrData } from "../../../types"; import { GenerateQrData } from "../../../types";
import crypto from "crypto"; import crypto from "crypto";
@@ -57,6 +58,10 @@ router.post("/generate", async (req: Request<{}, any, GenerateQrData["body"]>, r
emitToUser(recipient.login, 'pendingQr', pendingQr); emitToUser(recipient.login, 'pendingQr', pendingQr);
} }
if (groupId) {
await markGroupQrGenerated(login, groupId);
}
res.status(200).json({ success: true, count: recipients.length }); res.status(200).json({ success: true, count: recipients.length });
} catch (e: any) { } catch (e: any) {
next(e); next(e);
+3
View File
@@ -768,6 +768,9 @@ OrderGroup:
discountValue: discountValue:
description: Hodnota slevy (procenta pro typ 'percent', haléře pro typ 'fixed') description: Hodnota slevy (procenta pro typ 'percent', haléře pro typ 'fixed')
type: integer type: integer
qrGenerated:
description: Příznak, zda byly pro skupinu vygenerovány QR kódy (blokuje opakované generování)
type: boolean
# --- NEVYŘÍZENÉ QR KÓDY --- # --- NEVYŘÍZENÉ QR KÓDY ---
PendingQr: PendingQr: