Files
zerp/frontend/src/app/crm/page.tsx

1233 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
import { useSearchParams } from 'next/navigation'
import ProtectedRoute from '@/components/ProtectedRoute'
import Modal from '@/components/Modal'
import LoadingSpinner from '@/components/LoadingSpinner'
import Link from 'next/link'
import { toast } from 'react-hot-toast'
import {
TrendingUp,
Plus,
Search,
Filter,
DollarSign,
Target,
Award,
Clock,
ArrowLeft,
Users,
CheckCircle2,
XCircle,
AlertCircle,
Edit,
Trash2,
Calendar,
Building2,
User,
Loader2,
FileText,
TrendingDown
} from 'lucide-react'
import { dealsAPI, Deal, CreateDealData, UpdateDealData, DealFilters } from '@/lib/api/deals'
import { contactsAPI } from '@/lib/api/contacts'
import { pipelinesAPI, Pipeline } from '@/lib/api/pipelines'
import { useLanguage } from '@/contexts/LanguageContext'
function CRMContent() {
const { t } = useLanguage()
const searchParams = useSearchParams()
// State Management
const [deals, setDeals] = useState<Deal[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Pagination
const [currentPage, setCurrentPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [total, setTotal] = useState(0)
const pageSize = 10
// Filters
const [searchTerm, setSearchTerm] = useState('')
const [selectedStructure, setSelectedStructure] = useState('all')
const [selectedStage, setSelectedStage] = useState('all')
const [selectedStatus, setSelectedStatus] = useState('all')
// Modals
const [showCreateModal, setShowCreateModal] = useState(false)
const [showEditModal, setShowEditModal] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [showWinDialog, setShowWinDialog] = useState(false)
const [showLoseDialog, setShowLoseDialog] = useState(false)
const [selectedDeal, setSelectedDeal] = useState<Deal | null>(null)
// Form Data
const [formData, setFormData] = useState<CreateDealData>({
name: '',
contactId: '',
structure: 'B2B',
pipelineId: '',
stage: 'LEAD',
estimatedValue: 0,
probability: 50,
expectedCloseDate: ''
})
const [formErrors, setFormErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
// Win/Lose Forms
const [winData, setWinData] = useState({ actualValue: 0, wonReason: '' })
const [loseData, setLoseData] = useState({ lostReason: '' })
// Contacts for dropdown
const [contacts, setContacts] = useState<any[]>([])
const [loadingContacts, setLoadingContacts] = useState(false)
// Pipelines for dropdown
const [pipelines, setPipelines] = useState<Pipeline[]>([])
const [loadingPipelines, setLoadingPipelines] = useState(false)
const editHandledRef = useRef<string | null>(null)
// Fetch Contacts for dropdown
useEffect(() => {
const fetchContacts = async () => {
setLoadingContacts(true)
try {
const data = await contactsAPI.getAll({ pageSize: 100 })
setContacts(data.contacts)
} catch (err) {
console.error('Failed to load contacts:', err)
} finally {
setLoadingContacts(false)
}
}
fetchContacts()
}, [])
// Fetch Pipelines for dropdown
useEffect(() => {
const fetchPipelines = async () => {
setLoadingPipelines(true)
try {
const data = await pipelinesAPI.getAll()
setPipelines(data)
} catch (err) {
console.error('Failed to load pipelines:', err)
toast.error('Failed to load pipelines')
} finally {
setLoadingPipelines(false)
}
}
fetchPipelines()
}, [])
// Fetch Deals (with debouncing for search)
const fetchDeals = useCallback(async () => {
setLoading(true)
setError(null)
try {
const filters: DealFilters = {
page: currentPage,
pageSize,
}
if (searchTerm) filters.search = searchTerm
if (selectedStructure !== 'all') filters.structure = selectedStructure
if (selectedStage !== 'all') filters.stage = selectedStage
if (selectedStatus !== 'all') filters.status = selectedStatus
const data = await dealsAPI.getAll(filters)
setDeals(data.deals)
setTotal(data.total)
setTotalPages(data.totalPages)
} catch (err: any) {
setError(err.response?.data?.message || 'Failed to load deals')
toast.error('Failed to load deals')
} finally {
setLoading(false)
}
}, [currentPage, searchTerm, selectedStructure, selectedStage, selectedStatus])
// Debounced search
useEffect(() => {
const debounce = setTimeout(() => {
setCurrentPage(1)
fetchDeals()
}, 500)
return () => clearTimeout(debounce)
}, [searchTerm])
// Fetch on filter/page change
useEffect(() => {
fetchDeals()
}, [currentPage, selectedStructure, selectedStage, selectedStatus])
// Handle ?edit=dealId from URL (e.g. from deal detail page)
const editId = searchParams.get('edit')
useEffect(() => {
if (!editId || editHandledRef.current === editId) return
const deal = deals.find(d => d.id === editId)
if (deal) {
editHandledRef.current = editId
setSelectedDeal(deal)
setFormData({
name: deal.name,
contactId: deal.contactId,
structure: deal.structure,
pipelineId: deal.pipelineId,
stage: deal.stage,
estimatedValue: deal.estimatedValue,
probability: deal.probability,
expectedCloseDate: deal.expectedCloseDate?.split('T')[0] || ''
})
setShowEditModal(true)
} else if (!loading) {
editHandledRef.current = editId
dealsAPI.getById(editId).then((d) => {
setSelectedDeal(d)
setFormData({
name: d.name,
contactId: d.contactId,
structure: d.structure,
pipelineId: d.pipelineId,
stage: d.stage,
estimatedValue: d.estimatedValue,
probability: d.probability,
expectedCloseDate: d.expectedCloseDate?.split('T')[0] || ''
})
setShowEditModal(true)
}).catch(() => toast.error('Deal not found'))
}
}, [editId, loading, deals])
// Form Validation
const validateForm = (): boolean => {
const errors: Record<string, string> = {}
if (!formData.name || formData.name.trim().length < 3) {
errors.name = t('crm.dealNameMin')
}
if (!formData.contactId) {
errors.contactId = t('crm.contactRequired')
}
if (!formData.structure) {
errors.structure = t('crm.structureRequired')
}
if (!formData.pipelineId) {
errors.pipelineId = t('crm.pipelineRequired')
}
if (!formData.stage) {
errors.stage = t('crm.stageRequired')
}
if (!formData.estimatedValue || formData.estimatedValue <= 0) {
errors.estimatedValue = t('crm.valueRequired')
}
setFormErrors(errors)
return Object.keys(errors).length === 0
}
// Create Deal
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault()
if (!validateForm()) {
toast.error(t('crm.fixFormErrors'))
return
}
setSubmitting(true)
try {
await dealsAPI.create(formData)
toast.success(t('crm.createSuccess'))
setShowCreateModal(false)
resetForm()
fetchDeals()
} catch (err: any) {
const message = err.response?.data?.message || 'Failed to create deal'
toast.error(message)
if (err.response?.data?.errors) {
setFormErrors(err.response.data.errors)
}
} finally {
setSubmitting(false)
}
}
// Edit Deal
const handleEdit = async (e: React.FormEvent) => {
e.preventDefault()
if (!selectedDeal || !validateForm()) {
toast.error(t('crm.fixFormErrors'))
return
}
setSubmitting(true)
try {
await dealsAPI.update(selectedDeal.id, formData as UpdateDealData)
toast.success(t('crm.updateSuccess'))
setShowEditModal(false)
resetForm()
fetchDeals()
} catch (err: any) {
const message = err.response?.data?.message || 'Failed to update deal'
toast.error(message)
} finally {
setSubmitting(false)
}
}
// Delete Deal (Archive)
const handleDelete = async () => {
if (!selectedDeal) return
setSubmitting(true)
try {
await dealsAPI.lose(selectedDeal.id, 'Deal deleted by user')
toast.success('Deal marked as lost!')
setShowDeleteDialog(false)
setSelectedDeal(null)
fetchDeals()
} catch (err: any) {
const message = err.response?.data?.message || 'Failed to delete deal'
toast.error(message)
} finally {
setSubmitting(false)
}
}
// Win Deal
const handleWin = async () => {
if (!selectedDeal || !winData.actualValue || !winData.wonReason) {
toast.error('Please fill all fields')
return
}
setSubmitting(true)
try {
await dealsAPI.win(selectedDeal.id, winData.actualValue, winData.wonReason)
toast.success(t('crm.winSuccess'))
setShowWinDialog(false)
setSelectedDeal(null)
setWinData({ actualValue: 0, wonReason: '' })
fetchDeals()
} catch (err: any) {
const message = err.response?.data?.message || 'Failed to mark deal as won'
toast.error(message)
} finally {
setSubmitting(false)
}
}
// Lose Deal
const handleLose = async () => {
if (!selectedDeal || !loseData.lostReason) {
toast.error('Please provide a reason')
return
}
setSubmitting(true)
try {
await dealsAPI.lose(selectedDeal.id, loseData.lostReason)
toast.success(t('crm.loseSuccess'))
setShowLoseDialog(false)
setSelectedDeal(null)
setLoseData({ lostReason: '' })
fetchDeals()
} catch (err: any) {
const message = err.response?.data?.message || 'Failed to mark deal as lost'
toast.error(message)
} finally {
setSubmitting(false)
}
}
// Pipelines filtered by selected structure (or all if no match)
const filteredPipelines = formData.structure
? pipelines.filter(p => p.structure === formData.structure)
: pipelines
const displayPipelines = filteredPipelines.length > 0 ? filteredPipelines : pipelines
// Utility Functions
const resetForm = () => {
const defaultStructure = 'B2B'
const matchingPipelines = pipelines.filter(p => p.structure === defaultStructure)
const firstPipeline = matchingPipelines[0] || pipelines[0]
const firstStage = firstPipeline?.stages?.length
? (firstPipeline.stages as { name: string }[])[0].name
: 'LEAD'
setFormData({
name: '',
contactId: '',
structure: defaultStructure,
pipelineId: firstPipeline?.id ?? '',
stage: firstStage,
estimatedValue: 0,
probability: 50,
expectedCloseDate: ''
})
setFormErrors({})
setSelectedDeal(null)
}
const openEditModal = (deal: Deal) => {
setSelectedDeal(deal)
setFormData({
name: deal.name,
contactId: deal.contactId,
structure: deal.structure,
pipelineId: deal.pipelineId,
stage: deal.stage,
estimatedValue: deal.estimatedValue,
probability: deal.probability,
expectedCloseDate: deal.expectedCloseDate?.split('T')[0] || ''
})
setShowEditModal(true)
}
const openDeleteDialog = (deal: Deal) => {
setSelectedDeal(deal)
setShowDeleteDialog(true)
}
const openWinDialog = (deal: Deal) => {
setSelectedDeal(deal)
setWinData({ actualValue: deal.estimatedValue, wonReason: '' })
setShowWinDialog(true)
}
const openLoseDialog = (deal: Deal) => {
setSelectedDeal(deal)
setLoseData({ lostReason: '' })
setShowLoseDialog(true)
}
const getStageColor = (stage: string) => {
const colors: Record<string, string> = {
LEAD: 'bg-gray-100 text-gray-700',
QUALIFIED: 'bg-blue-100 text-blue-700',
PROPOSAL: 'bg-purple-100 text-purple-700',
NEGOTIATION: 'bg-orange-100 text-orange-700',
WON: 'bg-green-100 text-green-700',
LOST: 'bg-red-100 text-red-700'
}
return colors[stage] || 'bg-gray-100 text-gray-700'
}
const getStageLabel = (stage: string) => {
const labels: Record<string, string> = {
LEAD: 'عميل محتمل',
QUALIFIED: 'مؤهل',
PROPOSAL: 'عرض',
NEGOTIATION: 'تفاوض',
WON: 'فوز',
LOST: 'خسارة'
}
return labels[stage] || stage
}
const getStructureLabel = (structure: string) => {
const labels: Record<string, string> = {
B2B: 'شركة لشركة',
B2C: 'شركة لفرد',
B2G: 'شركة لحكومة',
PARTNERSHIP: 'شراكة'
}
return labels[structure] || structure
}
// Calculate stats
const totalValue = deals.reduce((sum, deal) => sum + deal.estimatedValue, 0)
const expectedValue = deals.reduce((sum, deal) => sum + (deal.estimatedValue * (deal.probability || 0) / 100), 0)
const wonDeals = deals.filter(d => d.status === 'WON').length
const activeDeals = deals.filter(d => d.status === 'ACTIVE').length
// Render Form Fields Component
const FormFields = ({ isEdit = false }: { isEdit?: boolean }) => (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Deal Structure */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('crm.structure')} <span className="text-red-500">*</span>
</label>
<select
value={formData.structure}
onChange={(e) => {
const structure = e.target.value
const matchingPipelines = pipelines.filter(p => p.structure === structure)
const firstPipeline = matchingPipelines[0] || pipelines[0]
const firstStage = firstPipeline?.stages?.length
? (firstPipeline.stages as { name: string }[])[0].name
: 'LEAD'
setFormData({ ...formData, structure, pipelineId: firstPipeline?.id ?? '', stage: firstStage })
}}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-500"
>
<option value="B2B">{t('crm.structureB2B')}</option>
<option value="B2C">{t('crm.structureB2C')}</option>
<option value="B2G">{t('crm.structureB2G')}</option>
<option value="PARTNERSHIP">{t('crm.structurePartnership')}</option>
</select>
{formErrors.structure && <p className="text-red-500 text-xs mt-1">{formErrors.structure}</p>}
</div>
{/* Pipeline */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('crm.pipeline')} <span className="text-red-500">*</span>
</label>
<select
value={formData.pipelineId}
onChange={(e) => {
const pipelineId = e.target.value
const pipeline = displayPipelines.find(p => p.id === pipelineId)
const firstStage = pipeline?.stages?.length
? (pipeline.stages as { name: string }[])[0].name
: 'LEAD'
setFormData({ ...formData, pipelineId, stage: firstStage })
}}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-500"
disabled={loadingPipelines || isEdit}
>
<option value="">{loadingPipelines ? t('common.loading') : t('crm.selectPipeline')}</option>
{displayPipelines.map(p => (
<option key={p.id} value={p.id}>{p.name} {p.structure ? `(${p.structure})` : ''}</option>
))}
</select>
{formErrors.pipelineId && <p className="text-red-500 text-xs mt-1">{formErrors.pipelineId}</p>}
</div>
{/* Contact */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('crm.contact')} <span className="text-red-500">*</span>
</label>
<select
value={formData.contactId}
onChange={(e) => setFormData({ ...formData, contactId: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-500"
disabled={loadingContacts}
>
<option value="">{t('crm.selectContact')}</option>
{contacts.map(contact => (
<option key={contact.id} value={contact.id}>{contact.name}</option>
))}
</select>
{formErrors.contactId && <p className="text-red-500 text-xs mt-1">{formErrors.contactId}</p>}
</div>
</div>
{/* Deal Name */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('crm.dealName')} <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-green-500"
placeholder={t('crm.enterDealName')}
/>
{formErrors.name && <p className="text-red-500 text-xs mt-1">{formErrors.name}</p>}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Stage */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('crm.stage')} <span className="text-red-500">*</span>
</label>
<select
value={formData.stage}
onChange={(e) => setFormData({ ...formData, stage: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-500"
>
{(() => {
const selectedPipeline = pipelines.find(p => p.id === formData.pipelineId)
const stages = (selectedPipeline?.stages as { name: string; nameAr?: string }[] | undefined) ?? []
if (stages.length > 0) {
const stageNames = new Set(stages.map(s => s.name))
const options = stages.map(s => (
<option key={s.name} value={s.name}>{s.nameAr ? `${s.name} - ${s.nameAr}` : s.name}</option>
))
if (formData.stage && !stageNames.has(formData.stage)) {
options.unshift(<option key={formData.stage} value={formData.stage}>{formData.stage}</option>)
}
return options
}
return (
<>
<option value="LEAD">Lead - عميل محتمل</option>
<option value="QUALIFIED">Qualified - مؤهل</option>
<option value="PROPOSAL">Proposal - عرض</option>
<option value="NEGOTIATION">Negotiation - تفاوض</option>
</>
)
})()}
</select>
{formErrors.stage && <p className="text-red-500 text-xs mt-1">{formErrors.stage}</p>}
</div>
{/* Probability */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('crm.probability')} (%)
</label>
<input
type="number"
min="0"
max="100"
value={formData.probability || 50}
onChange={(e) => setFormData({ ...formData, probability: parseInt(e.target.value) })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-500"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Estimated Value */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('crm.estimatedValue')} <span className="text-red-500">*</span>
</label>
<input
type="number"
min="0"
value={formData.estimatedValue}
onChange={(e) => setFormData({ ...formData, estimatedValue: parseFloat(e.target.value) })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-500"
placeholder="0.00"
/>
{formErrors.estimatedValue && <p className="text-red-500 text-xs mt-1">{formErrors.estimatedValue}</p>}
</div>
{/* Expected Close Date */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('crm.expectedCloseDate')}
</label>
<input
type="date"
value={formData.expectedCloseDate || ''}
onChange={(e) => setFormData({ ...formData, expectedCloseDate: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-500"
/>
</div>
</div>
{/* Expected Value Display */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-blue-900">Expected Value:</span>
<span className="text-lg font-bold text-blue-900">
{(formData.estimatedValue * (formData.probability || 0) / 100).toLocaleString()} SAR
</span>
</div>
<p className="text-xs text-blue-700 mt-1">
Calculated as: Estimated Value × Probability
</p>
</div>
{/* Form Actions */}
<div className="flex items-center justify-end gap-3 pt-4 border-t">
<button
type="button"
onClick={() => {
isEdit ? setShowEditModal(false) : setShowCreateModal(false)
resetForm()
}}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
disabled={submitting}
>
{t('common.cancel')}
</button>
<button
type="submit"
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled={submitting}
>
{submitting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
{isEdit ? t('crm.updating') : t('crm.creating')}
</>
) : (
<>
{isEdit ? t('crm.updateDeal') : t('crm.createDeal')}
</>
)}
</button>
</div>
</div>
)
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<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="/dashboard"
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-green-100 p-2 rounded-lg">
<TrendingUp className="h-6 w-6 text-green-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">{t('crm.title')}</h1>
<p className="text-sm text-gray-600">{t('crm.subtitle')}</p>
</div>
</div>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => {
resetForm()
setShowCreateModal(true)
}}
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
<Plus className="h-4 w-4" />
{t('crm.addDeal')}
</button>
</div>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-200">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">{t('crm.totalValue')}</p>
<p className="text-3xl font-bold text-gray-900 mt-1">
{(totalValue / 1000).toFixed(0)}K
</p>
<p className="text-xs text-gray-600 mt-1">SAR</p>
</div>
<div className="bg-blue-100 p-3 rounded-lg">
<DollarSign className="h-8 w-8 text-blue-600" />
</div>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-200">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">{t('crm.expectedValue')}</p>
<p className="text-3xl font-bold text-gray-900 mt-1">
{(expectedValue / 1000).toFixed(0)}K
</p>
<p className="text-xs text-green-600 mt-1">
{totalValue > 0 ? ((expectedValue / totalValue) * 100).toFixed(0) : 0}% {t('crm.conversion')}
</p>
</div>
<div className="bg-green-100 p-3 rounded-lg">
<Target className="h-8 w-8 text-green-600" />
</div>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-200">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">{t('crm.activeDeals')}</p>
<p className="text-3xl font-bold text-gray-900 mt-1">{activeDeals}</p>
<p className="text-xs text-orange-600 mt-1">{t('crm.inPipeline')}</p>
</div>
<div className="bg-orange-100 p-3 rounded-lg">
<Clock className="h-8 w-8 text-orange-600" />
</div>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-200">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">{t('crm.wonDeals')}</p>
<p className="text-3xl font-bold text-gray-900 mt-1">{wonDeals}</p>
<p className="text-xs text-green-600 mt-1">
{total > 0 ? ((wonDeals / total) * 100).toFixed(0) : 0}% {t('crm.winRate')}
</p>
</div>
<div className="bg-purple-100 p-3 rounded-lg">
<Award className="h-8 w-8 text-purple-600" />
</div>
</div>
</div>
</div>
{/* Filters and Search */}
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-200 mb-6">
<div className="flex flex-col md:flex-row gap-4">
{/* Search */}
<div className="flex-1 relative">
<Search className="absolute right-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
type="text"
placeholder={t('crm.searchPlaceholder')}
value={searchTerm}
onChange={(e) => 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-green-500"
/>
</div>
{/* Structure Filter */}
<select
value={selectedStructure}
onChange={(e) => setSelectedStructure(e.target.value)}
className="px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-500"
>
<option value="all">{t('crm.allStructures')}</option>
<option value="B2B">B2B</option>
<option value="B2C">B2C</option>
<option value="B2G">B2G</option>
<option value="PARTNERSHIP">Partnership</option>
</select>
{/* Stage Filter */}
<select
value={selectedStage}
onChange={(e) => setSelectedStage(e.target.value)}
className="px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-500"
>
<option value="all">{t('crm.allStages')}</option>
<option value="LEAD">Lead</option>
<option value="QUALIFIED">Qualified</option>
<option value="PROPOSAL">Proposal</option>
<option value="NEGOTIATION">Negotiation</option>
<option value="WON">Won</option>
<option value="LOST">Lost</option>
</select>
{/* Status Filter */}
<select
value={selectedStatus}
onChange={(e) => setSelectedStatus(e.target.value)}
className="px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-500"
>
<option value="all">{t('crm.allStatus')}</option>
<option value="ACTIVE">Active</option>
<option value="WON">Won</option>
<option value="LOST">Lost</option>
</select>
</div>
</div>
{/* Deals Table */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
{loading ? (
<div className="p-12">
<LoadingSpinner size="lg" message={t('crm.loadingDeals')} />
</div>
) : error ? (
<div className="p-12 text-center">
<p className="text-red-600 mb-4">{error}</p>
<button
onClick={fetchDeals}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
>
{t('crm.retry')}
</button>
</div>
) : deals.length === 0 ? (
<div className="p-12 text-center">
<TrendingUp className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-600 mb-4">{t('crm.noDealsFound')}</p>
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
>
{t('crm.createFirstDeal')}
</button>
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">{t('crm.deal')}</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">{t('crm.contact')}</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">{t('crm.structure')}</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">{t('crm.value')}</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">{t('crm.probability')}</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">{t('crm.stage')}</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">{t('common.actions')}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{deals.map((deal) => (
<tr key={deal.id} className="hover:bg-gray-50 transition-colors">
<td className="px-6 py-4">
<div>
<Link
href={`/crm/deals/${deal.id}`}
className="font-semibold text-gray-900 hover:text-green-600 hover:underline"
>
{deal.name}
</Link>
<p className="text-xs text-gray-600">{deal.dealNumber}</p>
</div>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-gray-400" />
<span className="text-sm text-gray-900">
{deal.contact?.name || 'N/A'}
</span>
</div>
</td>
<td className="px-6 py-4">
<span className="text-sm text-gray-900">
{getStructureLabel(deal.structure)}
</span>
</td>
<td className="px-6 py-4">
<div>
<span className="text-sm font-semibold text-gray-900">
{(deal.estimatedValue ?? 0).toLocaleString()} SAR
</span>
{(deal.actualValue ?? 0) > 0 && (
<p className="text-xs text-green-600">
Actual: {(deal.actualValue ?? 0).toLocaleString()}
</p>
)}
</div>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2">
<div className="w-16 h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-green-500 rounded-full"
style={{ width: `${deal.probability || 0}%` }}
/>
</div>
<span className="text-sm text-gray-600">{deal.probability || 0}%</span>
</div>
</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${getStageColor(deal.stage)}`}>
{getStageLabel(deal.stage)}
</span>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2">
{deal.status === 'ACTIVE' && (
<>
<button
onClick={() => openWinDialog(deal)}
className="p-2 hover:bg-green-50 text-green-600 rounded-lg transition-colors"
title={t('crm.markWon')}
>
<CheckCircle2 className="h-4 w-4" />
</button>
<button
onClick={() => openLoseDialog(deal)}
className="p-2 hover:bg-red-50 text-red-600 rounded-lg transition-colors"
title={t('crm.markLost')}
>
<XCircle className="h-4 w-4" />
</button>
</>
)}
<button
onClick={() => openEditModal(deal)}
className="p-2 hover:bg-blue-50 text-blue-600 rounded-lg transition-colors"
title={t('common.edit')}
>
<Edit className="h-4 w-4" />
</button>
<button
onClick={() => openDeleteDialog(deal)}
className="p-2 hover:bg-red-50 text-red-600 rounded-lg transition-colors"
title={t('crm.deleteDeal')}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
<p className="text-sm text-gray-600">
Showing <span className="font-semibold">{((currentPage - 1) * pageSize) + 1}</span> to{' '}
<span className="font-semibold">{Math.min(currentPage * pageSize, total)}</span> of{' '}
<span className="font-semibold">{total}</span> deals
</p>
<div className="flex items-center gap-2">
<button
onClick={() => setCurrentPage(currentPage - 1)}
disabled={currentPage === 1}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('crm.paginationPrevious')}
</button>
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
const page = i + 1
return (
<button
key={page}
onClick={() => setCurrentPage(page)}
className={`px-4 py-2 rounded-lg transition-colors ${
currentPage === page
? 'bg-green-600 text-white'
: 'border border-gray-300 text-gray-700 hover:bg-gray-50'
}`}
>
{page}
</button>
)
})}
{totalPages > 5 && <span className="px-2">...</span>}
<button
onClick={() => setCurrentPage(currentPage + 1)}
disabled={currentPage === totalPages}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('crm.paginationNext')}
</button>
</div>
</div>
</>
)}
</div>
</main>
{/* Create Modal */}
<Modal
isOpen={showCreateModal}
onClose={() => {
setShowCreateModal(false)
resetForm()
}}
title={t('crm.createNewDeal')}
size="xl"
>
<form onSubmit={handleCreate}>
<FormFields />
</form>
</Modal>
{/* Edit Modal */}
<Modal
isOpen={showEditModal}
onClose={() => {
setShowEditModal(false)
resetForm()
}}
title={t('crm.editDeal')}
size="xl"
>
<form onSubmit={handleEdit}>
<FormFields isEdit />
</form>
</Modal>
{/* Win Dialog */}
{showWinDialog && selectedDeal && (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={() => setShowWinDialog(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">
<div className="flex items-center gap-4 mb-4">
<div className="bg-green-100 p-3 rounded-full">
<Award className="h-6 w-6 text-green-600" />
</div>
<div>
<h3 className="text-lg font-bold text-gray-900">{t('crm.markDealWon')}</h3>
<p className="text-sm text-gray-600">{selectedDeal.name}</p>
</div>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('crm.actualValue')} <span className="text-red-500">*</span>
</label>
<input
type="number"
min="0"
value={winData.actualValue}
onChange={(e) => setWinData({ ...winData, actualValue: parseFloat(e.target.value) })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('crm.reasonForWinning')} <span className="text-red-500">*</span>
</label>
<textarea
value={winData.wonReason}
onChange={(e) => setWinData({ ...winData, wonReason: e.target.value })}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-500"
placeholder={t('crm.winPlaceholder')}
/>
</div>
</div>
<div className="flex items-center justify-end gap-3 mt-6">
<button
onClick={() => setShowWinDialog(false)}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
disabled={submitting}
>
{t('common.cancel')}
</button>
<button
onClick={handleWin}
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50"
disabled={submitting}
>
{submitting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
{t('crm.processing')}
</>
) : (
t('crm.markWon')
)}
</button>
</div>
</div>
</div>
</div>
)}
{/* Lose Dialog */}
{showLoseDialog && selectedDeal && (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={() => setShowLoseDialog(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">
<div className="flex items-center gap-4 mb-4">
<div className="bg-red-100 p-3 rounded-full">
<TrendingDown className="h-6 w-6 text-red-600" />
</div>
<div>
<h3 className="text-lg font-bold text-gray-900">{t('crm.markDealLost')}</h3>
<p className="text-sm text-gray-600">{selectedDeal.name}</p>
</div>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('crm.reasonForLosing')} <span className="text-red-500">*</span>
</label>
<textarea
value={loseData.lostReason}
onChange={(e) => setLoseData({ ...loseData, lostReason: e.target.value })}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
placeholder={t('crm.losePlaceholder')}
/>
</div>
</div>
<div className="flex items-center justify-end gap-3 mt-6">
<button
onClick={() => setShowLoseDialog(false)}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
disabled={submitting}
>
{t('common.cancel')}
</button>
<button
onClick={handleLose}
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50"
disabled={submitting}
>
{submitting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
{t('crm.processing')}
</>
) : (
t('crm.markLost')
)}
</button>
</div>
</div>
</div>
</div>
)}
{/* Delete Confirmation Dialog */}
{showDeleteDialog && selectedDeal && (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={() => setShowDeleteDialog(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">
<div className="flex items-center gap-4 mb-4">
<div className="bg-red-100 p-3 rounded-full">
<Trash2 className="h-6 w-6 text-red-600" />
</div>
<div>
<h3 className="text-lg font-bold text-gray-900">{t('crm.deleteDeal')}</h3>
<p className="text-sm text-gray-600">{t('crm.deleteDealDesc')}</p>
</div>
</div>
<p className="text-gray-700 mb-6">
{t('crm.deleteDealConfirm')} <span className="font-semibold">{selectedDeal.name}</span>?
</p>
<div className="flex items-center justify-end gap-3">
<button
onClick={() => {
setShowDeleteDialog(false)
setSelectedDeal(null)
}}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
disabled={submitting}
>
{t('common.cancel')}
</button>
<button
onClick={handleDelete}
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50"
disabled={submitting}
>
{submitting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
{t('crm.deleting')}
</>
) : (
t('crm.deleteDeal')
)}
</button>
</div>
</div>
</div>
</div>
)}
</div>
)
}
export default function CRMPage() {
return (
<ProtectedRoute>
<CRMContent />
</ProtectedRoute>
)
}