diff --git a/client/src/Api.ts b/client/src/Api.ts index bbfa744..d6dcfa7 100644 --- a/client/src/Api.ts +++ b/client/src/Api.ts @@ -106,4 +106,8 @@ export const login = async (login?: string) => { export const changeDepartureTime = async (login: string, time: string, dayIndex?: number) => { return await api.post('/api/changeDepartureTime', JSON.stringify({ login, time, dayIndex })); +} + +export const updatePizzaFee = async (login: string, text?: string, price?: number) => { + return await api.post('/api/updatePizzaFee', JSON.stringify({ login, text, price })); } \ No newline at end of file diff --git a/client/src/App.css b/client/src/App.css index fb087e5..1b698f5 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -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; diff --git a/client/src/App.tsx b/client/src/App.tsx index ec30fb1..28c1dfc 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -355,6 +355,7 @@ function App() {
  • Oprava generování QR kódů pro Pizza day
  • Serverová validace času odchodu
  • Loader při zakládání Pizza day
  • +
  • Možnost ručního zadání příplatku k Pizza day objednávkám
  • {dayIndex != null && @@ -429,7 +430,7 @@ function App() { {userPayload.departureTime && ({userPayload.departureTime})} {login === auth.login && { 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} />} {userChoices?.length && food ?
      @@ -442,7 +443,7 @@ function App() { {foodName} {login === auth.login && { doRemoveFoodChoice(locationKey, foodIndex); - }} title={`Odstranit ${foodName}`} className='trash-icon' icon={faTrashCan} />} + }} title={`Odstranit ${foodName}`} className='action-icon' icon={faTrashCan} />} })}
    @@ -565,13 +566,12 @@ function App() { } - + { data.pizzaDay.state === PizzaDayState.DELIVERED && myOrder?.hasQr &&

    QR platba

    QR kód -

    Pozor, QR kód nezohledňuje případné přidané ingredience.

    } diff --git a/client/src/components/Header.tsx b/client/src/components/Header.tsx index 73643a3..c0aabc9 100644 --- a/client/src/components/Header.tsx +++ b/client/src/components/Header.tsx @@ -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(false); - const bankAccountRef = useRef(null); - const nameRef = useRef(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() { - - - Bankovní účet - - -

    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.
    Pokud vaše číslo účtu neobsahuje předčíslí, je možné ho zcela vynechat.

    Číslo účtu není ukládáno na serveru, posílá se na něj pouze za účelem vygenerování QR kódů.

    - Číslo účtu:
    - Název příjemce (jméno majitele účtu): -
    - - - - -
    + } \ No newline at end of file diff --git a/client/src/components/PizzaOrderList.tsx b/client/src/components/PizzaOrderList.tsx index 3ed9465..d14c039 100644 --- a/client/src/components/PizzaOrderList.tsx +++ b/client/src/components/PizzaOrderList.tsx @@ -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

    Zatím žádné objednávky...

    } - 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 - - - - - - - - - - {orders.map(order => - - - - - )} - - - - - -
    JménoObjednávkaPoznámkaCena
    {order.customer}{order.pizzaList.map((pizzaOrder, index) => - - {`${pizzaOrder.name}, ${pizzaOrder.size} (${pizzaOrder.price} Kč)`} - {auth?.login === order.customer && state === PizzaDayState.CREATED && - { - onDelete(pizzaOrder); - }} title='Odstranit' className='trash-icon' icon={faTrashCan} /> - } - ) - .reduce((prev, curr, index) => [prev,
    , curr])} -
    {order.note || '-'}{order.totalPrice} Kč
    Celkem{`${total} Kč`}
    + return <> + + + + + + + + + + + + {orders.map(order => + + )} + + + + + +
    JménoObjednávkaPoznámkaPříplatekCena
    Celkem{`${total} Kč`}
    + } \ No newline at end of file diff --git a/client/src/components/PizzaOrderRow.tsx b/client/src/components/PizzaOrderRow.tsx new file mode 100644 index 0000000..115de2b --- /dev/null +++ b/client/src/components/PizzaOrderRow.tsx @@ -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(false); + + const saveFees = (customer: string, text?: string, price?: number) => { + onFeeModalSave(customer, text, price); + setFeeModalOpen(false); + } + + return <> + {order.customer} + {order.pizzaList.map((pizzaOrder, index) => + + {`${pizzaOrder.name}, ${pizzaOrder.size} (${pizzaOrder.price} Kč)`} + {auth?.login === order.customer && state === PizzaDayState.CREATED && + { + onDelete(pizzaOrder); + }} title='Odstranit' className='action-icon' icon={faTrashCan} /> + } + ) + .reduce((prev, curr, index) => [prev,
    , curr])} + + {order.note || '-'} + {order.fee?.price ? `${order.fee.price} Kč${order.fee.text ? ` (${order.fee.text})` : ''}` : '-'} + + {order.totalPrice} Kč{auth?.login === creator && state === PizzaDayState.CREATED && { setFeeModalOpen(true) }} title='Nastavit příplatek' className='action-icon' icon={faMoneyBill1} />} + + setFeeModalOpen(false)} onSave={saveFees} initialValues={{ text: order.fee?.text, price: order.fee?.price?.toString() }} /> + +} \ No newline at end of file diff --git a/client/src/components/modals/BankAccountModal.tsx b/client/src/components/modals/BankAccountModal.tsx new file mode 100644 index 0000000..3d51723 --- /dev/null +++ b/client/src/components/modals/BankAccountModal.tsx @@ -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(null); + const nameRef = useRef(null); + + return + + Bankovní účet + + +

    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.
    Pokud vaše číslo účtu neobsahuje předčíslí, je možné ho zcela vynechat.

    Číslo účtu není ukládáno na serveru, posílá se na něj pouze za účelem vygenerování QR kódů.

    + Číslo účtu:
    + Název příjemce (jméno majitele účtu): +
    + + + + +
    +} \ No newline at end of file diff --git a/client/src/components/modals/PizzaAdditionalFeeModal.tsx b/client/src/components/modals/PizzaAdditionalFeeModal.tsx new file mode 100644 index 0000000..f843f57 --- /dev/null +++ b/client/src/components/modals/PizzaAdditionalFeeModal.tsx @@ -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(null); + const priceRef = useRef(null); + + const doSubmit = () => { + onSave(customerName, textRef.current?.value, parseInt(priceRef.current?.value || "0")); + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + onSave(customerName, textRef.current?.value, parseInt(priceRef.current?.value || "0")); + } + } + + return + + Příplatky za objednávku pro {customerName} + + + Popis:
    + Cena v Kč:
    +
    Je možné zadávat i záporné částky (např. v případě slev)
    +
    + + + + +
    +} \ No newline at end of file diff --git a/server/src/index.ts b/server/src/index.ts index a013c22..e6efd51 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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) { diff --git a/server/src/pizza.ts b/server/src/pizza.ts index 3444997..aeb8486 100644 --- a/server/src/pizza.ts +++ b/server/src/pizza.ts @@ -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; +} \ No newline at end of file diff --git a/types/Types.ts b/types/Types.ts index 29dc8f3..c2c5176 100644 --- a/types/Types.ts +++ b/types/Types.ts @@ -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 }