amr7aj
This commit is contained in:
parent
11cb45896b
commit
64d433d6e7
@ -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
38
backend/src/routes/pos.js
Normal 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
560
backend/src/services/pos.js
Normal 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;
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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'
|
||||
},
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
747
frontend/src/pages/cashier.tsx
Normal file
747
frontend/src/pages/cashier.tsx
Normal 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;
|
||||
@ -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>;
|
||||
};
|
||||
|
||||
|
||||
@ -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: '',
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user