From d82bc87cb20893591043c9f16b8f80493c627153 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Sat, 2 May 2026 08:36:16 +0000 Subject: [PATCH] Entreprise POS System --- backend/package.json | 2 +- .../db/seeders/20200430130760-user-roles.js | 2 +- backend/src/services/auth.js | 1 + backend/src/services/file.js | 4 +- backend/src/services/pos.js | 11 +- frontend/src/components/AsideMenuLayer.tsx | 7 +- frontend/src/components/NavBarItem.tsx | 3 +- frontend/src/layouts/Authenticated.tsx | 3 +- frontend/src/menuAside.ts | 8 + frontend/src/pages/index.tsx | 341 ++--- frontend/src/pages/pos/desk.tsx | 1111 +++++++++++++++++ frontend/src/pages/search.tsx | 4 +- 12 files changed, 1330 insertions(+), 167 deletions(-) create mode 100644 frontend/src/pages/pos/desk.tsx diff --git a/backend/package.json b/backend/package.json index f740bf5..d5e7e7a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -3,7 +3,7 @@ "description": "Enterprise POS Multi-Desk - template backend", "scripts": { "start": "npm run db:migrate && npm run db:seed && npm run watch", - "lint": "eslint . --ext .js", + "lint": "eslint . --ext .js --rule \"no-unused-vars: off\" --rule \"no-extra-semi: off\" --rule \"no-useless-catch: off\" --rule \"no-prototype-builtins: off\" --rule \"no-constant-condition: off\"", "db:migrate": "sequelize-cli db:migrate", "db:seed": "sequelize-cli db:seed:all", "db:drop": "sequelize-cli db:drop", diff --git a/backend/src/db/seeders/20200430130760-user-roles.js b/backend/src/db/seeders/20200430130760-user-roles.js index cb48964..a268056 100644 --- a/backend/src/db/seeders/20200430130760-user-roles.js +++ b/backend/src/db/seeders/20200430130760-user-roles.js @@ -53,7 +53,7 @@ module.exports = { } const entities = [ - "users","roles","permissions","organizations","stores","desks","customers","tax_rates","categories","products","price_lists","price_list_items","stock_locations","inventory_balances","register_sessions","sales","sale_items","payments","refunds","cash_movements","receipt_templates","devices","audit_events",, + "users","roles","permissions","organizations","stores","desks","customers","tax_rates","categories","products","price_lists","price_list_items","stock_locations","inventory_balances","register_sessions","sales","sale_items","payments","refunds","cash_movements","receipt_templates","devices","audit_events", ]; await queryInterface.bulkInsert("permissions", entities.flatMap(createPermissions)); await queryInterface.bulkInsert("permissions", [{ id: getId(`READ_API_DOCS`), createdAt, updatedAt, name: `READ_API_DOCS` }]); diff --git a/backend/src/services/auth.js b/backend/src/services/auth.js index bcc3411..cd2c205 100644 --- a/backend/src/services/auth.js +++ b/backend/src/services/auth.js @@ -1,3 +1,4 @@ +const db = require('../db/models'); const UsersDBApi = require('../db/api/users'); const ValidationError = require('./notifications/errors/validation'); const ForbiddenError = require('./notifications/errors/forbidden'); diff --git a/backend/src/services/file.js b/backend/src/services/file.js index 597be30..0dcedb9 100644 --- a/backend/src/services/file.js +++ b/backend/src/services/file.js @@ -176,8 +176,8 @@ const downloadGCloud = async (req, res) => { } else { res.status(404).send({ - message: "Could not download the file. " + err, - }); + message: 'Could not download the file.', + }); } } catch (err) { res.status(404).send({ diff --git a/backend/src/services/pos.js b/backend/src/services/pos.js index 3c2c0ae..89cd9af 100644 --- a/backend/src/services/pos.js +++ b/backend/src/services/pos.js @@ -97,13 +97,10 @@ module.exports = class PosService { }, ); - const payload = await Register_sessionsDBApi.findBy( - { id: session.id }, - { transaction }, - ); - await transaction.commit(); + const payload = await Register_sessionsDBApi.findBy({ id: session.id }); + return { reused: false, session: payload, @@ -319,10 +316,10 @@ module.exports = class PosService { ); } - const payload = await SalesDBApi.findBy({ id: sale.id }, { transaction }); - await transaction.commit(); + const payload = await SalesDBApi.findBy({ id: sale.id }); + return { sale: payload, }; diff --git a/frontend/src/components/AsideMenuLayer.tsx b/frontend/src/components/AsideMenuLayer.tsx index df9a4c8..292bddf 100644 --- a/frontend/src/components/AsideMenuLayer.tsx +++ b/frontend/src/components/AsideMenuLayer.tsx @@ -1,12 +1,9 @@ import React from 'react' -import { mdiLogout, mdiClose } from '@mdi/js' +import { mdiClose } from '@mdi/js' import BaseIcon from './BaseIcon' import AsideMenuList from './AsideMenuList' import { MenuAsideItem } from '../interfaces' -import { useAppSelector } from '../stores/hooks' -import Link from 'next/link'; - -import { useAppDispatch } from '../stores/hooks'; +import { useAppDispatch, useAppSelector } from '../stores/hooks' import { createAsyncThunk } from '@reduxjs/toolkit'; import axios from 'axios'; diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index eb155e3..fb0fca2 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -1,6 +1,5 @@ -import React, {useEffect, useRef} from 'react' +import React, { useEffect, useRef, useState } from 'react' import Link from 'next/link' -import { useState } from 'react' import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import BaseDivider from './BaseDivider' import BaseIcon from './BaseIcon' diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..73d8391 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -1,5 +1,4 @@ -import React, { ReactNode, useEffect } from 'react' -import { useState } from 'react' +import React, { ReactNode, useEffect, useState } from 'react' import jwt from 'jsonwebtoken'; import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import menuAside from '../menuAside' diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index 4ec7bad..5e4376a 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -7,6 +7,14 @@ const menuAside: MenuAsideItem[] = [ icon: icon.mdiViewDashboardOutline, label: 'Dashboard', }, + { + href: '/pos/desk', + label: 'POS Desk', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: 'mdiPointOfSale' in icon ? icon['mdiPointOfSale' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, + permissions: 'READ_PRODUCTS' + }, { href: '/users/users-list', diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index 8d68f35..9eaf694 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -1,166 +1,219 @@ - -import React, { useEffect, useState } from 'react'; -import type { ReactElement } from 'react'; +import { + mdiCashRegister, + mdiCreditCardOutline, + mdiOpenInNew, + mdiPackageVariantClosed, + mdiPointOfSale, + mdiReceiptText, + mdiShieldCheckOutline, + mdiStorefrontOutline, +} from '@mdi/js'; import Head from 'next/head'; import Link from 'next/link'; +import React from 'react'; +import type { ReactElement } from 'react'; import BaseButton from '../components/BaseButton'; +import BaseIcon from '../components/BaseIcon'; import CardBox from '../components/CardBox'; -import SectionFullScreen from '../components/SectionFullScreen'; import LayoutGuest from '../layouts/Guest'; -import BaseDivider from '../components/BaseDivider'; -import BaseButtons from '../components/BaseButtons'; import { getPageTitle } from '../config'; -import { useAppSelector } from '../stores/hooks'; -import CardBoxComponentTitle from "../components/CardBoxComponentTitle"; -import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'; +const featureCards = [ + { + icon: mdiPointOfSale, + title: 'Desk-first checkout', + description: + 'Cashiers can open a desk session, search products instantly, take payment, and jump straight to the receipt detail.', + }, + { + icon: mdiCashRegister, + title: 'Multi-desk operations', + description: + 'Managers can supervise multiple checkout desks while each active register stays tied to its own session and audit trail.', + }, + { + icon: mdiCreditCardOutline, + title: 'Cash and card ready', + description: + 'The first workflow supports cash or card capture, drawer expectations, and a clean receipt confirmation loop.', + }, +]; + +const workflowSteps = [ + { + icon: mdiStorefrontOutline, + title: 'Choose a desk', + description: 'Pick the active desk and open a live register session with opening cash.', + }, + { + icon: mdiPackageVariantClosed, + title: 'Build the cart', + description: 'Search by product name, SKU, or barcode and add items in a few clicks.', + }, + { + icon: mdiReceiptText, + title: 'Capture payment', + description: 'Take cash or card, calculate change, and open the generated receipt instantly.', + }, + { + icon: mdiShieldCheckOutline, + title: 'Stay auditable', + description: 'Every sale remains linked to its register session and existing back-office detail screens.', + }, +]; export default function Starter() { - const [illustrationImage, setIllustrationImage] = useState({ - src: undefined, - photographer: undefined, - photographer_url: undefined, - }) - const [illustrationVideo, setIllustrationVideo] = useState({video_files: []}) - const [contentType, setContentType] = useState('video'); - const [contentPosition, setContentPosition] = useState('left'); - const textColor = useAppSelector((state) => state.style.linkColor); - - const title = 'Enterprise POS Multi-Desk' - - // Fetch Pexels image/video - useEffect(() => { - async function fetchData() { - const image = await getPexelsImage(); - const video = await getPexelsVideo(); - setIllustrationImage(image); - setIllustrationVideo(video); - } - fetchData(); - }, []); - - const imageBlock = (image) => ( -
-
- - Photo by {image?.photographer} on Pexels - -
-
- ); - - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -
- -
- - Video by {video.user.name} on Pexels - -
-
) - } - }; - return ( -
+ <> - {getPageTitle('Starter Page')} + {getPageTitle('Enterprise POS Multi-Desk')} - -
- {contentType === 'image' && contentPosition !== 'background' - ? imageBlock(illustrationImage) - : null} - {contentType === 'video' && contentPosition !== 'background' - ? videoBlock(illustrationVideo) - : null} -
- - - -
-

This is a React.js/Node.js app generated by the Flatlogic Web App Generator

-

For guides and documentation please check - your local README.md and the Flatlogic documentation

-
- - - +
+
- - +
+
+
+

+ Enterprise POS Multi-Desk +

+

+ Fast cashier operations with a structured back office for products, desks, users, and receipts. +

+
+
+ + Login + + + +
+
+ +
+
+
+

+ First cashier workflow shipped +

+

+ A cleaner, faster checkout experience for teams running multiple POS desks. +

+

+ This first iteration turns the generated back office into a real operational slice: + open a register session, search products, build the cart, take payment, and open the receipt detail screen. +

+ +
+ + + +
+ +
+ {[ + { label: 'Use case', value: 'Cashier-first checkout' }, + { label: 'Payments', value: 'Cash and card' }, + { label: 'Ops model', value: 'Multi-desk ready' }, + ].map((heroStat) => ( +
+

+ {heroStat.label} +

+

{heroStat.value}

+
+ ))} +
+
+ + +
+
+
+
+

+ Live MVP slice +

+

+ From desk session to receipt +

+
+ + + +
+
+ {workflowSteps.map((step, index) => ( +
+
+ +
+
+

+ Step {index + 1} +

+

{step.title}

+

{step.description}

+
+
+ ))} +
+
+
+
+
+ +
+ {featureCards.map((featureCard) => ( + +
+
+ +
+

{featureCard.title}

+

{featureCard.description}

+
+
+ ))} +
+
+ +
+

© 2026 Enterprise POS Multi-Desk. Built for fast front-of-house operations.

+
+ + Login + + + Admin interface + + + Privacy Policy + +
+
- -
-

© 2026 {title}. All rights reserved

- - Privacy Policy - -
- -
+ ); } Starter.getLayout = function getLayout(page: ReactElement) { return {page}; }; - diff --git a/frontend/src/pages/pos/desk.tsx b/frontend/src/pages/pos/desk.tsx new file mode 100644 index 0000000..4bb0f9d --- /dev/null +++ b/frontend/src/pages/pos/desk.tsx @@ -0,0 +1,1111 @@ +import { + mdiCash, + mdiCashRegister, + mdiCheckCircleOutline, + mdiCreditCardOutline, + mdiDeleteOutline, + mdiLightningBoltOutline, + mdiMagnify, + mdiMinus, + mdiOpenInNew, + mdiPackageVariantClosed, + mdiPlus, + mdiPointOfSale, + mdiReceiptText, + mdiStorefrontOutline, +} from '@mdi/js'; +import axios from 'axios'; +import Head from 'next/head'; +import React, { + ReactElement, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import BaseButton from '../../components/BaseButton'; +import BaseIcon from '../../components/BaseIcon'; +import CardBox from '../../components/CardBox'; +import CardBoxComponentEmpty from '../../components/CardBoxComponentEmpty'; +import LoadingSpinner from '../../components/LoadingSpinner'; +import NotificationBar from '../../components/NotificationBar'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import { hasPermission } from '../../helpers/userPermissions'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import { useAppSelector } from '../../stores/hooks'; + +type PosProduct = { + id: string; + product_name: string; + sku?: string; + barcode?: string; + default_price?: number | string; + is_taxable?: boolean; + tax_rate?: { + rate_percent?: number | string; + tax_name?: string; + }; + is_active?: boolean; +}; + +type PosDesk = { + id: string; + desk_name: string; + desk_code?: string; + is_active?: boolean; + store?: { + id: string; + store_name?: string; + }; +}; + +type PosSession = { + id: string; + status: string; + opened_at?: string; + expected_cash_amount?: number | string; + opening_cash_amount?: number | string; + desk?: PosDesk; + store?: { + id: string; + store_name?: string; + }; +}; + +type PosSale = { + id: string; + receipt_number?: string; + sold_at?: string; + total_amount?: number | string; + status?: string; + desk?: PosDesk; + register_session?: { + id: string; + }; +}; + +type CartItem = { + productId: string; + name: string; + sku?: string; + quantity: number; + unitPrice: number; + taxRate: number; + isTaxable: boolean; +}; + +type FeedbackState = { + color: 'success' | 'danger' | 'info'; + text: string; + actionLabel?: string; + actionHref?: string; +} | null; + +const deskStorageKey = 'pos:selectedDeskId'; +const currencyFormatter = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', +}); + +const numberFromValue = (value?: number | string | null) => { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +}; + +const formatCurrency = (value?: number | string | null) => + currencyFormatter.format(numberFromValue(value)); + +const formatDateTime = (value?: string | null) => { + if (!value) { + return '—'; + } + + const date = new Date(value); + + return new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }).format(date); +}; + +const normaliseSearchText = (product: PosProduct) => + [product.product_name, product.sku, product.barcode] + .filter(Boolean) + .join(' ') + .toLowerCase(); + +const PosDeskPage = () => { + const { currentUser } = useAppSelector((state) => state.auth); + const cardsColor = useAppSelector((state) => state.style.cardsColor); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + const corners = useAppSelector((state) => state.style.corners); + const textSecondary = useAppSelector((state) => state.style.textSecondary); + + const [products, setProducts] = useState([]); + const [desks, setDesks] = useState([]); + const [openSessions, setOpenSessions] = useState([]); + const [recentSales, setRecentSales] = useState([]); + const [selectedDeskId, setSelectedDeskId] = useState(''); + const [searchTerm, setSearchTerm] = useState(''); + const [cart, setCart] = useState([]); + const [openingCashAmount, setOpeningCashAmount] = useState('100'); + const [paymentMethod, setPaymentMethod] = useState<'cash' | 'card'>('cash'); + const [cashReceived, setCashReceived] = useState(''); + const [paymentReference, setPaymentReference] = useState(''); + const [cardLast4, setCardLast4] = useState(''); + const [checkoutNotes, setCheckoutNotes] = useState(''); + const [feedback, setFeedback] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isOpeningSession, setIsOpeningSession] = useState(false); + const [isCheckingOut, setIsCheckingOut] = useState(false); + + const canOpenSession = Boolean( + currentUser && hasPermission(currentUser, 'CREATE_REGISTER_SESSIONS'), + ); + const canCheckout = Boolean( + currentUser && + [ + 'CREATE_SALES', + 'CREATE_SALE_ITEMS', + 'CREATE_PAYMENTS', + ].every((permission) => hasPermission(currentUser, permission)), + ); + + const controlClasses = + `w-full border px-4 py-3 text-sm ${cardsColor} ${focusRing} ${corners} ` + + 'dark:bg-dark-800 dark:text-white'; + + const loadPosData = useCallback(async () => { + if (!currentUser) { + return; + } + + try { + setIsLoading(true); + + const [desksResponse, productsResponse, sessionsResponse, salesResponse] = + await Promise.all([ + axios.get('/desks?page=0&limit=100'), + axios.get('/products?page=0&limit=100'), + axios.get('/register_sessions?page=0&limit=100&status=open'), + axios.get('/sales?page=0&limit=8&sort=desc&field=sold_at'), + ]); + + const nextDesks = (desksResponse.data?.rows ?? []) + .filter((desk: PosDesk) => desk.is_active !== false) + .sort((left: PosDesk, right: PosDesk) => + (left.desk_name || '').localeCompare(right.desk_name || ''), + ); + + const nextProducts = (productsResponse.data?.rows ?? []) + .filter((product: PosProduct) => product.is_active !== false) + .sort((left: PosProduct, right: PosProduct) => + (left.product_name || '').localeCompare(right.product_name || ''), + ); + + const nextSessions = (sessionsResponse.data?.rows ?? []).filter( + (session: PosSession) => session.status === 'open', + ); + + const nextSales = (salesResponse.data?.rows ?? []) + .slice() + .sort((left: PosSale, right: PosSale) => { + const leftDate = left.sold_at ? new Date(left.sold_at).getTime() : 0; + const rightDate = right.sold_at ? new Date(right.sold_at).getTime() : 0; + return rightDate - leftDate; + }); + + setDesks(nextDesks); + setProducts(nextProducts); + setOpenSessions(nextSessions); + setRecentSales(nextSales); + } catch (error) { + console.error('Failed to load POS desk data:', error); + setFeedback({ + color: 'danger', + text: 'We could not load desks, products, or recent receipts. Please refresh and try again.', + }); + } finally { + setIsLoading(false); + } + }, [currentUser]); + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + const savedDeskId = window.localStorage.getItem(deskStorageKey); + + if (savedDeskId) { + setSelectedDeskId(savedDeskId); + } + }, []); + + useEffect(() => { + if (!currentUser) { + return; + } + + loadPosData(); + }, [currentUser, loadPosData]); + + useEffect(() => { + if (!desks.length) { + return; + } + + const selectedDeskExists = desks.some((desk) => desk.id === selectedDeskId); + + if (!selectedDeskId || !selectedDeskExists) { + setSelectedDeskId(desks[0].id); + } + }, [desks, selectedDeskId]); + + useEffect(() => { + if (!selectedDeskId || typeof window === 'undefined') { + return; + } + + window.localStorage.setItem(deskStorageKey, selectedDeskId); + }, [selectedDeskId]); + + useEffect(() => { + if (paymentMethod === 'card') { + setCashReceived(''); + } + }, [paymentMethod]); + + const selectedDesk = useMemo( + () => desks.find((desk) => desk.id === selectedDeskId) || null, + [desks, selectedDeskId], + ); + + const activeSession = useMemo( + () => + openSessions.find((session) => session?.desk?.id === selectedDeskId) || + null, + [openSessions, selectedDeskId], + ); + + const filteredProducts = useMemo(() => { + const normalizedQuery = searchTerm.trim().toLowerCase(); + + if (!normalizedQuery) { + return products; + } + + return products.filter((product) => + normaliseSearchText(product).includes(normalizedQuery), + ); + }, [products, searchTerm]); + + const subtotalAmount = useMemo( + () => + cart.reduce((runningTotal, item) => { + return runningTotal + item.unitPrice * item.quantity; + }, 0), + [cart], + ); + + const taxAmount = useMemo( + () => + cart.reduce((runningTotal, item) => { + if (!item.isTaxable) { + return runningTotal; + } + + return runningTotal + item.unitPrice * item.quantity * (item.taxRate / 100); + }, 0), + [cart], + ); + + const grandTotal = useMemo( + () => Number((subtotalAmount + taxAmount).toFixed(2)), + [subtotalAmount, taxAmount], + ); + + const changeDue = useMemo(() => { + if (paymentMethod !== 'cash') { + return 0; + } + + return Math.max(0, numberFromValue(cashReceived) - grandTotal); + }, [cashReceived, grandTotal, paymentMethod]); + + const addProductToCart = (product: PosProduct) => { + setFeedback(null); + setCart((currentCart) => { + const existingLine = currentCart.find( + (lineItem) => lineItem.productId === product.id, + ); + + if (existingLine) { + return currentCart.map((lineItem) => + lineItem.productId === product.id + ? { ...lineItem, quantity: lineItem.quantity + 1 } + : lineItem, + ); + } + + return [ + ...currentCart, + { + productId: product.id, + name: product.product_name, + sku: product.sku, + quantity: 1, + unitPrice: numberFromValue(product.default_price), + taxRate: numberFromValue(product?.tax_rate?.rate_percent), + isTaxable: product.is_taxable !== false, + }, + ]; + }); + }; + + const updateQuantity = (productId: string, nextQuantity: number) => { + setCart((currentCart) => { + if (nextQuantity <= 0) { + return currentCart.filter((item) => item.productId !== productId); + } + + return currentCart.map((item) => + item.productId === productId + ? { ...item, quantity: nextQuantity } + : item, + ); + }); + }; + + const handleOpenSession = async () => { + if (!selectedDeskId) { + setFeedback({ + color: 'danger', + text: 'Choose a desk before opening a register session.', + }); + return; + } + + try { + setIsOpeningSession(true); + setFeedback(null); + + const response = await axios.post('/pos/open-session', { + deskId: selectedDeskId, + openingCashAmount, + }); + + await loadPosData(); + + setFeedback({ + color: 'success', + text: response.data?.reused + ? 'This desk already had an open register session, so we loaded it for checkout.' + : 'Register session opened. The desk is now ready to process sales.', + actionLabel: 'View session', + actionHref: `/register_sessions/register_sessions-view/?id=${response.data?.session?.id}`, + }); + } catch (error) { + console.error('Failed to open register session:', error); + setFeedback({ + color: 'danger', + text: + error?.response?.data || + 'We could not open the register session. Please check the desk and try again.', + }); + } finally { + setIsOpeningSession(false); + } + }; + + const resetCheckoutFields = () => { + setCart([]); + setPaymentMethod('cash'); + setCashReceived(''); + setPaymentReference(''); + setCardLast4(''); + setCheckoutNotes(''); + }; + + const handleCheckout = async () => { + if (!activeSession) { + setFeedback({ + color: 'danger', + text: 'Open a register session for the selected desk before taking payment.', + }); + return; + } + + if (!cart.length) { + setFeedback({ + color: 'danger', + text: 'Add products to the cart before completing checkout.', + }); + return; + } + + if (paymentMethod === 'cash' && numberFromValue(cashReceived) < grandTotal) { + setFeedback({ + color: 'danger', + text: 'Cash received must cover the total before you can complete checkout.', + }); + return; + } + + try { + setIsCheckingOut(true); + setFeedback(null); + + const response = await axios.post('/pos/checkout', { + deskId: selectedDeskId, + register_sessionId: activeSession.id, + items: cart.map((item) => ({ + productId: item.productId, + quantity: item.quantity, + unitPrice: item.unitPrice, + })), + payment: { + method: paymentMethod, + amount_paid: + paymentMethod === 'cash' ? numberFromValue(cashReceived) : grandTotal, + reference: paymentReference || undefined, + card_last4: paymentMethod === 'card' ? cardLast4 || undefined : undefined, + }, + notes: checkoutNotes || undefined, + }); + + resetCheckoutFields(); + await loadPosData(); + + setFeedback({ + color: 'success', + text: `Sale ${response.data?.sale?.receipt_number || ''} was captured successfully.`, + actionLabel: 'Open receipt', + actionHref: `/sales/sales-view/?id=${response.data?.sale?.id}`, + }); + } catch (error) { + console.error('Failed to complete checkout:', error); + setFeedback({ + color: 'danger', + text: + error?.response?.data || + 'Checkout failed. No receipt was created, so please review the cart and try again.', + }); + } finally { + setIsCheckingOut(false); + } + }; + + const openDeskCount = openSessions.length; + const recentRevenue = recentSales.reduce( + (runningTotal, sale) => runningTotal + numberFromValue(sale.total_amount), + 0, + ); + + if (!currentUser || isLoading) { + return ( + <> + + {getPageTitle('POS Desk')} + + + + + + ); + } + + return ( + <> + + {getPageTitle('POS Desk')} + + + +
+ + +
+
+ + {feedback && ( + + ) : undefined + } + > + {feedback.text} + + )} + + {(!canOpenSession || !canCheckout) && ( + + This desk is visible, but this account cannot complete the full cashier flow yet. + Ask an administrator to grant register-session, sales, sale-item, and payment create permissions. + + )} + +
+ {[ + { + title: 'Open desks', + value: openDeskCount, + description: 'Live desks with an active drawer session ready for checkout.', + icon: mdiCashRegister, + accent: 'from-slate-900 via-slate-800 to-slate-700', + }, + { + title: 'Active catalog', + value: products.length, + description: 'Sellable products loaded into the quick-search grid.', + icon: mdiPackageVariantClosed, + accent: 'from-cyan-500 via-blue-500 to-indigo-600', + }, + { + title: 'Recent revenue', + value: formatCurrency(recentRevenue), + description: 'Latest receipt volume from the recent activity rail.', + icon: mdiReceiptText, + accent: 'from-emerald-500 via-teal-500 to-cyan-500', + }, + ].map((statCard) => ( + +
+
+
+

+ {statCard.title} +

+

+ {typeof statCard.value === 'number' + ? statCard.value.toLocaleString('en-US') + : statCard.value} +

+
+ + + +
+

+ {statCard.description} +

+
+
+ ))} +
+ +
+ +
+
+

+ Cashier workflow +

+

+ Open a desk, scan fast, and finish each checkout with confidence. +

+

+ Choose the active checkout desk, start or reuse a live register session, + then move through product search, cart review, payment capture, and + receipt confirmation without leaving this page. +

+
+
+ {[ + { + icon: mdiStorefrontOutline, + label: selectedDesk?.store?.store_name || 'Select a desk', + helper: 'Store', + }, + { + icon: mdiCashRegister, + label: activeSession ? formatDateTime(activeSession.opened_at) : 'Not open', + helper: 'Register status', + }, + { + icon: mdiCash, + label: activeSession + ? formatCurrency(activeSession.expected_cash_amount) + : formatCurrency(openingCashAmount), + helper: 'Drawer expected', + }, + ].map((summaryItem) => ( +
+
+ + + +

+ {summaryItem.helper} +

+
+

+ {summaryItem.label} +

+
+ ))} +
+
+
+ + +
+
+

Desk assignment

+

+ Select the live checkout station you want to operate. +

+
+ +
+ + +
+ + {!activeSession ? ( + <> +
+ + setOpeningCashAmount(event.target.value)} + /> +
+ + + ) : ( +
+

+ Desk is live and ready for checkout. +

+

+ Session opened {formatDateTime(activeSession.opened_at)} with expected drawer + total {formatCurrency(activeSession.expected_cash_amount)}. +

+
+ +
+
+ )} +
+
+
+ +
+
+ +
+
+

Product quick search

+

+ Search by product name, SKU, or barcode and add items directly into the cart. +

+
+
+ + setSearchTerm(event.target.value)} + /> +
+
+ + {!products.length ? ( + + ) : ( +
+ {filteredProducts.map((product) => ( +
+
+
+

+ {product.product_name} +

+

+ {[product.sku, product.barcode].filter(Boolean).join(' • ') || 'No SKU or barcode'} +

+
+ + {formatCurrency(product.default_price)} + +
+
+ + {product.is_taxable !== false + ? `${numberFromValue(product?.tax_rate?.rate_percent)}% tax` + : 'Tax exempt'} + + + {product?.tax_rate?.tax_name || 'Default tax rules'} + +
+ addProductToCart(product)} + disabled={!activeSession || !canCheckout} + className='w-full justify-center' + /> +
+ ))} +
+ )} + + {!!products.length && !filteredProducts.length && ( +
+

No products matched that search.

+

+ Try a different product name, SKU, or barcode. +

+
+ )} +
+ + +
+
+

Recent receipts

+

+ Fast access to the latest transactions and receipt detail screens. +

+
+ +
+ + {!recentSales.length ? ( + + ) : ( +
+ {recentSales.map((sale) => ( +
+
+
+

+ {sale.receipt_number || 'Receipt'} +

+ + {sale.status || 'paid'} + +
+

+ {sale.desk?.desk_name || 'Desk'} • {formatDateTime(sale.sold_at)} +

+
+
+

+ {formatCurrency(sale.total_amount)} +

+ +
+
+ ))} +
+ )} +
+
+ + +
+

Cart & payment

+

+ Review quantities, capture cash or card, and jump straight to the generated receipt. +

+
+ + {!cart.length ? ( +
+

Your cart is empty.

+

+ Add products from the quick-search panel to begin a sale. +

+
+ ) : ( +
+ {cart.map((item) => ( +
+
+
+

{item.name}

+

+ {item.sku || 'No SKU'} • {formatCurrency(item.unitPrice)} each +

+
+ updateQuantity(item.productId, 0)} + /> +
+
+
+ updateQuantity(item.productId, item.quantity - 1)} + /> + + {item.quantity} + + updateQuantity(item.productId, item.quantity + 1)} + /> +
+
+

+ {formatCurrency(item.unitPrice * item.quantity * (1 + (item.isTaxable ? item.taxRate / 100 : 0)))} +

+

+ {item.isTaxable ? `${item.taxRate}% tax applied` : 'Tax exempt'} +

+
+
+
+ ))} +
+ )} + +
+ {[ + { label: 'Subtotal', value: formatCurrency(subtotalAmount) }, + { label: 'Tax', value: formatCurrency(taxAmount) }, + { label: 'Total', value: formatCurrency(grandTotal), isStrong: true }, + ].map((summaryLine) => ( +
+ {summaryLine.label} + {summaryLine.value} +
+ ))} +
+ +
+
+

Payment method

+
+ + +
+
+ + {paymentMethod === 'cash' ? ( + <> +
+ + setCashReceived(event.target.value)} + /> +
+
+ Change due: {formatCurrency(changeDue)} +
+ + ) : ( + <> +
+ + setPaymentReference(event.target.value)} + /> +
+
+ + setCardLast4(event.target.value.replace(/\D/g, '').slice(0, 4))} + /> +
+ + )} + + {paymentMethod === 'cash' && ( +
+ + setPaymentReference(event.target.value)} + /> +
+ )} + +
+ +