Možnost příplatků u Pizza day objednávek
This commit is contained in:
parent
c3d35ccc9c
commit
eb27591727
@ -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 }));
|
||||
}
|
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
}
|
@ -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} Kč</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} Kč`}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</Table>
|
||||
</>
|
||||
}
|
45
client/src/components/PizzaOrderRow.tsx
Normal file
45
client/src/components/PizzaOrderRow.tsx
Normal 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} Kč${order.fee.text ? ` (${order.fee.text})` : ''}` : '-'}</td>
|
||||
<td>
|
||||
{order.totalPrice} Kč{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() }} />
|
||||
</>
|
||||
}
|
35
client/src/components/modals/BankAccountModal.tsx
Normal file
35
client/src/components/modals/BankAccountModal.tsx
Normal 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>
|
||||
}
|
45
client/src/components/modals/PizzaAdditionalFeeModal.tsx
Normal file
45
client/src/components/modals/PizzaAdditionalFeeModal.tsx
Normal 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 Kč: <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>
|
||||
}
|
@ -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) {
|
||||
|
@ -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;
|
||||
}
|
@ -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
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user