Deploy rule, CRM enhancements, Company Employee category, bilingual, contacts module completion

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Talal Sharabi
2026-02-19 14:59:34 +04:00
parent 0b126cb676
commit 680ba3871e
51 changed files with 11456 additions and 477 deletions

View 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>
)
}