diff --git a/components/dashboard/dashboardController.js b/components/dashboard/dashboardController.js new file mode 100644 index 0000000..8ad0988 --- /dev/null +++ b/components/dashboard/dashboardController.js @@ -0,0 +1,11 @@ +const catchAsync = require('../../globalErrorHandler/catchAsync') +const dashboardService = require('./dashboardService') + +exports.getDashboardStats = catchAsync(async (req, res, next) => { + const stats = await dashboardService.getDashboardStats() + + res.status(200).json({ + status: 'success', + data: stats, + }) +}) diff --git a/components/dashboard/dashboardRoutes.js b/components/dashboard/dashboardRoutes.js new file mode 100644 index 0000000..4b36fa1 --- /dev/null +++ b/components/dashboard/dashboardRoutes.js @@ -0,0 +1,12 @@ +const express = require('express') +const dashboardController = require('./dashboardController') +const loginCheck = require('../../middlewares/loginCheck') + +const router = new express.Router() + +router.use(loginCheck.loginProtect) + +router.get('/stats', dashboardController.getDashboardStats) +router.get('/summary', dashboardController.getDashboardStats) + +module.exports = router diff --git a/components/dashboard/dashboardService.js b/components/dashboard/dashboardService.js new file mode 100644 index 0000000..7bc3968 --- /dev/null +++ b/components/dashboard/dashboardService.js @@ -0,0 +1,232 @@ +const Property = require('../property/propertyModel') +const Category = require('../category/categoryModel') +const PropertyStatus = require('../propertyStatus/propertyStatusModel') +const Warehouse = require('../warehouse/warehouseModel') +const WarehouseItem = require('../warehouseItem/warehouseItemModel') +const WarehouseItemTransaction = require('../warehouseItem/warehouseItemTransactionModel') +const User = require('../user/userModel') +const Role = require('../role/roleModel') +const { getSignedUrl } = require('../file/fileService') + +/** + * Aggregates live dashboard statistics across properties, warehouses, inventory, and users + */ +const getDashboardStats = async () => { + // 1. Overview counts + const [ + totalProperties, + totalAreaResult, + totalWarehouseItems, + totalWarehouses, + totalWarehouseTransactions, + totalUsers, + totalCategories, + totalRoles, + ] = await Promise.all([ + Property.countDocuments({ isActive: { $ne: false } }), + Property.aggregate([ + { $match: { isActive: { $ne: false }, area: { $gt: 0 } } }, + { $group: { _id: null, totalArea: { $sum: '$area' } } }, + ]), + WarehouseItem.countDocuments({ isActive: { $ne: false } }), + Warehouse.countDocuments(), + WarehouseItemTransaction.countDocuments(), + User.countDocuments({ isActive: { $ne: false } }), + Category.countDocuments(), + Role.countDocuments(), + ]) + + const totalArea = totalAreaResult?.[0]?.totalArea || 0 + + // 2. Properties grouped by Category + const propertiesByCategoryRaw = await Property.aggregate([ + { $match: { isActive: { $ne: false } } }, + { + $group: { + _id: '$category', + count: { $sum: 1 }, + totalArea: { $sum: { $ifNull: ['$area', 0] } }, + }, + }, + { + $lookup: { + from: 'categories', + localField: '_id', + foreignField: '_id', + as: 'categoryDoc', + }, + }, + { $unwind: { path: '$categoryDoc', preserveNullAndEmptyArrays: true } }, + { $sort: { count: -1 } }, + ]) + + const propertiesByCategory = propertiesByCategoryRaw.map((item) => ({ + categoryId: item._id, + name: item.categoryDoc?.nativeName || item.categoryDoc?.englishName || 'بدون دسته‌بندی', + englishName: item.categoryDoc?.englishName || 'Uncategorized', + count: item.count, + totalArea: item.totalArea || 0, + percentage: totalProperties > 0 ? Math.round((item.count / totalProperties) * 100) : 0, + })) + + // 3. Properties grouped by Status + const propertiesByStatusRaw = await Property.aggregate([ + { $match: { isActive: { $ne: false } } }, + { + $group: { + _id: '$status', + count: { $sum: 1 }, + }, + }, + { + $lookup: { + from: 'propertystatuses', + localField: '_id', + foreignField: '_id', + as: 'statusDoc', + }, + }, + { $unwind: { path: '$statusDoc', preserveNullAndEmptyArrays: true } }, + { $sort: { count: -1 } }, + ]) + + const propertiesByStatus = propertiesByStatusRaw.map((item) => ({ + statusId: item._id, + name: item.statusDoc?.nativeName || item.statusDoc?.englishName || 'نامشخص', + englishName: item.statusDoc?.englishName || 'Unknown', + color: item.statusDoc?.color || '#3b82f6', + count: item.count, + percentage: totalProperties > 0 ? Math.round((item.count / totalProperties) * 100) : 0, + })) + + // 4. Warehouse transactions summary (IN vs OUT) + const warehouseFlowRaw = await WarehouseItemTransaction.aggregate([ + { + $group: { + _id: '$type', + count: { $sum: 1 }, + totalQuantity: { $sum: '$quantity' }, + }, + }, + ]) + + const warehouseFlow = { + inCount: warehouseFlowRaw.find((f) => f._id === 'IN')?.count || 0, + outCount: warehouseFlowRaw.find((f) => f._id === 'OUT')?.count || 0, + inQuantity: warehouseFlowRaw.find((f) => f._id === 'IN')?.totalQuantity || 0, + outQuantity: warehouseFlowRaw.find((f) => f._id === 'OUT')?.totalQuantity || 0, + } + + // 5. Recent registered properties (Last 6) + const recentPropertiesRaw = await Property.find({ isActive: { $ne: false } }) + .sort({ createdAt: -1 }) + .limit(6) + .populate('category', 'nativeName englishName') + .populate('status', 'nativeName englishName color') + .lean() + + const recentProperties = await Promise.all( + recentPropertiesRaw.map(async (prop) => { + let thumb = null + if (prop.mainImageUrls && prop.mainImageUrls.length > 0) { + thumb = await getSignedUrl(prop.mainImageUrls[0], 'properties').catch(() => null) + } + return { + _id: prop._id, + propertyCode: prop.propertyCode || prop._id.toString().slice(-6), + name: prop.name || 'ملک بدون عنوان', + category: prop.category?.nativeName || 'عمومی', + status: prop.status?.nativeName || 'عادی', + statusColor: prop.status?.color || '#3b82f6', + ownerName: prop.ownerName || '—', + area: prop.area || 0, + dong: prop.dong || 6, + imageUrl: thumb || 'https://cloud.mbmanager.ir/images/item-no-image.png', + registrationPlate: prop.registrationPlate || '—', + createdAt: prop.createdAt, + } + }) + ) + + // 6. Recent warehouse transactions (Last 6) + const recentWarehouseTransactionsRaw = await WarehouseItemTransaction.find() + .sort({ createdAt: -1 }) + .limit(6) + .populate('item', 'nameFa nameEn code unitFa') + .populate('warehouse', 'name location') + .lean() + + const recentWarehouseTransactions = recentWarehouseTransactionsRaw.map((tx) => ({ + _id: tx._id, + type: tx.type, // 'IN' | 'OUT' + quantity: tx.quantity, + unit: tx.unit || tx.item?.unitFa || 'عدد', + itemName: tx.item?.nameFa || 'کالای نامشخص', + itemCode: tx.item?.code || '—', + warehouseName: tx.warehouse?.name || 'انبار مرکزی', + reason: tx.reason || (tx.type === 'IN' ? 'ورود به انبار' : 'خروج از انبار'), + actorName: tx.actorName || 'کاربر سیستم', + date: tx.date || tx.createdAt, + })) + + // 7. Monthly Property & Warehouse Trend (Last 6 months) + const sixMonthsAgo = new Date() + sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 5) + sixMonthsAgo.setDate(1) + sixMonthsAgo.setHours(0, 0, 0, 0) + + const monthlyPropertiesRaw = await Property.aggregate([ + { $match: { createdAt: { $gte: sixMonthsAgo }, isActive: { $ne: false } } }, + { + $group: { + _id: { + year: { $year: '$createdAt' }, + month: { $month: '$createdAt' }, + }, + count: { $sum: 1 }, + }, + }, + { $sort: { '_id.year': 1, '_id.month': 1 } }, + ]) + + const monthlyWarehouseRaw = await WarehouseItemTransaction.aggregate([ + { $match: { createdAt: { $gte: sixMonthsAgo } } }, + { + $group: { + _id: { + year: { $year: '$createdAt' }, + month: { $month: '$createdAt' }, + type: '$type', + }, + count: { $sum: 1 }, + }, + }, + { $sort: { '_id.year': 1, '_id.month': 1 } }, + ]) + + return { + overview: { + totalProperties, + totalArea, + totalWarehouseItems, + totalWarehouses, + totalWarehouseTransactions, + totalUsers, + totalCategories, + totalRoles, + }, + propertiesByCategory, + propertiesByStatus, + warehouseFlow, + recentProperties, + recentWarehouseTransactions, + monthlyTrend: { + properties: monthlyPropertiesRaw, + warehouse: monthlyWarehouseRaw, + }, + } +} + +module.exports = { + getDashboardStats, +} diff --git a/components/property/propertySeeder.js b/components/property/propertySeeder.js index df2e7ef..a68073e 100644 --- a/components/property/propertySeeder.js +++ b/components/property/propertySeeder.js @@ -20,6 +20,7 @@ const runSeeder = async () => { status: availableStatus?._id, nationalGeoCode: '537123395123439', description: 'مجتمع اداری ۱۰ طبقه با دسترسی عالی به بزرگراه صدر و مترو', + area: 1500, dong: 6, ownerName: 'شرکت سرمایه‌گذاری پارس', registrationPlate: '۱۲۳۴/۵۶', @@ -42,6 +43,7 @@ const runSeeder = async () => { status: availableStatus?._id, nationalGeoCode: '536987394852139', description: 'زمین دو کله با بر ۲۵ متر و دستور نقشه ۵ طبقه مسکونی', + area: 600, dong: 6, ownerName: 'محسن رضایی', registrationPlate: '۴۵۶۷/۸۹', @@ -64,6 +66,7 @@ const runSeeder = async () => { status: reservedStatus?._id || availableStatus?._id, nationalGeoCode: '539145395412039', description: 'واحد ۳۰۰ متری طبقه ۸ با ویوی ابدی کاخ نیاوران و ۳ پارکینگ سندی', + area: 300, dong: 6, ownerName: 'سارا صادقی', registrationPlate: '۷۸۹۰/۱۲', @@ -86,6 +89,7 @@ const runSeeder = async () => { status: availableStatus?._id, nationalGeoCode: '645123328456139', description: 'باغ ۲۰۰۰ متری با درختان کهنسال و عمارت دوبلکس ۴۰۰ متری', + area: 2000, dong: 6, ownerName: 'علیرضا زندی', registrationPlate: '۳۲۱۴/۵۶', @@ -108,6 +112,7 @@ const runSeeder = async () => { status: inReviewStatus?._id || availableStatus?._id, nationalGeoCode: '584123361456239', description: 'مغازه تجاری دو دهنه بر خیابان اصلی چهارباغ با سند شش دانگ ملکیت', + area: 180, dong: 6, ownerName: 'محمد مهدی اصفهانی', registrationPlate: '۹۶۳۲/۱۴', diff --git a/components/user/userController.js b/components/user/userController.js index 3641772..d7e078a 100644 --- a/components/user/userController.js +++ b/components/user/userController.js @@ -39,20 +39,26 @@ exports.loginUser = catchAsync(async (req, res, next) => { }) exports.logoutUser = catchAsync(async (req, res, next) => { - res.cookie('belonging_token', '', { - httpOnly: true, - maxAge: 0, - path: '/', - }) - res.cookie('bm-token', '', { - httpOnly: false, - maxAge: 0, - path: '/', - }) + const isProduction = process.env.NODE_ENV === 'production' + + // Clear default host cookies + res.clearCookie('belonging_token', { path: '/' }) + res.clearCookie('bm-token', { path: '/' }) + res.clearCookie('jwt', { path: '/' }) + + // Clear domain cookies if in production or mbmanager domain + if (isProduction || req.hostname.includes('mbmanager.ir')) { + res.clearCookie('belonging_token', { domain: '.mbmanager.ir', path: '/' }) + res.clearCookie('bm-token', { domain: '.mbmanager.ir', path: '/' }) + res.clearCookie('jwt', { domain: '.mbmanager.ir', path: '/' }) + } + + res.cookie('belonging_token', '', { maxAge: 0, expires: new Date(0), path: '/' }) + res.cookie('bm-token', '', { maxAge: 0, expires: new Date(0), path: '/' }) res.status(200).json({ status: 'success', - message: 'کاربر خارج شد', + message: 'کاربر با موفقیت خارج شد', }) }) diff --git a/components/warehouse/warehouseSeeder.js b/components/warehouse/warehouseSeeder.js new file mode 100644 index 0000000..e7bb169 --- /dev/null +++ b/components/warehouse/warehouseSeeder.js @@ -0,0 +1,176 @@ +const Warehouse = require('./warehouseModel') +const WarehouseItem = require('../warehouseItem/warehouseItemModel') +const WarehouseItemTransaction = require('../warehouseItem/warehouseItemTransactionModel') +const WarehouseItemStock = require('../warehouseItem/warehouseItemStockModel') +const User = require('../user/userModel') +const chalk = require('chalk') + +const runSeeder = async () => { + // 1. Find or create admin user for actor + const adminUser = await User.findOne({ email: 'superadmin@mbmanager.ir' }) || await User.findOne() + + // 2. Warehouses + const sampleWarehouses = [ + { + name: 'انبار مرکزی تهران', + code: 'WH-TEH-01', + location: 'تهران، جاده مخصوص کرج، کیلومتر ۱۱', + description: 'انبار اصلی مصالح ساختمانی و تجهیزات مکانیکی', + isActive: true, + }, + { + name: 'انبار پروژه نیاوران', + code: 'WH-NYA-02', + location: 'تهران، نیاوران، خیابان باهنر', + description: 'انبار پای کار پروژه برج یاس', + isActive: true, + }, + { + name: 'انبار مصالح اصفهان', + code: 'WH-ISF-01', + location: 'اصفهان، شهرک صنعتی جی', + description: 'انبار پشتیبانی پروژه‌های مرکزی', + isActive: true, + }, + ] + + const createdWarehouses = [] + for (const wh of sampleWarehouses) { + let doc = await Warehouse.findOne({ name: wh.name }) + if (!doc) { + doc = await Warehouse.create(wh) + } + createdWarehouses.push(doc) + } + + // 3. Warehouse Items + const sampleItems = [ + { + nameFa: 'سیمان تیپ ۲ (کیسه ۵۰ کیلویی)', + nameEn: 'Cement Type 2 (50kg)', + code: 'CEM-T2-50', + unitFa: 'کیسه', + unitEn: 'Bag', + category: 'مصالح پایه', + description: 'سیمان پرتلند تیپ ۲ استاندارد', + }, + { + nameFa: 'میلگرد آجدار سایز ۱۶ (A3)', + nameEn: 'Rebar A3 Size 16', + code: 'REB-16-A3', + unitFa: 'شاخه', + unitEn: 'Piece', + category: 'آهن آلات', + description: 'میلگرد شاخه ۱۲ متری فولاد مبارکه', + }, + { + nameFa: 'سرامیک پرسلان ۶۰*۱۲۰', + nameEn: 'Porcelain Tile 60x120', + code: 'TIL-60120-P', + unitFa: 'مترمربع', + unitEn: 'Sqm', + category: 'نازک‌کاری', + description: 'سرامیک درجه یک کالیبره مات', + }, + { + nameFa: 'لوله پلی‌اتیلن سایز ۶۳', + nameEn: 'PE Pipe Size 63', + code: 'PIP-PE-63', + unitFa: 'متر', + unitEn: 'Meter', + category: 'تأسیسات مکانیکی', + description: 'لوله آبرسانی ۱۰ بار استاندارد', + }, + { + nameFa: 'رنگ اکریلیک پایه آب (حلب ۲۰ لیتری)', + nameEn: 'Acrylic Paint 20L', + code: 'PNT-ACR-20L', + unitFa: 'حلب', + unitEn: 'Can', + category: 'رنگ و نقاشی', + description: 'رنگ مات قابل شستشو درجه یک', + }, + ] + + const createdItems = [] + for (const it of sampleItems) { + let doc = await WarehouseItem.findOne({ code: it.code }) + if (!doc) { + doc = await WarehouseItem.create(it) + } + createdItems.push(doc) + } + + // 4. Sample Transactions + const txCount = await WarehouseItemTransaction.countDocuments() + if (txCount === 0 && adminUser && createdWarehouses.length > 0 && createdItems.length > 0) { + const sampleTransactions = [ + { + item: createdItems[0]._id, + warehouse: createdWarehouses[0]._id, + type: 'IN', + quantity: 500, + unit: createdItems[0].unitFa, + reason: 'خرید مستقیم از کارخانه سیمان تهران', + actor: adminUser._id, + actorName: adminUser.name || 'مدیر سیستم', + note: 'بارگیری از کارخانه با فاکتور شماره ۱۲۸۹', + }, + { + item: createdItems[1]._id, + warehouse: createdWarehouses[0]._id, + type: 'IN', + quantity: 120, + unit: createdItems[1].unitFa, + reason: 'تأمین آهن‌آلات اسکلت سازه', + actor: adminUser._id, + actorName: adminUser.name || 'مدیر سیستم', + }, + { + item: createdItems[0]._id, + warehouse: createdWarehouses[1]._id, + type: 'OUT', + quantity: 80, + unit: createdItems[0].unitFa, + reason: 'مصرف در بتن‌ریزی سقف طبقه پنجم', + actor: adminUser._id, + actorName: adminUser.name || 'مدیر سیستم', + }, + { + item: createdItems[2]._id, + warehouse: createdWarehouses[0]._id, + type: 'IN', + quantity: 350, + unit: createdItems[2].unitFa, + reason: 'خرید مصالح نازک‌کاری', + actor: adminUser._id, + actorName: adminUser.name || 'مدیر سیستم', + }, + { + item: createdItems[3]._id, + warehouse: createdWarehouses[1]._id, + type: 'IN', + quantity: 200, + unit: createdItems[3].unitFa, + reason: 'تجهیز تأسیسات لوله‌کشی آب', + actor: adminUser._id, + actorName: adminUser.name || 'مدیر سیستم', + }, + ] + + for (const tx of sampleTransactions) { + await WarehouseItemTransaction.create(tx) + + // Update or create stock + const delta = tx.type === 'IN' ? tx.quantity : -tx.quantity + await WarehouseItemStock.findOneAndUpdate( + { item: tx.item, warehouse: tx.warehouse }, + { $inc: { quantity: delta } }, + { upsert: true, new: true } + ) + } + console.log(chalk.green.bold(`WAREHOUSE: Created ${sampleTransactions.length} sample inventory transactions`)) + } +} + +module.exports = { runSeeder } diff --git a/seeder/seederRunner.js b/seeder/seederRunner.js index f7f6405..dd574c1 100644 --- a/seeder/seederRunner.js +++ b/seeder/seederRunner.js @@ -4,6 +4,7 @@ const roleSeeder = require('../components/role/roleSeeder') const featureSeeder = require('../components/feature/featureSeeder') const propertyStatusSeeder = require('../components/propertyStatus/propertyStatusSeeder') const propertySeeder = require('../components/property/propertySeeder') +const warehouseSeeder = require('../components/warehouse/warehouseSeeder') const propertyService = require('../components/property/propertyService') const transactionService = require('../components/transaction/transactionService') @@ -14,6 +15,7 @@ module.exports.run = async () => { await featureSeeder.runSeeder() await propertyStatusSeeder.runSeeder() await propertySeeder.runSeeder() + await warehouseSeeder.runSeeder() try { await propertyService.indexAllProperties() diff --git a/setup/routerEndpoints.js b/setup/routerEndpoints.js index 78dcd86..c6999b2 100644 --- a/setup/routerEndpoints.js +++ b/setup/routerEndpoints.js @@ -11,9 +11,11 @@ const featureRoutes = require('../components/feature/featureRoutes') const warehouseItemRoutes = require('../components/warehouseItem/warehouseItemRoutes') const warehouseRoutes = require('../components/warehouse/warehouseRoutes') const settingRoutes = require('../components/setting/settingRoutes') +const dashboardRoutes = require('../components/dashboard/dashboardRoutes') const routerEndpoints = new express.Router() +routerEndpoints.use('/dashboard', dashboardRoutes) routerEndpoints.use('/users', userRoutes) routerEndpoints.use('/permissions', permissionRoutes) routerEndpoints.use('/properties', propertyRoutes)