Možnost příplatků u Pizza day objednávek

This commit is contained in:
Martin Berka 2023-09-24 20:15:04 +02:00
parent c3d35ccc9c
commit eb27591727
11 changed files with 233 additions and 74 deletions

View File

@ -107,3 +107,7 @@ export const login = async (login?: string) => {
export const changeDepartureTime = async (login: string, time: string, dayIndex?: number) => {
return await api.post<any, any>('/api/changeDepartureTime', JSON.stringify({ login, time, dayIndex }));
}
export const updatePizzaFee = async (login: string, text?: string, price?: number) => {
return await api.post<any, any>('/api/updatePizzaFee', JSON.stringify({ login, text, price }));
}

View File

@ -84,7 +84,7 @@
margin-bottom: 0;
}
.table> :not(caption) .trash-icon {
.table> :not(caption) .action-icon {
color: rgb(0, 89, 255);
cursor: pointer;
margin-left: 10px;

View File

@ -355,6 +355,7 @@ function App() {
<li>Oprava generování QR kódů pro Pizza day</li>
<li>Serverová validace času odchodu</li>
<li>Loader při zakládání Pizza day</li>
<li>Možnost ručního zadání příplatku k Pizza day objednávkám</li>
</ul>
</Alert>
{dayIndex != null &&
@ -429,7 +430,7 @@ function App() {
{userPayload.departureTime && <small> ({userPayload.departureTime})</small>}
{login === auth.login && <FontAwesomeIcon onClick={() => {
doRemoveChoices(locationKey);
}} title={`Odstranit volbu ${locationName}, včetně případných zvolených jídel`} className='trash-icon' icon={faTrashCan} />}
}} title={`Odstranit volbu ${locationName}, včetně případných zvolených jídel`} className='action-icon' icon={faTrashCan} />}
</td>
{userChoices?.length && food ? <td className='w-100'>
<ul>
@ -442,7 +443,7 @@ function App() {
{foodName}
{login === auth.login && <FontAwesomeIcon onClick={() => {
doRemoveFoodChoice(locationKey, foodIndex);
}} title={`Odstranit ${foodName}`} className='trash-icon' icon={faTrashCan} />}
}} title={`Odstranit ${foodName}`} className='action-icon' icon={faTrashCan} />}
</li>
})}
</ul>
@ -565,13 +566,12 @@ function App() {
</Button>
</div>
}
<PizzaOrderList state={data.pizzaDay.state} orders={data.pizzaDay.orders} onDelete={handlePizzaDelete} />
<PizzaOrderList state={data.pizzaDay.state} orders={data.pizzaDay.orders} onDelete={handlePizzaDelete} creator={data.pizzaDay.creator} />
{
data.pizzaDay.state === PizzaDayState.DELIVERED && myOrder?.hasQr &&
<div className='qr-code'>
<h3>QR platba</h3>
<img src={getQrUrl(auth.login)} alt='QR kód' />
<p>Pozor, QR kód nezohledňuje případné přidané ingredience.</p>
</div>
}
</div>

View File

@ -1,6 +1,7 @@
import React, { useRef, useState } from "react";
import { Navbar, Nav, NavDropdown, Modal, Button } from "react-bootstrap";
import { useState } from "react";
import { Navbar, Nav, NavDropdown } from "react-bootstrap";
import { useAuth } from "../context/auth";
import BankAccountModal from "./modals/BankAccountModal";
import { useBank } from "../context/bank";
@ -8,8 +9,6 @@ export default function Header() {
const auth = useAuth();
const bank = useBank();
const [modalOpen, setModalOpen] = useState<boolean>(false);
const bankAccountRef = useRef<HTMLInputElement>(null);
const nameRef = useRef<HTMLInputElement>(null);
const openBankSettings = () => {
setModalOpen(true);
@ -29,14 +28,14 @@ export default function Header() {
return n !== Infinity && String(n) === str && n >= 0;
}
const save = () => {
if (bankAccountRef.current?.value) {
const save = (bankAccountNumber?: string, bankAccountHolderName?: string) => {
if (bankAccountNumber) {
try {
// Validace kódu banky
if (bankAccountRef.current?.value.indexOf('/') < 0) {
if (bankAccountNumber.indexOf('/') < 0) {
throw Error("Číslo účtu neobsahuje lomítko/kód banky")
}
const split = bankAccountRef.current?.value.split("/");
const split = bankAccountNumber.split("/");
if (split[1].length !== 4) {
throw Error("Kód banky musí být 4 číslice")
}
@ -71,8 +70,8 @@ export default function Header() {
return
}
}
bank?.setBankAccountNumber(bankAccountRef.current?.value);
bank?.setBankAccountHolderName(nameRef.current?.value);
bank?.setBankAccountNumber(bankAccountNumber);
bank?.setBankAccountHolderName(bankAccountHolderName);
closeModal();
}
@ -87,23 +86,6 @@ export default function Header() {
</NavDropdown>
</Nav>
</Navbar.Collapse>
<Modal show={modalOpen} onHide={closeModal} size="lg">
<Modal.Header closeButton>
<Modal.Title>Bankovní účet</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>Nastavením čísla účtu umožníte automatické generování QR kódů pro úhradu za vámi provedené objednávky v rámci Pizza day.<br />Pokud vaše číslo účtu neobsahuje předčíslí, je možné ho zcela vynechat.<br /><br />Číslo účtu není ukládáno na serveru, posílá se na něj pouze za účelem vygenerování QR kódů.</p>
Číslo účtu: <input className="mb-3" ref={bankAccountRef} type="text" placeholder="123456-1234567890/1234" defaultValue={bank?.bankAccount} /> <br />
Název příjemce (jméno majitele účtu): <input ref={nameRef} type="text" placeholder="Jan Novák" defaultValue={bank?.holderName} />
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={closeModal}>
Storno
</Button>
<Button variant="primary" onClick={save}>
Uložit
</Button>
</Modal.Footer>
</Modal>
<BankAccountModal isOpen={modalOpen} onClose={closeModal} onSave={save} />
</Navbar>
}

View File

@ -1,49 +1,49 @@
import React from "react";
import { Table } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faTrashCan } from "@fortawesome/free-regular-svg-icons";
import { useAuth } from "../context/auth";
import { Order, PizzaDayState, PizzaOrder } from "../types";
import { updatePizzaFee } from "../Api";
import PizzaOrderRow from "./PizzaOrderRow";
export default function PizzaOrderList({ state, orders, onDelete }: { state: PizzaDayState, orders: Order[], onDelete: (pizzaOrder: PizzaOrder) => void }) {
type Props = {
state: PizzaDayState,
orders: Order[],
onDelete: (pizzaOrder: PizzaOrder) => void,
creator: string,
}
export default function PizzaOrderList({ state, orders, onDelete, creator }: Props) {
const auth = useAuth();
const saveFees = async (customer: string, text?: string, price?: number) => {
await updatePizzaFee(customer, text, price);
}
if (!orders?.length) {
return <p className="mt-3"><i>Zatím žádné objednávky...</i></p>
}
const total = orders.map(order => order.pizzaList.map(o => o.price).reduce((total, i) => total + i)).reduce((total, i) => total + i);
const total = orders.reduce((total, order) => total + order.totalPrice, 0);
return <Table className="mt-3" striped bordered hover>
return <>
<Table className="mt-3" striped bordered hover>
<thead>
<tr>
<th>Jméno</th>
<th>Objednávka</th>
<th>Poznámka</th>
<th>Příplatek</th>
<th>Cena</th>
</tr>
</thead>
<tbody>
{orders.map(order => <tr key={order.customer}>
<td>{order.customer}</td>
<td>{order.pizzaList.map<React.ReactNode>((pizzaOrder, index) =>
<span key={index}>
{`${pizzaOrder.name}, ${pizzaOrder.size} (${pizzaOrder.price} Kč)`}
{auth?.login === order.customer && state === PizzaDayState.CREATED &&
<FontAwesomeIcon onClick={() => {
onDelete(pizzaOrder);
}} title='Odstranit' className='trash-icon' icon={faTrashCan} />
}
</span>)
.reduce((prev, curr, index) => [prev, <br key={`br-${index}`} />, curr])}
</td>
<td>{order.note || '-'}</td>
<td>{order.totalPrice} </td>
<PizzaOrderRow creator={creator} state={state} order={order} onDelete={onDelete} onFeeModalSave={saveFees} />
</tr>)}
<tr style={{ fontWeight: 'bold' }}>
<td colSpan={3}>Celkem</td>
<td colSpan={4}>Celkem</td>
<td>{`${total}`}</td>
</tr>
</tbody>
</Table>
</>
}

View File

@ -0,0 +1,45 @@
import React, { useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faMoneyBill1, faTrashCan } from "@fortawesome/free-regular-svg-icons";
import { useAuth } from "../context/auth";
import { Order, PizzaDayState, PizzaOrder } from "../types";
import PizzaAdditionalFeeModal from "./modals/PizzaAdditionalFeeModal";
type Props = {
creator: string,
order: Order,
state: PizzaDayState,
onDelete: (order: PizzaOrder) => void,
onFeeModalSave: (customer: string, name?: string, price?: number) => void,
}
export default function PizzaOrderRow({ creator, order, state, onDelete, onFeeModalSave }: Props) {
const auth = useAuth();
const [isFeeModalOpen, setFeeModalOpen] = useState<boolean>(false);
const saveFees = (customer: string, text?: string, price?: number) => {
onFeeModalSave(customer, text, price);
setFeeModalOpen(false);
}
return <>
<td>{order.customer}</td>
<td>{order.pizzaList.map<React.ReactNode>((pizzaOrder, index) =>
<span key={index}>
{`${pizzaOrder.name}, ${pizzaOrder.size} (${pizzaOrder.price} Kč)`}
{auth?.login === order.customer && state === PizzaDayState.CREATED &&
<FontAwesomeIcon onClick={() => {
onDelete(pizzaOrder);
}} title='Odstranit' className='action-icon' icon={faTrashCan} />
}
</span>)
.reduce((prev, curr, index) => [prev, <br key={`br-${index}`} />, curr])}
</td>
<td style={{ maxWidth: "200px" }}>{order.note || '-'}</td>
<td style={{ maxWidth: "200px" }}>{order.fee?.price ? `${order.fee.price}${order.fee.text ? ` (${order.fee.text})` : ''}` : '-'}</td>
<td>
{order.totalPrice} {auth?.login === creator && state === PizzaDayState.CREATED && <FontAwesomeIcon onClick={() => { setFeeModalOpen(true) }} title='Nastavit příplatek' className='action-icon' icon={faMoneyBill1} />}
</td>
<PizzaAdditionalFeeModal customerName={order.customer} isOpen={isFeeModalOpen} onClose={() => setFeeModalOpen(false)} onSave={saveFees} initialValues={{ text: order.fee?.text, price: order.fee?.price?.toString() }} />
</>
}

View File

@ -0,0 +1,35 @@
import { useRef } from "react";
import { Modal, Button } from "react-bootstrap"
import { useBank } from "../../context/bank";
type Props = {
isOpen: boolean,
onClose: () => void,
onSave: (bankAccountNumber?: string, bankAccountHolderName?: string) => void,
}
/** Modální dialog pro nastavení čísla účtu a jména majitele. */
export default function BankAccountModal({ isOpen, onClose, onSave }: Props) {
const bank = useBank();
const bankAccountRef = useRef<HTMLInputElement>(null);
const nameRef = useRef<HTMLInputElement>(null);
return <Modal show={isOpen} onHide={onClose} size="lg">
<Modal.Header closeButton>
<Modal.Title>Bankovní účet</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>Nastavením čísla účtu umožníte automatické generování QR kódů pro úhradu za vámi provedené objednávky v rámci Pizza day.<br />Pokud vaše číslo účtu neobsahuje předčíslí, je možné ho zcela vynechat.<br /><br />Číslo účtu není ukládáno na serveru, posílá se na něj pouze za účelem vygenerování QR kódů.</p>
Číslo účtu: <input className="mb-3" ref={bankAccountRef} type="text" placeholder="123456-1234567890/1234" defaultValue={bank?.bankAccount} /> <br />
Název příjemce (jméno majitele účtu): <input ref={nameRef} type="text" placeholder="Jan Novák" defaultValue={bank?.holderName} />
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={onClose}>
Storno
</Button>
<Button variant="primary" onClick={() => onSave(bankAccountRef.current?.value, nameRef.current?.value)}>
Uložit
</Button>
</Modal.Footer>
</Modal>
}

View File

@ -0,0 +1,45 @@
import { useRef } from "react";
import { Modal, Button } from "react-bootstrap"
type Props = {
customerName: string,
isOpen: boolean,
onClose: () => void,
onSave: (customer: string, name?: string, price?: number) => void,
initialValues?: { text?: string, price?: string },
}
/** Modální dialog pro nastavení příplatků za pizzu. */
export default function PizzaAdditionalFeeModal({ customerName, isOpen, onClose, onSave, initialValues }: Props) {
const textRef = useRef<HTMLInputElement>(null);
const priceRef = useRef<HTMLInputElement>(null);
const doSubmit = () => {
onSave(customerName, textRef.current?.value, parseInt(priceRef.current?.value || "0"));
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
onSave(customerName, textRef.current?.value, parseInt(priceRef.current?.value || "0"));
}
}
return <Modal show={isOpen} onHide={onClose} size="lg">
<Modal.Header closeButton>
<Modal.Title>Příplatky za objednávku pro {customerName}</Modal.Title>
</Modal.Header>
<Modal.Body>
Popis: <input className="mb-3" ref={textRef} type="text" placeholder="např. kuřecí maso" defaultValue={initialValues?.text} onKeyDown={handleKeyDown} /> <br />
Cena v : <input ref={priceRef} type="number" placeholder="0" defaultValue={initialValues?.price} onKeyDown={handleKeyDown} /> <br />
<div className="mt-3" style={{ fontSize: 'small' }}>Je možné zadávat i záporné částky (např. v případě slev)</div>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={onClose}>
Storno
</Button>
<Button variant="primary" onClick={doSubmit}>
Uložit
</Button>
</Modal.Footer>
</Modal>
}

View File

@ -3,7 +3,7 @@ import { Server } from "socket.io";
import bodyParser from "body-parser";
import cors from 'cors';
import { addChoice, getData, getDateForWeekIndex, getToday, removeChoice, removeChoices, updateDepartureTime } from "./service";
import { addPizzaOrder, createPizzaDay, deletePizzaDay, finishPizzaDelivery, finishPizzaOrder, getPizzaList, lockPizzaDay, removePizzaOrder, unlockPizzaDay, updatePizzaDayNote } from "./pizza";
import { addPizzaOrder, createPizzaDay, deletePizzaDay, finishPizzaDelivery, finishPizzaOrder, getPizzaList, lockPizzaDay, removePizzaOrder, unlockPizzaDay, updatePizzaDayNote, updatePizzaFee } from "./pizza";
import dotenv from 'dotenv';
import path from 'path';
import { getQr } from "./qr";
@ -312,6 +312,18 @@ app.post("/api/changeDepartureTime", async (req, res, next) => {
} catch (e: any) { next(e) }
});
app.post("/api/updatePizzaFee", async (req, res, next) => {
const login = getLogin(parseToken(req));
if (!req.body.login) {
return res.status(400).json({ error: "Nebyl předán login cílového uživatele" });
}
try {
const data = await updatePizzaFee(login, req.body.login, req.body.text, req.body.price);
io.emit("message", data);
res.status(200).json(data);
} catch (e: any) { next(e) }
});
// Middleware pro zpracování chyb
app.use((err: any, req: any, res: any, next: any) => {
if (err instanceof InsufficientPermissions) {

View File

@ -237,8 +237,7 @@ export async function finishPizzaDelivery(login: string, bankAccount?: string, b
for (const order of clientData.pizzaDay.orders) {
if (order.customer !== login) { // zatím platí creator = objednávající, a pro toho nemá QR kód smysl
let message = order.pizzaList.map(pizza => `Pizza ${pizza.name} (${pizza.size})`).join(', ');
const price = order.pizzaList.map(pizza => pizza.price).reduce((partial, a) => partial + a, 0);
await generateQr(order.customer, bankAccount, bankAccountHolder, price, message);
await generateQr(order.customer, bankAccount, bankAccountHolder, order.totalPrice, message);
order.hasQr = true;
}
}
@ -271,3 +270,39 @@ export async function updatePizzaDayNote(login: string, note?: string) {
await storage.setData(today, clientData);
return clientData;
}
/**
* Aktualizuje příplatek uživatele k objednávce pizzy.
* V případě nevyplnění ceny je příplatek odebrán.
*
* @param login přihlašovací jméno aktuálního uživatele
* @param targetLogin přihlašovací jméno uživatele, kterému je nastavován příplatek
* @param text text popisující příplatek
* @param price celková cena příplatku
*/
export async function updatePizzaFee(login: string, targetLogin: string, text?: string, price?: number) {
const today = formatDate(getToday());
let clientData: ClientData = await storage.getData(today);
if (!clientData.pizzaDay) {
throw Error("Pizza day pro dnešní den neexistuje");
}
if (clientData.pizzaDay.state !== PizzaDayState.CREATED) {
throw Error(`Pizza day není ve stavu ${PizzaDayState.CREATED}`);
}
if (clientData.pizzaDay.creator !== login) {
throw Error("Příplatky může měnit pouze zakladatel Pizza day");
}
const targetOrder = clientData.pizzaDay.orders.find(o => o.customer === targetLogin);
if (!targetOrder || !targetOrder.pizzaList.length) {
throw Error(`Pizza day neobsahuje žádné objednávky uživatele ${targetLogin}`);
}
if (!price) {
delete targetOrder.fee;
} else {
targetOrder.fee = { text, price };
}
// Přepočet ceny
targetOrder.totalPrice = targetOrder.pizzaList.reduce((price, pizzaOrder) => price + pizzaOrder.price, 0) + (targetOrder.fee?.price || 0);
await storage.setData(today, clientData);
return clientData;
}

View File

@ -45,7 +45,8 @@ export interface PizzaOrder {
export interface Order {
customer: string, // jméno objednatele
pizzaList: PizzaOrder[], // seznam objednaných pizz
totalPrice: number, // celková cena všech objednaných pizz a krabic
fee?: { text?: string, price: number }, // příplatek (např. za extra ingredience)
totalPrice: number, // celková cena všech objednaných pizz, krabic a příplatků
hasQr?: boolean, // true, pokud je k objednávce vygenerován QR kód pro platbu
note?: string, // volitelná uživatelská poznámka k objednávce
}