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
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:
+31
-17
@@ -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} Kč)
|
<strong>{formatDateString(qr.date)}</strong> — {qr.creator} ({qr.totalPrice / 100} Kč)
|
||||||
{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) {
|
||||||
|
|||||||
@@ -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 (Kč)</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}
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user