feat(crm): add contracts, cost sheets, invoices modules and API clients
Made-with: Cursor
This commit is contained in:
@@ -7,7 +7,6 @@ import { toast } from 'react-hot-toast'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Edit,
|
||||
Archive,
|
||||
History,
|
||||
Award,
|
||||
TrendingDown,
|
||||
@@ -15,15 +14,23 @@ import {
|
||||
Target,
|
||||
Calendar,
|
||||
User,
|
||||
Building2,
|
||||
FileText,
|
||||
Clock,
|
||||
Loader2
|
||||
Loader2,
|
||||
Plus,
|
||||
Check,
|
||||
X,
|
||||
Receipt,
|
||||
FileSignature
|
||||
} from 'lucide-react'
|
||||
import ProtectedRoute from '@/components/ProtectedRoute'
|
||||
import LoadingSpinner from '@/components/LoadingSpinner'
|
||||
import Modal from '@/components/Modal'
|
||||
import { dealsAPI, Deal } from '@/lib/api/deals'
|
||||
import { quotesAPI, Quote } from '@/lib/api/quotes'
|
||||
import { costSheetsAPI, CostSheet, CostSheetItem } from '@/lib/api/costSheets'
|
||||
import { contractsAPI, Contract, CreateContractData } from '@/lib/api/contracts'
|
||||
import { invoicesAPI, Invoice, InvoiceItem } from '@/lib/api/invoices'
|
||||
import { useLanguage } from '@/contexts/LanguageContext'
|
||||
|
||||
function DealDetailContent() {
|
||||
@@ -34,15 +41,165 @@ function DealDetailContent() {
|
||||
|
||||
const [deal, setDeal] = useState<Deal | null>(null)
|
||||
const [quotes, setQuotes] = useState<Quote[]>([])
|
||||
const [costSheets, setCostSheets] = useState<CostSheet[]>([])
|
||||
const [contracts, setContracts] = useState<Contract[]>([])
|
||||
const [invoices, setInvoices] = useState<Invoice[]>([])
|
||||
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 [activeTab, setActiveTab] = useState<'info' | 'quotes' | 'costSheets' | 'contracts' | 'invoices' | 'history'>('info')
|
||||
const [showWinDialog, setShowWinDialog] = useState(false)
|
||||
const [showLoseDialog, setShowLoseDialog] = useState(false)
|
||||
const [showCostSheetModal, setShowCostSheetModal] = useState(false)
|
||||
const [showContractModal, setShowContractModal] = useState(false)
|
||||
const [showInvoiceModal, setShowInvoiceModal] = useState(false)
|
||||
const [showPaymentModal, setShowPaymentModal] = useState<Invoice | null>(null)
|
||||
const [winData, setWinData] = useState({ actualValue: 0, wonReason: '' })
|
||||
const [loseData, setLoseData] = useState({ lostReason: '' })
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [costSheetForm, setCostSheetForm] = useState({
|
||||
items: [{ description: '', source: '', cost: 0, quantity: 1 }] as CostSheetItem[],
|
||||
totalCost: 0,
|
||||
suggestedPrice: 0,
|
||||
profitMargin: 0,
|
||||
})
|
||||
const [contractForm, setContractForm] = useState<CreateContractData>({
|
||||
dealId: '',
|
||||
title: '',
|
||||
type: 'SALES',
|
||||
clientInfo: {},
|
||||
companyInfo: {},
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
value: 0,
|
||||
paymentTerms: {},
|
||||
deliveryTerms: {},
|
||||
terms: '',
|
||||
})
|
||||
const [invoiceForm, setInvoiceForm] = useState({
|
||||
items: [{ description: '', quantity: 1, unitPrice: 0, total: 0 }] as InvoiceItem[],
|
||||
subtotal: 0,
|
||||
taxAmount: 0,
|
||||
total: 0,
|
||||
dueDate: '',
|
||||
})
|
||||
const [paymentForm, setPaymentForm] = useState({ paidAmount: 0, paidDate: new Date().toISOString().slice(0, 10) })
|
||||
|
||||
useEffect(() => {
|
||||
if (showPaymentModal) {
|
||||
setPaymentForm({ paidAmount: Number(showPaymentModal?.total) || 0, paidDate: new Date().toISOString().slice(0, 10) })
|
||||
}
|
||||
}, [showPaymentModal])
|
||||
|
||||
useEffect(() => {
|
||||
if (deal && showContractModal) {
|
||||
const contact = deal.contact
|
||||
setContractForm((f) => ({
|
||||
...f,
|
||||
dealId: deal.id,
|
||||
clientInfo: contact ? { name: contact.name, email: contact.email, phone: contact.phone } : {},
|
||||
companyInfo: f.companyInfo && Object.keys(f.companyInfo).length ? f.companyInfo : {},
|
||||
}))
|
||||
}
|
||||
}, [deal, showContractModal])
|
||||
|
||||
const handleCreateCostSheet = async () => {
|
||||
const items = costSheetForm.items.filter((i) => i.cost > 0 || i.description)
|
||||
if (!items.length || costSheetForm.totalCost <= 0 || costSheetForm.suggestedPrice <= 0) {
|
||||
toast.error(t('crm.fixFormErrors'))
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await costSheetsAPI.create({
|
||||
dealId,
|
||||
items,
|
||||
totalCost: costSheetForm.totalCost,
|
||||
suggestedPrice: costSheetForm.suggestedPrice,
|
||||
profitMargin: costSheetForm.profitMargin,
|
||||
})
|
||||
toast.success(t('crm.costSheetCreated'))
|
||||
setShowCostSheetModal(false)
|
||||
setCostSheetForm({ items: [{ description: '', source: '', cost: 0, quantity: 1 }], totalCost: 0, suggestedPrice: 0, profitMargin: 0 })
|
||||
fetchCostSheets()
|
||||
} catch (e: any) {
|
||||
toast.error(e.response?.data?.message || 'Failed')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateContract = async () => {
|
||||
if (!contractForm.title || !contractForm.startDate || contractForm.value <= 0) {
|
||||
toast.error(t('crm.fixFormErrors'))
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await contractsAPI.create({
|
||||
...contractForm,
|
||||
dealId,
|
||||
clientInfo: contractForm.clientInfo || {},
|
||||
companyInfo: contractForm.companyInfo || {},
|
||||
paymentTerms: contractForm.paymentTerms || {},
|
||||
deliveryTerms: contractForm.deliveryTerms || {},
|
||||
})
|
||||
toast.success(t('crm.contractCreated'))
|
||||
setShowContractModal(false)
|
||||
setContractForm({ dealId: '', title: '', type: 'SALES', clientInfo: {}, companyInfo: {}, startDate: '', endDate: '', value: 0, paymentTerms: {}, deliveryTerms: {}, terms: '' })
|
||||
fetchContracts()
|
||||
} catch (e: any) {
|
||||
toast.error(e.response?.data?.message || 'Failed')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateInvoice = async () => {
|
||||
const items = invoiceForm.items.filter((i) => i.quantity > 0 && i.unitPrice >= 0)
|
||||
if (!items.length || invoiceForm.total <= 0 || !invoiceForm.dueDate) {
|
||||
toast.error(t('crm.fixFormErrors'))
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await invoicesAPI.create({
|
||||
dealId,
|
||||
items: items.map((i) => ({ ...i, total: (i.quantity || 0) * (i.unitPrice || 0) })),
|
||||
subtotal: invoiceForm.subtotal,
|
||||
taxAmount: invoiceForm.taxAmount,
|
||||
total: invoiceForm.total,
|
||||
dueDate: invoiceForm.dueDate,
|
||||
})
|
||||
toast.success(t('crm.invoiceCreated'))
|
||||
setShowInvoiceModal(false)
|
||||
setInvoiceForm({ items: [{ description: '', quantity: 1, unitPrice: 0, total: 0 }], subtotal: 0, taxAmount: 0, total: 0, dueDate: '' })
|
||||
fetchInvoices()
|
||||
} catch (e: any) {
|
||||
toast.error(e.response?.data?.message || 'Failed')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRecordPayment = async () => {
|
||||
if (!showPaymentModal || paymentForm.paidAmount <= 0) {
|
||||
toast.error(t('crm.fixFormErrors'))
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await invoicesAPI.recordPayment(showPaymentModal.id, paymentForm.paidAmount, paymentForm.paidDate)
|
||||
toast.success(t('crm.paymentRecorded'))
|
||||
setShowPaymentModal(null)
|
||||
setPaymentForm({ paidAmount: 0, paidDate: new Date().toISOString().slice(0, 10) })
|
||||
fetchInvoices()
|
||||
} catch (e: any) {
|
||||
toast.error(e.response?.data?.message || 'Failed')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchDeal()
|
||||
@@ -51,6 +208,9 @@ function DealDetailContent() {
|
||||
useEffect(() => {
|
||||
if (deal) {
|
||||
fetchQuotes()
|
||||
fetchCostSheets()
|
||||
fetchContracts()
|
||||
fetchInvoices()
|
||||
fetchHistory()
|
||||
}
|
||||
}, [deal])
|
||||
@@ -88,6 +248,33 @@ function DealDetailContent() {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchCostSheets = async () => {
|
||||
try {
|
||||
const data = await costSheetsAPI.getByDeal(dealId)
|
||||
setCostSheets(data || [])
|
||||
} catch {
|
||||
setCostSheets([])
|
||||
}
|
||||
}
|
||||
|
||||
const fetchContracts = async () => {
|
||||
try {
|
||||
const data = await contractsAPI.getByDeal(dealId)
|
||||
setContracts(data || [])
|
||||
} catch {
|
||||
setContracts([])
|
||||
}
|
||||
}
|
||||
|
||||
const fetchInvoices = async () => {
|
||||
try {
|
||||
const data = await invoicesAPI.getByDeal(dealId)
|
||||
setInvoices(data || [])
|
||||
} catch {
|
||||
setInvoices([])
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
ACTIVE: 'bg-green-100 text-green-700',
|
||||
@@ -272,18 +459,18 @@ function DealDetailContent() {
|
||||
<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) => (
|
||||
<nav className="flex gap-4 px-6 overflow-x-auto">
|
||||
{(['info', 'quotes', 'costSheets', 'contracts', 'invoices', '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 ${
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm transition-colors whitespace-nowrap ${
|
||||
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')}
|
||||
{tab === 'info' ? t('crm.dealInfo') : tab === 'quotes' ? t('crm.quotes') : tab === 'costSheets' ? t('crm.costSheets') : tab === 'contracts' ? t('crm.contracts') : tab === 'invoices' ? t('crm.invoices') : t('crm.history')}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
@@ -377,6 +564,135 @@ function DealDetailContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'costSheets' && (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="font-semibold text-gray-900">{t('crm.costSheets')}</h3>
|
||||
<button onClick={() => setShowCostSheetModal(true)} className="flex items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 text-sm">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('crm.addCostSheet')}
|
||||
</button>
|
||||
</div>
|
||||
{costSheets.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">
|
||||
{costSheets.map((cs) => (
|
||||
<div key={cs.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">{cs.costSheetNumber}</p>
|
||||
<p className="text-sm text-gray-500">v{cs.version} · {cs.status}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{Number(cs.totalCost)?.toLocaleString()} SAR cost · {Number(cs.suggestedPrice)?.toLocaleString()} SAR suggested · {Number(cs.profitMargin)}% margin
|
||||
</p>
|
||||
</div>
|
||||
{cs.status === 'DRAFT' && (
|
||||
<div className="flex gap-2">
|
||||
<button onClick={async () => { try { await costSheetsAPI.approve(cs.id); toast.success(t('crm.costSheetApproved')); fetchCostSheets(); } catch (e: any) { toast.error(e.response?.data?.message || 'Failed'); } }} className="flex items-center gap-1 px-2 py-1 text-green-600 border border-green-300 rounded hover:bg-green-50 text-sm">
|
||||
<Check className="h-4 w-4" />
|
||||
{t('crm.approve')}
|
||||
</button>
|
||||
<button onClick={async () => { try { await costSheetsAPI.reject(cs.id); toast.success(t('crm.costSheetRejected')); fetchCostSheets(); } catch (e: any) { toast.error(e.response?.data?.message || 'Failed'); } }} className="flex items-center gap-1 px-2 py-1 text-red-600 border border-red-300 rounded hover:bg-red-50 text-sm">
|
||||
<X className="h-4 w-4" />
|
||||
{t('crm.reject')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-2">{formatDate(cs.createdAt)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'contracts' && (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="font-semibold text-gray-900">{t('crm.contracts')}</h3>
|
||||
<button onClick={() => setShowContractModal(true)} className="flex items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 text-sm">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('crm.addContract')}
|
||||
</button>
|
||||
</div>
|
||||
{contracts.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<FileSignature className="h-12 w-12 mx-auto mb-4 text-gray-300" />
|
||||
<p>{t('common.noData')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{contracts.map((c) => (
|
||||
<div key={c.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">{c.contractNumber} · {c.title}</p>
|
||||
<p className="text-sm text-gray-500">{c.type} · {c.status}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{Number(c.value)?.toLocaleString()} SAR · {formatDate(c.startDate)} {c.endDate ? `– ${formatDate(c.endDate)}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
{c.status === 'PENDING_SIGNATURE' && (
|
||||
<button onClick={async () => { try { await contractsAPI.sign(c.id); toast.success(t('crm.contractSigned')); fetchContracts(); } catch (e: any) { toast.error(e.response?.data?.message || 'Failed'); } }} className="flex items-center gap-1 px-2 py-1 text-green-600 border border-green-300 rounded hover:bg-green-50 text-sm">
|
||||
<FileSignature className="h-4 w-4" />
|
||||
{t('crm.markSigned')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-2">{formatDate(c.createdAt)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'invoices' && (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="font-semibold text-gray-900">{t('crm.invoices')}</h3>
|
||||
<button onClick={() => setShowInvoiceModal(true)} className="flex items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 text-sm">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('crm.addInvoice')}
|
||||
</button>
|
||||
</div>
|
||||
{invoices.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<Receipt className="h-12 w-12 mx-auto mb-4 text-gray-300" />
|
||||
<p>{t('common.noData')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{invoices.map((inv) => (
|
||||
<div key={inv.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">{inv.invoiceNumber}</p>
|
||||
<p className="text-sm text-gray-500">{inv.status}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{Number(inv.total)?.toLocaleString()} SAR · {inv.paidAmount ? `${Number(inv.paidAmount)?.toLocaleString()} paid` : ''} · due {formatDate(inv.dueDate)}
|
||||
</p>
|
||||
</div>
|
||||
{(inv.status === 'SENT' || inv.status === 'OVERDUE') && (
|
||||
<button onClick={() => setShowPaymentModal(inv)} className="flex items-center gap-1 px-2 py-1 text-green-600 border border-green-300 rounded hover:bg-green-50 text-sm">
|
||||
<DollarSign className="h-4 w-4" />
|
||||
{t('crm.recordPayment')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-2">{formatDate(inv.createdAt)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'history' && (
|
||||
<div>
|
||||
{history.length === 0 ? (
|
||||
@@ -527,6 +843,179 @@ function DealDetailContent() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cost Sheet Modal */}
|
||||
<Modal isOpen={showCostSheetModal} onClose={() => setShowCostSheetModal(false)} title={t('crm.addCostSheet')} size="xl">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600">{t('crm.costSheetItems')}</p>
|
||||
{costSheetForm.items.map((item, idx) => (
|
||||
<div key={idx} className="grid grid-cols-12 gap-2 items-end">
|
||||
<input placeholder={t('crm.description')} value={item.description || ''} onChange={(e) => {
|
||||
const next = [...costSheetForm.items]; next[idx] = { ...next[idx], description: e.target.value }; setCostSheetForm({ ...costSheetForm, items: next })
|
||||
}} className="col-span-4 px-3 py-2 border rounded-lg" />
|
||||
<input placeholder={t('crm.source')} value={item.source || ''} onChange={(e) => {
|
||||
const next = [...costSheetForm.items]; next[idx] = { ...next[idx], source: e.target.value }; setCostSheetForm({ ...costSheetForm, items: next })
|
||||
}} className="col-span-2 px-3 py-2 border rounded-lg" />
|
||||
<input type="number" placeholder="Cost" value={item.cost || ''} onChange={(e) => {
|
||||
const next = [...costSheetForm.items]; next[idx] = { ...next[idx], cost: parseFloat(e.target.value) || 0 }; const total = next.reduce((s, i) => s + (i.cost || 0) * (i.quantity || 1), 0); setCostSheetForm({ ...costSheetForm, items: next, totalCost: total })
|
||||
}} className="col-span-2 px-3 py-2 border rounded-lg" />
|
||||
<input type="number" placeholder="Qty" value={item.quantity || 1} onChange={(e) => {
|
||||
const next = [...costSheetForm.items]; next[idx] = { ...next[idx], quantity: parseInt(e.target.value) || 1 }; const total = next.reduce((s, i) => s + (i.cost || 0) * (i.quantity || 1), 0); setCostSheetForm({ ...costSheetForm, items: next, totalCost: total })
|
||||
}} className="col-span-2 px-3 py-2 border rounded-lg" />
|
||||
<button type="button" onClick={() => setCostSheetForm({ ...costSheetForm, items: costSheetForm.items.filter((_, i) => i !== idx) })} className="col-span-2 py-2 text-red-600 hover:bg-red-50 rounded">×</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={() => setCostSheetForm({ ...costSheetForm, items: [...costSheetForm.items, { description: '', source: '', cost: 0, quantity: 1 }] })} className="text-sm text-green-600 hover:underline">
|
||||
+ {t('crm.addRow')}
|
||||
</button>
|
||||
<div className="grid grid-cols-3 gap-4 pt-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.totalCost')}</label>
|
||||
<input type="number" value={costSheetForm.totalCost || ''} onChange={(e) => setCostSheetForm({ ...costSheetForm, totalCost: parseFloat(e.target.value) || 0 })} className="w-full px-3 py-2 border rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.suggestedPrice')}</label>
|
||||
<input type="number" value={costSheetForm.suggestedPrice || ''} onChange={(e) => setCostSheetForm({ ...costSheetForm, suggestedPrice: parseFloat(e.target.value) || 0 })} className="w-full px-3 py-2 border rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.profitMargin')} (%)</label>
|
||||
<input type="number" value={costSheetForm.profitMargin || ''} onChange={(e) => setCostSheetForm({ ...costSheetForm, profitMargin: parseFloat(e.target.value) || 0 })} className="w-full px-3 py-2 border rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button onClick={() => setShowCostSheetModal(false)} className="px-4 py-2 border rounded-lg">{t('common.cancel')}</button>
|
||||
<button onClick={handleCreateCostSheet} disabled={submitting} className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg disabled:opacity-50">
|
||||
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Contract Modal */}
|
||||
<Modal isOpen={showContractModal} onClose={() => setShowContractModal(false)} title={t('crm.addContract')} size="xl">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.contractTitle')} *</label>
|
||||
<input value={contractForm.title} onChange={(e) => setContractForm({ ...contractForm, title: e.target.value })} className="w-full px-3 py-2 border rounded-lg" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.contractType')}</label>
|
||||
<select value={contractForm.type} onChange={(e) => setContractForm({ ...contractForm, type: e.target.value })} className="w-full px-3 py-2 border rounded-lg">
|
||||
<option value="SALES">{t('crm.contractTypeSales')}</option>
|
||||
<option value="SERVICE">{t('crm.contractTypeService')}</option>
|
||||
<option value="MAINTENANCE">{t('crm.contractTypeMaintenance')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.contractValue')} *</label>
|
||||
<input type="number" value={contractForm.value || ''} onChange={(e) => setContractForm({ ...contractForm, value: parseFloat(e.target.value) || 0 })} className="w-full px-3 py-2 border rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.startDate')} *</label>
|
||||
<input type="date" value={contractForm.startDate} onChange={(e) => setContractForm({ ...contractForm, startDate: e.target.value })} className="w-full px-3 py-2 border rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.endDate')}</label>
|
||||
<input type="date" value={contractForm.endDate || ''} onChange={(e) => setContractForm({ ...contractForm, endDate: e.target.value })} className="w-full px-3 py-2 border rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.paymentTerms')}</label>
|
||||
<input placeholder="e.g. Net 30" value={typeof contractForm.paymentTerms === 'object' && contractForm.paymentTerms?.description ? (contractForm.paymentTerms as any).description : ''} onChange={(e) => setContractForm({ ...contractForm, paymentTerms: { description: e.target.value } })} className="w-full px-3 py-2 border rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.deliveryTerms')}</label>
|
||||
<input placeholder="e.g. FOB" value={typeof contractForm.deliveryTerms === 'object' && contractForm.deliveryTerms?.description ? (contractForm.deliveryTerms as any).description : ''} onChange={(e) => setContractForm({ ...contractForm, deliveryTerms: { description: e.target.value } })} className="w-full px-3 py-2 border rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.terms')}</label>
|
||||
<textarea value={contractForm.terms} onChange={(e) => setContractForm({ ...contractForm, terms: e.target.value })} rows={3} className="w-full px-3 py-2 border rounded-lg" />
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button onClick={() => setShowContractModal(false)} className="px-4 py-2 border rounded-lg">{t('common.cancel')}</button>
|
||||
<button onClick={handleCreateContract} disabled={submitting} className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg disabled:opacity-50">
|
||||
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Invoice Modal */}
|
||||
<Modal isOpen={showInvoiceModal} onClose={() => setShowInvoiceModal(false)} title={t('crm.addInvoice')} size="xl">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600">{t('crm.invoiceItems')}</p>
|
||||
{invoiceForm.items.map((item, idx) => (
|
||||
<div key={idx} className="grid grid-cols-12 gap-2 items-end">
|
||||
<input placeholder={t('crm.description')} value={item.description || ''} onChange={(e) => { const next = [...invoiceForm.items]; next[idx] = { ...next[idx], description: e.target.value }; setInvoiceForm({ ...invoiceForm, items: next }) }} className="col-span-4 px-3 py-2 border rounded-lg" />
|
||||
<input type="number" placeholder="Qty" value={item.quantity || 1} onChange={(e) => {
|
||||
const next = [...invoiceForm.items]; next[idx] = { ...next[idx], quantity: parseInt(e.target.value) || 1, unitPrice: next[idx].unitPrice || 0 }; next[idx].total = (next[idx].quantity || 0) * (next[idx].unitPrice || 0); const sub = next.reduce((s, i) => s + ((i.quantity || 0) * (i.unitPrice || 0)), 0); setInvoiceForm({ ...invoiceForm, items: next, subtotal: sub, total: sub + invoiceForm.taxAmount })
|
||||
}} className="col-span-2 px-3 py-2 border rounded-lg" />
|
||||
<input type="number" placeholder="Unit Price" value={item.unitPrice || ''} onChange={(e) => {
|
||||
const next = [...invoiceForm.items]; next[idx] = { ...next[idx], unitPrice: parseFloat(e.target.value) || 0 }; next[idx].total = (next[idx].quantity || 0) * (next[idx].unitPrice || 0); const sub = next.reduce((s, i) => s + ((i.quantity || 0) * (i.unitPrice || 0)), 0); setInvoiceForm({ ...invoiceForm, items: next, subtotal: sub, total: sub + invoiceForm.taxAmount })
|
||||
}} className="col-span-2 px-3 py-2 border rounded-lg" />
|
||||
<span className="col-span-2 py-2 text-gray-600">{(item.quantity || 0) * (item.unitPrice || 0)}</span>
|
||||
<button type="button" onClick={() => setInvoiceForm({ ...invoiceForm, items: invoiceForm.items.filter((_, i) => i !== idx) })} className="col-span-2 py-2 text-red-600 hover:bg-red-50 rounded">×</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={() => setInvoiceForm({ ...invoiceForm, items: [...invoiceForm.items, { description: '', quantity: 1, unitPrice: 0, total: 0 }] })} className="text-sm text-green-600 hover:underline">
|
||||
+ {t('crm.addRow')}
|
||||
</button>
|
||||
<div className="grid grid-cols-4 gap-4 pt-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.subtotal')}</label>
|
||||
<input type="number" value={invoiceForm.subtotal || ''} onChange={(e) => setInvoiceForm({ ...invoiceForm, subtotal: parseFloat(e.target.value) || 0, total: (parseFloat(e.target.value) || 0) + invoiceForm.taxAmount })} className="w-full px-3 py-2 border rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.taxAmount')}</label>
|
||||
<input type="number" value={invoiceForm.taxAmount || ''} onChange={(e) => setInvoiceForm({ ...invoiceForm, taxAmount: parseFloat(e.target.value) || 0, total: invoiceForm.subtotal + (parseFloat(e.target.value) || 0) })} className="w-full px-3 py-2 border rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.total')}</label>
|
||||
<input type="number" value={invoiceForm.total || ''} onChange={(e) => setInvoiceForm({ ...invoiceForm, total: parseFloat(e.target.value) || 0 })} className="w-full px-3 py-2 border rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.dueDate')} *</label>
|
||||
<input type="date" value={invoiceForm.dueDate} onChange={(e) => setInvoiceForm({ ...invoiceForm, dueDate: e.target.value })} className="w-full px-3 py-2 border rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button onClick={() => setShowInvoiceModal(false)} className="px-4 py-2 border rounded-lg">{t('common.cancel')}</button>
|
||||
<button onClick={handleCreateInvoice} disabled={submitting} className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg disabled:opacity-50">
|
||||
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Record Payment Modal */}
|
||||
{showPaymentModal && (
|
||||
<Modal isOpen={!!showPaymentModal} onClose={() => setShowPaymentModal(null)} title={t('crm.recordPayment')}>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600">{showPaymentModal.invoiceNumber} · {Number(showPaymentModal.total)?.toLocaleString()} SAR</p>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.paidAmount')} *</label>
|
||||
<input type="number" value={paymentForm.paidAmount || ''} onChange={(e) => setPaymentForm({ ...paymentForm, paidAmount: parseFloat(e.target.value) || 0 })} className="w-full px-3 py-2 border rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('crm.paidDate')}</label>
|
||||
<input type="date" value={paymentForm.paidDate} onChange={(e) => setPaymentForm({ ...paymentForm, paidDate: e.target.value })} className="w-full px-3 py-2 border rounded-lg" />
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button onClick={() => setShowPaymentModal(null)} className="px-4 py-2 border rounded-lg">{t('common.cancel')}</button>
|
||||
<button onClick={handleRecordPayment} disabled={submitting} className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg disabled:opacity-50">
|
||||
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
{t('crm.recordPayment')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -274,7 +274,49 @@ const translations = {
|
||||
processing: 'Processing...',
|
||||
deleting: 'Deleting...',
|
||||
deleteDealConfirm: 'Are you sure you want to delete',
|
||||
deleteDealDesc: 'This will mark the deal as lost'
|
||||
deleteDealDesc: 'This will mark the deal as lost',
|
||||
costSheets: 'Cost Sheets',
|
||||
contracts: 'Contracts',
|
||||
invoices: 'Invoices',
|
||||
addCostSheet: 'Add Cost Sheet',
|
||||
addContract: 'Add Contract',
|
||||
addInvoice: 'Add Invoice',
|
||||
approve: 'Approve',
|
||||
reject: 'Reject',
|
||||
markSigned: 'Mark Signed',
|
||||
recordPayment: 'Record Payment',
|
||||
costSheetApproved: 'Cost sheet approved',
|
||||
costSheetRejected: 'Cost sheet rejected',
|
||||
contractSigned: 'Contract signed',
|
||||
paymentRecorded: 'Payment recorded',
|
||||
costSheetCreated: 'Cost sheet created',
|
||||
contractCreated: 'Contract created',
|
||||
invoiceCreated: 'Invoice created',
|
||||
costSheetItems: 'Cost items (description, source, cost, quantity)',
|
||||
invoiceItems: 'Line items (description, quantity, unit price)',
|
||||
description: 'Description',
|
||||
source: 'Source',
|
||||
addRow: 'Add row',
|
||||
totalCost: 'Total Cost',
|
||||
suggestedPrice: 'Suggested Price',
|
||||
profitMargin: 'Profit Margin',
|
||||
contractTitle: 'Contract Title',
|
||||
contractType: 'Contract Type',
|
||||
contractTypeSales: 'Sales',
|
||||
contractTypeService: 'Service',
|
||||
contractTypeMaintenance: 'Maintenance',
|
||||
contractValue: 'Contract Value',
|
||||
startDate: 'Start Date',
|
||||
endDate: 'End Date',
|
||||
paymentTerms: 'Payment Terms',
|
||||
deliveryTerms: 'Delivery Terms',
|
||||
terms: 'Terms & Conditions',
|
||||
subtotal: 'Subtotal',
|
||||
taxAmount: 'Tax Amount',
|
||||
total: 'Total',
|
||||
dueDate: 'Due Date',
|
||||
paidAmount: 'Paid Amount',
|
||||
paidDate: 'Paid Date'
|
||||
},
|
||||
import: {
|
||||
title: 'Import Contacts',
|
||||
@@ -508,7 +550,49 @@ const translations = {
|
||||
processing: 'جاري المعالجة...',
|
||||
deleting: 'جاري الحذف...',
|
||||
deleteDealConfirm: 'هل أنت متأكد من حذف',
|
||||
deleteDealDesc: 'سيتم تحديد الصفقة كخاسرة'
|
||||
deleteDealDesc: 'سيتم تحديد الصفقة كخاسرة',
|
||||
costSheets: 'كشوفات التكلفة',
|
||||
contracts: 'العقود',
|
||||
invoices: 'الفواتير',
|
||||
addCostSheet: 'إضافة كشف تكلفة',
|
||||
addContract: 'إضافة عقد',
|
||||
addInvoice: 'إضافة فاتورة',
|
||||
approve: 'موافقة',
|
||||
reject: 'رفض',
|
||||
markSigned: 'توقيع',
|
||||
recordPayment: 'تسجيل الدفع',
|
||||
costSheetApproved: 'تمت الموافقة على كشف التكلفة',
|
||||
costSheetRejected: 'تم رفض كشف التكلفة',
|
||||
contractSigned: 'تم توقيع العقد',
|
||||
paymentRecorded: 'تم تسجيل الدفع',
|
||||
costSheetCreated: 'تم إنشاء كشف التكلفة',
|
||||
contractCreated: 'تم إنشاء العقد',
|
||||
invoiceCreated: 'تم إنشاء الفاتورة',
|
||||
costSheetItems: 'بنود التكلفة (الوصف، المصدر، التكلفة، الكمية)',
|
||||
invoiceItems: 'بنود الفاتورة (الوصف، الكمية، سعر الوحدة)',
|
||||
description: 'الوصف',
|
||||
source: 'المصدر',
|
||||
addRow: 'إضافة صف',
|
||||
totalCost: 'إجمالي التكلفة',
|
||||
suggestedPrice: 'السعر المقترح',
|
||||
profitMargin: 'هامش الربح',
|
||||
contractTitle: 'عنوان العقد',
|
||||
contractType: 'نوع العقد',
|
||||
contractTypeSales: 'مبيعات',
|
||||
contractTypeService: 'خدمة',
|
||||
contractTypeMaintenance: 'صيانة',
|
||||
contractValue: 'قيمة العقد',
|
||||
startDate: 'تاريخ البداية',
|
||||
endDate: 'تاريخ النهاية',
|
||||
paymentTerms: 'شروط الدفع',
|
||||
deliveryTerms: 'شروط التسليم',
|
||||
terms: 'الشروط والأحكام',
|
||||
subtotal: 'المجموع الفرعي',
|
||||
taxAmount: 'ضريبة',
|
||||
total: 'الإجمالي',
|
||||
dueDate: 'تاريخ الاستحقاق',
|
||||
paidAmount: 'المبلغ المدفوع',
|
||||
paidDate: 'تاريخ الدفع'
|
||||
},
|
||||
import: {
|
||||
title: 'استيراد جهات الاتصال',
|
||||
|
||||
65
frontend/src/lib/api/contracts.ts
Normal file
65
frontend/src/lib/api/contracts.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { api } from '../api'
|
||||
|
||||
export interface Contract {
|
||||
id: string
|
||||
contractNumber: string
|
||||
dealId: string
|
||||
deal?: any
|
||||
version?: number
|
||||
title: string
|
||||
type: string
|
||||
clientInfo: any
|
||||
companyInfo: any
|
||||
startDate: string
|
||||
endDate?: string
|
||||
value: number
|
||||
paymentTerms: any
|
||||
deliveryTerms: any
|
||||
terms: string
|
||||
status: string
|
||||
signedAt?: string
|
||||
documentUrl?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CreateContractData {
|
||||
dealId: string
|
||||
title: string
|
||||
type: string
|
||||
clientInfo: any
|
||||
companyInfo: any
|
||||
startDate: string
|
||||
endDate?: string
|
||||
value: number
|
||||
paymentTerms: any
|
||||
deliveryTerms: any
|
||||
terms: string
|
||||
}
|
||||
|
||||
export const contractsAPI = {
|
||||
getByDeal: async (dealId: string): Promise<Contract[]> => {
|
||||
const response = await api.get(`/crm/deals/${dealId}/contracts`)
|
||||
return response.data.data || []
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<Contract> => {
|
||||
const response = await api.get(`/crm/contracts/${id}`)
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
create: async (data: CreateContractData): Promise<Contract> => {
|
||||
const response = await api.post('/crm/contracts', data)
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
update: async (id: string, data: Partial<CreateContractData>): Promise<Contract> => {
|
||||
const response = await api.put(`/crm/contracts/${id}`, data)
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
sign: async (id: string): Promise<Contract> => {
|
||||
const response = await api.post(`/crm/contracts/${id}/sign`)
|
||||
return response.data.data
|
||||
}
|
||||
}
|
||||
60
frontend/src/lib/api/costSheets.ts
Normal file
60
frontend/src/lib/api/costSheets.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { api } from '../api'
|
||||
|
||||
export interface CostSheetItem {
|
||||
description?: string
|
||||
source?: string
|
||||
cost: number
|
||||
quantity: number
|
||||
}
|
||||
|
||||
export interface CostSheet {
|
||||
id: string
|
||||
costSheetNumber: string
|
||||
dealId: string
|
||||
deal?: any
|
||||
version: number
|
||||
items: CostSheetItem[] | any
|
||||
totalCost: number
|
||||
suggestedPrice: number
|
||||
profitMargin: number
|
||||
status: string
|
||||
approvedBy?: string
|
||||
approvedAt?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CreateCostSheetData {
|
||||
dealId: string
|
||||
items: CostSheetItem[] | any[]
|
||||
totalCost: number
|
||||
suggestedPrice: number
|
||||
profitMargin: number
|
||||
}
|
||||
|
||||
export const costSheetsAPI = {
|
||||
getByDeal: async (dealId: string): Promise<CostSheet[]> => {
|
||||
const response = await api.get(`/crm/deals/${dealId}/cost-sheets`)
|
||||
return response.data.data || []
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<CostSheet> => {
|
||||
const response = await api.get(`/crm/cost-sheets/${id}`)
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
create: async (data: CreateCostSheetData): Promise<CostSheet> => {
|
||||
const response = await api.post('/crm/cost-sheets', data)
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
approve: async (id: string): Promise<CostSheet> => {
|
||||
const response = await api.post(`/crm/cost-sheets/${id}/approve`)
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
reject: async (id: string): Promise<CostSheet> => {
|
||||
const response = await api.post(`/crm/cost-sheets/${id}/reject`)
|
||||
return response.data.data
|
||||
}
|
||||
}
|
||||
64
frontend/src/lib/api/invoices.ts
Normal file
64
frontend/src/lib/api/invoices.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { api } from '../api'
|
||||
|
||||
export interface InvoiceItem {
|
||||
description?: string
|
||||
quantity: number
|
||||
unitPrice: number
|
||||
total?: number
|
||||
}
|
||||
|
||||
export interface Invoice {
|
||||
id: string
|
||||
invoiceNumber: string
|
||||
dealId?: string
|
||||
deal?: any
|
||||
items: InvoiceItem[] | any
|
||||
subtotal: number
|
||||
taxAmount: number
|
||||
total: number
|
||||
status: string
|
||||
dueDate: string
|
||||
paidDate?: string
|
||||
paidAmount?: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CreateInvoiceData {
|
||||
dealId?: string
|
||||
items: InvoiceItem[] | any[]
|
||||
subtotal: number
|
||||
taxAmount: number
|
||||
total: number
|
||||
dueDate: string
|
||||
}
|
||||
|
||||
export const invoicesAPI = {
|
||||
getByDeal: async (dealId: string): Promise<Invoice[]> => {
|
||||
const response = await api.get(`/crm/deals/${dealId}/invoices`)
|
||||
return response.data.data || []
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<Invoice> => {
|
||||
const response = await api.get(`/crm/invoices/${id}`)
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
create: async (data: CreateInvoiceData): Promise<Invoice> => {
|
||||
const response = await api.post('/crm/invoices', data)
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
update: async (id: string, data: Partial<CreateInvoiceData>): Promise<Invoice> => {
|
||||
const response = await api.put(`/crm/invoices/${id}`, data)
|
||||
return response.data.data
|
||||
},
|
||||
|
||||
recordPayment: async (id: string, paidAmount: number, paidDate?: string): Promise<Invoice> => {
|
||||
const response = await api.post(`/crm/invoices/${id}/record-payment`, {
|
||||
paidAmount,
|
||||
paidDate: paidDate || new Date().toISOString(),
|
||||
})
|
||||
return response.data.data
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user