diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 177f305..f8a42b4 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -1624,3 +1624,12 @@ model Approval { @@map("approvals") } +model SystemSetting { + key String @id + value String + category String @default("general") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("system_settings") +} \ No newline at end of file diff --git a/backend/src/modules/suppliers/suppliers.routes.ts b/backend/src/modules/suppliers/suppliers.routes.ts index 012e41f..110bc5c 100644 --- a/backend/src/modules/suppliers/suppliers.routes.ts +++ b/backend/src/modules/suppliers/suppliers.routes.ts @@ -8,13 +8,13 @@ const router = Router(); router.use(authenticate); -router.get('/', authorize('contacts', 'contacts', 'read'), suppliersController.findAll); -router.get('/stats', authorize('contacts', 'contacts', 'read'), suppliersController.getStats); -router.get('/export', authorize('contacts', 'contacts', 'read'), suppliersController.export); +router.get('/', authorize('suppliers', 'suppliers', 'read'), suppliersController.findAll); +router.get('/stats', authorize('suppliers', 'suppliers', 'read'), suppliersController.getStats); +router.get('/export', authorize('suppliers', 'suppliers', 'read'), suppliersController.export); router.get( '/:id', - authorize('contacts', 'contacts', 'read'), + authorize('suppliers', 'suppliers', 'read'), param('id').isUUID(), validate, suppliersController.findById @@ -22,7 +22,7 @@ router.get( router.post( '/', - authorize('contacts', 'contacts', 'create'), + authorize('suppliers', 'suppliers', 'create'), [ body('name').optional({ values: 'falsy' }).trim(), body('companyName').optional({ values: 'falsy' }).trim(), @@ -40,7 +40,7 @@ router.post( router.put( '/:id', - authorize('contacts', 'contacts', 'update'), + authorize('suppliers', 'suppliers', 'update'), [ param('id').isUUID(), body('email') @@ -57,7 +57,7 @@ router.put( router.post( '/:id/archive', - authorize('contacts', 'contacts', 'archive'), + authorize('suppliers', 'suppliers', 'archive'), param('id').isUUID(), validate, suppliersController.archive diff --git a/frontend/src/app/admin/permission-groups/page.tsx b/frontend/src/app/admin/permission-groups/page.tsx index 2691e94..12c4d40 100644 --- a/frontend/src/app/admin/permission-groups/page.tsx +++ b/frontend/src/app/admin/permission-groups/page.tsx @@ -9,6 +9,7 @@ import LoadingSpinner from '@/components/LoadingSpinner'; const MODULES = [ { id: 'contacts', name: 'إدارة جهات الاتصال', nameEn: 'Contact Management' }, + { id: 'suppliers', name: 'إدارة الموردين', nameEn: 'Supplier Management' }, { id: 'crm', name: 'إدارة علاقات العملاء', nameEn: 'CRM' }, { id: 'tenders', name: 'إدارة الٍٍٍمناقصات', nameEn: 'Tender Management' }, { id: 'inventory', name: 'المخزون والأصول', nameEn: 'Inventory & Assets' }, diff --git a/frontend/src/app/admin/roles/page.tsx b/frontend/src/app/admin/roles/page.tsx index eb2a98d..2c28530 100644 --- a/frontend/src/app/admin/roles/page.tsx +++ b/frontend/src/app/admin/roles/page.tsx @@ -10,6 +10,7 @@ import LoadingSpinner from '@/components/LoadingSpinner'; const MODULES = [ { id: 'contacts', name: 'إدارة جهات الاتصال', nameEn: 'Contact Management' }, + { id: 'suppliers', name: 'إدارة الموردين', nameEn: 'Supplier Management' }, { id: 'crm', name: 'إدارة علاقات العملاء', nameEn: 'CRM' }, { id: 'tenders', name: 'إدارة المناقصات', nameEn: 'Tender Management' }, { id: 'inventory', name: 'المخزون والأصول', nameEn: 'Inventory & Assets' }, diff --git a/frontend/src/app/suppliers/page.tsx b/frontend/src/app/suppliers/page.tsx index 6ac759e..88b3811 100644 --- a/frontend/src/app/suppliers/page.tsx +++ b/frontend/src/app/suppliers/page.tsx @@ -4,10 +4,11 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import type { FormEvent } from 'react' import Link from 'next/link' import { toast } from 'react-hot-toast' -import { ArrowLeft, BadgeCheck, Building2, CircleDollarSign, Download, Edit, Eye, Filter, Landmark, Loader2, Mail, Phone, Plus, Search, Star, Tag, Trash2, Truck, X } from 'lucide-react' +import { ArrowLeft, BadgeCheck, Building2, CircleDollarSign, Download, Edit, Eye, Filter, Landmark, Loader2,Shield, Mail, Phone, Plus, Search, Star, Tag, Trash2, Truck, X } from 'lucide-react' import ProtectedRoute from '@/components/ProtectedRoute' import LoadingSpinner from '@/components/LoadingSpinner' import Modal from '@/components/Modal' +import { useAuth } from '@/contexts/AuthContext' import SupplierCategorySelector from '@/components/suppliers/SupplierCategorySelector' import { suppliersAPI, Supplier, SupplierFilters, CreateSupplierData, UpdateSupplierData, SupplierStats } from '@/lib/api/suppliers' import { DEFAULT_SUPPLIER_CATEGORIES, isSupplierSystemCategoryName, uniqueSupplierCategories } from '@/lib/supplierCategories' @@ -204,6 +205,13 @@ function SuppliersContent() { const [selectedSupplier, setSelectedSupplier] = useState(null) const [submitting, setSubmitting] = useState(false) const [exporting, setExporting] = useState(false) + const { hasPermission } = useAuth() + + const canViewSupplier = hasPermission('suppliers', 'view') + const canCreateSupplier = hasPermission('suppliers', 'create') + const canEditSupplier = hasPermission('suppliers', 'edit') + const canDeleteSupplier = hasPermission('suppliers', 'delete') + const canExportSupplier = hasPermission('suppliers', 'export') const availableCategories = useMemo(() => uniqueSupplierCategories([...DEFAULT_SUPPLIER_CATEGORIES, ...suppliers.flatMap(getSupplierCategoryLabels)]), [suppliers]) @@ -216,10 +224,16 @@ function SuppliersContent() { }, [currentPage, searchTerm, selectedStatus, selectedCategory]) const fetchStats = useCallback(async () => { + if (!canViewSupplier) return try { setStats(await suppliersAPI.getStats()) } catch { setStats({ total: 0, active: 0, inactive: 0, blocked: 0 }) } - }, []) + }, [canViewSupplier]) const fetchSuppliers = useCallback(async () => { + if (!canViewSupplier) { + setLoading(false) + return + } + setLoading(true) setError(null) try { @@ -234,7 +248,7 @@ function SuppliersContent() { } finally { setLoading(false) } - }, [buildFilters]) + }, [buildFilters, canViewSupplier]) useEffect(() => { fetchStats() }, [fetchStats]) useEffect(() => { const t = setTimeout(() => { setCurrentPage(1); fetchSuppliers() }, 500); return () => clearTimeout(t) }, [searchTerm]) @@ -260,8 +274,8 @@ function SuppliersContent() { const handleArchive = async () => { if (!selectedSupplier) return setSubmitting(true) - try { await suppliersAPI.archive(selectedSupplier.id, 'Archived from Supplier Management'); toast.success('تم أرشفة المورد بنجاح'); setShowArchiveDialog(false); setSelectedSupplier(null); await refresh() } - catch (err: any) { toast.error(err.response?.data?.message || 'فشل أرشفة المورد') } + try { await suppliersAPI.archive(selectedSupplier.id, 'Archived from Supplier Management'); toast.success('تم حذف المورد بنجاح'); setShowArchiveDialog(false); setSelectedSupplier(null); await refresh() } + catch (err: any) { toast.error(err.response?.data?.message || 'فشل حذف المورد') } finally { setSubmitting(false) } } @@ -283,6 +297,39 @@ function SuppliersContent() { finally { setExporting(false) } } + if (!canViewSupplier) { + return ( +
+
+
+
+ + + +
+
+ +
+
+

إدارة الموردين

+

Supplier Management

+
+
+
+
+
+ +
+
+ +

غير مصرح بالوصول

+

لا تملك صلاحية عرض إدارة الموردين.

+
+
+
+ ) + } + return (
@@ -292,7 +339,28 @@ function SuppliersContent() {

إدارة الموردين

Supplier Management

-
+
+ {canExportSupplier && ( + + )} + + {canCreateSupplier && ( + + )} + +
@@ -303,13 +371,68 @@ function SuppliersContent() {
setSearchTerm(e.target.value)} className="w-full pr-10 pl-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500 bg-white text-gray-900" />
{showAdvancedFilters &&
}
- {loading ?
: error ?

{error}

: suppliers.length === 0 ?

لا يوجد موردين مطابقين للفلاتر الحالية

: <>
{suppliers.map((supplier) => { const customFields = supplier.customFields || {}; const categoryLabels = getSupplierCategoryLabels(supplier); return })}
SupplierContact InfoCategoryRatingStatusActions
{getSupplierName(supplier).charAt(0).toUpperCase()}

{getSupplierName(supplier)}

{customFields.supplierCode &&

{customFields.supplierCode}

}{supplier.companyNameAr &&

{supplier.companyNameAr}

}
{getContactPerson(supplier) !== '-' &&
{getContactPerson(supplier)}{customFields.contactPosition && ({customFields.contactPosition})}
}{supplier.email &&
{supplier.email}
}{(supplier.phone || supplier.mobile) &&
{supplier.phone || supplier.mobile}
}
{categoryLabels.length > 0 ? categoryLabels.map((category) => {category}) : بدون تصنيف}{customFields.paymentTerms &&
{customFields.paymentTerms}
}
{renderRating(supplier.rating)}{supplier.status === 'ACTIVE' ? 'Active' : supplier.status}

Showing {((currentPage - 1) * pageSize) + 1} to {Math.min(currentPage * pageSize, total)} of {total} suppliers

{Array.from({ length: Math.min(5, totalPages) }, (_, i) => i + 1).map((page) => )}
} + {loading ?
: error ?

{error}

: suppliers.length === 0 ?

لا يوجد موردين مطابقين للفلاتر الحالية

: <>
{suppliers.map((supplier) => { const customFields = supplier.customFields || {}; const categoryLabels = getSupplierCategoryLabels(supplier); return })}
SupplierContact InfoCategoryRatingStatusActions
{getSupplierName(supplier).charAt(0).toUpperCase()}

{getSupplierName(supplier)}

{customFields.supplierCode &&

{customFields.supplierCode}

}{supplier.companyNameAr &&

{supplier.companyNameAr}

}
{getContactPerson(supplier) !== '-' &&
{getContactPerson(supplier)}{customFields.contactPosition && ({customFields.contactPosition})}
}{supplier.email &&
{supplier.email}
}{(supplier.phone || supplier.mobile) &&
{supplier.phone || supplier.mobile}
}
{categoryLabels.length > 0 ? categoryLabels.map((category) => {category}) : بدون تصنيف}{customFields.paymentTerms &&
{customFields.paymentTerms}
}
{renderRating(supplier.rating)}{supplier.status === 'ACTIVE' ? 'Active' : supplier.status} +
+ {canViewSupplier && ( + + + + )} + + {canEditSupplier && ( + + )} + + {canDeleteSupplier && ( + + )} +
+

Showing {((currentPage - 1) * pageSize) + 1} to {Math.min(currentPage * pageSize, total)} of {total} suppliers

{Array.from({ length: Math.min(5, totalPages) }, (_, i) => i + 1).map((page) => )}
}
- setShowCreateModal(false)} title="إضافة مورد جديد" size="xl"> setShowCreateModal(false)} submitting={submitting} /> - { setShowEditModal(false); setSelectedSupplier(null) }} title="تعديل المورد" size="xl"> { setShowEditModal(false); setSelectedSupplier(null) }} submitting={submitting} /> - {showArchiveDialog && selectedSupplier &&
setShowArchiveDialog(false)} />

أرشفة المورد

سيتم إخفاء المورد من قائمة الموردين

هل أنت متأكد من أرشفة {getSupplierName(selectedSupplier)}؟

} + {canCreateSupplier && ( + setShowCreateModal(false)} title="إضافة مورد جديد" size="xl"> + setShowCreateModal(false)} + submitting={submitting} + /> + + )} + + {canEditSupplier && ( + { setShowEditModal(false); setSelectedSupplier(null) }} title="تعديل المورد" size="xl"> + { setShowEditModal(false); setSelectedSupplier(null) }} + submitting={submitting} + /> + + )} + + {canDeleteSupplier && showArchiveDialog && selectedSupplier &&
setShowArchiveDialog(false)} />

حذف المورد

سيتم إخفاء المورد من قائمة الموردين

هل أنت متأكد من حذف {getSupplierName(selectedSupplier)}؟

}
) } diff --git a/frontend/src/contexts/LanguageContext.tsx b/frontend/src/contexts/LanguageContext.tsx index 8bbae55..9d55260 100644 --- a/frontend/src/contexts/LanguageContext.tsx +++ b/frontend/src/contexts/LanguageContext.tsx @@ -551,7 +551,7 @@ const translations = { view: 'عرض', win: 'فوز', lose: 'خسارة', - archive: 'أرشفة', + delete: 'حذف', deleteDeal: 'حذف الصفقة', markWon: 'تحديد كفائز', markLost: 'تحديد كخاسر', @@ -563,7 +563,7 @@ const translations = { updateSuccess: 'تم تحديث الصفقة بنجاح', winSuccess: 'تم الفوز بالصفقة بنجاح', loseSuccess: 'تم تحديد الصفقة كخاسرة', - deleteSuccess: 'تم أرشفة الصفقة بنجاح', + deleteSuccess: 'تم حذف الصفقة بنجاح', fixFormErrors: 'يرجى إصلاح أخطاء النموذج', pipelineRequired: 'مسار المبيعات مطلوب', dealNameMin: 'اسم الصفقة يجب أن يكون 3 أحرف على الأقل',