This commit is contained in:
Flatlogic Bot 2026-03-31 19:41:37 +00:00
parent 11cb45896b
commit 64d433d6e7
9 changed files with 1560 additions and 180 deletions

View File

@ -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',

38
backend/src/routes/pos.js Normal file
View File

@ -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;

560
backend/src/services/pos.js Normal file
View File

@ -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;
}
}
};

View File

@ -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;
}

View File

@ -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'
},

View File

@ -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,
},

View File

@ -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<WorkspaceData>(initialWorkspace);
const [selectedShopId, setSelectedShopId] = useState('');
const [query, setQuery] = useState('');
const [activeCategoryId, setActiveCategoryId] = useState('all');
const [cart, setCart] = useState<CartItem[]>([]);
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<any>(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<string, string> = {
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 = (
<CardBox className="border-dashed border-2 border-sky-100 bg-white/80">
<div className="space-y-3 py-6 text-center text-slate-600">
<p className="text-lg font-bold text-slate-900">لا توجد منتجات جاهزة للبيع بعد</p>
<p>ابدأ بإضافة منتجات وأقسام من لوحة الإدارة ليظهر الكاشير بشكل كامل.</p>
<div className="flex flex-wrap items-center justify-center gap-3">
{canCreateProducts && <BaseButton href="/products/products-new" color="success" label="إضافة منتج" />}
<BaseButton href="/products/products-list" color="info" label="عرض المنتجات" />
</div>
</div>
</CardBox>
);
return (
<>
<Head>
<title>{getPageTitle('الكاشير')}</title>
</Head>
<SectionMain>
<div className="app-rtl space-y-6" dir="rtl">
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="شاشة الكاشير وتقارير اليوم" main>
{''}
</SectionTitleLineWithButton>
<CardBox className="overflow-hidden border-0 bg-gradient-to-l from-emerald-500 via-emerald-600 to-sky-500 text-white shadow-xl shadow-emerald-100/70">
<div className="grid gap-6 px-2 py-2 lg:grid-cols-[1.35fr,0.65fr] lg:items-center">
<div className="space-y-3">
<span className="inline-flex items-center rounded-full bg-white/20 px-4 py-1 text-sm font-bold">
نظام بيع عربي سريع لمحل المنظفات
</span>
<div>
<h1 className="text-3xl font-extrabold leading-tight lg:text-4xl">بيع أسرع، فواتير أوضح، وربح يومي محسوب بدقة</h1>
<p className="mt-3 max-w-3xl text-base text-emerald-50 lg:text-lg">
ابحث عن المنتج فوراً، أضفه للفاتورة بضغطة واحدة، وراقب المبيعات والأرباح اليومية مع تحديث أسعار الدولار من نفس الشاشة.
</p>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-3 lg:grid-cols-1">
<div className="rounded-2xl bg-white/14 p-4 backdrop-blur-sm">
<div className="text-sm text-emerald-50">مبيعات اليوم</div>
<div className="mt-2 text-2xl font-extrabold">{formatMoney(workspace.summary.totalSales)}</div>
</div>
<div className="rounded-2xl bg-white/14 p-4 backdrop-blur-sm">
<div className="text-sm text-emerald-50">أرباح اليوم</div>
<div className="mt-2 text-2xl font-extrabold">{formatMoney(workspace.summary.totalProfit)}</div>
</div>
<div className="rounded-2xl bg-white/14 p-4 backdrop-blur-sm">
<div className="text-sm text-emerald-50">عدد الفواتير</div>
<div className="mt-2 text-2xl font-extrabold">{workspace.summary.invoiceCount}</div>
</div>
</div>
</div>
</CardBox>
{errorMessage ? (
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{errorMessage}</div>
) : null}
{successInvoice ? (
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-4 text-sm text-emerald-800">
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
<div>
<p className="font-bold text-emerald-900">تمت العملية بنجاح</p>
<p className="mt-1">
{successInvoice.id ? `تم إنشاء الفاتورة رقم ${successInvoice.invoice_number}.` : successInvoice.invoice_number}
</p>
</div>
{successInvoice.id ? (
<Link
href={`/sales_invoices/sales_invoices-view/?id=${successInvoice.id}`}
className="font-bold text-emerald-700 underline decoration-emerald-300 underline-offset-4"
>
فتح تفاصيل الفاتورة
</Link>
) : null}
</div>
</div>
) : null}
{loading ? (
<CardBox>
<LoadingSpinner />
</CardBox>
) : !workspace.shops.length ? (
<CardBox>
<div className="space-y-4 py-8 text-center">
<h2 className="text-2xl font-bold text-slate-900">لا يوجد محل مرتبط بحسابك بعد</h2>
<p className="text-slate-600">أنشئ أول محل ليتم تفعيل شاشة الكاشير وتقارير اليوم.</p>
<div className="flex flex-wrap items-center justify-center gap-3">
{canCreateShops && <BaseButton href="/shops/shops-new" color="success" label="إضافة محل" />}
<BaseButton href="/shops/shops-list" color="info" label="عرض المحلات" />
</div>
</div>
</CardBox>
) : (
<div className="grid gap-6 xl:grid-cols-[1.35fr,0.65fr]">
<div className="space-y-6">
<CardBox className="border-0 bg-white shadow-lg shadow-sky-100/60">
<div className="grid gap-4 lg:grid-cols-[0.7fr,1.3fr] lg:items-end">
<div>
<label className="mb-2 block text-sm font-bold text-slate-700">المحل الحالي</label>
<select
value={selectedShopId}
onChange={(event) => loadWorkspace(event.target.value)}
className={`h-12 w-full border border-slate-200 bg-white px-4 text-right text-slate-800 transition ${focusRing} ${corners}`}
>
{(workspace.shops || []).map((shop) => (
<option key={shop.id} value={shop.id}>
{shop.shop_name}
</option>
))}
</select>
</div>
<div className="grid gap-3 md:grid-cols-3">
<div className="rounded-2xl border border-slate-100 bg-slate-50 px-4 py-3">
<div className="text-xs font-bold text-slate-500">العملة</div>
<div className="mt-1 text-lg font-bold text-slate-900">{workspace.selectedShop?.currency_name || 'دينار عراقي'}</div>
</div>
<div className="rounded-2xl border border-slate-100 bg-slate-50 px-4 py-3">
<div className="text-xs font-bold text-slate-500">سعر الدولار الحالي</div>
<div className="mt-1 text-lg font-bold text-slate-900">{workspace.selectedShop?.usd_rate || 0}</div>
</div>
<div className="rounded-2xl border border-slate-100 bg-slate-50 px-4 py-3">
<div className="text-xs font-bold text-slate-500">آخر تحديث</div>
<div className="mt-1 text-sm font-bold text-slate-900">{formatDateTime(workspace.latestPriceChange?.changed_at)}</div>
</div>
</div>
</div>
</CardBox>
<CardBox className="border-0 bg-white shadow-lg shadow-sky-100/60">
<div className="space-y-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 className="text-xl font-bold text-slate-900">بحث سريع عن المنتجات</h2>
<p className="text-sm text-slate-500">اكتب أول حرف من اسم المنتج أو الباركود أو SKU وستظهر النتائج فوراً بدون إعادة تحميل.</p>
</div>
<div className="flex flex-wrap gap-2">
<BaseButton href="/products/products-list" color="info" label="إدارة المنتجات" />
<BaseButton href="/categories/categories-list" color="info" label="إدارة الأقسام" />
</div>
</div>
<div className="grid gap-4 lg:grid-cols-[1.3fr,0.7fr]">
<input
value={query}
onChange={(event) => 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}`}
/>
<div className="rounded-2xl border border-sky-100 bg-sky-50 px-4 py-3 text-sm text-sky-800">
<div className="font-bold">اقتراحات مباشرة</div>
<div className="mt-2 flex flex-wrap gap-2">
{suggestions.length ? (
suggestions.map((product) => (
<button
key={product.id}
type="button"
onClick={() => addProductToCart(product.id)}
className="rounded-full bg-white px-3 py-1.5 font-bold text-slate-700 transition hover:-translate-y-0.5 hover:text-emerald-700"
>
{product.product_name}
</button>
))
) : (
<span>ابدأ الكتابة لعرض الاقتراحات.</span>
)}
</div>
</div>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setActiveCategoryId('all')}
className={`rounded-full px-4 py-2 text-sm font-bold transition ${
activeCategoryId === 'all'
? 'bg-emerald-600 text-white shadow-lg shadow-emerald-100'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
كل الأقسام
</button>
{(workspace.categories || []).map((category) => (
<button
key={category.id}
type="button"
onClick={() => setActiveCategoryId(category.id)}
className={`rounded-full px-4 py-2 text-sm font-bold transition ${
activeCategoryId === category.id
? 'bg-sky-600 text-white shadow-lg shadow-sky-100'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
}`}
>
{category.category_name}
</button>
))}
</div>
</div>
</CardBox>
{(workspace.products || []).length ? (
<div className="grid gap-4 sm:grid-cols-2 2xl:grid-cols-3">
{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 (
<button
key={product.id}
type="button"
onClick={() => addProductToCart(product.id)}
className="group rounded-3xl border border-slate-100 bg-white p-5 text-right shadow-md shadow-slate-100/70 transition duration-200 hover:-translate-y-1 hover:border-emerald-200 hover:shadow-xl"
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-lg font-extrabold text-slate-900">{product.product_name}</div>
<div className="mt-1 text-sm text-slate-500">{product.category_name || 'بدون قسم'}</div>
</div>
<span className={`rounded-full px-3 py-1 text-xs font-bold ${lowStock ? 'bg-amber-100 text-amber-700' : 'bg-emerald-50 text-emerald-700'}`}>
{product.stock_quantity == null ? 'مخزون مفتوح' : `المخزون ${product.stock_quantity}`}
</span>
</div>
<div className="mt-6 grid gap-3 sm:grid-cols-2">
<div className="rounded-2xl bg-slate-50 px-3 py-3">
<div className="text-xs font-bold text-slate-500">سعر البيع</div>
<div className="mt-1 text-xl font-extrabold text-slate-900">{formatMoney(product.sale_price || 0)}</div>
</div>
<div className="rounded-2xl bg-sky-50 px-3 py-3">
<div className="text-xs font-bold text-sky-600">السعر بالدولار</div>
<div className="mt-1 text-xl font-extrabold text-sky-900">{formatUsd(dollarPrice)}</div>
</div>
</div>
<div className="mt-4 flex items-center justify-between text-sm text-slate-500">
<span>{product.sku || product.barcode || 'منتج سريع البيع'}</span>
<span className="font-bold text-emerald-700 transition group-hover:text-emerald-800">أضف للفاتورة</span>
</div>
</button>
);
})}
</div>
) : (
emptyProductState
)}
</div>
<div className="space-y-6">
<CardBox className="border-0 bg-white shadow-lg shadow-emerald-100/60">
<div className="space-y-4">
<div>
<h2 className="text-xl font-bold text-slate-900">الفاتورة الحالية</h2>
<p className="text-sm text-slate-500">أزرار كبيرة وواضحة مناسبة للاستخدام داخل المحل.</p>
</div>
<div className="space-y-3">
{cartDetails.length ? (
cartDetails.map((item) => (
<div key={item.productId} className="rounded-2xl border border-slate-100 bg-slate-50 p-4">
<div className="flex items-start justify-between gap-4">
<div>
<div className="font-bold text-slate-900">{item.product.product_name}</div>
<div className="mt-1 text-sm text-slate-500">{item.product.category_name}</div>
</div>
<button
type="button"
onClick={() => updateCartQuantity(item.productId, 0)}
className="text-sm font-bold text-red-500 transition hover:text-red-700"
>
حذف
</button>
</div>
<div className="mt-4 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => updateCartQuantity(item.productId, item.quantity - 1)}
className="h-10 w-10 rounded-2xl bg-white text-xl font-bold text-slate-700 shadow-sm transition hover:bg-slate-100"
>
-
</button>
<div className="min-w-14 rounded-2xl bg-white px-3 py-2 text-center text-lg font-extrabold text-slate-900 shadow-sm">
{item.quantity}
</div>
<button
type="button"
onClick={() => updateCartQuantity(item.productId, item.quantity + 1)}
className="h-10 w-10 rounded-2xl bg-emerald-600 text-xl font-bold text-white shadow-lg shadow-emerald-100 transition hover:bg-emerald-700"
>
+
</button>
</div>
<div className="text-left">
<div className="text-sm text-slate-500">إجمالي السطر</div>
<div className="text-lg font-extrabold text-slate-900">{formatMoney(item.lineTotal)}</div>
</div>
</div>
</div>
))
) : (
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50 px-4 py-8 text-center text-slate-500">
اختر منتجات من القائمة لتكوين الفاتورة.
</div>
)}
</div>
<div className="rounded-3xl bg-slate-900 p-5 text-white shadow-xl shadow-slate-200/80">
<div className="grid gap-3 text-sm">
<div className="flex items-center justify-between">
<span className="text-slate-300">عدد القطع</span>
<span className="text-xl font-extrabold">{cartSummary.quantity}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-300">الإجمالي</span>
<span className="text-2xl font-extrabold text-emerald-300">{formatMoney(cartSummary.total)}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-slate-300">الربح المتوقع</span>
<span className="text-lg font-bold text-sky-300">{formatMoney(cartSummary.profit)}</span>
</div>
</div>
</div>
<div>
<label className="mb-2 block text-sm font-bold text-slate-700">طريقة الدفع</label>
<select
value={paymentMethod}
onChange={(event) => setPaymentMethod(event.target.value)}
className={`h-12 w-full border border-slate-200 bg-white px-4 text-right text-slate-800 transition ${focusRing} ${corners}`}
>
<option value="cash">نقدي</option>
<option value="card">بطاقة</option>
<option value="transfer">تحويل</option>
<option value="mixed">مختلط</option>
</select>
</div>
<div>
<label className="mb-2 block text-sm font-bold text-slate-700">ملاحظات الفاتورة</label>
<textarea
value={notes}
onChange={(event) => setNotes(event.target.value)}
placeholder="مثال: زبون دائم - طلب سريع"
className={`min-h-28 w-full border border-slate-200 bg-white px-4 py-3 text-right text-slate-800 transition ${focusRing} ${corners}`}
/>
</div>
<BaseButton
color="success"
label={submitting ? 'جارٍ حفظ الفاتورة...' : canCheckout ? 'تأكيد البيع وحفظ الفاتورة' : 'لا تملك صلاحية إنشاء الفواتير'}
onClick={handleCheckout}
disabled={submitting || !cartDetails.length || !canCheckout}
className="!flex h-14 w-full !items-center !justify-center text-lg font-bold"
/>
</div>
</CardBox>
<CardBox className="border-0 bg-white shadow-lg shadow-sky-100/60">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-slate-900">أدوات سعر الدولار</h2>
<p className="text-sm text-slate-500">تحديث سعر اليوم ثم تطبيق الزيادة أو الرجوع للسعر السابق.</p>
</div>
<span className="rounded-full bg-sky-50 px-3 py-1 text-xs font-bold text-sky-700">
آخر حركة: {workspace.latestPriceChange?.summary || 'لا توجد حركات بعد'}
</span>
</div>
<div>
<label className="mb-2 block text-sm font-bold text-slate-700">سعر الدولار اليومي</label>
<input
value={usdRateInput}
onChange={(event) => setUsdRateInput(event.target.value)}
placeholder="مثال: 1470"
className={`h-12 w-full border border-slate-200 bg-white px-4 text-right text-slate-800 transition ${focusRing} ${corners}`}
/>
</div>
<div className="grid gap-3">
<BaseButton
color="info"
label={pricingBusy ? 'جارٍ الحفظ...' : 'حفظ سعر الدولار'}
onClick={() => handlePricingAction('set_rate')}
disabled={pricingBusy || !canManagePricing}
className="!flex h-12 w-full !items-center !justify-center font-bold"
/>
<BaseButton
color="success"
label={pricingBusy ? 'جارٍ تحديث الأسعار...' : 'تطبيق الأسعار حسب الدولار'}
onClick={() => handlePricingAction('apply_prices')}
disabled={pricingBusy || !canManagePricing}
className="!flex h-12 w-full !items-center !justify-center font-bold"
/>
<BaseButton
color="warning"
label={pricingBusy ? 'جارٍ الاسترجاع...' : 'إرجاع الأسعار السابقة'}
onClick={() => handlePricingAction('restore_prices')}
disabled={pricingBusy || !canManagePricing}
className="!flex h-12 w-full !items-center !justify-center font-bold"
/>
</div>
{!canManagePricing ? <p className="text-xs text-slate-500">هذه الأدوات متاحة لمدير المحل أو من يملك صلاحية تحديث المحلات.</p> : null}
</div>
</CardBox>
<CardBox className="border-0 bg-white shadow-lg shadow-slate-100/80">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-slate-900">فواتير اليوم</h2>
<p className="text-sm text-slate-500">قائمة مباشرة بآخر الفواتير المدفوعة مع الربح المحسوب.</p>
</div>
<BaseButton href="/sales_invoices/sales_invoices-list" color="info" label="كل الفواتير" />
</div>
<div className="space-y-3">
{(workspace.recentInvoices || []).length ? (
workspace.recentInvoices.map((invoice) => (
<Link
key={invoice.id}
href={`/sales_invoices/sales_invoices-view/?id=${invoice.id}`}
className="block rounded-2xl border border-slate-100 bg-slate-50 px-4 py-4 transition hover:-translate-y-0.5 hover:border-emerald-200 hover:bg-white"
>
<div className="flex items-start justify-between gap-4">
<div>
<div className="font-extrabold text-slate-900">{invoice.invoice_number}</div>
<div className="mt-1 text-sm text-slate-500">{formatDateTime(invoice.sold_at)}</div>
</div>
<span className="rounded-full bg-white px-3 py-1 text-xs font-bold text-slate-600 shadow-sm">
{invoice.payment_method}
</span>
</div>
<div className="mt-4 grid gap-2 sm:grid-cols-3">
<div>
<div className="text-xs text-slate-500">إجمالي الفاتورة</div>
<div className="font-bold text-slate-900">{formatMoney(invoice.total_amount)}</div>
</div>
<div>
<div className="text-xs text-slate-500">الربح</div>
<div className="font-bold text-emerald-700">{formatMoney(invoice.total_profit_amount)}</div>
</div>
<div>
<div className="text-xs text-slate-500">عدد القطع</div>
<div className="font-bold text-slate-900">{invoice.item_count}</div>
</div>
</div>
</Link>
))
) : (
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50 px-4 py-8 text-center text-slate-500">
لم تُسجَّل أي فاتورة مدفوعة اليوم بعد.
</div>
)}
</div>
</div>
</CardBox>
</div>
</div>
)}
</div>
</SectionMain>
</>
);
};
CashierPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated permission="READ_PRODUCTS">{page}</LayoutAuthenticated>;
};
export default CashierPage;

View File

@ -1,166 +1,167 @@
import React, { useEffect, useState } from 'react';
import type { ReactElement } from 'react';
import Head from 'next/head';
import Link from 'next/link';
import type { ReactElement } from 'react';
import React from 'react';
import BaseButton from '../components/BaseButton';
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 highlights = [
{
title: 'كاشير سريع',
text: 'بحث فوري عن المنتجات، إضافة للفاتورة بضغطة واحدة، وحساب مباشر للإجمالي.',
},
{
title: 'تسعير حسب الدولار',
text: 'حفظ سعر الدولار اليومي وتطبيق تحديث جماعي على أسعار البيع مع إمكانية الرجوع.',
},
{
title: 'تقارير يومية',
text: 'متابعة مبيعات اليوم، الأرباح، وعدد الفواتير مع تفاصيل واضحة لكل فاتورة.',
},
];
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('image');
const [contentPosition, setContentPosition] = useState('right');
const textColor = useAppSelector((state) => state.style.linkColor);
const title = 'Multi-Client Detergents POS'
// Fetch Pexels image/video
useEffect(() => {
async function fetchData() {
const image = await getPexelsImage();
const video = await getPexelsVideo();
setIllustrationImage(image);
setIllustrationVideo(video);
}
fetchData();
}, []);
const imageBlock = (image) => (
<div
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
style={{
backgroundImage: `${
image
? `url(${image?.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}}
>
<div className='flex justify-center w-full bg-blue-300/20'>
<a
className='text-[8px]'
href={image?.photographer_url}
target='_blank'
rel='noreferrer'
>
Photo by {image?.photographer} on Pexels
</a>
</div>
</div>
);
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video?.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video?.user?.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
}
};
const quickLinks = [
{ href: '/login', label: 'تسجيل الدخول' },
{ href: '/dashboard', label: 'واجهة الإدارة' },
{ href: '/cashier', label: 'شاشة الكاشير' },
{ href: '/products/products-list', label: 'المنتجات' },
];
export default function HomePage() {
return (
<div
style={
contentPosition === 'background'
? {
backgroundImage: `${
illustrationImage
? `url(${illustrationImage.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}
: {}
}
>
<>
<Head>
<title>{getPageTitle('Starter Page')}</title>
<title>{getPageTitle('نظام إدارة محل منظفات')}</title>
</Head>
<SectionFullScreen bg='violet'>
<div
className={`flex ${
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
} min-h-screen w-full`}
>
{contentType === 'image' && contentPosition !== 'background'
? imageBlock(illustrationImage)
: null}
{contentType === 'video' && contentPosition !== 'background'
? videoBlock(illustrationVideo)
: null}
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<CardBoxComponentTitle title="Welcome to your Multi-Client Detergents POS app!"/>
<div className="space-y-3">
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
<p className='text-center text-gray-500'>For guides and documentation please check
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
<div className="app-rtl min-h-screen bg-[radial-gradient(circle_at_top,_rgba(16,185,129,0.16),_transparent_32%),radial-gradient(circle_at_bottom_left,_rgba(14,165,233,0.16),_transparent_28%),linear-gradient(180deg,#f8fafc_0%,#ffffff_65%)]" dir="rtl">
<header className="sticky top-0 z-20 border-b border-white/70 bg-white/80 backdrop-blur-xl">
<div className="mx-auto flex max-w-7xl items-center justify-between gap-4 px-6 py-4">
<div>
<div className="text-xl font-extrabold text-slate-900">منظفات برو</div>
<div className="text-sm text-slate-500">منصة عربية حديثة لإدارة المبيعات والمخزون</div>
</div>
<BaseButtons>
<BaseButton
href='/login'
label='Login'
color='info'
className='w-full'
/>
<nav className="flex flex-wrap items-center gap-2">
{quickLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className="rounded-full px-4 py-2 text-sm font-bold text-slate-700 transition hover:bg-slate-100 hover:text-emerald-700"
>
{link.label}
</Link>
))}
</nav>
</div>
</header>
</BaseButtons>
</CardBox>
</div>
</div>
</SectionFullScreen>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
Privacy Policy
</Link>
</div>
<main className="mx-auto flex max-w-7xl flex-col gap-10 px-6 py-10 lg:py-16">
<section className="grid gap-8 lg:grid-cols-[1.1fr,0.9fr] lg:items-center">
<div className="space-y-6">
<span className="inline-flex rounded-full border border-emerald-100 bg-emerald-50 px-4 py-2 text-sm font-bold text-emerald-700">
موقع تعريفي + نظام كاشير عربي متعدد العملاء
</span>
<div className="space-y-4">
<h1 className="text-4xl font-extrabold leading-tight text-slate-950 lg:text-6xl">
إدارة مبيعات محل المنظفات بشكل أسرع وأوضح وأجمل
</h1>
<p className="max-w-2xl text-lg leading-8 text-slate-600">
واجهة عربية بالكامل، تصميم مريح للعين، شاشة كاشير مناسبة للمحل التجاري، وتسعير ذكي حسب الدولار مع تقارير يومية وأرباح دقيقة.
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<BaseButton href="/login" color="success" label="ابدأ من لوحة الإدارة" className="!px-6 !py-3 text-base font-bold" />
<BaseButton href="/cashier" color="info" label="جرّب شاشة الكاشير" className="!px-6 !py-3 text-base font-bold" />
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="rounded-3xl border border-white bg-white/90 p-5 shadow-lg shadow-emerald-100/70">
<div className="text-sm font-bold text-slate-500">اللغة والاتجاه</div>
<div className="mt-2 text-2xl font-extrabold text-slate-900">عربي + RTL</div>
</div>
<div className="rounded-3xl border border-white bg-white/90 p-5 shadow-lg shadow-sky-100/70">
<div className="text-sm font-bold text-slate-500">التوسع</div>
<div className="mt-2 text-2xl font-extrabold text-slate-900">+200 منتج</div>
</div>
<div className="rounded-3xl border border-white bg-white/90 p-5 shadow-lg shadow-slate-100/80">
<div className="text-sm font-bold text-slate-500">المستخدمون</div>
<div className="mt-2 text-2xl font-extrabold text-slate-900">عدة عملاء</div>
</div>
</div>
</div>
</div>
<CardBox className="border-0 bg-slate-950 text-white shadow-2xl shadow-sky-100/60">
<div className="space-y-5 p-2">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-bold text-slate-400">المشهد الأول في النظام</div>
<div className="mt-1 text-2xl font-extrabold">شاشة كاشير عصرية للمحل</div>
</div>
<span className="rounded-full bg-white/10 px-3 py-1 text-xs font-bold text-sky-200">MVP جاهز</span>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-3xl bg-white/5 p-4 ring-1 ring-white/10">
<div className="text-sm text-slate-400">بحث فوري</div>
<div className="mt-2 text-lg font-bold">اقتراحات مباشرة بدون إعادة تحميل</div>
</div>
<div className="rounded-3xl bg-white/5 p-4 ring-1 ring-white/10">
<div className="text-sm text-slate-400">فاتورة سريعة</div>
<div className="mt-2 text-lg font-bold">إجمالي وربح وعدد قطع في نفس اللحظة</div>
</div>
<div className="rounded-3xl bg-white/5 p-4 ring-1 ring-white/10">
<div className="text-sm text-slate-400">سعر الدولار</div>
<div className="mt-2 text-lg font-bold">حفظ السعر اليومي + تطبيق جماعي + استرجاع</div>
</div>
<div className="rounded-3xl bg-white/5 p-4 ring-1 ring-white/10">
<div className="text-sm text-slate-400">تقارير اليوم</div>
<div className="mt-2 text-lg font-bold">مبيعات وأرباح وفواتير اليوم من نفس الشاشة</div>
</div>
</div>
<div className="rounded-[28px] bg-gradient-to-l from-emerald-500 to-sky-500 p-5 text-slate-950">
<div className="text-sm font-bold">الوصول السريع</div>
<div className="mt-2 text-2xl font-extrabold">لوحة الإدارة ما زالت متاحة بالكامل</div>
<p className="mt-2 text-sm leading-7 text-slate-900/80">
من هنا يمكنك دخول الواجهة الإدارية الحالية لإدارة المنتجات، الأقسام، المحلات، الفواتير، والمستخدمين بدون حذف أي شيء من البنية الجاهزة.
</p>
</div>
</div>
</CardBox>
</section>
<section className="grid gap-4 lg:grid-cols-3">
{highlights.map((item) => (
<CardBox key={item.title} className="border-0 bg-white/90 shadow-lg shadow-slate-100/80">
<div className="space-y-3">
<div className="inline-flex rounded-full bg-slate-100 px-3 py-1 text-xs font-bold text-slate-600">ميزة أساسية</div>
<h2 className="text-2xl font-extrabold text-slate-900">{item.title}</h2>
<p className="leading-8 text-slate-600">{item.text}</p>
</div>
</CardBox>
))}
</section>
<section className="rounded-[32px] border border-slate-100 bg-white/90 p-8 shadow-xl shadow-slate-100/80">
<div className="grid gap-6 lg:grid-cols-[1fr,auto] lg:items-center">
<div>
<div className="text-sm font-bold text-emerald-600">ماذا ستجد الآن؟</div>
<h2 className="mt-2 text-3xl font-extrabold text-slate-950">أول شريحة MVP تعمل من البداية للنهاية</h2>
<p className="mt-3 max-w-3xl text-lg leading-8 text-slate-600">
صفحة عامة جميلة، رابط مباشر للوحة الإدارة، وشاشة كاشير عربية تجمع البيع السريع مع تقرير اليوم وأدوات تحديث الأسعار حسب الدولار.
</p>
</div>
<div className="flex flex-wrap gap-3">
<BaseButton href="/dashboard" color="info" label="فتح واجهة الإدارة" className="!px-6 !py-3 text-base font-bold" />
<BaseButton href="/sales_invoices/sales_invoices-list" color="success" label="عرض الفواتير" className="!px-6 !py-3 text-base font-bold" />
</div>
</div>
</section>
</main>
</div>
</>
);
}
Starter.getLayout = function getLayout(page: ReactElement) {
HomePage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -31,23 +31,23 @@ export const white: StyleObject = {
asideMenuItem: 'text-gray-700 hover:bg-gray-100/70 dark:text-dark-500 dark:hover:text-white dark:hover:bg-dark-800',
asideMenuItemActive: 'font-bold text-black dark:text-white',
asideMenuDropdown: 'bg-gray-100/75',
navBarItemLabel: 'text-blue-600',
navBarItemLabelHover: 'hover:text-black',
navBarItemLabel: 'text-emerald-600',
navBarItemLabelHover: 'hover:text-emerald-800',
navBarItemLabelActiveColor: 'text-black',
overlay: 'from-white via-gray-100 to-white',
activeLinkColor: 'bg-gray-100/70',
bgLayoutColor: 'bg-gray-50',
iconsColor: 'text-blue-500',
activeLinkColor: 'bg-emerald-50',
bgLayoutColor: 'bg-slate-50',
iconsColor: 'text-emerald-600',
cardsColor: 'bg-white',
focusRingColor: 'focus:ring focus:ring-blue-600 focus:border-blue-600 focus:outline-none border-gray-300 dark:focus:ring-blue-600 dark:focus:border-blue-600',
focusRingColor: 'focus:ring focus:ring-emerald-500 focus:border-emerald-400 focus:outline-none border-slate-200 dark:focus:ring-emerald-500 dark:focus:border-emerald-500',
corners: 'rounded',
cardsStyle: 'bg-white border border-pavitra-400',
linkColor: 'text-blue-600',
websiteHeder: 'border-b border-gray-200',
borders: 'border-gray-200',
cardsStyle: 'bg-white border border-sky-100 shadow-sm shadow-slate-100/80',
linkColor: 'text-emerald-600',
websiteHeder: 'border-b border-slate-200',
borders: 'border-slate-200',
shadow: '',
websiteSectionStyle: '',
textSecondary: 'text-gray-500',
websiteSectionStyle: 'bg-white',
textSecondary: 'text-slate-500',
}
@ -91,17 +91,17 @@ export const basic: StyleObject = {
navBarItemLabelHover: 'hover:text-blue-500',
navBarItemLabelActiveColor: 'text-blue-600',
overlay: 'from-gray-700 via-gray-900 to-gray-700',
activeLinkColor: 'bg-gray-100/70',
bgLayoutColor: 'bg-gray-50',
iconsColor: 'text-blue-500',
activeLinkColor: 'bg-emerald-50',
bgLayoutColor: 'bg-slate-50',
iconsColor: 'text-emerald-600',
cardsColor: 'bg-white',
focusRingColor: 'focus:ring focus:ring-blue-600 focus:border-blue-600 focus:outline-none dark:focus:ring-blue-600 border-gray-300 dark:focus:border-blue-600',
corners: 'rounded',
cardsStyle: 'bg-white border border-pavitra-400',
cardsStyle: 'bg-white border border-sky-100 shadow-sm shadow-slate-100/80',
linkColor: 'text-black',
websiteHeder: '',
borders: '',
shadow: '',
websiteSectionStyle: '',
websiteSectionStyle: 'bg-white',
textSecondary: '',
}