add supplier management module

This commit is contained in:
Aya
2026-05-06 10:56:31 +03:00
parent 8621096a82
commit da4cb36036
22 changed files with 1579 additions and 583 deletions

View File

@@ -106,7 +106,8 @@ function ContactDetailContent() {
SCHOOL: 'bg-yellow-100 text-yellow-700',
UN: 'bg-sky-100 text-sky-700',
NGO: 'bg-pink-100 text-pink-700',
INSTITUTION: 'bg-gray-100 text-gray-700'
INSTITUTION: 'bg-gray-100 text-gray-700',
SUPPLIER: 'bg-emerald-100 text-emerald-700'
}
return colors[type] || 'bg-gray-100 text-gray-700'
}
@@ -124,7 +125,8 @@ function ContactDetailContent() {
SCHOOL: 'مدارس - Schools',
UN: 'UN - United Nations',
NGO: 'NGO - Non-Governmental Organization',
INSTITUTION: 'مؤسسة - Institution'
INSTITUTION: 'مؤسسة - Institution',
SUPPLIER: 'مورّد - Supplier'
}
return labels[type] || type
}
@@ -370,7 +372,7 @@ function ContactDetailContent() {
{ id: 'address', label: 'Address', icon: MapPin },
{ id: 'categories', label: 'Categories & Tags', icon: Tag },
{ id: 'relationships', label: 'Relationships', icon: Users },
...((contact.type === 'COMPANY' || contact.type === 'HOLDING')
...((['COMPANY', 'HOLDING', 'SUPPLIER'].includes(contact.type))
? [{ id: 'hierarchy', label: 'Hierarchy', icon: Building2 }]
: []
),
@@ -646,7 +648,7 @@ function ContactDetailContent() {
)}
{/* Hierarchy Tab */}
{activeTab === 'hierarchy' && (contact.type === 'COMPANY' || contact.type === 'HOLDING') && (
{activeTab === 'hierarchy' && (['COMPANY', 'HOLDING', 'SUPPLIER'].includes(contact.type)) && (
<div>
<HierarchyTree rootContactId={contactId} />
</div>

View File

@@ -29,6 +29,7 @@ import {
} from 'lucide-react'
import { contactsAPI, Contact, CreateContactData, UpdateContactData, ContactFilters } from '@/lib/api/contacts'
import { categoriesAPI, Category } from '@/lib/api/categories'
import { isSupplierOnlyCategoryName } from '@/lib/supplierCategories'
import ContactForm from '@/components/contacts/ContactForm'
import ContactImport from '@/components/contacts/ContactImport'
@@ -54,8 +55,6 @@ function ContactsContent() {
const [searchTerm, setSearchTerm] = useState('')
const [selectedType, setSelectedType] = useState('all')
const [selectedSpecialization, setSelectedSpecialization] = useState('all')
const [createDefaultType, setCreateDefaultType] = useState('INDIVIDUAL')
const [selectedStatus, setSelectedStatus] = useState('all')
const [selectedSource, setSelectedSource] = useState('all')
const [selectedRating, setSelectedRating] = useState('all')
@@ -80,11 +79,11 @@ function ContactsContent() {
const filters: ContactFilters = {
page: currentPage,
pageSize,
excludeSuppliers: true,
}
if (searchTerm) filters.search = searchTerm
if (selectedType !== 'all') filters.type = selectedType
if (selectedSpecialization !== 'all') filters.specialization = selectedSpecialization
if (selectedStatus !== 'all') filters.status = selectedStatus
if (selectedSource !== 'all') filters.source = selectedSource
if (selectedRating !== 'all') filters.rating = parseInt(selectedRating)
@@ -100,7 +99,7 @@ function ContactsContent() {
} finally {
setLoading(false)
}
}, [currentPage, searchTerm, selectedType, selectedStatus, selectedSource, selectedRating, selectedCategory, selectedSpecialization])
}, [currentPage, searchTerm, selectedType, selectedStatus, selectedSource, selectedRating, selectedCategory])
useEffect(() => {
const debounce = setTimeout(() => {
@@ -112,7 +111,7 @@ function ContactsContent() {
useEffect(() => {
fetchContacts()
}, [currentPage, selectedType, selectedStatus, selectedSource, selectedRating, selectedCategory, selectedSpecialization])
}, [currentPage, selectedType, selectedStatus, selectedSource, selectedRating, selectedCategory])
const handleCreate = async (data: CreateContactData) => {
setSubmitting(true)
@@ -195,7 +194,8 @@ function ContactsContent() {
SCHOOL: 'bg-yellow-100 text-yellow-700',
UN: 'bg-sky-100 text-sky-700',
NGO: 'bg-pink-100 text-pink-700',
INSTITUTION: 'bg-gray-100 text-gray-700'
INSTITUTION: 'bg-gray-100 text-gray-700',
SUPPLIER: 'bg-emerald-100 text-emerald-700'
}
return colors[type] || 'bg-gray-100 text-gray-700'
}
@@ -217,7 +217,8 @@ function ContactsContent() {
SCHOOL: 'مدارس',
UN: 'UN',
NGO: 'NGO',
INSTITUTION: 'مؤسسة'
INSTITUTION: 'مؤسسة',
SUPPLIER: 'مورّد'
}
return labels[type] || type
}
@@ -234,6 +235,7 @@ function ContactsContent() {
'UN',
'NGO',
'INSTITUTION',
'SUPPLIER',
])
const isOrganizationContact = (contact: Contact) => organizationTypes.has(contact.type)
@@ -250,21 +252,6 @@ function ContactsContent() {
return (contact as any).nameAr || ''
}
const supplierSpecializations = [
'كاميرات',
'شبكات',
'أجهزة كومبيوتر',
'projectors',
'مقاسم هاتفية',
' Mobile - Tablet',
'firewall',
'طاقة بديلة',
'حديد',
' باركود - POS',
'أجهزة منزلية',
'تكييف وتبريد',
]
return (
<div className="min-h-screen bg-gray-50">
<header className="bg-white shadow-sm border-b">
@@ -325,20 +312,8 @@ function ContactsContent() {
<button
onClick={() => {
resetForm()
setCreateDefaultType('SUPPLIER')
setShowCreateModal(true)
}}
className="flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors"
>
<Plus className="h-4 w-4" />
إضافة موردين
</button>
<button
onClick={() => {
resetForm()
setCreateDefaultType('INDIVIDUAL')
setShowCreateModal(true)
}}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="h-4 w-4" />
@@ -436,10 +411,8 @@ function ContactsContent() {
<option value="UN">UN</option>
<option value="NGO">NGO</option>
<option value="INSTITUTION">Institution</option>
<option value="SUPPLIER">Suppliers - موردين</option>
</select>
<select
value={selectedStatus}
onChange={(e) => setSelectedStatus(e.target.value)}
@@ -484,23 +457,7 @@ function ContactsContent() {
<option value="OTHER">Other</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
اختصاص المورد
</label>
<select
value={selectedSpecialization}
onChange={(e) => setSelectedSpecialization(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
>
<option value="all">كل الاختصاصات</option>
{supplierSpecializations.map((item) => (
<option key={item} value={item}>
{item}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Rating</label>
<select
@@ -525,9 +482,11 @@ function ContactsContent() {
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
>
<option value="all">All Categories</option>
{flattenCategories(categories).map((cat) => (
<option key={cat.id} value={cat.id}>{cat.name}{cat.nameAr ? ` (${cat.nameAr})` : ''}</option>
))}
{flattenCategories(categories)
.filter((cat) => !isSupplierOnlyCategoryName(cat.name, cat.nameAr))
.map((cat) => (
<option key={cat.id} value={cat.id}>{cat.name}{cat.nameAr ? ` (${cat.nameAr})` : ''}</option>
))}
</select>
</div>
@@ -541,7 +500,6 @@ function ContactsContent() {
setSelectedRating('all')
setSelectedCategory('all')
setCurrentPage(1)
setSelectedSpecialization('all')
}}
className="w-full px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
>
@@ -577,8 +535,8 @@ function ContactsContent() {
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
{createDefaultType === 'SUPPLIER' ? 'إضافة مورد' : 'Create New Contact'}
</button>
Create First Contact
</button>
</div>
) : (
<>
@@ -781,8 +739,7 @@ function ContactsContent() {
size="xl"
>
<ContactForm
key={`create-${createDefaultType}`}
defaultType={createDefaultType}
key="create-contact"
onSubmit={async (data) => {
await handleCreate(data as CreateContactData)
}}
@@ -870,6 +827,7 @@ function ContactsContent() {
if (selectedStatus !== 'all') filters.status = selectedStatus
if (selectedCategory !== 'all') filters.category = selectedCategory
if (exportExcludeCompanyEmployees) filters.excludeCompanyEmployees = true
filters.excludeSuppliers = true
const blob = await contactsAPI.export(filters)
const url = window.URL.createObjectURL(blob)

View File

@@ -22,7 +22,8 @@ import {
Settings,
Bell,
Shield,
FileText
FileText,
Truck
} from 'lucide-react'
import { dashboardAPI, notificationsAPI } from '@/lib/api'
import { portalAPI } from '@/lib/api/portal'
@@ -254,7 +255,17 @@ function DashboardContent() {
icon: Users,
color: 'bg-blue-500',
href: '/contacts',
description: 'إدارة العملاء والموردين وجهات الاتصال',
description: 'إدارة العملاء وجهات الاتصال',
permission: 'contacts'
},
{
id: 'suppliers',
name: 'إدارة الموردين',
nameEn: 'Supplier Management',
icon: Truck,
color: 'bg-emerald-500',
href: '/suppliers',
description: 'إدارة الموردين وبيانات التواصل والاعتماد',
permission: 'contacts'
},
{

View File

@@ -11,6 +11,7 @@ import {
TrendingUp,
Package,
CheckSquare,
Truck,
LogIn
} from 'lucide-react'
@@ -39,7 +40,12 @@ export default function Home() {
{
icon: Users,
title: 'إدارة جهات الاتصال',
description: 'نظام شامل لإدارة العملاء والموردين وجهات الاتصال'
description: 'نظام شامل لإدارة العملاء وجهات الاتصال'
},
{
icon: Truck,
title: 'إدارة الموردين',
description: 'وحدة مستقلة لإدارة الموردين وبيانات الاعتماد والتواصل'
},
{
icon: TrendingUp,

View File

@@ -337,14 +337,15 @@ export default function ManagedExpenseClaimsPage() {
<div className="space-y-1">
{claim.attachments.map((attachment) => (
<button
<a
key={attachment.id}
type="button"
onClick={() => openAttachment(attachment)}
className="block w-full text-right rounded-lg border bg-white px-3 py-2 text-sm text-blue-600 hover:underline"
href={`${process.env.NEXT_PUBLIC_API_URL}/hr/portal/expense-claims/attachments/${attachment.id}/view`}
target="_blank"
rel="noreferrer"
className="block rounded-lg border bg-white px-3 py-2 text-sm text-blue-600 hover:underline"
>
{attachment.originalName}
</button>
</a>
))}
</div>
</div>

View File

@@ -0,0 +1,139 @@
'use client'
import { useEffect, useState } from 'react'
import type { ReactNode } from 'react'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import { toast } from 'react-hot-toast'
import { ArrowLeft, Building2, Calendar, CircleDollarSign, Copy, FileText, Globe, Landmark, Mail, MapPin, Phone, Star, Tag, Truck, XCircle } from 'lucide-react'
import ProtectedRoute from '@/components/ProtectedRoute'
import LoadingSpinner from '@/components/LoadingSpinner'
import { suppliersAPI, Supplier } from '@/lib/api/suppliers'
import { isSupplierSystemCategoryName, uniqueSupplierCategories } from '@/lib/supplierCategories'
function renderStars(rating?: number) {
if (!rating) return <span className="text-gray-400 text-sm">بدون تقييم</span>
return <div className="flex items-center gap-1">{[1, 2, 3, 4, 5].map((star) => <Star key={star} className={`h-5 w-5 ${star <= rating ? 'fill-yellow-400 text-yellow-400' : 'text-gray-300'}`} />)}</div>
}
function Field({ label, value, mono = false }: { label: string; value?: any; mono?: boolean }) {
if (!value) return null
return <div><dt className="text-sm font-medium text-gray-500">{label}</dt><dd className={`mt-1 text-sm text-gray-900 ${mono ? 'font-mono' : ''}`}>{value}</dd></div>
}
function getSupplierCategoryLabels(supplier: Supplier): string[] {
const customFields = supplier.customFields || {}
if (Array.isArray(customFields.supplierCategories)) {
const categories = uniqueSupplierCategories(customFields.supplierCategories)
if (categories.length > 0) return categories
}
if (customFields.supplierCategory) return [String(customFields.supplierCategory)]
return uniqueSupplierCategories((supplier.categories || [])
.filter((category: any) => !isSupplierSystemCategoryName(category.name, category.nameAr))
.map((category: any) => category.nameAr ? `${category.name} (${category.nameAr})` : category.name))
}
function isSupplierSystemCategory(category: any) {
const name = String(category?.name || '').trim().toLowerCase()
const nameAr = String(category?.nameAr || '')
return name === 'supplier' || name === 'suppliers' || nameAr.includes('مورد')
}
function getCategoryLabels(supplier: Supplier) {
const categoryNames = (supplier.categories || [])
.filter((category: any) => !isSupplierSystemCategory(category))
.map((category: any) => category.nameAr ? `${category.name} (${category.nameAr})` : category.name)
.filter(Boolean)
if (categoryNames.length > 0) return categoryNames
return supplier.customFields?.supplierCategory ? [supplier.customFields.supplierCategory] : []
}
function CategoryBadges({ labels }: { labels: string[] }) {
if (labels.length === 0) return <span className="text-gray-400 text-sm">بدون تصنيف</span>
return (
<div className="flex flex-wrap gap-2">
{labels.map((label) => (
<span key={label} className="inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-medium bg-emerald-100 text-emerald-700">
<Tag className="h-3 w-3" />
{label}
</span>
))}
</div>
)
}
function SupplierDetailContent() {
const params = useParams()
const supplierId = params.id as string
const [supplier, setSupplier] = useState<Supplier | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [copiedField, setCopiedField] = useState<string | null>(null)
useEffect(() => {
const fetchSupplier = async () => {
setLoading(true)
setError(null)
try { setSupplier(await suppliersAPI.getById(supplierId)) }
catch (err: any) { const message = err.response?.data?.message || 'Failed to load supplier'; setError(message); toast.error(message) }
finally { setLoading(false) }
}
fetchSupplier()
}, [supplierId])
const copyToClipboard = (text: string, field: string) => {
navigator.clipboard.writeText(text)
setCopiedField(field)
toast.success(`${field} copied`)
setTimeout(() => setCopiedField(null), 1800)
}
if (loading) return <div className="min-h-screen bg-gray-50 flex items-center justify-center"><LoadingSpinner size="lg" message="Loading supplier details..." /></div>
if (error || !supplier) return <div className="min-h-screen bg-gray-50 flex items-center justify-center"><div className="text-center"><XCircle className="h-16 w-16 text-red-500 mx-auto mb-4" /><h2 className="text-2xl font-bold text-gray-900 mb-2">Supplier Not Found</h2><p className="text-gray-600 mb-6">{error || 'This supplier does not exist'}</p><Link href="/suppliers" className="inline-flex items-center gap-2 px-6 py-3 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700"><ArrowLeft className="h-4 w-4" /> Back to Suppliers</Link></div></div>
const customFields = supplier.customFields || {}
const supplierName = supplier.companyName || supplier.name
const contactPerson = supplier.name && supplier.name !== supplierName ? supplier.name : ''
const supplierCategoryLabels = getSupplierCategoryLabels(supplier)
const categoryLabels = supplierCategoryLabels
return (
<div className="min-h-screen bg-gray-50">
<header className="bg-white shadow-sm border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/suppliers" className="p-2 hover:bg-gray-100 rounded-lg transition-colors"><ArrowLeft className="h-5 w-5 text-gray-600" /></Link>
<div className="flex items-center gap-3"><div className="bg-emerald-100 p-2 rounded-lg"><Truck className="h-6 w-6 text-emerald-600" /></div><div><div className="flex items-center gap-3"><h1 className="text-2xl font-bold text-gray-900">{supplierName}</h1><span className={`px-3 py-1 rounded-full text-xs font-medium ${supplier.status === 'ACTIVE' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-700'}`}>{supplier.status}</span></div><p className="text-sm text-gray-600 mt-1">Supplier Management {supplier.uniqueContactId}</p></div></div>
</div>
</div>
<nav className="flex items-center gap-2 text-sm text-gray-600 mt-4"><Link href="/dashboard" className="hover:text-emerald-600">Dashboard</Link><span>/</span><Link href="/suppliers" className="hover:text-emerald-600">Suppliers</Link><span>/</span><span className="text-gray-900 font-medium">{supplierName}</span></nav>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-1"><div className="bg-white rounded-xl shadow-sm border p-6"><div className="text-center mb-6"><div className="h-32 w-32 rounded-full bg-gradient-to-br from-emerald-600 to-emerald-700 flex items-center justify-center text-white text-4xl font-bold mx-auto mb-4">{supplierName.charAt(0).toUpperCase()}</div><h2 className="text-xl font-bold text-gray-900">{supplierName}</h2>{supplier.companyNameAr && <p className="text-gray-600 mt-1" dir="rtl">{supplier.companyNameAr}</p>}{customFields.supplierCode && <p className="text-sm text-gray-500 mt-2 font-mono">{customFields.supplierCode}</p>}</div><div className="mb-6 pb-6 border-b"><label className="text-sm font-medium text-gray-700 block mb-2">Rating</label>{renderStars(supplier.rating)}</div><div className="space-y-2">{supplier.email && <button onClick={() => copyToClipboard(supplier.email!, 'Email')} className="w-full flex items-center gap-3 px-4 py-3 border border-gray-200 rounded-lg hover:bg-gray-50"><Mail className="h-5 w-5 text-gray-600" /><span className="flex-1 text-left text-sm">{supplier.email}</span><Copy className={`h-4 w-4 ${copiedField === 'Email' ? 'text-green-600' : 'text-gray-400'}`} /></button>}{(supplier.phone || supplier.mobile) && <button onClick={() => copyToClipboard((supplier.phone || supplier.mobile)!, 'Phone')} className="w-full flex items-center gap-3 px-4 py-3 border border-gray-200 rounded-lg hover:bg-gray-50"><Phone className="h-5 w-5 text-gray-600" /><span className="flex-1 text-left text-sm">{supplier.phone || supplier.mobile}</span><Copy className={`h-4 w-4 ${copiedField === 'Phone' ? 'text-green-600' : 'text-gray-400'}`} /></button>}{supplier.website && <a href={supplier.website.startsWith('http') ? supplier.website : `https://${supplier.website}`} target="_blank" rel="noopener noreferrer" className="w-full flex items-center gap-3 px-4 py-3 border border-gray-200 rounded-lg hover:bg-gray-50"><Globe className="h-5 w-5 text-gray-600" /><span className="flex-1 text-left text-sm">{supplier.website}</span></a>}</div><div className="mt-6 pt-6 border-t space-y-2"><div className="flex items-center gap-2 text-sm text-gray-600"><Calendar className="h-4 w-4" /><span>Created: {new Date(supplier.createdAt).toLocaleDateString()}</span></div><div className="space-y-2"><div className="flex items-center gap-2 text-sm text-gray-600"><Tag className="h-4 w-4" /><span>Categories</span></div><CategoryBadges labels={categoryLabels} /></div></div></div></div>
<div className="lg:col-span-2 space-y-6">
<InfoCard icon={Building2} title="Supplier Information"><dl className="grid grid-cols-1 md:grid-cols-2 gap-4"><Field label="Supplier Name" value={supplierName} /><Field label="Arabic Name" value={supplier.companyNameAr} /><Field label="Contact Person" value={contactPerson} /><Field label="Contact Position" value={customFields.contactPosition} /><Field label="Supplier Code" value={customFields.supplierCode} mono /><div><dt className="text-sm font-medium text-gray-500">Supplier Categories</dt><dd className="mt-2"><CategoryBadges labels={categoryLabels} /></dd></div></dl></InfoCard>
<InfoCard icon={Landmark} title="Legal & Financial"><dl className="grid grid-cols-1 md:grid-cols-2 gap-4"><Field label="Tax Number" value={supplier.taxNumber} mono /><Field label="Commercial Register" value={supplier.commercialRegister} mono /><Field label="Payment Terms" value={customFields.paymentTerms} /><Field label="Bank Name" value={customFields.bankName} /><Field label="Bank Account / IBAN" value={customFields.bankAccount} mono /></dl>{!supplier.taxNumber && !supplier.commercialRegister && !customFields.paymentTerms && !customFields.bankName && !customFields.bankAccount && <div className="text-center py-6 text-gray-500"><CircleDollarSign className="h-10 w-10 mx-auto mb-2 text-gray-300" /><p>No financial information available</p></div>}</InfoCard>
<InfoCard icon={MapPin} title="Address & Notes"><dl className="grid grid-cols-1 md:grid-cols-2 gap-4"><Field label="Address" value={supplier.address} /><Field label="City" value={supplier.city} /><Field label="Country" value={supplier.country} /><Field label="Postal Code" value={supplier.postalCode} /></dl>{customFields.notes && <div className="mt-6 pt-6 border-t"><div className="flex items-center gap-2 mb-2"><FileText className="h-4 w-4 text-gray-500" /><h4 className="font-medium text-gray-900">Notes</h4></div><p className="text-sm text-gray-700 whitespace-pre-wrap">{customFields.notes}</p></div>}</InfoCard>
</div>
</div>
</main>
</div>
)
}
function InfoCard({ icon: Icon, title, children }: { icon: any; title: string; children: ReactNode }) {
return <div className="bg-white rounded-xl shadow-sm border p-6"><div className="flex items-center gap-2 mb-4"><Icon className="h-5 w-5 text-emerald-600" /><h3 className="text-lg font-semibold text-gray-900">{title}</h3></div>{children}</div>
}
export default function SupplierDetailPage() {
return <ProtectedRoute><SupplierDetailContent /></ProtectedRoute>
}

File diff suppressed because one or more lines are too long

View File

@@ -3,15 +3,17 @@
import { useState, useEffect } from 'react'
import { ChevronRight, ChevronDown, Plus, Check, Folder, FolderOpen, X } from 'lucide-react'
import { categoriesAPI, Category } from '@/lib/api/categories'
import { filterContactCategoryTree } from '@/lib/supplierCategories'
import { toast } from 'react-hot-toast'
interface CategorySelectorProps {
selectedIds: string[]
onChange: (selectedIds: string[]) => void
multiSelect?: boolean
categoryFilter?: (category: Category) => boolean
}
export default function CategorySelector({ selectedIds, onChange, multiSelect = true }: CategorySelectorProps) {
export default function CategorySelector({ selectedIds, onChange, multiSelect = true, categoryFilter }: CategorySelectorProps) {
const [categories, setCategories] = useState<Category[]>([])
const [loading, setLoading] = useState(true)
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
@@ -25,11 +27,28 @@ export default function CategorySelector({ selectedIds, onChange, multiSelect =
fetchCategories()
}, [])
const filterCategoryTree = (items: Category[]): Category[] => {
if (!categoryFilter) return items
return items
.map((category) => {
const children = category.children ? filterCategoryTree(category.children) : []
const shouldShow = categoryFilter(category)
if (!shouldShow && children.length === 0) return null
return { ...category, children } as Category
})
.filter(Boolean) as Category[]
}
const visibleCategories = filterCategoryTree(categories)
const fetchCategories = async () => {
setLoading(true)
try {
const data = await categoriesAPI.getTree()
setCategories(data)
setCategories(filterContactCategoryTree(data))
} catch (error) {
toast.error('Failed to load categories')
} finally {
@@ -102,6 +121,7 @@ export default function CategorySelector({ selectedIds, onChange, multiSelect =
{/* Expand/Collapse */}
{hasChildren ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
toggleExpand(category.id)
@@ -127,6 +147,7 @@ export default function CategorySelector({ selectedIds, onChange, multiSelect =
{/* Category Name */}
<button
type="button"
onClick={() => toggleSelect(category.id)}
className="flex-1 text-left flex items-center gap-2"
>
@@ -179,7 +200,7 @@ export default function CategorySelector({ selectedIds, onChange, multiSelect =
}
return selectedIds
.map(id => findCategory(categories, id))
.map(id => findCategory(visibleCategories, id))
.filter(cat => cat !== null) as Category[]
}
@@ -203,6 +224,7 @@ export default function CategorySelector({ selectedIds, onChange, multiSelect =
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
/>
<button
type="button"
onClick={() => setShowAddModal(true)}
className="px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
title="Add Category"
@@ -221,6 +243,7 @@ export default function CategorySelector({ selectedIds, onChange, multiSelect =
>
{category.name}
<button
type="button"
onClick={() => removeSelected(category.id)}
className="hover:text-blue-900"
>
@@ -233,11 +256,12 @@ export default function CategorySelector({ selectedIds, onChange, multiSelect =
{/* Category Tree */}
<div className="border border-gray-200 rounded-lg p-3 max-h-96 overflow-y-auto bg-white">
{categories.length === 0 ? (
{visibleCategories.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<Folder className="h-12 w-12 mx-auto mb-2 text-gray-300" />
<p>No categories found</p>
<button
type="button"
onClick={() => setShowAddModal(true)}
className="mt-2 text-blue-600 hover:text-blue-700 text-sm"
>
@@ -245,7 +269,7 @@ export default function CategorySelector({ selectedIds, onChange, multiSelect =
</button>
</div>
) : (
categories.map(category => renderCategory(category))
visibleCategories.map(category => renderCategory(category))
)}
</div>
@@ -296,7 +320,7 @@ export default function CategorySelector({ selectedIds, onChange, multiSelect =
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
>
<option value="">None (Root Category)</option>
{categories.map(cat => (
{visibleCategories.map(cat => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
))}
</select>
@@ -305,6 +329,7 @@ export default function CategorySelector({ selectedIds, onChange, multiSelect =
<div className="flex items-center justify-end gap-3 mt-6">
<button
type="button"
onClick={() => {
setShowAddModal(false)
setNewCategoryName('')
@@ -316,6 +341,7 @@ export default function CategorySelector({ selectedIds, onChange, multiSelect =
Cancel
</button>
<button
type="button"
onClick={handleAddCategory}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>

View File

@@ -13,7 +13,6 @@ interface ContactFormProps {
onSubmit: (data: CreateContactData | UpdateContactData) => Promise<void>
onCancel: () => void
submitting?: boolean
defaultType?: string
}
const buildInitialFormData = (contact?: Contact): CreateContactData => ({
@@ -40,12 +39,10 @@ const buildInitialFormData = (contact?: Contact): CreateContactData => ({
customFields: contact?.customFields
})
export default function ContactForm({ contact, onSubmit, onCancel, submitting = false, defaultType = 'INDIVIDUAL' }: ContactFormProps) { const isEdit = !!contact
export default function ContactForm({ contact, onSubmit, onCancel, submitting = false }: ContactFormProps) {
const isEdit = !!contact
const [formData, setFormData] = useState<CreateContactData>({
...buildInitialFormData(contact),
type: contact?.type || defaultType,
})
const [formData, setFormData] = useState<CreateContactData>(buildInitialFormData(contact))
const [rating, setRating] = useState<number>(contact?.rating || 0)
const [newTag, setNewTag] = useState('')
const [formErrors, setFormErrors] = useState<Record<string, string>>({})
@@ -53,13 +50,11 @@ const [formData, setFormData] = useState<CreateContactData>({
const [employees, setEmployees] = useState<Employee[]>([])
useEffect(() => {
setFormData({
...buildInitialFormData(contact),
type: contact?.type || defaultType,
})
setFormData(buildInitialFormData(contact))
setRating(contact?.rating || 0)
setNewTag('')
setFormErrors({})
}, [contact, defaultType])
}, [contact])
useEffect(() => {
categoriesAPI.getTree().then(setCategories).catch(() => {})
@@ -96,24 +91,11 @@ const [formData, setFormData] = useState<CreateContactData>({
'UN',
'NGO',
'INSTITUTION',
'SUPPLIER',
])
const isSupplier = formData.type === 'SUPPLIER'
const isOrganizationType = organizationTypes.has(formData.type)
const showCompanyFields = isOrganizationType && !isSupplier
const supplierSpecializations = [
'كاميرات',
'شبكات',
'أجهزة كومبيوتر',
'projectors',
'مقاسم هاتفية',
' Mobile - Tablet',
'firewall',
'طاقة بديلة',
'حديد',
' باركود - POS',
'أجهزة منزلية',
'تكييف وتبريد',
]
const showCompanyFields = isOrganizationType
const validateForm = (): boolean => {
const errors: Record<string, string> = {}
@@ -122,10 +104,6 @@ const [formData, setFormData] = useState<CreateContactData>({
errors.name = 'Name must be at least 2 characters'
}
if (isSupplier && (!formData.tags || formData.tags.length === 0)) {
errors.tags = 'الاختصاص مطلوب'
}
if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
errors.email = 'Invalid email format'
}
@@ -149,7 +127,7 @@ const [formData, setFormData] = useState<CreateContactData>({
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!validateForm()) return
const cleanData = Object.entries({ ...formData, rating }).reduce((acc, [key, value]) => {
const cleanData = Object.entries({ ...formData, rating }).reduce((acc, [key, value]) => {
const requiredFields = ['type', 'name', 'source', 'country']
// keep required fields as-is
@@ -181,8 +159,8 @@ const cleanData = Object.entries({ ...formData, rating }).reduce((acc, [key, val
}
if (!cleanData.parentId) {
delete cleanData.parentId
}
delete cleanData.parentId
}
if (cleanData.categories && cleanData.categories.length === 0) {
delete cleanData.categories
@@ -246,7 +224,7 @@ const cleanData = Object.entries({ ...formData, rating }).reduce((acc, [key, val
<option value="UN">UN - الأمم المتحدة</option>
<option value="NGO">NGO - منظمة غير حكومية</option>
<option value="INSTITUTION">Institution - مؤسسة</option>
<option value="SUPPLIER">Supplier - مورد</option>
<option value="SUPPLIER">Supplier - مورّد</option>
</select>
{formErrors.type && <p className="text-red-500 text-xs mt-1">{formErrors.type}</p>}
</div>
@@ -275,106 +253,50 @@ const cleanData = Object.entries({ ...formData, rating }).reduce((acc, [key, val
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{isSupplier ? 'اسم المورد' : isOrganizationType ? 'Contact Person Name' : 'Name'} <span className="text-red-500">*</span>
{isOrganizationType ? 'Contact Person Name' : 'Name'} <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
placeholder={isSupplier ? 'أدخل اسم المورد' : isOrganizationType ? 'Enter contact person name' : 'Enter contact name'}
placeholder={isOrganizationType ? 'Enter contact person name' : 'Enter contact name'}
/>
{formErrors.name && <p className="text-red-500 text-xs mt-1">{formErrors.name}</p>}
</div>
{isSupplier && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
الاختصاص <span className="text-red-500">*</span>
</label>
<div className="flex flex-wrap gap-2 mb-3">
{supplierSpecializations.map((item) => {
const checked = formData.tags?.includes(item) || false
return (
<button
key={item}
type="button"
onClick={() => {
setFormData({
...formData,
tags: checked
? (formData.tags || []).filter((tag) => tag !== item)
: [...(formData.tags || []), item],
})
}}
className={`px-3 py-1 rounded-full border text-sm transition-colors ${
checked
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
}`}
>
{item}
</button>
)
})}
</div>
<div className="flex gap-2">
<input
type="text"
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addTag())}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
placeholder="أضف اختصاص آخر"
/>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Rating
</label>
<div className="flex items-center gap-2">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
type="button"
onClick={() => setRating(star)}
className="focus:outline-none transition-colors"
>
<Star
className={`h-8 w-8 ${
star <= rating
? 'fill-yellow-400 text-yellow-400'
: 'text-gray-300 hover:text-yellow-200'
}`}
/>
</button>
))}
{rating > 0 && (
<button
type="button"
onClick={addTag}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
onClick={() => setRating(0)}
className="ml-2 text-sm text-gray-500 hover:text-gray-700"
>
<Plus className="h-5 w-5" />
Clear
</button>
</div>
{formErrors.tags && <p className="text-red-500 text-xs mt-1">{formErrors.tags}</p>}
)}
</div>
)}
{!isSupplier && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Rating
</label>
<div className="flex items-center gap-2">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
type="button"
onClick={() => setRating(star)}
className="focus:outline-none transition-colors"
>
<Star
className={`h-8 w-8 ${
star <= rating
? 'fill-yellow-400 text-yellow-400'
: 'text-gray-300 hover:text-yellow-200'
}`}
/>
</button>
))}
{rating > 0 && (
<button
type="button"
onClick={() => setRating(0)}
className="ml-2 text-sm text-gray-500 hover:text-gray-700"
>
Clear
</button>
)}
</div>
</div>
)}
</div>
</div>
</div>
@@ -493,76 +415,64 @@ const cleanData = Object.entries({ ...formData, rating }).reduce((acc, [key, val
)}
<div className="pt-6 border-t">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
{isSupplier ? 'العنوان' : 'Address Information'}
</h3>
{isSupplier ? (
<h3 className="text-lg font-semibold text-gray-900 mb-4">Address Information</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
العنوان
Street Address
</label>
<input
type="text"
value={formData.address || ''}
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
placeholder="العنوان"
placeholder="Street address"
/>
</div>
) : (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Street Address
City
</label>
<input
type="text"
value={formData.address || ''}
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
value={formData.city || ''}
onChange={(e) => setFormData({ ...formData, city: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
placeholder="Street address"
placeholder="City"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">City</label>
<input
type="text"
value={formData.city || ''}
onChange={(e) => setFormData({ ...formData, city: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
placeholder="City"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Country
</label>
<input
type="text"
value={formData.country || ''}
onChange={(e) => setFormData({ ...formData, country: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
placeholder="Country"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Country</label>
<input
type="text"
value={formData.country || ''}
onChange={(e) => setFormData({ ...formData, country: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
placeholder="Country"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Postal Code</label>
<input
type="text"
value={formData.postalCode || ''}
onChange={(e) => setFormData({ ...formData, postalCode: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
placeholder="Postal code"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Postal Code
</label>
<input
type="text"
value={formData.postalCode || ''}
onChange={(e) => setFormData({ ...formData, postalCode: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
placeholder="Postal code"
/>
</div>
</div>
)}
</div>
</div>
{!isSupplier && (
<div className="pt-6 border-t">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Categories</h3>
<CategorySelector
@@ -571,7 +481,6 @@ const cleanData = Object.entries({ ...formData, rating }).reduce((acc, [key, val
multiSelect={true}
/>
</div>
)}
{isCompanyEmployeeSelected && (
<div className="pt-6 border-t">
@@ -594,50 +503,48 @@ const cleanData = Object.entries({ ...formData, rating }).reduce((acc, [key, val
</div>
)}
{!isSupplier && (
<div className="pt-6 border-t">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Tags</h3>
<div className="space-y-3">
<div className="flex gap-2">
<input
type="text"
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addTag())}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
placeholder="Add a tag (press Enter)"
/>
<button
type="button"
onClick={addTag}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="h-5 w-5" />
</button>
</div>
<div className="pt-6 border-t">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Tags</h3>
<div className="space-y-3">
<div className="flex gap-2">
<input
type="text"
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addTag())}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
placeholder="Add a tag (press Enter)"
/>
<button
type="button"
onClick={addTag}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="h-5 w-5" />
</button>
</div>
{formData.tags && formData.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{formData.tags.map((tag, index) => (
<span
key={index}
className="inline-flex items-center gap-2 px-3 py-1 bg-gray-100 text-gray-700 rounded-full text-sm"
>
#{tag}
<button
type="button"
onClick={() => removeTag(tag)}
className="hover:text-red-600 transition-colors"
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
)}
</div>
</div>
)}
{formData.tags && formData.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{formData.tags.map((tag, index) => (
<span
key={index}
className="inline-flex items-center gap-2 px-3 py-1 bg-gray-100 text-gray-700 rounded-full text-sm"
>
#{tag}
<button
type="button"
onClick={() => removeTag(tag)}
className="hover:text-red-600 transition-colors"
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
)}
</div>
</div>
<DuplicateAlert
email={formData.email}

View File

@@ -0,0 +1,168 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { Check, Folder, Plus, X } from 'lucide-react'
import { toast } from 'react-hot-toast'
import { DEFAULT_SUPPLIER_CATEGORIES, normalizeSupplierCategoryName, uniqueSupplierCategories } from '@/lib/supplierCategories'
interface SupplierCategorySelectorProps {
selectedCategories: string[]
onChange: (categories: string[]) => void
availableCategories?: string[]
}
const STORAGE_KEY = 'zerp_supplier_custom_categories'
function readStoredCategories(): string[] {
if (typeof window === 'undefined') return []
try {
const stored = window.localStorage.getItem(STORAGE_KEY)
return stored ? JSON.parse(stored) : []
} catch {
return []
}
}
function writeStoredCategories(categories: string[]) {
if (typeof window === 'undefined') return
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(categories))
}
export default function SupplierCategorySelector({ selectedCategories, onChange, availableCategories = [] }: SupplierCategorySelectorProps) {
const [searchTerm, setSearchTerm] = useState('')
const [showAddModal, setShowAddModal] = useState(false)
const [newCategoryName, setNewCategoryName] = useState('')
const [storedCategories, setStoredCategories] = useState<string[]>([])
useEffect(() => {
setStoredCategories(readStoredCategories())
}, [])
const options = useMemo(
() => uniqueSupplierCategories([
...DEFAULT_SUPPLIER_CATEGORIES,
...availableCategories,
...storedCategories,
...selectedCategories,
]),
[availableCategories, selectedCategories, storedCategories],
)
const filteredOptions = options.filter((category) =>
normalizeSupplierCategoryName(category).includes(normalizeSupplierCategoryName(searchTerm)),
)
const isSelected = (category: string) =>
selectedCategories.some((selected) => normalizeSupplierCategoryName(selected) === normalizeSupplierCategoryName(category))
const toggleCategory = (category: string) => {
if (isSelected(category)) {
onChange(selectedCategories.filter((selected) => normalizeSupplierCategoryName(selected) !== normalizeSupplierCategoryName(category)))
return
}
onChange(uniqueSupplierCategories([...selectedCategories, category]))
}
const removeCategory = (category: string) => {
onChange(selectedCategories.filter((selected) => normalizeSupplierCategoryName(selected) !== normalizeSupplierCategoryName(category)))
}
const addCategory = () => {
const label = newCategoryName.trim()
if (!label) {
toast.error('اسم التصنيف مطلوب')
return
}
const nextStored = uniqueSupplierCategories([...storedCategories, label])
setStoredCategories(nextStored)
writeStoredCategories(nextStored)
onChange(uniqueSupplierCategories([...selectedCategories, label]))
setNewCategoryName('')
setShowAddModal(false)
toast.success('تمت إضافة التصنيف')
}
return (
<div className="space-y-3">
<div className="flex gap-2">
<input
type="text"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
placeholder="Search categories..."
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500 bg-white text-gray-900"
/>
<button type="button" onClick={() => setShowAddModal(true)} className="px-3 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors" title="Add Category">
<Plus className="h-5 w-5" />
</button>
</div>
{selectedCategories.length > 0 && (
<div className="flex flex-wrap gap-2 p-3 bg-gray-50 rounded-lg">
{selectedCategories.map((category) => (
<span key={category} className="inline-flex items-center gap-2 px-3 py-1 bg-emerald-100 text-emerald-700 rounded-full text-sm">
{category}
<button type="button" onClick={() => removeCategory(category)} className="hover:text-emerald-900"><X className="h-3 w-3" /></button>
</span>
))}
</div>
)}
<div className="border border-gray-200 rounded-lg p-3 max-h-96 overflow-y-auto bg-white">
{filteredOptions.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<Folder className="h-12 w-12 mx-auto mb-2 text-gray-300" />
<p>لا يوجد تصنيفات مطابقة</p>
<button type="button" onClick={() => setShowAddModal(true)} className="mt-2 text-emerald-600 hover:text-emerald-700 text-sm">إضافة تصنيف جديد</button>
</div>
) : (
filteredOptions.map((category) => {
const selected = isSelected(category)
return (
<div key={category} className={`flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition-colors ${selected ? 'bg-emerald-50 border border-emerald-200' : 'hover:bg-gray-50'}`} onClick={() => toggleCategory(category)}>
<div className="w-6" />
<Folder className="h-4 w-4 text-gray-600" />
<span className="flex-1 text-sm font-medium text-gray-900 text-right">{category}</span>
<button
type="button"
onClick={(event) => { event.stopPropagation(); toggleCategory(category) }}
className={`w-5 h-5 rounded border-2 flex items-center justify-center cursor-pointer transition-colors ${selected ? 'bg-emerald-600 border-emerald-600' : 'border-gray-300 bg-white hover:border-emerald-400'}`}
>
{selected && <Check className="h-3 w-3 text-white" />}
</button>
</div>
)
})
)}
</div>
{showAddModal && (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={() => setShowAddModal(false)} />
<div className="flex min-h-screen items-center justify-center p-4">
<div className="relative bg-white rounded-xl shadow-2xl p-6 max-w-md w-full">
<h3 className="text-lg font-bold text-gray-900 mb-4">إضافة تصنيف مورد</h3>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">اسم التصنيف <span className="text-red-500">*</span></label>
<input
type="text"
value={newCategoryName}
onChange={(event) => setNewCategoryName(event.target.value)}
onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); addCategory() } }}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500 bg-white text-gray-900"
placeholder="مثال: أجهزة طباعة"
autoFocus
/>
</div>
<div className="flex items-center justify-end gap-3 mt-6">
<button type="button" onClick={() => { setShowAddModal(false); setNewCategoryName('') }} className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50">إلغاء</button>
<button type="button" onClick={addCategory} className="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700">إضافة</button>
</div>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -65,13 +65,13 @@ export interface UpdateContactData extends Partial<CreateContactData> {
export interface ContactFilters {
search?: string
type?: string
specialization?: string
status?: string
category?: string
source?: string
rating?: number
page?: number
pageSize?: number
excludeSuppliers?: boolean
}
export interface ContactsResponse {
@@ -88,13 +88,13 @@ export const contactsAPI = {
const params = new URLSearchParams()
if (filters.search) params.append('search', filters.search)
if (filters.type) params.append('type', filters.type)
if (filters.specialization) params.append('specialization', filters.specialization)
if (filters.status) params.append('status', filters.status)
if (filters.category) params.append('category', filters.category)
if (filters.source) params.append('source', filters.source)
if (filters.rating) params.append('rating', filters.rating.toString())
if (filters.page) params.append('page', filters.page.toString())
if (filters.pageSize) params.append('pageSize', filters.pageSize.toString())
if (filters.excludeSuppliers) params.append('excludeSuppliers', 'true')
const response = await api.get(`/contacts?${params.toString()}`)
const { data, pagination } = response.data
@@ -156,6 +156,7 @@ export const contactsAPI = {
if (filters.status) params.append('status', filters.status)
if (filters.category) params.append('category', filters.category)
if (filters.excludeCompanyEmployees) params.append('excludeCompanyEmployees', 'true')
if (filters.excludeSuppliers) params.append('excludeSuppliers', 'true')
const response = await api.get(`/contacts/export?${params.toString()}`, {
responseType: 'blob'

View File

@@ -0,0 +1,123 @@
import { api } from '../api'
import { Contact } from './contacts'
export interface Supplier extends Contact {
customFields?: {
supplierCode?: string
supplierCategory?: string
supplierCategories?: string[]
paymentTerms?: string
bankName?: string
bankAccount?: string
contactPosition?: string
notes?: string
[key: string]: any
}
}
export interface SupplierFilters {
search?: string
status?: string
rating?: number
category?: string
page?: number
pageSize?: number
}
export interface SuppliersResponse {
suppliers: Supplier[]
total: number
page: number
pageSize: number
totalPages: number
}
export interface SupplierStats {
total: number
active: number
inactive: number
blocked: number
}
export interface CreateSupplierData {
name: string
nameAr?: string
email?: string
phone?: string
mobile?: string
website?: string
companyName?: string
companyNameAr?: string
taxNumber?: string
commercialRegister?: string
address?: string
city?: string
country?: string
postalCode?: string
categories?: string[]
tags?: string[]
source?: string
rating?: number
customFields?: Supplier['customFields']
}
export interface UpdateSupplierData extends Partial<CreateSupplierData> {
status?: string
}
const buildParams = (filters: SupplierFilters = {}) => {
const params = new URLSearchParams()
if (filters.search) params.append('search', filters.search)
if (filters.status) params.append('status', filters.status)
if (filters.rating) params.append('rating', filters.rating.toString())
if (filters.category) params.append('category', filters.category)
if (filters.page) params.append('page', filters.page.toString())
if (filters.pageSize) params.append('pageSize', filters.pageSize.toString())
return params
}
export const suppliersAPI = {
getAll: async (filters: SupplierFilters = {}): Promise<SuppliersResponse> => {
const params = buildParams(filters)
const response = await api.get(`/suppliers?${params.toString()}`)
const { data, pagination } = response.data
return {
suppliers: data || [],
total: pagination?.total || 0,
page: pagination?.page || 1,
pageSize: pagination?.pageSize || 20,
totalPages: pagination?.totalPages || 0,
}
},
getStats: async (): Promise<SupplierStats> => {
const response = await api.get('/suppliers/stats')
return response.data.data
},
getById: async (id: string): Promise<Supplier> => {
const response = await api.get(`/suppliers/${id}`)
return response.data.data
},
create: async (data: CreateSupplierData): Promise<Supplier> => {
const response = await api.post('/suppliers', data)
return response.data.data
},
update: async (id: string, data: UpdateSupplierData): Promise<Supplier> => {
const response = await api.put(`/suppliers/${id}`, data)
return response.data.data
},
archive: async (id: string, reason?: string): Promise<Supplier> => {
const response = await api.post(`/suppliers/${id}/archive`, { reason })
return response.data.data
},
export: async (filters: SupplierFilters = {}): Promise<Blob> => {
const params = buildParams(filters)
const response = await api.get(`/suppliers/export?${params.toString()}`, { responseType: 'blob' })
return response.data
}
}

View File

@@ -0,0 +1,66 @@
import type { Category } from './api/categories'
export const DEFAULT_SUPPLIER_CATEGORIES = [
'كاميرات',
'شبكات',
'أجهزة كومبيوتر',
'projectors',
'مقاسم هاتفية',
'Mobile - Tablet',
'firewall',
'طاقة بديلة',
'حديد',
'باركود - POS',
'أجهزة منزلية',
'تكييف وتبريد',
]
const SUPPLIER_SYSTEM_NAMES = ['Supplier', 'Suppliers']
const SUPPLIER_SYSTEM_AR_NAMES = ['مورد', 'مورّد', 'موردين']
export function normalizeSupplierCategoryName(value?: string | null) {
return String(value || '').trim().replace(/\s+/g, ' ').toLowerCase()
}
export function isSupplierSystemCategoryName(name?: string | null, nameAr?: string | null) {
const normalizedName = normalizeSupplierCategoryName(name)
const normalizedNameAr = normalizeSupplierCategoryName(nameAr)
return (
SUPPLIER_SYSTEM_NAMES.map(normalizeSupplierCategoryName).includes(normalizedName) ||
SUPPLIER_SYSTEM_AR_NAMES.some((word) => normalizedNameAr.includes(normalizeSupplierCategoryName(word)))
)
}
export function isSupplierBusinessCategoryName(name?: string | null, nameAr?: string | null) {
const normalizedName = normalizeSupplierCategoryName(name)
const normalizedNameAr = normalizeSupplierCategoryName(nameAr)
return DEFAULT_SUPPLIER_CATEGORIES.some((category) => {
const normalizedCategory = normalizeSupplierCategoryName(category)
return normalizedName === normalizedCategory || normalizedNameAr === normalizedCategory
})
}
export function isSupplierOnlyCategoryName(name?: string | null, nameAr?: string | null) {
return isSupplierSystemCategoryName(name, nameAr) || isSupplierBusinessCategoryName(name, nameAr)
}
export function filterContactCategoryTree(categories: Category[]): Category[] {
return categories
.filter((category) => !isSupplierOnlyCategoryName(category.name, category.nameAr))
.map((category) => ({
...category,
children: category.children ? filterContactCategoryTree(category.children) : undefined,
}))
}
export function uniqueSupplierCategories(values: Array<string | undefined | null>) {
const map = new Map<string, string>()
values.forEach((value) => {
const label = String(value || '').trim()
if (!label) return
const key = normalizeSupplierCategoryName(label)
if (!map.has(key)) map.set(key, label)
})
return Array.from(map.values())
}