From 64d433d6e7d610190bfab0ce4a03b70ff9c1f246 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Tue, 31 Mar 2026 19:41:37 +0000 Subject: [PATCH] amr7aj --- backend/src/index.js | 2 + backend/src/routes/pos.js | 38 ++ backend/src/services/pos.js | 560 ++++++++++++++++++++++++ frontend/src/css/main.css | 24 ++ frontend/src/menuAside.ts | 34 +- frontend/src/menuNavBar.ts | 8 +- frontend/src/pages/cashier.tsx | 747 +++++++++++++++++++++++++++++++++ frontend/src/pages/index.tsx | 293 ++++++------- frontend/src/styles.ts | 34 +- 9 files changed, 1560 insertions(+), 180 deletions(-) create mode 100644 backend/src/routes/pos.js create mode 100644 backend/src/services/pos.js create mode 100644 frontend/src/pages/cashier.tsx diff --git a/backend/src/index.js b/backend/src/index.js index 4f15f68..9f7628a 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -42,6 +42,7 @@ const sales_invoicesRoutes = require('./routes/sales_invoices'); const sales_invoice_itemsRoutes = require('./routes/sales_invoice_items'); const price_change_logsRoutes = require('./routes/price_change_logs'); +const posRoutes = require('./routes/pos'); const getBaseUrl = (url) => { @@ -120,6 +121,7 @@ app.use('/api/sales_invoices', passport.authenticate('jwt', {session: false}), s app.use('/api/sales_invoice_items', passport.authenticate('jwt', {session: false}), sales_invoice_itemsRoutes); app.use('/api/price_change_logs', passport.authenticate('jwt', {session: false}), price_change_logsRoutes); +app.use('/api/pos', passport.authenticate('jwt', {session: false}), posRoutes); app.use( '/api/openai', diff --git a/backend/src/routes/pos.js b/backend/src/routes/pos.js new file mode 100644 index 0000000..9d1c4fd --- /dev/null +++ b/backend/src/routes/pos.js @@ -0,0 +1,38 @@ +const express = require('express'); + +const PosService = require('../services/pos'); +const wrapAsync = require('../helpers').wrapAsync; +const { checkPermissions } = require('../middlewares/check-permissions'); + +const router = express.Router(); + +router.get( + '/workspace', + checkPermissions('READ_PRODUCTS'), + wrapAsync(async (req, res) => { + const payload = await PosService.getWorkspace(req.currentUser, req.query.shopId); + res.status(200).send(payload); + }), +); + +router.post( + '/checkout', + checkPermissions('CREATE_SALES_INVOICES'), + wrapAsync(async (req, res) => { + const payload = await PosService.checkout(req.currentUser, req.body); + res.status(200).send(payload); + }), +); + +router.post( + '/pricing', + checkPermissions('UPDATE_SHOPS'), + wrapAsync(async (req, res) => { + const payload = await PosService.updatePricing(req.currentUser, req.body); + res.status(200).send(payload); + }), +); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/services/pos.js b/backend/src/services/pos.js new file mode 100644 index 0000000..cd7ed1f --- /dev/null +++ b/backend/src/services/pos.js @@ -0,0 +1,560 @@ +const db = require('../db/models'); +const ValidationError = require('./notifications/errors/validation'); + +const { Op } = db.Sequelize; + +const PAYMENT_METHODS = new Set(['cash', 'card', 'transfer', 'mixed']); +const PRICING_ACTIONS = new Set(['set_rate', 'apply_prices', 'restore_prices']); + +const toNumber = (value, fallback = 0) => { + const parsed = Number.parseFloat(String(value ?? '')); + return Number.isFinite(parsed) ? parsed : fallback; +}; + +const roundMoney = (value) => Number(toNumber(value).toFixed(2)); + +const formatInvoiceNumber = () => { + const stamp = new Date().toISOString().replace(/[-:TZ.]/g, '').slice(0, 14); + const suffix = Math.floor(Math.random() * 900 + 100); + return `INV-${stamp}-${suffix}`; +}; + +const buildOrgWhere = (currentUser) => { + if (currentUser?.app_role?.globalAccess) { + return {}; + } + + if (!currentUser?.organizationsId) { + return { id: null }; + } + + return { + organizationsId: currentUser.organizationsId, + }; +}; + +const buildShopWhere = (currentUser, shopId) => { + const orgWhere = buildOrgWhere(currentUser); + + if (orgWhere.id === null) { + return orgWhere; + } + + return { + ...orgWhere, + ...(shopId ? { id: shopId } : {}), + }; +}; + +const getStartAndEndOfToday = () => { + const start = new Date(); + start.setHours(0, 0, 0, 0); + + const end = new Date(start); + end.setDate(end.getDate() + 1); + + return { start, end }; +}; + +const mapInvoice = (invoice) => ({ + id: invoice.id, + invoice_number: invoice.invoice_number, + sold_at: invoice.sold_at, + total_amount: roundMoney(invoice.total_amount), + total_profit_amount: roundMoney(invoice.total_profit_amount), + payment_method: invoice.payment_method, + item_count: (invoice.sales_invoice_items_invoice || []).reduce( + (sum, item) => sum + toNumber(item.quantity, 0), + 0, + ), + cashier_name: [invoice.cashier?.firstName, invoice.cashier?.lastName] + .filter(Boolean) + .join(' ') + .trim(), +}); + +module.exports = class PosService { + static async getWorkspace(currentUser, shopId) { + const shops = await db.shops.findAll({ + where: buildShopWhere(currentUser), + attributes: [ + 'id', + 'shop_name', + 'currency_name', + 'usd_rate', + 'allow_negative_stock', + 'is_active', + 'organizationsId', + ], + order: [['createdAt', 'ASC']], + }); + + if (!shops.length) { + return { + shops: [], + selectedShop: null, + categories: [], + products: [], + summary: { + totalSales: 0, + totalProfit: 0, + invoiceCount: 0, + }, + recentInvoices: [], + latestPriceChange: null, + }; + } + + const selectedShop = + shops.find((shop) => shop.id === shopId) || + shops[0]; + + const categories = await db.categories.findAll({ + where: { + shopId: selectedShop.id, + ...buildOrgWhere(currentUser), + }, + attributes: ['id', 'category_name', 'description'], + order: [ + ['sort_order', 'ASC'], + ['category_name', 'ASC'], + ], + }); + + const products = await db.products.findAll({ + where: { + shopId: selectedShop.id, + ...buildOrgWhere(currentUser), + }, + attributes: [ + 'id', + 'product_name', + 'sku', + 'barcode', + 'cost_price', + 'sale_price', + 'sale_price_backup', + 'usd_price', + 'stock_quantity', + 'is_active', + 'categoryId', + ], + include: [ + { + model: db.categories, + as: 'category', + attributes: ['id', 'category_name'], + }, + ], + order: [ + ['categoryId', 'ASC'], + ['product_name', 'ASC'], + ], + }); + + const { start, end } = getStartAndEndOfToday(); + + const invoiceWhere = { + shopId: selectedShop.id, + ...buildOrgWhere(currentUser), + sold_at: { + [Op.gte]: start, + [Op.lt]: end, + }, + status: 'paid', + }; + + const summaryInvoices = await db.sales_invoices.findAll({ + where: invoiceWhere, + attributes: ['id', 'total_amount', 'total_profit_amount'], + }); + + const invoices = await db.sales_invoices.findAll({ + where: invoiceWhere, + attributes: [ + 'id', + 'invoice_number', + 'sold_at', + 'total_amount', + 'total_profit_amount', + 'payment_method', + ], + include: [ + { + model: db.sales_invoice_items, + as: 'sales_invoice_items_invoice', + attributes: ['id', 'quantity'], + }, + { + model: db.users, + as: 'cashier', + attributes: ['id', 'firstName', 'lastName'], + }, + ], + order: [['sold_at', 'DESC']], + limit: 12, + }); + + const summary = summaryInvoices.reduce( + (acc, invoice) => ({ + totalSales: acc.totalSales + toNumber(invoice.total_amount), + totalProfit: acc.totalProfit + toNumber(invoice.total_profit_amount), + invoiceCount: acc.invoiceCount + 1, + }), + { totalSales: 0, totalProfit: 0, invoiceCount: 0 }, + ); + + const latestPriceChange = await db.price_change_logs.findOne({ + where: { + shopId: selectedShop.id, + ...buildOrgWhere(currentUser), + }, + order: [ + ['changed_at', 'DESC'], + ['createdAt', 'DESC'], + ], + }); + + return { + shops: shops.map((shop) => ({ + id: shop.id, + shop_name: shop.shop_name, + currency_name: shop.currency_name, + usd_rate: roundMoney(shop.usd_rate), + allow_negative_stock: Boolean(shop.allow_negative_stock), + is_active: Boolean(shop.is_active), + })), + selectedShop: { + id: selectedShop.id, + shop_name: selectedShop.shop_name, + currency_name: selectedShop.currency_name, + usd_rate: roundMoney(selectedShop.usd_rate), + allow_negative_stock: Boolean(selectedShop.allow_negative_stock), + is_active: Boolean(selectedShop.is_active), + }, + categories: categories.map((category) => ({ + id: category.id, + category_name: category.category_name, + description: category.description, + })), + products: products.map((product) => ({ + id: product.id, + product_name: product.product_name, + sku: product.sku, + barcode: product.barcode, + cost_price: roundMoney(product.cost_price), + sale_price: roundMoney(product.sale_price), + sale_price_backup: roundMoney(product.sale_price_backup), + usd_price: product.usd_price == null ? null : roundMoney(product.usd_price), + stock_quantity: product.stock_quantity, + is_active: Boolean(product.is_active), + categoryId: product.categoryId, + category_name: product.category?.category_name || 'بدون قسم', + })), + summary: { + totalSales: roundMoney(summary.totalSales), + totalProfit: roundMoney(summary.totalProfit), + invoiceCount: summary.invoiceCount, + }, + recentInvoices: invoices.map(mapInvoice), + latestPriceChange: latestPriceChange + ? { + id: latestPriceChange.id, + changed_at: latestPriceChange.changed_at || latestPriceChange.createdAt, + change_type: latestPriceChange.change_type, + usd_rate_before: roundMoney(latestPriceChange.usd_rate_before), + usd_rate_after: roundMoney(latestPriceChange.usd_rate_after), + summary: latestPriceChange.summary, + } + : null, + }; + } + + static async checkout(currentUser, payload) { + const transaction = await db.sequelize.transaction(); + + try { + const rawItems = Array.isArray(payload?.items) ? payload.items : []; + const normalizedItems = rawItems + .map((item) => ({ + productId: item?.productId, + quantity: Number.parseInt(String(item?.quantity ?? ''), 10), + })) + .filter((item) => item.productId && Number.isInteger(item.quantity) && item.quantity > 0); + + if (!normalizedItems.length) { + throw new ValidationError('errors.validation.message'); + } + + const paymentMethod = String(payload?.paymentMethod || 'cash'); + if (!PAYMENT_METHODS.has(paymentMethod)) { + throw new ValidationError('errors.validation.message'); + } + + const shop = await db.shops.findOne({ + where: buildShopWhere(currentUser, payload?.shopId), + transaction, + }); + + if (!shop) { + throw new ValidationError('errors.validation.message'); + } + + const uniqueProductIds = [...new Set(normalizedItems.map((item) => item.productId))]; + const products = await db.products.findAll({ + where: { + id: uniqueProductIds, + shopId: shop.id, + ...buildOrgWhere(currentUser), + }, + transaction, + }); + + if (products.length !== uniqueProductIds.length) { + throw new ValidationError('errors.validation.message'); + } + + const productMap = new Map(products.map((product) => [product.id, product])); + let subtotal = 0; + let totalCost = 0; + const lineItems = []; + + for (const item of normalizedItems) { + const product = productMap.get(item.productId); + const salePrice = toNumber(product.sale_price); + const costPrice = toNumber(product.cost_price); + const stockQuantity = product.stock_quantity; + + if ( + !shop.allow_negative_stock && + stockQuantity != null && + Number.isFinite(stockQuantity) && + stockQuantity < item.quantity + ) { + throw new Error(`الكمية غير كافية للمنتج: ${product.product_name}`); + } + + const lineSubtotal = roundMoney(salePrice * item.quantity); + const lineCost = roundMoney(costPrice * item.quantity); + const lineProfit = roundMoney(lineSubtotal - lineCost); + + subtotal += lineSubtotal; + totalCost += lineCost; + + lineItems.push({ + product, + quantity: item.quantity, + lineSubtotal, + lineProfit, + }); + } + + const totalAmount = roundMoney(subtotal); + const totalCostAmount = roundMoney(totalCost); + const totalProfitAmount = roundMoney(totalAmount - totalCostAmount); + const soldAt = new Date(); + + const invoice = await db.sales_invoices.create( + { + invoice_number: formatInvoiceNumber(), + sold_at: soldAt, + status: 'paid', + subtotal_amount: totalAmount, + discount_amount: 0, + total_amount: totalAmount, + total_cost_amount: totalCostAmount, + total_profit_amount: totalProfitAmount, + payment_method: paymentMethod, + notes: payload?.notes || null, + shopId: shop.id, + cashierId: currentUser.id, + organizationsId: currentUser.organizationsId || shop.organizationsId || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await db.sales_invoice_items.bulkCreate( + lineItems.map((item) => ({ + product_name_snapshot: item.product.product_name, + cost_price_snapshot: roundMoney(item.product.cost_price), + sale_price_snapshot: roundMoney(item.product.sale_price), + quantity: item.quantity, + line_subtotal: item.lineSubtotal, + line_profit: item.lineProfit, + invoiceId: invoice.id, + productId: item.product.id, + organizationsId: currentUser.organizationsId || shop.organizationsId || null, + createdById: currentUser.id, + updatedById: currentUser.id, + })), + { transaction }, + ); + + for (const item of lineItems) { + if (item.product.stock_quantity != null) { + await item.product.update( + { + stock_quantity: toNumber(item.product.stock_quantity) - item.quantity, + updatedById: currentUser.id, + }, + { transaction }, + ); + } + } + + await transaction.commit(); + + return { + id: invoice.id, + invoice_number: invoice.invoice_number, + sold_at: soldAt, + total_amount: totalAmount, + total_profit_amount: totalProfitAmount, + items_count: lineItems.reduce((sum, item) => sum + item.quantity, 0), + }; + } catch (error) { + await transaction.rollback(); + console.error('POS checkout failed:', error); + throw error; + } + } + + static async updatePricing(currentUser, payload) { + const transaction = await db.sequelize.transaction(); + + try { + const action = String(payload?.action || ''); + if (!PRICING_ACTIONS.has(action)) { + throw new ValidationError('errors.validation.message'); + } + + const shop = await db.shops.findOne({ + where: buildShopWhere(currentUser, payload?.shopId), + transaction, + }); + + if (!shop) { + throw new ValidationError('errors.validation.message'); + } + + const existingRate = toNumber(shop.usd_rate, 0); + const incomingRate = toNumber(payload?.usdRate, existingRate); + + if ((action === 'set_rate' || action === 'apply_prices') && incomingRate <= 0) { + throw new Error('يرجى إدخال سعر دولار صحيح أكبر من صفر.'); + } + + const products = await db.products.findAll({ + where: { + shopId: shop.id, + ...buildOrgWhere(currentUser), + }, + transaction, + }); + + if (action === 'set_rate' || action === 'apply_prices') { + await shop.update( + { + usd_rate: incomingRate, + updatedById: currentUser.id, + }, + { transaction }, + ); + } + + let changedProducts = 0; + let message = ''; + let changeType = 'usd_rate_update'; + + if (action === 'apply_prices') { + changeType = 'bulk_increase_by_usd'; + + for (const product of products) { + const currentSalePrice = toNumber(product.sale_price, 0); + const baseUsdPrice = + product.usd_price != null + ? toNumber(product.usd_price, 0) + : existingRate > 0 + ? roundMoney(currentSalePrice / existingRate) + : 0; + + const updatedSalePrice = roundMoney(baseUsdPrice * incomingRate); + + await product.update( + { + sale_price_backup: currentSalePrice, + usd_price: baseUsdPrice, + sale_price: updatedSalePrice, + updatedById: currentUser.id, + }, + { transaction }, + ); + changedProducts += 1; + } + + message = `تم تحديث أسعار البيع لعدد ${changedProducts} منتج حسب سعر الدولار.`; + } + + if (action === 'restore_prices') { + changeType = 'bulk_restore_previous'; + + for (const product of products) { + if (product.sale_price_backup == null) { + continue; + } + + await product.update( + { + sale_price: product.sale_price_backup, + sale_price_backup: null, + updatedById: currentUser.id, + }, + { transaction }, + ); + changedProducts += 1; + } + + message = changedProducts + ? `تمت إعادة ${changedProducts} سعر إلى القيمة السابقة.` + : 'لا توجد أسعار محفوظة للاسترجاع حالياً.'; + } + + if (action === 'set_rate') { + message = `تم حفظ سعر الدولار الجديد للمحل بنجاح.`; + } + + await db.price_change_logs.create( + { + changed_at: new Date(), + change_type: changeType, + usd_rate_before: existingRate || null, + usd_rate_after: action === 'restore_prices' ? existingRate || null : incomingRate, + summary: message, + shopId: shop.id, + changed_byId: currentUser.id, + organizationsId: currentUser.organizationsId || shop.organizationsId || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await transaction.commit(); + + return { + success: true, + action, + shopId: shop.id, + usdRate: action === 'restore_prices' ? roundMoney(shop.usd_rate) : roundMoney(incomingRate), + changedProducts, + message, + }; + } catch (error) { + await transaction.rollback(); + console.error('POS pricing update failed:', error); + throw error; + } + } +}; diff --git a/frontend/src/css/main.css b/frontend/src/css/main.css index f061e28..447d5e5 100644 --- a/frontend/src/css/main.css +++ b/frontend/src/css/main.css @@ -1,3 +1,4 @@ +@import url('https://fonts.googleapis.com/css2?family=Tajawal:wght@400;500;700;800&display=swap'); @import "tailwind/_base.css"; @import "tailwind/_components.css"; @import "tailwind/_utilities.css"; @@ -33,3 +34,26 @@ .introjs-prevbutton{ @apply bg-transparent border border-blue-600 text-blue-600 !important; } + +html, body { + font-family: 'Tajawal', 'Segoe UI', Tahoma, Arial, sans-serif; +} + +body { + text-rendering: optimizeLegibility; +} + +.app-rtl { + direction: rtl; + text-align: right; +} + +.app-rtl input, +.app-rtl textarea, +.app-rtl select { + direction: rtl; +} + +.app-rtl .ltr-chip { + direction: ltr; +} diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index 6ee4ae6..ea9983a 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -5,12 +5,12 @@ const menuAside: MenuAsideItem[] = [ { href: '/dashboard', icon: icon.mdiViewDashboardOutline, - label: 'Dashboard', + label: 'لوحة التحكم', }, { href: '/users/users-list', - label: 'Users', + label: 'المستخدمون', // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore icon: icon.mdiAccountGroup ?? icon.mdiTable, @@ -18,7 +18,7 @@ const menuAside: MenuAsideItem[] = [ }, { href: '/roles/roles-list', - label: 'Roles', + label: 'الأدوار', // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore icon: icon.mdiShieldAccountVariantOutline ?? icon.mdiTable, @@ -26,7 +26,7 @@ const menuAside: MenuAsideItem[] = [ }, { href: '/permissions/permissions-list', - label: 'Permissions', + label: 'الصلاحيات', // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore icon: icon.mdiShieldAccountOutline ?? icon.mdiTable, @@ -34,7 +34,7 @@ const menuAside: MenuAsideItem[] = [ }, { href: '/organizations/organizations-list', - label: 'Organizations', + label: 'المنظمات', // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore icon: icon.mdiTable ?? icon.mdiTable, @@ -42,7 +42,7 @@ const menuAside: MenuAsideItem[] = [ }, { href: '/shops/shops-list', - label: 'Shops', + label: 'المحلات', // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore icon: 'mdiStorefront' in icon ? icon['mdiStorefront' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, @@ -50,15 +50,23 @@ const menuAside: MenuAsideItem[] = [ }, { href: '/categories/categories-list', - label: 'Categories', + label: 'الأقسام', // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore icon: 'mdiShape' in icon ? icon['mdiShape' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, permissions: 'READ_CATEGORIES' }, + { + href: '/cashier', + label: 'الكاشير', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: 'mdiCashRegister' in icon ? icon['mdiCashRegister' as keyof typeof icon] : ('mdiReceipt' in icon ? icon['mdiReceipt' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable), + permissions: 'READ_PRODUCTS' + }, { href: '/products/products-list', - label: 'Products', + label: 'المنتجات', // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore icon: 'mdiSprayBottle' in icon ? icon['mdiSprayBottle' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, @@ -66,7 +74,7 @@ const menuAside: MenuAsideItem[] = [ }, { href: '/sales_invoices/sales_invoices-list', - label: 'Sales invoices', + label: 'الفواتير', // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore icon: 'mdiReceipt' in icon ? icon['mdiReceipt' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, @@ -74,7 +82,7 @@ const menuAside: MenuAsideItem[] = [ }, { href: '/sales_invoice_items/sales_invoice_items-list', - label: 'Sales invoice items', + label: 'عناصر الفواتير', // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore icon: 'mdiFormatListBulleted' in icon ? icon['mdiFormatListBulleted' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, @@ -82,7 +90,7 @@ const menuAside: MenuAsideItem[] = [ }, { href: '/price_change_logs/price_change_logs-list', - label: 'Price change logs', + label: 'سجل الأسعار', // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore icon: 'mdiCurrencyUsd' in icon ? icon['mdiCurrencyUsd' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, @@ -90,7 +98,7 @@ const menuAside: MenuAsideItem[] = [ }, { href: '/profile', - label: 'Profile', + label: 'الملف الشخصي', icon: icon.mdiAccountCircle, }, @@ -98,7 +106,7 @@ const menuAside: MenuAsideItem[] = [ { href: '/api-docs', target: '_blank', - label: 'Swagger API', + label: 'توثيق API', icon: icon.mdiFileCode, permissions: 'READ_API_DOCS' }, diff --git a/frontend/src/menuNavBar.ts b/frontend/src/menuNavBar.ts index a5dd956..2c28174 100644 --- a/frontend/src/menuNavBar.ts +++ b/frontend/src/menuNavBar.ts @@ -19,7 +19,7 @@ const menuNavBar: MenuNavBarItem[] = [ menu: [ { icon: mdiAccount, - label: 'My Profile', + label: 'الملف الشخصي', href: '/profile', }, { @@ -27,20 +27,20 @@ const menuNavBar: MenuNavBarItem[] = [ }, { icon: mdiLogout, - label: 'Log Out', + label: 'تسجيل الخروج', isLogout: true, }, ], }, { icon: mdiThemeLightDark, - label: 'Light/Dark', + label: 'الوضع الليلي', isDesktopNoLabel: true, isToggleLightDark: true, }, { icon: mdiLogout, - label: 'Log out', + label: 'خروج', isDesktopNoLabel: true, isLogout: true, }, diff --git a/frontend/src/pages/cashier.tsx b/frontend/src/pages/cashier.tsx new file mode 100644 index 0000000..22913cd --- /dev/null +++ b/frontend/src/pages/cashier.tsx @@ -0,0 +1,747 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import Link from 'next/link'; +import React, { ReactElement, useCallback, useEffect, useMemo, useState } from 'react'; +import axios from 'axios'; + +import BaseButton from '../components/BaseButton'; +import CardBox from '../components/CardBox'; +import LoadingSpinner from '../components/LoadingSpinner'; +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 CartItem = { + productId: string; + quantity: number; +}; + +type WorkspaceData = { + shops: any[]; + selectedShop: any; + categories: any[]; + products: any[]; + summary: { + totalSales: number; + totalProfit: number; + invoiceCount: number; + }; + recentInvoices: any[]; + latestPriceChange: any; +}; + +const formatMoney = (value: number) => `${new Intl.NumberFormat('ar-IQ').format(value || 0)} د.ع`; + +const formatUsd = (value: number | null) => { + if (value == null || Number.isNaN(value)) { + return '--'; + } + + return `${value.toFixed(2)} $`; +}; + +const formatDateTime = (value?: string | Date) => { + if (!value) { + return '--'; + } + + return new Date(value).toLocaleString('ar-IQ', { + hour: '2-digit', + minute: '2-digit', + year: 'numeric', + month: 'short', + day: 'numeric', + }); +}; + +const initialWorkspace: WorkspaceData = { + shops: [], + selectedShop: null, + categories: [], + products: [], + summary: { + totalSales: 0, + totalProfit: 0, + invoiceCount: 0, + }, + recentInvoices: [], + latestPriceChange: null, +}; + +const CashierPage = () => { + const { currentUser } = useAppSelector((state) => state.auth); + const corners = useAppSelector((state) => state.style.corners); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + + const [workspace, setWorkspace] = useState(initialWorkspace); + const [selectedShopId, setSelectedShopId] = useState(''); + const [query, setQuery] = useState(''); + const [activeCategoryId, setActiveCategoryId] = useState('all'); + const [cart, setCart] = useState([]); + const [paymentMethod, setPaymentMethod] = useState('cash'); + const [notes, setNotes] = useState(''); + const [usdRateInput, setUsdRateInput] = useState(''); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [pricingBusy, setPricingBusy] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + const [successInvoice, setSuccessInvoice] = useState(null); + + const canCheckout = Boolean(currentUser && hasPermission(currentUser, 'CREATE_SALES_INVOICES')); + const canManagePricing = Boolean(currentUser && hasPermission(currentUser, 'UPDATE_SHOPS')); + const canCreateProducts = Boolean(currentUser && hasPermission(currentUser, 'CREATE_PRODUCTS')); + const canCreateShops = Boolean(currentUser && hasPermission(currentUser, 'CREATE_SHOPS')); + + const loadWorkspace = useCallback( + async (shopId?: string) => { + setLoading(true); + setErrorMessage(''); + + try { + const { data } = await axios.get('/pos/workspace', { + params: shopId ? { shopId } : undefined, + }); + + setWorkspace(data); + const resolvedShopId = shopId || data.selectedShop?.id || ''; + setSelectedShopId(resolvedShopId); + setUsdRateInput(data.selectedShop?.usd_rate ? String(data.selectedShop.usd_rate) : ''); + } catch (error: any) { + console.error('POS workspace load failed:', error); + setErrorMessage(error?.response?.data || 'تعذر تحميل شاشة الكاشير حالياً.'); + } finally { + setLoading(false); + } + }, + [], + ); + + useEffect(() => { + loadWorkspace(); + }, [loadWorkspace]); + + useEffect(() => { + setSuccessInvoice(null); + }, [selectedShopId]); + + const productMap = useMemo( + () => + new Map( + (workspace.products || []).map((product) => [product.id, product]), + ), + [workspace.products], + ); + + const filteredProducts = useMemo(() => { + const normalizedQuery = query.trim().toLowerCase(); + + return (workspace.products || []).filter((product) => { + const matchesCategory = activeCategoryId === 'all' || product.categoryId === activeCategoryId; + const searchableText = [product.product_name, product.sku, product.barcode, product.category_name] + .filter(Boolean) + .join(' ') + .toLowerCase(); + const matchesQuery = !normalizedQuery || searchableText.includes(normalizedQuery); + + return matchesCategory && matchesQuery; + }); + }, [activeCategoryId, query, workspace.products]); + + const suggestions = useMemo(() => filteredProducts.slice(0, 8), [filteredProducts]); + + const cartDetails = useMemo(() => { + return cart + .map((item) => { + const product = productMap.get(item.productId); + if (!product) { + return null; + } + + const lineTotal = (product.sale_price || 0) * item.quantity; + const lineProfit = ((product.sale_price || 0) - (product.cost_price || 0)) * item.quantity; + + return { + ...item, + product, + lineTotal, + lineProfit, + }; + }) + .filter(Boolean) as any[]; + }, [cart, productMap]); + + const cartSummary = useMemo(() => { + return cartDetails.reduce( + (acc, item) => ({ + quantity: acc.quantity + item.quantity, + total: acc.total + item.lineTotal, + profit: acc.profit + item.lineProfit, + }), + { quantity: 0, total: 0, profit: 0 }, + ); + }, [cartDetails]); + + const addProductToCart = (productId: string) => { + setCart((current) => { + const existing = current.find((item) => item.productId === productId); + if (existing) { + return current.map((item) => + item.productId === productId ? { ...item, quantity: item.quantity + 1 } : item, + ); + } + + return [...current, { productId, quantity: 1 }]; + }); + setSuccessInvoice(null); + }; + + const updateCartQuantity = (productId: string, nextQuantity: number) => { + setCart((current) => + current + .map((item) => (item.productId === productId ? { ...item, quantity: nextQuantity } : item)) + .filter((item) => item.quantity > 0), + ); + }; + + const handleCheckout = async () => { + if (!selectedShopId || !cart.length) { + setErrorMessage('أضف منتجاً واحداً على الأقل قبل حفظ الفاتورة.'); + return; + } + + setSubmitting(true); + setErrorMessage(''); + + try { + const { data } = await axios.post('/pos/checkout', { + shopId: selectedShopId, + paymentMethod, + notes, + items: cart.map((item) => ({ + productId: item.productId, + quantity: item.quantity, + })), + }); + + setSuccessInvoice(data); + setCart([]); + setNotes(''); + setQuery(''); + setActiveCategoryId('all'); + await loadWorkspace(selectedShopId); + } catch (error: any) { + console.error('POS checkout failed:', error); + setErrorMessage(error?.response?.data || 'حدث خطأ أثناء حفظ الفاتورة.'); + } finally { + setSubmitting(false); + } + }; + + const handlePricingAction = async (action: 'set_rate' | 'apply_prices' | 'restore_prices') => { + if (!selectedShopId) { + return; + } + + setPricingBusy(true); + setErrorMessage(''); + setSuccessInvoice(null); + + try { + const payload: Record = { + shopId: selectedShopId, + action, + }; + + if (action !== 'restore_prices') { + payload.usdRate = usdRateInput; + } + + const { data } = await axios.post('/pos/pricing', payload); + await loadWorkspace(selectedShopId); + setErrorMessage(''); + setSuccessInvoice({ + invoice_number: data.message, + total_amount: 0, + total_profit_amount: 0, + }); + } catch (error: any) { + console.error('POS pricing action failed:', error); + setErrorMessage(error?.response?.data || 'تعذر تنفيذ تحديث الأسعار.'); + } finally { + setPricingBusy(false); + } + }; + + const emptyProductState = ( + +
+

لا توجد منتجات جاهزة للبيع بعد

+

ابدأ بإضافة منتجات وأقسام من لوحة الإدارة ليظهر الكاشير بشكل كامل.

+
+ {canCreateProducts && } + +
+
+
+ ); + + return ( + <> + + {getPageTitle('الكاشير')} + + +
+ + {''} + + + +
+
+ + نظام بيع عربي سريع لمحل المنظفات + +
+

بيع أسرع، فواتير أوضح، وربح يومي محسوب بدقة

+

+ ابحث عن المنتج فوراً، أضفه للفاتورة بضغطة واحدة، وراقب المبيعات والأرباح اليومية مع تحديث أسعار الدولار من نفس الشاشة. +

+
+
+
+
+
مبيعات اليوم
+
{formatMoney(workspace.summary.totalSales)}
+
+
+
أرباح اليوم
+
{formatMoney(workspace.summary.totalProfit)}
+
+
+
عدد الفواتير
+
{workspace.summary.invoiceCount}
+
+
+
+
+ + {errorMessage ? ( +
{errorMessage}
+ ) : null} + + {successInvoice ? ( +
+
+
+

تمت العملية بنجاح

+

+ {successInvoice.id ? `تم إنشاء الفاتورة رقم ${successInvoice.invoice_number}.` : successInvoice.invoice_number} +

+
+ {successInvoice.id ? ( + + فتح تفاصيل الفاتورة + + ) : null} +
+
+ ) : null} + + {loading ? ( + + + + ) : !workspace.shops.length ? ( + +
+

لا يوجد محل مرتبط بحسابك بعد

+

أنشئ أول محل ليتم تفعيل شاشة الكاشير وتقارير اليوم.

+
+ {canCreateShops && } + +
+
+
+ ) : ( +
+
+ +
+
+ + +
+
+
+
العملة
+
{workspace.selectedShop?.currency_name || 'دينار عراقي'}
+
+
+
سعر الدولار الحالي
+
{workspace.selectedShop?.usd_rate || 0}
+
+
+
آخر تحديث
+
{formatDateTime(workspace.latestPriceChange?.changed_at)}
+
+
+
+
+ + +
+
+
+

بحث سريع عن المنتجات

+

اكتب أول حرف من اسم المنتج أو الباركود أو SKU وستظهر النتائج فوراً بدون إعادة تحميل.

+
+
+ + +
+
+ +
+ setQuery(event.target.value)} + placeholder="ابحث باسم المنتج أو الباركود..." + className={`h-14 w-full border border-slate-200 bg-slate-50 px-4 text-right text-lg text-slate-900 transition ${focusRing} ${corners}`} + /> +
+
اقتراحات مباشرة
+
+ {suggestions.length ? ( + suggestions.map((product) => ( + + )) + ) : ( + ابدأ الكتابة لعرض الاقتراحات. + )} +
+
+
+ +
+ + {(workspace.categories || []).map((category) => ( + + ))} +
+
+
+ + {(workspace.products || []).length ? ( +
+ {filteredProducts.map((product) => { + const dollarPrice = product.usd_price ?? ((product.sale_price || 0) / (workspace.selectedShop?.usd_rate || 1)); + const lowStock = product.stock_quantity != null && product.stock_quantity <= 3; + + return ( + + ); + })} +
+ ) : ( + emptyProductState + )} +
+ +
+ +
+
+

الفاتورة الحالية

+

أزرار كبيرة وواضحة مناسبة للاستخدام داخل المحل.

+
+ +
+ {cartDetails.length ? ( + cartDetails.map((item) => ( +
+
+
+
{item.product.product_name}
+
{item.product.category_name}
+
+ +
+
+
+ +
+ {item.quantity} +
+ +
+
+
إجمالي السطر
+
{formatMoney(item.lineTotal)}
+
+
+
+ )) + ) : ( +
+ اختر منتجات من القائمة لتكوين الفاتورة. +
+ )} +
+ +
+
+
+ عدد القطع + {cartSummary.quantity} +
+
+ الإجمالي + {formatMoney(cartSummary.total)} +
+
+ الربح المتوقع + {formatMoney(cartSummary.profit)} +
+
+
+ +
+ + +
+ +
+ +