Deploy rule, CRM enhancements, Company Employee category, bilingual, contacts module completion
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
540
frontend/src/app/crm/deals/[id]/page.tsx
Normal file
540
frontend/src/app/crm/deals/[id]/page.tsx
Normal file
@@ -0,0 +1,540 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { toast } from 'react-hot-toast'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Edit,
|
||||
Archive,
|
||||
History,
|
||||
Award,
|
||||
TrendingDown,
|
||||
DollarSign,
|
||||
Target,
|
||||
Calendar,
|
||||
User,
|
||||
Building2,
|
||||
FileText,
|
||||
Clock,
|
||||
Loader2
|
||||
} from 'lucide-react'
|
||||
import ProtectedRoute from '@/components/ProtectedRoute'
|
||||
import LoadingSpinner from '@/components/LoadingSpinner'
|
||||
import { dealsAPI, Deal } from '@/lib/api/deals'
|
||||
import { quotesAPI, Quote } from '@/lib/api/quotes'
|
||||
import { useLanguage } from '@/contexts/LanguageContext'
|
||||
|
||||
function DealDetailContent() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const dealId = params.id as string
|
||||
const { t } = useLanguage()
|
||||
|
||||
const [deal, setDeal] = useState<Deal | null>(null)
|
||||
const [quotes, setQuotes] = useState<Quote[]>([])
|
||||
const [history, setHistory] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [activeTab, setActiveTab] = useState<'info' | 'quotes' | 'history'>('info')
|
||||
const [showWinDialog, setShowWinDialog] = useState(false)
|
||||
const [showLoseDialog, setShowLoseDialog] = useState(false)
|
||||
const [winData, setWinData] = useState({ actualValue: 0, wonReason: '' })
|
||||
const [loseData, setLoseData] = useState({ lostReason: '' })
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchDeal()
|
||||
}, [dealId])
|
||||
|
||||
useEffect(() => {
|
||||
if (deal) {
|
||||
fetchQuotes()
|
||||
fetchHistory()
|
||||
}
|
||||
}, [deal])
|
||||
|
||||
const fetchDeal = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await dealsAPI.getById(dealId)
|
||||
setDeal(data)
|
||||
} catch (err: any) {
|
||||
const message = err.response?.data?.message || 'Failed to load deal'
|
||||
setError(message)
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchQuotes = async () => {
|
||||
try {
|
||||
const data = await quotesAPI.getByDeal(dealId)
|
||||
setQuotes(data || [])
|
||||
} catch {
|
||||
setQuotes([])
|
||||
}
|
||||
}
|
||||
|
||||
const fetchHistory = async () => {
|
||||
try {
|
||||
const data = await dealsAPI.getHistory(dealId)
|
||||
setHistory(data || [])
|
||||
} catch {
|
||||
setHistory([])
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
ACTIVE: 'bg-green-100 text-green-700',
|
||||
WON: 'bg-blue-100 text-blue-700',
|
||||
LOST: 'bg-red-100 text-red-700'
|
||||
}
|
||||
return colors[status] || 'bg-gray-100 text-gray-700'
|
||||
}
|
||||
|
||||
const getStructureLabel = (structure: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
B2B: 'B2B',
|
||||
B2C: 'B2C',
|
||||
B2G: 'B2G',
|
||||
PARTNERSHIP: 'Partnership'
|
||||
}
|
||||
return labels[structure] || structure
|
||||
}
|
||||
|
||||
const formatDate = (dateStr?: string) => {
|
||||
if (!dateStr) return '—'
|
||||
return new Date(dateStr).toLocaleDateString()
|
||||
}
|
||||
|
||||
const handleWin = async () => {
|
||||
if (!deal || !winData.actualValue || !winData.wonReason) {
|
||||
toast.error(t('crm.fixFormErrors'))
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await dealsAPI.win(deal.id, winData.actualValue, winData.wonReason)
|
||||
toast.success(t('crm.winSuccess'))
|
||||
setShowWinDialog(false)
|
||||
setWinData({ actualValue: 0, wonReason: '' })
|
||||
fetchDeal()
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || 'Failed to mark as won')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLose = async () => {
|
||||
if (!deal || !loseData.lostReason) {
|
||||
toast.error(t('crm.fixFormErrors'))
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await dealsAPI.lose(deal.id, loseData.lostReason)
|
||||
toast.success(t('crm.loseSuccess'))
|
||||
setShowLoseDialog(false)
|
||||
setLoseData({ lostReason: '' })
|
||||
fetchDeal()
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || 'Failed to mark as lost')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<LoadingSpinner size="lg" message={t('crm.loadingDeals')} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !deal) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-gray-900 mb-2">{error || 'Deal not found'}</p>
|
||||
<Link
|
||||
href="/crm"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t('common.back')} {t('nav.crm')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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="/crm"
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5 text-gray-600" />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-gray-900">{deal.name}</h1>
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${getStatusColor(deal.status)}`}>
|
||||
{deal.status}
|
||||
</span>
|
||||
<span className="px-3 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-700">
|
||||
{getStructureLabel(deal.structure)} - {deal.stage}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-1">{deal.dealNumber}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{deal.status === 'ACTIVE' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
setWinData({ actualValue: deal.estimatedValue, wonReason: '' })
|
||||
setShowWinDialog(true)
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-green-300 text-green-700 rounded-lg hover:bg-green-50"
|
||||
>
|
||||
<Award className="h-4 w-4" />
|
||||
{t('crm.win')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setLoseData({ lostReason: '' })
|
||||
setShowLoseDialog(true)
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-red-300 text-red-700 rounded-lg hover:bg-red-50"
|
||||
>
|
||||
<TrendingDown className="h-4 w-4" />
|
||||
{t('crm.lose')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => router.push(`/crm?edit=${dealId}`)}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
{t('common.edit')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('history')}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<History className="h-4 w-4" />
|
||||
{t('crm.history')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex items-center gap-2 text-sm text-gray-600 mt-4">
|
||||
<Link href="/dashboard" className="hover:text-green-600">{t('nav.dashboard')}</Link>
|
||||
<span>/</span>
|
||||
<Link href="/crm" className="hover:text-green-600">{t('nav.crm')}</Link>
|
||||
<span>/</span>
|
||||
<span className="text-gray-900 font-medium">{deal.name}</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-24 w-24 rounded-full bg-gradient-to-br from-green-500 to-green-600 flex items-center justify-center text-white text-3xl font-bold mx-auto">
|
||||
{deal.name.charAt(0)}
|
||||
</div>
|
||||
<h2 className="mt-3 font-semibold text-gray-900">{deal.name}</h2>
|
||||
<span className={`inline-block mt-2 px-3 py-1 rounded-full text-xs font-medium ${getStatusColor(deal.status)}`}>
|
||||
{deal.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex gap-4 px-6">
|
||||
{(['info', 'quotes', 'history'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeTab === tab
|
||||
? 'border-green-600 text-green-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{tab === 'info' ? t('crm.dealInfo') : tab === 'quotes' ? t('crm.quotes') : t('crm.history')}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{activeTab === 'info' && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<User className="h-5 w-5 text-gray-400 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">{t('crm.contact')}</p>
|
||||
<Link
|
||||
href={`/contacts/${deal.contactId}`}
|
||||
className="font-medium text-green-600 hover:underline"
|
||||
>
|
||||
{deal.contact?.name || '—'}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<Target className="h-5 w-5 text-gray-400 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">{t('crm.stage')}</p>
|
||||
<p className="font-medium text-gray-900">{deal.stage}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<DollarSign className="h-5 w-5 text-gray-400 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">{t('crm.estimatedValue')}</p>
|
||||
<p className="font-medium text-gray-900">
|
||||
{deal.estimatedValue?.toLocaleString() || 0} SAR
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<Target className="h-5 w-5 text-gray-400 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">{t('crm.probability')}</p>
|
||||
<p className="font-medium text-gray-900">{deal.probability || 0}%</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<Calendar className="h-5 w-5 text-gray-400 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">{t('crm.expectedCloseDate')}</p>
|
||||
<p className="font-medium text-gray-900">{formatDate(deal.expectedCloseDate)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<User className="h-5 w-5 text-gray-400 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">{t('crm.owner')}</p>
|
||||
<p className="font-medium text-gray-900">{deal.owner?.username || deal.owner?.email || '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'quotes' && (
|
||||
<div>
|
||||
{quotes.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<FileText className="h-12 w-12 mx-auto mb-4 text-gray-300" />
|
||||
<p>{t('common.noData')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{quotes.map((q) => (
|
||||
<div
|
||||
key={q.id}
|
||||
className="border border-gray-200 rounded-lg p-4 hover:bg-gray-50"
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{q.quoteNumber}</p>
|
||||
<p className="text-sm text-gray-500">v{q.version} · {q.status}</p>
|
||||
</div>
|
||||
<p className="font-semibold text-gray-900">{Number(q.total)?.toLocaleString()} SAR</p>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
{formatDate(q.validUntil)} · {formatDate(q.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'history' && (
|
||||
<div>
|
||||
{history.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<Clock className="h-12 w-12 mx-auto mb-4 text-gray-300" />
|
||||
<p>{t('common.noData')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{history.map((h: any, i: number) => (
|
||||
<div key={i} className="flex gap-4 border-b border-gray-100 pb-4 last:border-0">
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center">
|
||||
<History className="h-5 w-5 text-gray-500" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-gray-900">{h.action}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{formatDate(h.createdAt)} · {h.userId || '—'}
|
||||
</p>
|
||||
{h.changes && (
|
||||
<pre className="mt-2 text-xs text-gray-600 overflow-x-auto max-h-24">
|
||||
{JSON.stringify(h.changes, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Win Dialog */}
|
||||
{showWinDialog && deal && (
|
||||
<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">{deal.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"
|
||||
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 disabled:opacity-50"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
{t('crm.markWon')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lose Dialog */}
|
||||
{showLoseDialog && deal && (
|
||||
<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">{deal.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"
|
||||
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 disabled:opacity-50"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
{t('crm.markLost')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DealDetailPage() {
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<DealDetailContent />
|
||||
</ProtectedRoute>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
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'
|
||||
@@ -31,8 +32,12 @@ import {
|
||||
} 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)
|
||||
@@ -80,6 +85,11 @@ function CRMContent() {
|
||||
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 () => {
|
||||
@@ -96,6 +106,23 @@ function CRMContent() {
|
||||
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)
|
||||
@@ -137,28 +164,70 @@ function CRMContent() {
|
||||
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 = 'Deal name must be at least 3 characters'
|
||||
errors.name = t('crm.dealNameMin')
|
||||
}
|
||||
|
||||
if (!formData.contactId) {
|
||||
errors.contactId = 'Contact is required'
|
||||
errors.contactId = t('crm.contactRequired')
|
||||
}
|
||||
|
||||
if (!formData.structure) {
|
||||
errors.structure = 'Deal structure is required'
|
||||
errors.structure = t('crm.structureRequired')
|
||||
}
|
||||
|
||||
if (!formData.pipelineId) {
|
||||
errors.pipelineId = t('crm.pipelineRequired')
|
||||
}
|
||||
|
||||
if (!formData.stage) {
|
||||
errors.stage = 'Stage is required'
|
||||
errors.stage = t('crm.stageRequired')
|
||||
}
|
||||
|
||||
if (!formData.estimatedValue || formData.estimatedValue <= 0) {
|
||||
errors.estimatedValue = 'Estimated value must be greater than 0'
|
||||
errors.estimatedValue = t('crm.valueRequired')
|
||||
}
|
||||
|
||||
setFormErrors(errors)
|
||||
@@ -169,19 +238,14 @@ function CRMContent() {
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!validateForm()) {
|
||||
toast.error('Please fix form errors')
|
||||
toast.error(t('crm.fixFormErrors'))
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
// Create a default pipeline ID for now (we'll need to fetch pipelines later)
|
||||
const dealData = {
|
||||
...formData,
|
||||
pipelineId: '00000000-0000-0000-0000-000000000001' // Placeholder
|
||||
}
|
||||
await dealsAPI.create(dealData)
|
||||
toast.success('Deal created successfully!')
|
||||
await dealsAPI.create(formData)
|
||||
toast.success(t('crm.createSuccess'))
|
||||
setShowCreateModal(false)
|
||||
resetForm()
|
||||
fetchDeals()
|
||||
@@ -200,14 +264,14 @@ function CRMContent() {
|
||||
const handleEdit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!selectedDeal || !validateForm()) {
|
||||
toast.error('Please fix form errors')
|
||||
toast.error(t('crm.fixFormErrors'))
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await dealsAPI.update(selectedDeal.id, formData as UpdateDealData)
|
||||
toast.success('Deal updated successfully!')
|
||||
toast.success(t('crm.updateSuccess'))
|
||||
setShowEditModal(false)
|
||||
resetForm()
|
||||
fetchDeals()
|
||||
@@ -248,7 +312,7 @@ function CRMContent() {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await dealsAPI.win(selectedDeal.id, winData.actualValue, winData.wonReason)
|
||||
toast.success('🎉 Deal won successfully!')
|
||||
toast.success(t('crm.winSuccess'))
|
||||
setShowWinDialog(false)
|
||||
setSelectedDeal(null)
|
||||
setWinData({ actualValue: 0, wonReason: '' })
|
||||
@@ -271,7 +335,7 @@ function CRMContent() {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await dealsAPI.lose(selectedDeal.id, loseData.lostReason)
|
||||
toast.success('Deal marked as lost')
|
||||
toast.success(t('crm.loseSuccess'))
|
||||
setShowLoseDialog(false)
|
||||
setSelectedDeal(null)
|
||||
setLoseData({ lostReason: '' })
|
||||
@@ -284,14 +348,26 @@ function CRMContent() {
|
||||
}
|
||||
}
|
||||
|
||||
// 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: 'B2B',
|
||||
pipelineId: '',
|
||||
stage: 'LEAD',
|
||||
structure: defaultStructure,
|
||||
pipelineId: firstPipeline?.id ?? '',
|
||||
stage: firstStage,
|
||||
estimatedValue: 0,
|
||||
probability: 50,
|
||||
expectedCloseDate: ''
|
||||
@@ -379,25 +455,59 @@ function CRMContent() {
|
||||
{/* Deal Structure */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Deal Structure <span className="text-red-500">*</span>
|
||||
{t('crm.structure')} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={formData.structure}
|
||||
onChange={(e) => setFormData({ ...formData, structure: e.target.value })}
|
||||
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">B2B - شركة لشركة</option>
|
||||
<option value="B2C">B2C - شركة لفرد</option>
|
||||
<option value="B2G">B2G - شركة لحكومة</option>
|
||||
<option value="PARTNERSHIP">Partnership - شراكة</option>
|
||||
<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">
|
||||
Contact <span className="text-red-500">*</span>
|
||||
{t('crm.contact')} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={formData.contactId}
|
||||
@@ -405,7 +515,7 @@ function CRMContent() {
|
||||
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="">Select Contact</option>
|
||||
<option value="">{t('crm.selectContact')}</option>
|
||||
{contacts.map(contact => (
|
||||
<option key={contact.id} value={contact.id}>{contact.name}</option>
|
||||
))}
|
||||
@@ -417,14 +527,14 @@ function CRMContent() {
|
||||
{/* Deal Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Deal Name <span className="text-red-500">*</span>
|
||||
{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="Enter deal name"
|
||||
placeholder={t('crm.enterDealName')}
|
||||
/>
|
||||
{formErrors.name && <p className="text-red-500 text-xs mt-1">{formErrors.name}</p>}
|
||||
</div>
|
||||
@@ -433,17 +543,35 @@ function CRMContent() {
|
||||
{/* Stage */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Stage <span className="text-red-500">*</span>
|
||||
{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"
|
||||
>
|
||||
<option value="LEAD">Lead - عميل محتمل</option>
|
||||
<option value="QUALIFIED">Qualified - مؤهل</option>
|
||||
<option value="PROPOSAL">Proposal - عرض</option>
|
||||
<option value="NEGOTIATION">Negotiation - تفاوض</option>
|
||||
{(() => {
|
||||
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>
|
||||
@@ -451,7 +579,7 @@ function CRMContent() {
|
||||
{/* Probability */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Probability (%)
|
||||
{t('crm.probability')} (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
@@ -468,7 +596,7 @@ function CRMContent() {
|
||||
{/* Estimated Value */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Estimated Value (SAR) <span className="text-red-500">*</span>
|
||||
{t('crm.estimatedValue')} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
@@ -484,7 +612,7 @@ function CRMContent() {
|
||||
{/* Expected Close Date */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Expected Close Date
|
||||
{t('crm.expectedCloseDate')}
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
@@ -519,7 +647,7 @@ function CRMContent() {
|
||||
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
@@ -529,11 +657,11 @@ function CRMContent() {
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{isEdit ? 'Updating...' : 'Creating...'}
|
||||
{isEdit ? t('crm.updating') : t('crm.creating')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isEdit ? 'Update Deal' : 'Create Deal'}
|
||||
{isEdit ? t('crm.updateDeal') : t('crm.createDeal')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
@@ -559,8 +687,8 @@ function CRMContent() {
|
||||
<TrendingUp className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">إدارة علاقات العملاء</h1>
|
||||
<p className="text-sm text-gray-600">CRM & Sales Pipeline</p>
|
||||
<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>
|
||||
@@ -574,7 +702,7 @@ function CRMContent() {
|
||||
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" />
|
||||
New Deal
|
||||
{t('crm.addDeal')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -587,7 +715,7 @@ function CRMContent() {
|
||||
<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">Total Value</p>
|
||||
<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>
|
||||
@@ -602,12 +730,12 @@ function CRMContent() {
|
||||
<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">Expected Value</p>
|
||||
<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}% conversion
|
||||
{totalValue > 0 ? ((expectedValue / totalValue) * 100).toFixed(0) : 0}% {t('crm.conversion')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-green-100 p-3 rounded-lg">
|
||||
@@ -619,9 +747,9 @@ function CRMContent() {
|
||||
<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">Active Deals</p>
|
||||
<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">In pipeline</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" />
|
||||
@@ -632,10 +760,10 @@ function CRMContent() {
|
||||
<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">Won Deals</p>
|
||||
<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}% win rate
|
||||
{total > 0 ? ((wonDeals / total) * 100).toFixed(0) : 0}% {t('crm.winRate')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-purple-100 p-3 rounded-lg">
|
||||
@@ -653,7 +781,7 @@ function CRMContent() {
|
||||
<Search className="absolute right-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search deals (name, deal number...)"
|
||||
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"
|
||||
@@ -666,7 +794,7 @@ function CRMContent() {
|
||||
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">All Structures</option>
|
||||
<option value="all">{t('crm.allStructures')}</option>
|
||||
<option value="B2B">B2B</option>
|
||||
<option value="B2C">B2C</option>
|
||||
<option value="B2G">B2G</option>
|
||||
@@ -679,7 +807,7 @@ function CRMContent() {
|
||||
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">All Stages</option>
|
||||
<option value="all">{t('crm.allStages')}</option>
|
||||
<option value="LEAD">Lead</option>
|
||||
<option value="QUALIFIED">Qualified</option>
|
||||
<option value="PROPOSAL">Proposal</option>
|
||||
@@ -694,7 +822,7 @@ function CRMContent() {
|
||||
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">All Status</option>
|
||||
<option value="all">{t('crm.allStatus')}</option>
|
||||
<option value="ACTIVE">Active</option>
|
||||
<option value="WON">Won</option>
|
||||
<option value="LOST">Lost</option>
|
||||
@@ -706,7 +834,7 @@ function CRMContent() {
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-12">
|
||||
<LoadingSpinner size="lg" message="Loading deals..." />
|
||||
<LoadingSpinner size="lg" message={t('crm.loadingDeals')} />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-12 text-center">
|
||||
@@ -715,18 +843,18 @@ function CRMContent() {
|
||||
onClick={fetchDeals}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
|
||||
>
|
||||
Retry
|
||||
{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">No deals found</p>
|
||||
<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"
|
||||
>
|
||||
Create First Deal
|
||||
{t('crm.createFirstDeal')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -735,13 +863,13 @@ function CRMContent() {
|
||||
<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">Deal</th>
|
||||
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Contact</th>
|
||||
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Structure</th>
|
||||
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Value</th>
|
||||
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Probability</th>
|
||||
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Stage</th>
|
||||
<th className="px-6 py-4 text-right text-xs font-semibold text-gray-700 uppercase">Actions</th>
|
||||
<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">
|
||||
@@ -749,7 +877,12 @@ function CRMContent() {
|
||||
<tr key={deal.id} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">{deal.name}</p>
|
||||
<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>
|
||||
@@ -801,14 +934,14 @@ function CRMContent() {
|
||||
<button
|
||||
onClick={() => openWinDialog(deal)}
|
||||
className="p-2 hover:bg-green-50 text-green-600 rounded-lg transition-colors"
|
||||
title="Mark as Won"
|
||||
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="Mark as Lost"
|
||||
title={t('crm.markLost')}
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -817,14 +950,14 @@ function CRMContent() {
|
||||
<button
|
||||
onClick={() => openEditModal(deal)}
|
||||
className="p-2 hover:bg-blue-50 text-blue-600 rounded-lg transition-colors"
|
||||
title="Edit"
|
||||
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="Delete"
|
||||
title={t('crm.deleteDeal')}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -849,7 +982,7 @@ function CRMContent() {
|
||||
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"
|
||||
>
|
||||
Previous
|
||||
{t('crm.paginationPrevious')}
|
||||
</button>
|
||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
const page = i + 1
|
||||
@@ -873,7 +1006,7 @@ function CRMContent() {
|
||||
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"
|
||||
>
|
||||
Next
|
||||
{t('crm.paginationNext')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -889,7 +1022,7 @@ function CRMContent() {
|
||||
setShowCreateModal(false)
|
||||
resetForm()
|
||||
}}
|
||||
title="Create New Deal"
|
||||
title={t('crm.createNewDeal')}
|
||||
size="xl"
|
||||
>
|
||||
<form onSubmit={handleCreate}>
|
||||
@@ -904,7 +1037,7 @@ function CRMContent() {
|
||||
setShowEditModal(false)
|
||||
resetForm()
|
||||
}}
|
||||
title="Edit Deal"
|
||||
title={t('crm.editDeal')}
|
||||
size="xl"
|
||||
>
|
||||
<form onSubmit={handleEdit}>
|
||||
@@ -923,14 +1056,14 @@ function CRMContent() {
|
||||
<Award className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900">Mark Deal as Won</h3>
|
||||
<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">
|
||||
Actual Value (SAR) <span className="text-red-500">*</span>
|
||||
{t('crm.actualValue')} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
@@ -942,14 +1075,14 @@ function CRMContent() {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Reason for Winning <span className="text-red-500">*</span>
|
||||
{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="Why did we win this deal?"
|
||||
placeholder={t('crm.winPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -959,7 +1092,7 @@ function CRMContent() {
|
||||
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleWin}
|
||||
@@ -969,10 +1102,10 @@ function CRMContent() {
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Processing...
|
||||
{t('crm.processing')}
|
||||
</>
|
||||
) : (
|
||||
'🎉 Mark as Won'
|
||||
t('crm.markWon')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
@@ -992,21 +1125,21 @@ function CRMContent() {
|
||||
<TrendingDown className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900">Mark Deal as Lost</h3>
|
||||
<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">
|
||||
Reason for Losing <span className="text-red-500">*</span>
|
||||
{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="Why did we lose this deal?"
|
||||
placeholder={t('crm.losePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1016,7 +1149,7 @@ function CRMContent() {
|
||||
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLose}
|
||||
@@ -1026,10 +1159,10 @@ function CRMContent() {
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Processing...
|
||||
{t('crm.processing')}
|
||||
</>
|
||||
) : (
|
||||
'Mark as Lost'
|
||||
t('crm.markLost')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
@@ -1049,12 +1182,12 @@ function CRMContent() {
|
||||
<Trash2 className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900">Delete Deal</h3>
|
||||
<p className="text-sm text-gray-600">This will mark the deal as lost</p>
|
||||
<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">
|
||||
Are you sure you want to delete <span className="font-semibold">{selectedDeal.name}</span>?
|
||||
{t('crm.deleteDealConfirm')} <span className="font-semibold">{selectedDeal.name}</span>?
|
||||
</p>
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<button
|
||||
@@ -1065,7 +1198,7 @@ function CRMContent() {
|
||||
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
@@ -1075,10 +1208,10 @@ function CRMContent() {
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Deleting...
|
||||
{t('crm.deleting')}
|
||||
</>
|
||||
) : (
|
||||
'Delete Deal'
|
||||
t('crm.deleteDeal')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user