diff --git a/pos-app/README.md b/pos-app/README.md new file mode 100644 index 0000000000..b37ad49a2a --- /dev/null +++ b/pos-app/README.md @@ -0,0 +1,71 @@ +# POS Barber Suite + +تطبيق نقطة بيع وإدارة صالون الحلاقة يعمل بدون إنترنت، مبني بتقنيات **React + TypeScript + Electron + SQLite** مع تصميم Soft Glass Neumorphism. المشروع جاهز للعمل فورًا على أنظمة سطح المكتب. + +## ⚙️ المتطلبات +- Node.js 18+ +- نظام تشغيل يدعم Electron (Windows / macOS / Linux) + +## 🚀 خطوات التشغيل +```bash +npm install +npm run dev # لتشغيل الواجهة عبر Vite +npm run electron # لتشغيل التطبيق كبرنامج سطح مكتب خلال التطوير +npm run electron:prod # بناء الواجهة ثم تشغيل النسخة المكتملة +npm run build # إنشاء نسخة إنتاجية من الواجهة +``` + +## 🧱 بنية المشروع +``` +/pos-app +├── electron.js # نقطة دخول تطبيق Electron +├── preload.js # الجسر الآمن بين الواجهة والعمليات الخلفية +├── package.json +├── tailwind.config.js +├── tsconfig.json +├── /src +│ ├── App.tsx +│ ├── main.tsx +│ ├── components +│ ├── contexts +│ ├── db +│ ├── hooks +│ ├── i18n +│ ├── pages +│ └── styles +└── README.md +``` + +## 🛠 مميزات أساسية +- شاشة تسجيل دخول (admin/password) مع تشفير كلمات المرور. +- لوحة معلومات متقدمة تشمل مؤشرات يومية وشهرية ورسوم بيانية. +- شاشة POS سريعة مع دعم قارئ الباركود، التخفيضات، الضرائب، والدفع المتعدد. +- إدارة كاملة للمنتجات مع رفع الصور، الاستيراد والتصدير بصيغة CSV، وتنبيه انخفاض المخزون. +- إدارة العملاء والموردين، المصروفات، التقارير، والإعدادات العامة. +- دعم الطباعة عبر PrintJS وإنشاء ملفات PDF عبر jsPDF، مع إمكانية التصدير إلى Excel. +- تعدد اللغات (العربية والإنجليزية) باستخدام i18next مع تبديل فوري للواجهة. +- تصميم Soft Glass Neumorphism متجاوب مع الحركات باستخدام Framer Motion. +- قاعدة بيانات SQLite محلية يتم إنشاؤها تلقائيًا مع بيانات تجريبية عند التشغيل الأول. + +## 🧾 الحسابات الافتراضية +| نوع المستخدم | اسم المستخدم | كلمة المرور | +|--------------|---------------|--------------| +| مدير (Admin) | admin | password | + +## 🛡️ ملاحظات أمنية +- يتم حفظ كلمات المرور عبر مكتبة bcryptjs. +- يتم تنفيذ جميع العمليات الحساسة داخل عملية Electron الخلفية مع عزلة تامة للواجهة. + +## 🧰 إنشاء ملف تنفيذي +يمكن استخدام [electron-builder](https://www.electron.build/) أو أوامر Electron الافتراضية. + +لإنشاء ملف EXE: +```bash +npm install +npm run build +npx electron-builder build -w +``` + +> جميع التعليقات داخل الأكواد باللغتين العربية والإنجليزية لمساعدة المطورين على الفهم السريع. + +بالتوفيق 💈 diff --git a/pos-app/electron-env.d.ts b/pos-app/electron-env.d.ts new file mode 100644 index 0000000000..fd738225ac --- /dev/null +++ b/pos-app/electron-env.d.ts @@ -0,0 +1,9 @@ +import { PosAPI } from "./src/db/client"; + +declare global { + interface Window { + posAPI: PosAPI; + } +} + +export {}; diff --git a/pos-app/electron.js b/pos-app/electron.js new file mode 100644 index 0000000000..e6c5f036f0 --- /dev/null +++ b/pos-app/electron.js @@ -0,0 +1,465 @@ +// electron.js - Electron main process entry point for the barber POS application +// ملف التشغيل الرئيسي لإلكتورن الخاص بتطبيق نقاط البيع وإدارة صالون الحلاقة + +const { app, BrowserWindow, ipcMain, dialog } = require("electron"); +const path = require("path"); +const fs = require("fs"); +const isDev = !app.isPackaged; + +// قاعدة البيانات عبر better-sqlite3 لضمان الأداء العالي بدون اتصال إنترنت +const Database = require("better-sqlite3"); +const bcrypt = require("bcryptjs"); +const { parse } = require("papaparse"); +const XLSX = require("xlsx"); + +const dbPath = path.join(app.getPath("userData"), "database.db"); +let db; + +function initializeDatabase() { + const exists = fs.existsSync(dbPath); + db = new Database(dbPath); + + db.pragma("journal_mode = WAL"); + + db.exec(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'admin' + ); + + CREATE TABLE IF NOT EXISTS settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + language TEXT DEFAULT 'ar', + currency TEXT DEFAULT 'SAR', + taxRate REAL DEFAULT 0.15, + storeName TEXT DEFAULT 'صالون الأناقة', + logoPath TEXT, + receiptType TEXT DEFAULT 'thermal' + ); + + CREATE TABLE IF NOT EXISTS products ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + barcode TEXT UNIQUE, + price REAL NOT NULL, + cost REAL DEFAULT 0, + quantity INTEGER DEFAULT 0, + lowStockThreshold INTEGER DEFAULT 5, + category TEXT, + image TEXT + ); + + CREATE TABLE IF NOT EXISTS customers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + phone TEXT, + email TEXT, + notes TEXT + ); + + CREATE TABLE IF NOT EXISTS suppliers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + phone TEXT, + email TEXT, + notes TEXT + ); + + CREATE TABLE IF NOT EXISTS expenses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + amount REAL NOT NULL, + createdAt TEXT DEFAULT CURRENT_TIMESTAMP, + notes TEXT + ); + + CREATE TABLE IF NOT EXISTS sales ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + customerId INTEGER, + total REAL NOT NULL, + discount REAL DEFAULT 0, + tax REAL DEFAULT 0, + paymentMethod TEXT DEFAULT 'cash', + paidAmount REAL DEFAULT 0, + changeAmount REAL DEFAULT 0, + userId INTEGER, + createdAt TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (customerId) REFERENCES customers(id), + FOREIGN KEY (userId) REFERENCES users(id) + ); + + CREATE TABLE IF NOT EXISTS sale_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + saleId INTEGER NOT NULL, + productId INTEGER NOT NULL, + quantity INTEGER NOT NULL, + price REAL NOT NULL, + FOREIGN KEY (saleId) REFERENCES sales(id), + FOREIGN KEY (productId) REFERENCES products(id) + ); + `); + + if (!exists) { + const passwordHash = bcrypt.hashSync("password", 10); + db.prepare("INSERT INTO users (username, password, role) VALUES (?, ?, ?)").run("admin", passwordHash, "admin"); + db.prepare("INSERT OR REPLACE INTO settings (id, language, currency, taxRate, storeName) VALUES (1, 'ar', 'SAR', 0.15, 'صالون الأناقة')").run(); + + const productStmt = db.prepare(`INSERT INTO products (name, barcode, price, cost, quantity, lowStockThreshold, category) VALUES (?, ?, ?, ?, ?, ?, ?)`); + const sampleProducts = [ + ["قص شعر كلاسيكي", "1110001", 35, 10, 20, 5, "خدمات"], + ["حلاقة ذقن", "1110002", 25, 8, 15, 5, "خدمات"], + ["مجموعة عناية باللحية", "1110003", 60, 30, 10, 3, "منتجات"], + ["شمع حلاقة", "1110004", 20, 5, 25, 6, "منتجات"], + ["كريم ترطيب", "1110005", 40, 15, 12, 4, "منتجات"] + ]; + sampleProducts.forEach((p) => productStmt.run(...p)); + + const customerId = db.prepare("INSERT INTO customers (name, phone, notes) VALUES (?, ?, ?)").run("عميل تجريبي", "0500000000", "أهلاً بك").lastInsertRowid; + + const saleId = db + .prepare( + "INSERT INTO sales (customerId, total, discount, tax, paymentMethod, paidAmount, changeAmount, userId) VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + ) + .run(customerId, 100, 5, 15, "cash", 120, 20, 1).lastInsertRowid; + + db.prepare("INSERT INTO sale_items (saleId, productId, quantity, price) VALUES (?, ?, ?, ?)").run(saleId, 1, 1, 35); + db.prepare("INSERT INTO sale_items (saleId, productId, quantity, price) VALUES (?, ?, ?, ?)").run(saleId, 3, 1, 60); + + db.prepare("INSERT INTO expenses (title, amount, notes) VALUES (?, ?, ?)").run("مناديل", 15, "شراء مناديل ورقية"); + } +} + +function createWindow() { + const mainWindow = new BrowserWindow({ + width: 1400, + height: 900, + minWidth: 1100, + minHeight: 720, + backgroundColor: "#dfe7ef", + title: "POS Barber Suite", + webPreferences: { + preload: path.join(__dirname, "preload.js"), + contextIsolation: true, + nodeIntegration: false + } + }); + + if (isDev) { + mainWindow.loadURL("http://localhost:5173"); + mainWindow.webContents.openDevTools({ mode: "detach" }); + } else { + mainWindow.loadFile(path.join(__dirname, "dist", "index.html")); + } +} + +app.whenReady().then(() => { + initializeDatabase(); + createWindow(); + + app.on("activate", () => { + if (BrowserWindow.getAllWindows().length === 0) { + createWindow(); + } + }); +}); + +app.on("window-all-closed", () => { + if (process.platform !== "darwin") { + app.quit(); + } +}); + +// Helper utilities +function runQuery(sql, params = []) { + return db.prepare(sql).all(...params); +} + +function runGet(sql, params = []) { + return db.prepare(sql).get(...params); +} + +function runExecute(sql, params = []) { + return db.prepare(sql).run(...params); +} + +// مصادقة المستخدمين +ipcMain.handle("auth/login", (_event, credentials) => { + const user = runGet("SELECT * FROM users WHERE username = ?", [credentials.username]); + if (!user) { + return { success: false, message: "المستخدم غير موجود" }; + } + + const isValid = bcrypt.compareSync(credentials.password, user.password); + if (!isValid) { + return { success: false, message: "كلمة المرور غير صحيحة" }; + } + + const { password, ...safeUser } = user; + return { success: true, user: safeUser }; +}); + +// لوحة المعلومات +ipcMain.handle("dashboard/metrics", () => { + const today = runGet( + "SELECT IFNULL(SUM(total), 0) as total, COUNT(*) as orders FROM sales WHERE date(createdAt) = date('now')" + ); + const month = runGet( + "SELECT IFNULL(SUM(total), 0) as total, COUNT(*) as orders FROM sales WHERE strftime('%Y-%m', createdAt) = strftime('%Y-%m','now')" + ); + const expenses = runGet( + "SELECT IFNULL(SUM(amount), 0) as total FROM expenses WHERE strftime('%Y-%m', createdAt) = strftime('%Y-%m','now')" + ); + const topProducts = runQuery( + `SELECT p.name, SUM(si.quantity) as qty + FROM sale_items si + JOIN products p ON p.id = si.productId + GROUP BY p.name + ORDER BY qty DESC + LIMIT 5` + ); + const salesTrend = runQuery( + `SELECT strftime('%Y-%m-%d', createdAt) as day, SUM(total) as total + FROM sales + WHERE createdAt >= date('now', '-14 day') + GROUP BY day + ORDER BY day` + ); + + return { today, month, expenses, topProducts, salesTrend }; +}); + +// البحث عن المنتجات +ipcMain.handle("pos/searchProducts", (_event, query) => { + if (!query) { + return runQuery("SELECT * FROM products ORDER BY name LIMIT 25"); + } + return runQuery("SELECT * FROM products WHERE name LIKE ? OR barcode LIKE ? ORDER BY name LIMIT 25", [`%${query}%`, `%${query}%`]); +}); + +// إنشاء فاتورة جديدة +ipcMain.handle("pos/createSale", (_event, payload) => { + const { items, discount, taxRate, paymentMethod, paidAmount, customerId, userId } = payload; + const subtotal = items.reduce((acc, item) => acc + item.price * item.quantity, 0); + const discountValue = discount || 0; + const taxValue = (subtotal - discountValue) * taxRate; + const total = subtotal - discountValue + taxValue; + const changeAmount = paidAmount - total; + + const insertSale = db.prepare( + `INSERT INTO sales (customerId, total, discount, tax, paymentMethod, paidAmount, changeAmount, userId) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ); + const saleId = insertSale.run(customerId || null, total, discountValue, taxValue, paymentMethod, paidAmount, changeAmount, userId).lastInsertRowid; + + const insertItem = db.prepare( + "INSERT INTO sale_items (saleId, productId, quantity, price) VALUES (?, ?, ?, ?)" + ); + const updateStock = db.prepare("UPDATE products SET quantity = quantity - ? WHERE id = ?"); + + const transaction = db.transaction((saleItems) => { + saleItems.forEach((item) => { + insertItem.run(saleId, item.id, item.quantity, item.price); + updateStock.run(item.quantity, item.id); + }); + }); + + transaction(items); + + return { saleId, total, taxValue, discountValue, changeAmount }; +}); + +// إدارة المنتجات +ipcMain.handle("products/list", () => { + return runQuery("SELECT * FROM products ORDER BY name COLLATE NOCASE", []); +}); + +ipcMain.handle("products/save", (_event, product) => { + if (product.id) { + runExecute( + `UPDATE products SET name = ?, barcode = ?, price = ?, cost = ?, quantity = ?, lowStockThreshold = ?, category = ?, image = ? WHERE id = ?`, + [product.name, product.barcode, product.price, product.cost, product.quantity, product.lowStockThreshold, product.category, product.image, product.id] + ); + return { id: product.id }; + } + const result = runExecute( + `INSERT INTO products (name, barcode, price, cost, quantity, lowStockThreshold, category, image) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [product.name, product.barcode, product.price, product.cost, product.quantity, product.lowStockThreshold, product.category, product.image] + ); + return { id: result.lastInsertRowid }; +}); + +ipcMain.handle("products/delete", (_event, id) => { + runExecute("DELETE FROM products WHERE id = ?", [id]); + return { success: true }; +}); + +ipcMain.handle("products/importCsv", async () => { + const { canceled, filePaths } = await dialog.showOpenDialog({ + properties: ["openFile"], + filters: [{ name: "CSV", extensions: ["csv"] }] + }); + if (canceled || !filePaths.length) return { success: false }; + const content = fs.readFileSync(filePaths[0], "utf-8"); + const parsed = parse(content, { header: true, skipEmptyLines: true }); + const insert = db.prepare( + `INSERT INTO products (name, barcode, price, cost, quantity, lowStockThreshold, category) VALUES (?, ?, ?, ?, ?, ?, ?)` + ); + const transaction = db.transaction((rows) => { + rows.forEach((row) => { + insert.run(row.name, row.barcode, Number(row.price || 0), Number(row.cost || 0), Number(row.quantity || 0), Number(row.lowStockThreshold || 5), row.category || ""); + }); + }); + transaction(parsed.data); + return { success: true, imported: parsed.data.length }; +}); + +ipcMain.handle("products/exportCsv", async () => { + const products = runQuery("SELECT * FROM products"); + const csvContent = [Object.keys(products[0] || {}).join(",")] + .concat(products.map((p) => Object.values(p).join(","))) + .join("\n"); + const { filePath } = await dialog.showSaveDialog({ + defaultPath: "products.csv", + filters: [{ name: "CSV", extensions: ["csv"] }] + }); + if (!filePath) return { success: false }; + fs.writeFileSync(filePath, csvContent, "utf-8"); + return { success: true, filePath }; +}); + +// العملاء والموردون +ipcMain.handle("contacts/list", (_event, type) => { + const table = type === "supplier" ? "suppliers" : "customers"; + return runQuery(`SELECT * FROM ${table} ORDER BY name`); +}); + +ipcMain.handle("contacts/save", (_event, payload) => { + const table = payload.type === "supplier" ? "suppliers" : "customers"; + if (payload.data.id) { + runExecute(`UPDATE ${table} SET name = ?, phone = ?, email = ?, notes = ? WHERE id = ?`, [payload.data.name, payload.data.phone, payload.data.email, payload.data.notes, payload.data.id]); + return { id: payload.data.id }; + } + const result = runExecute(`INSERT INTO ${table} (name, phone, email, notes) VALUES (?, ?, ?, ?)`, [payload.data.name, payload.data.phone, payload.data.email, payload.data.notes]); + return { id: result.lastInsertRowid }; +}); + +ipcMain.handle("contacts/delete", (_event, payload) => { + const table = payload.type === "supplier" ? "suppliers" : "customers"; + runExecute(`DELETE FROM ${table} WHERE id = ?`, [payload.id]); + return { success: true }; +}); + +ipcMain.handle("contacts/export", async (_event, type) => { + const table = type === "supplier" ? "suppliers" : "customers"; + const rows = runQuery(`SELECT * FROM ${table}`); + const worksheet = XLSX.utils.json_to_sheet(rows); + const workbook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(workbook, worksheet, table); + const { filePath } = await dialog.showSaveDialog({ + defaultPath: `${table}.xlsx`, + filters: [{ name: "Excel", extensions: ["xlsx"] }] + }); + if (!filePath) return { success: false }; + XLSX.writeFile(workbook, filePath); + return { success: true, filePath }; +}); + +// المصروفات +ipcMain.handle("expenses/list", () => { + return runQuery("SELECT * FROM expenses ORDER BY datetime(createdAt) DESC"); +}); + +ipcMain.handle("expenses/save", (_event, expense) => { + if (expense.id) { + runExecute("UPDATE expenses SET title = ?, amount = ?, notes = ? WHERE id = ?", [expense.title, expense.amount, expense.notes, expense.id]); + return { id: expense.id }; + } + const result = runExecute("INSERT INTO expenses (title, amount, notes) VALUES (?, ?, ?)", [expense.title, expense.amount, expense.notes]); + return { id: result.lastInsertRowid }; +}); + +ipcMain.handle("expenses/delete", (_event, id) => { + runExecute("DELETE FROM expenses WHERE id = ?", [id]); + return { success: true }; +}); + +// التقارير +ipcMain.handle("reports/sales", (_event, payload) => { + const { from, to, userId } = payload; + let query = "SELECT s.*, u.username FROM sales s LEFT JOIN users u ON u.id = s.userId WHERE date(createdAt) BETWEEN date(?) AND date(?)"; + const params = [from, to]; + if (userId) { + query += " AND s.userId = ?"; + params.push(userId); + } + return runQuery(query + " ORDER BY datetime(createdAt) DESC", params); +}); + +ipcMain.handle("reports/export", async (_event, payload) => { + const rows = payload.rows || []; + if (!rows.length) return { success: false, message: "لا توجد بيانات" }; + if (payload.type === "excel") { + const worksheet = XLSX.utils.json_to_sheet(rows); + const workbook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(workbook, worksheet, "Sales"); + const { filePath } = await dialog.showSaveDialog({ defaultPath: "sales-report.xlsx", filters: [{ name: "Excel", extensions: ["xlsx"] }] }); + if (!filePath) return { success: false }; + XLSX.writeFile(workbook, filePath); + return { success: true, filePath }; + } + const { filePath } = await dialog.showSaveDialog({ defaultPath: "sales-report.json", filters: [{ name: "JSON", extensions: ["json"] }] }); + if (!filePath) return { success: false }; + fs.writeFileSync(filePath, JSON.stringify(rows, null, 2), "utf-8"); + return { success: true, filePath }; +}); + +// الإعدادات +ipcMain.handle("settings/get", () => { + return runGet("SELECT * FROM settings WHERE id = 1"); +}); + +ipcMain.handle("settings/save", (_event, settings) => { + runExecute( + `INSERT INTO settings (id, language, currency, taxRate, storeName, logoPath, receiptType) + VALUES (1, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET language = excluded.language, currency = excluded.currency, taxRate = excluded.taxRate, storeName = excluded.storeName, logoPath = excluded.logoPath, receiptType = excluded.receiptType`, + [settings.language, settings.currency, settings.taxRate, settings.storeName, settings.logoPath, settings.receiptType] + ); + return { success: true }; +}); + +ipcMain.handle("settings/uploadLogo", async () => { + const { canceled, filePaths } = await dialog.showOpenDialog({ properties: ["openFile"], filters: [{ name: "Images", extensions: ["png", "jpg", "jpeg"] }] }); + if (canceled || !filePaths.length) return { success: false }; + const logoPath = filePaths[0]; + runExecute("UPDATE settings SET logoPath = ? WHERE id = 1", [logoPath]); + return { success: true, logoPath }; +}); + +// المستخدمون والصلاحيات +ipcMain.handle("users/list", () => { + const users = runQuery("SELECT id, username, role FROM users ORDER BY username"); + return users; +}); + +ipcMain.handle("users/save", (_event, user) => { + if (user.id) { + if (user.password) { + const hash = bcrypt.hashSync(user.password, 10); + runExecute("UPDATE users SET username = ?, role = ?, password = ? WHERE id = ?", [user.username, user.role, hash, user.id]); + } else { + runExecute("UPDATE users SET username = ?, role = ? WHERE id = ?", [user.username, user.role, user.id]); + } + return { id: user.id }; + } + const hash = bcrypt.hashSync(user.password || "password", 10); + const result = runExecute("INSERT INTO users (username, password, role) VALUES (?, ?, ?)", [user.username, hash, user.role]); + return { id: result.lastInsertRowid }; +}); + +ipcMain.handle("users/delete", (_event, id) => { + runExecute("DELETE FROM users WHERE id = ?", [id]); + return { success: true }; +}); diff --git a/pos-app/index.html b/pos-app/index.html new file mode 100644 index 0000000000..09ceb5747a --- /dev/null +++ b/pos-app/index.html @@ -0,0 +1,15 @@ + + + + + + POS Barber Suite + + + + + +
+ + + diff --git a/pos-app/package.json b/pos-app/package.json new file mode 100644 index 0000000000..59a8cef0b4 --- /dev/null +++ b/pos-app/package.json @@ -0,0 +1,56 @@ +{ + "name": "pos-app", + "version": "1.0.0", + "description": "Offline barber shop POS and management system built with React, TypeScript, Electron, and SQLite.", + "author": "", + "license": "MIT", + "main": "electron.js", + "scripts": { + "postinstall": "electron-builder install-app-deps", + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "electron": "concurrently \"npm:dev\" \"npm:electron:start\"", + "electron:start": "wait-on tcp:5173 && electron .", + "electron:prod": "npm run build && electron ." + }, + "dependencies": { + "@headlessui/react": "^1.7.17", + "@heroicons/react": "^2.0.18", + "bcryptjs": "^2.4.3", + "better-sqlite3": "^9.4.5", + "chart.js": "^4.4.1", + "classnames": "^2.5.1", + "framer-motion": "^10.16.5", + "i18next": "^23.7.7", + "jsPDF": "^2.5.1", + "papaparse": "^5.4.1", + "print-js": "^1.6.0", + "react": "^18.2.0", + "react-chartjs-2": "^5.2.0", + "react-dom": "^18.2.0", + "react-hook-form": "^7.49.2", + "react-i18next": "^13.3.1", + "react-router-dom": "^6.20.1", + "recharts": "^2.9.0", + "xlsx": "^0.18.5" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.2", + "@types/node": "^20.10.5", + "@types/papaparse": "^5.3.10", + "@types/print-js": "^1.0.3", + "@types/react": "^18.2.37", + "@types/react-dom": "^18.2.15", + "@vitejs/plugin-react": "^4.2.0", + "autoprefixer": "^10.4.16", + "concurrently": "^8.2.2", + "electron": "^27.1.0", + "electron-builder": "^24.6.4", + "postcss": "^8.4.31", + "tailwindcss": "^3.3.5", + "typescript": "^5.3.3", + "vite": "^5.0.2", + "wait-on": "^7.2.0" + } +} diff --git a/pos-app/postcss.config.cjs b/pos-app/postcss.config.cjs new file mode 100644 index 0000000000..5cbc2c7d87 --- /dev/null +++ b/pos-app/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {} + } +}; diff --git a/pos-app/preload.js b/pos-app/preload.js new file mode 100644 index 0000000000..2beb74a5bf --- /dev/null +++ b/pos-app/preload.js @@ -0,0 +1,56 @@ +// preload.js - جسر الأمان بين الواجهة وعمليات النظام في Electron +const { contextBridge, ipcRenderer } = require("electron"); + +const channels = { + login: "auth/login", + dashboard: "dashboard/metrics", + searchProducts: "pos/searchProducts", + createSale: "pos/createSale", + listProducts: "products/list", + saveProduct: "products/save", + deleteProduct: "products/delete", + importProducts: "products/importCsv", + exportProducts: "products/exportCsv", + listContacts: "contacts/list", + saveContact: "contacts/save", + deleteContact: "contacts/delete", + exportContacts: "contacts/export", + listExpenses: "expenses/list", + saveExpense: "expenses/save", + deleteExpense: "expenses/delete", + salesReport: "reports/sales", + exportReport: "reports/export", + getSettings: "settings/get", + saveSettings: "settings/save", + uploadLogo: "settings/uploadLogo", + listUsers: "users/list", + saveUser: "users/save", + deleteUser: "users/delete" +}; + +contextBridge.exposeInMainWorld("posAPI", { + login: (payload) => ipcRenderer.invoke(channels.login, payload), + getDashboardMetrics: () => ipcRenderer.invoke(channels.dashboard), + searchProducts: (query) => ipcRenderer.invoke(channels.searchProducts, query), + createSale: (payload) => ipcRenderer.invoke(channels.createSale, payload), + listProducts: () => ipcRenderer.invoke(channels.listProducts), + saveProduct: (payload) => ipcRenderer.invoke(channels.saveProduct, payload), + deleteProduct: (id) => ipcRenderer.invoke(channels.deleteProduct, id), + importProducts: () => ipcRenderer.invoke(channels.importProducts), + exportProducts: () => ipcRenderer.invoke(channels.exportProducts), + listContacts: (type) => ipcRenderer.invoke(channels.listContacts, type), + saveContact: (payload) => ipcRenderer.invoke(channels.saveContact, payload), + deleteContact: (payload) => ipcRenderer.invoke(channels.deleteContact, payload), + exportContacts: (type) => ipcRenderer.invoke(channels.exportContacts, type), + listExpenses: () => ipcRenderer.invoke(channels.listExpenses), + saveExpense: (payload) => ipcRenderer.invoke(channels.saveExpense, payload), + deleteExpense: (id) => ipcRenderer.invoke(channels.deleteExpense, id), + salesReport: (payload) => ipcRenderer.invoke(channels.salesReport, payload), + exportReport: (payload) => ipcRenderer.invoke(channels.exportReport, payload), + getSettings: () => ipcRenderer.invoke(channels.getSettings), + saveSettings: (payload) => ipcRenderer.invoke(channels.saveSettings, payload), + uploadLogo: () => ipcRenderer.invoke(channels.uploadLogo), + listUsers: () => ipcRenderer.invoke(channels.listUsers), + saveUser: (payload) => ipcRenderer.invoke(channels.saveUser, payload), + deleteUser: (id) => ipcRenderer.invoke(channels.deleteUser, id) +}); diff --git a/pos-app/src/App.tsx b/pos-app/src/App.tsx new file mode 100644 index 0000000000..c78d887afd --- /dev/null +++ b/pos-app/src/App.tsx @@ -0,0 +1,154 @@ +import { useEffect } from "react"; +import { Route, Routes, Navigate, useLocation } from "react-router-dom"; +import { AnimatePresence, motion } from "framer-motion"; +import { useAuth } from "./contexts/AuthContext"; +import { useTranslation } from "react-i18next"; +import LoginPage from "./pages/LoginPage"; +import DashboardPage from "./pages/DashboardPage"; +import POSPage from "./pages/POSPage"; +import ProductsPage from "./pages/ProductsPage"; +import CustomersPage from "./pages/CustomersPage"; +import SuppliersPage from "./pages/SuppliersPage"; +import ExpensesPage from "./pages/ExpensesPage"; +import ReportsPage from "./pages/ReportsPage"; +import SettingsPage from "./pages/SettingsPage"; +import UsersPage from "./pages/UsersPage"; +import Layout from "./components/Layout"; + +const pageTransition = { + initial: { opacity: 0, y: 30 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, y: -30 } +}; + +function PrivateRoute({ children }: { children: JSX.Element }) { + const { user } = useAuth(); + if (!user) { + return ; + } + return children; +} + +function AdminRoute({ children }: { children: JSX.Element }) { + const { user } = useAuth(); + if (user?.role !== "admin") { + return ; + } + return children; +} + +function App() { + const location = useLocation(); + const { i18n } = useTranslation(); + const { settings } = useAuth(); + + useEffect(() => { + if (settings?.language) { + i18n.changeLanguage(settings.language); + document.dir = settings.language === "ar" ? "rtl" : "ltr"; + } + }, [settings?.language, i18n]); + + return ( + + + + + + } + /> + + + + } + > + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + + + } + /> + + + + } + /> + + + + + + } + /> + + } /> + + + ); +} + +export default App; diff --git a/pos-app/src/components/DataTable.tsx b/pos-app/src/components/DataTable.tsx new file mode 100644 index 0000000000..2bfb00504c --- /dev/null +++ b/pos-app/src/components/DataTable.tsx @@ -0,0 +1,44 @@ +import { ReactNode } from "react"; +import GlassCard from "./ui/GlassCard"; + +interface DataTableProps { + columns: { key: keyof T | string; header: string; render?: (row: T) => ReactNode }[]; + data: T[]; + emptyText?: string; +} + +export default function DataTable({ columns, data, emptyText }: DataTableProps) { + return ( + + + + + {columns.map((column) => ( + + ))} + + + + {data.length === 0 && ( + + + + )} + {data.map((row) => ( + + {columns.map((column) => ( + + ))} + + ))} + +
+ {column.header} +
+ {emptyText || "لا توجد بيانات"} +
+ {column.render ? column.render(row) : ((row as any)[column.key] ?? "-")} +
+
+ ); +} diff --git a/pos-app/src/components/Layout.tsx b/pos-app/src/components/Layout.tsx new file mode 100644 index 0000000000..c4387a8877 --- /dev/null +++ b/pos-app/src/components/Layout.tsx @@ -0,0 +1,89 @@ +import { Outlet, NavLink } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { useAuth } from "../contexts/AuthContext"; +import { useSettings } from "../contexts/SettingsContext"; +import GlassCard from "./ui/GlassCard"; +import GlassButton from "./ui/GlassButton"; +import { Bars3Icon, Cog6ToothIcon, CurrencyDollarIcon, HomeIcon, ShoppingBagIcon, UserGroupIcon, UsersIcon, ClipboardDocumentListIcon, QueueListIcon } from "@heroicons/react/24/outline"; + +const navLinks = [ + { to: "/", labelKey: "dashboard", icon: HomeIcon }, + { to: "/pos", labelKey: "pos", icon: CurrencyDollarIcon }, + { to: "/products", labelKey: "products", icon: ShoppingBagIcon }, + { to: "/customers", labelKey: "customers", icon: UserGroupIcon }, + { to: "/suppliers", labelKey: "suppliers", icon: UsersIcon }, + { to: "/expenses", labelKey: "expenses", icon: QueueListIcon }, + { to: "/reports", labelKey: "reports", icon: ClipboardDocumentListIcon, role: "admin" as const }, + { to: "/settings", labelKey: "settings", icon: Cog6ToothIcon } +]; + +export default function Layout() { + const { t } = useTranslation(); + const { logout, user } = useAuth(); + const { settings } = useSettings(); + + return ( +
+ +
+
+ +
+

{settings?.storeName}

+

Barber POS

+
+ {t("logout")} +
+
+ + + +
+
+ ); +} diff --git a/pos-app/src/components/POSCart.tsx b/pos-app/src/components/POSCart.tsx new file mode 100644 index 0000000000..bb64552b2f --- /dev/null +++ b/pos-app/src/components/POSCart.tsx @@ -0,0 +1,56 @@ +import GlassCard from "./ui/GlassCard"; +import GlassButton from "./ui/GlassButton"; +import { SaleItemPayload } from "../db/types"; +import { MinusIcon, PlusIcon, TrashIcon } from "@heroicons/react/24/outline"; + +interface POSCartProps { + items: SaleItemPayload[]; + onQuantityChange: (id: number, quantity: number) => void; + onRemove: (id: number) => void; + onClear: () => void; +} + +export default function POSCart({ items, onQuantityChange, onRemove, onClear }: POSCartProps) { + const subtotal = items.reduce((acc, item) => acc + item.price * item.quantity, 0); + + return ( + +
+

السلة

+ + تفريغ + +
+
+ {items.length === 0 &&

ابدأ بإضافة المنتجات إلى السلة.

} + {items.map((item) => ( +
+
+

{item.name}

+

{item.price.toFixed(2)} × {item.quantity}

+
+
+ onQuantityChange(item.id, Math.max(1, item.quantity - 1))} + > + + + {item.quantity} + onQuantityChange(item.id, item.quantity + 1)}> + + + onRemove(item.id)}> + + +
+
+ ))} +
+
+ الإجمالي الفرعي + {subtotal.toFixed(2)} +
+
+ ); +} diff --git a/pos-app/src/components/PageHeader.tsx b/pos-app/src/components/PageHeader.tsx new file mode 100644 index 0000000000..0f157e291b --- /dev/null +++ b/pos-app/src/components/PageHeader.tsx @@ -0,0 +1,19 @@ +import { ReactNode } from "react"; + +interface PageHeaderProps { + title: string; + description?: string; + actions?: ReactNode; +} + +export default function PageHeader({ title, description, actions }: PageHeaderProps) { + return ( +
+
+

{title}

+ {description &&

{description}

} +
+ {actions &&
{actions}
} +
+ ); +} diff --git a/pos-app/src/components/ui/GlassButton.tsx b/pos-app/src/components/ui/GlassButton.tsx new file mode 100644 index 0000000000..0ca31a3ba1 --- /dev/null +++ b/pos-app/src/components/ui/GlassButton.tsx @@ -0,0 +1,14 @@ +import clsx from "classnames"; +import { ButtonHTMLAttributes, DetailedHTMLProps } from "react"; + +export default function GlassButton({ className, ...props }: DetailedHTMLProps, HTMLButtonElement>) { + return ( +