From 18c13cdf7c56498330f02aae86c3b916faf03de5 Mon Sep 17 00:00:00 2001 From: Talal Sharabi Date: Wed, 11 Mar 2026 16:40:25 +0400 Subject: [PATCH] feat(crm): add contracts, cost sheets, invoices modules and API clients Made-with: Cursor --- .../src/modules/crm/contracts.controller.ts | 65 +++ backend/src/modules/crm/contracts.service.ts | 136 +++++ .../src/modules/crm/costSheets.controller.ts | 55 ++ backend/src/modules/crm/costSheets.service.ts | 113 ++++ backend/src/modules/crm/crm.routes.ts | 151 ++++++ .../src/modules/crm/invoices.controller.ts | 61 +++ backend/src/modules/crm/invoices.service.ts | 130 +++++ frontend/src/app/crm/deals/[id]/page.tsx | 505 +++++++++++++++++- frontend/src/contexts/LanguageContext.tsx | 88 ++- frontend/src/lib/api/contracts.ts | 65 +++ frontend/src/lib/api/costSheets.ts | 60 +++ frontend/src/lib/api/invoices.ts | 64 +++ 12 files changed, 1483 insertions(+), 10 deletions(-) create mode 100644 backend/src/modules/crm/contracts.controller.ts create mode 100644 backend/src/modules/crm/contracts.service.ts create mode 100644 backend/src/modules/crm/costSheets.controller.ts create mode 100644 backend/src/modules/crm/costSheets.service.ts create mode 100644 backend/src/modules/crm/invoices.controller.ts create mode 100644 backend/src/modules/crm/invoices.service.ts create mode 100644 frontend/src/lib/api/contracts.ts create mode 100644 frontend/src/lib/api/costSheets.ts create mode 100644 frontend/src/lib/api/invoices.ts diff --git a/backend/src/modules/crm/contracts.controller.ts b/backend/src/modules/crm/contracts.controller.ts new file mode 100644 index 0000000..c71b6c9 --- /dev/null +++ b/backend/src/modules/crm/contracts.controller.ts @@ -0,0 +1,65 @@ +import { Response, NextFunction } from 'express'; +import { AuthRequest } from '../../shared/middleware/auth'; +import { contractsService } from './contracts.service'; +import { ResponseFormatter } from '../../shared/utils/responseFormatter'; + +export class ContractsController { + async create(req: AuthRequest, res: Response, next: NextFunction) { + try { + const contract = await contractsService.create(req.body, req.user!.id); + res.status(201).json( + ResponseFormatter.success(contract, 'تم إنشاء العقد - Contract created') + ); + } catch (error) { + next(error); + } + } + + async findById(req: AuthRequest, res: Response, next: NextFunction) { + try { + const contract = await contractsService.findById(req.params.id); + res.json(ResponseFormatter.success(contract)); + } catch (error) { + next(error); + } + } + + async findByDeal(req: AuthRequest, res: Response, next: NextFunction) { + try { + const list = await contractsService.findByDeal(req.params.dealId); + res.json(ResponseFormatter.success(list)); + } catch (error) { + next(error); + } + } + + async update(req: AuthRequest, res: Response, next: NextFunction) { + try { + const contract = await contractsService.update(req.params.id, req.body, req.user!.id); + res.json(ResponseFormatter.success(contract, 'تم تحديث العقد - Contract updated')); + } catch (error) { + next(error); + } + } + + async updateStatus(req: AuthRequest, res: Response, next: NextFunction) { + try { + const { status } = req.body; + const contract = await contractsService.updateStatus(req.params.id, status, req.user!.id); + res.json(ResponseFormatter.success(contract, 'تم تحديث حالة العقد - Contract status updated')); + } catch (error) { + next(error); + } + } + + async markSigned(req: AuthRequest, res: Response, next: NextFunction) { + try { + const contract = await contractsService.markSigned(req.params.id, req.user!.id); + res.json(ResponseFormatter.success(contract, 'تم توقيع العقد - Contract signed')); + } catch (error) { + next(error); + } + } +} + +export const contractsController = new ContractsController(); diff --git a/backend/src/modules/crm/contracts.service.ts b/backend/src/modules/crm/contracts.service.ts new file mode 100644 index 0000000..f5ade12 --- /dev/null +++ b/backend/src/modules/crm/contracts.service.ts @@ -0,0 +1,136 @@ +import prisma from '../../config/database'; +import { AppError } from '../../shared/middleware/errorHandler'; +import { AuditLogger } from '../../shared/utils/auditLogger'; + +interface CreateContractData { + dealId: string; + title: string; + type: string; + clientInfo: any; + companyInfo: any; + startDate: Date; + endDate?: Date; + value: number; + paymentTerms: any; + deliveryTerms: any; + terms: string; +} + +class ContractsService { + async create(data: CreateContractData, userId: string) { + const contractNumber = await this.generateContractNumber(); + const contract = await prisma.contract.create({ + data: { + contractNumber, + dealId: data.dealId, + title: data.title, + type: data.type, + clientInfo: data.clientInfo || {}, + companyInfo: data.companyInfo || {}, + startDate: new Date(data.startDate), + endDate: data.endDate ? new Date(data.endDate) : null, + value: data.value, + paymentTerms: data.paymentTerms || {}, + deliveryTerms: data.deliveryTerms || {}, + terms: data.terms, + status: 'DRAFT', + }, + include: { + deal: { include: { contact: true, owner: true } }, + }, + }); + + await AuditLogger.log({ + entityType: 'CONTRACT', + entityId: contract.id, + action: 'CREATE', + userId, + }); + return contract; + } + + async findById(id: string) { + const contract = await prisma.contract.findUnique({ + where: { id }, + include: { + deal: { include: { contact: true, owner: true } }, + }, + }); + if (!contract) throw new AppError(404, 'العقد غير موجود - Contract not found'); + return contract; + } + + async findByDeal(dealId: string) { + return prisma.contract.findMany({ + where: { dealId }, + orderBy: { createdAt: 'desc' }, + }); + } + + async updateStatus(id: string, status: string, userId: string) { + const contract = await prisma.contract.update({ + where: { id }, + data: { status }, + include: { deal: true }, + }); + await AuditLogger.log({ + entityType: 'CONTRACT', + entityId: id, + action: 'STATUS_CHANGE', + userId, + changes: { status }, + }); + return contract; + } + + async markSigned(id: string, userId: string) { + const contract = await prisma.contract.update({ + where: { id }, + data: { status: 'ACTIVE', signedAt: new Date() }, + include: { deal: true }, + }); + await AuditLogger.log({ + entityType: 'CONTRACT', + entityId: id, + action: 'SIGN', + userId, + }); + return contract; + } + + async update(id: string, data: Partial, userId: string) { + const updateData: Record = { ...data }; + if (updateData.startDate) updateData.startDate = new Date(updateData.startDate); + if (updateData.endDate !== undefined) updateData.endDate = updateData.endDate ? new Date(updateData.endDate) : null; + const contract = await prisma.contract.update({ + where: { id }, + data: updateData, + include: { deal: { include: { contact: true, owner: true } } }, + }); + await AuditLogger.log({ + entityType: 'CONTRACT', + entityId: id, + action: 'UPDATE', + userId, + }); + return contract; + } + + private async generateContractNumber(): Promise { + const year = new Date().getFullYear(); + const prefix = `CTR-${year}-`; + const last = await prisma.contract.findFirst({ + where: { contractNumber: { startsWith: prefix } }, + orderBy: { createdAt: 'desc' }, + select: { contractNumber: true }, + }); + let next = 1; + if (last) { + const parts = last.contractNumber.split('-'); + next = parseInt(parts[2] || '0') + 1; + } + return `${prefix}${next.toString().padStart(6, '0')}`; + } +} + +export const contractsService = new ContractsService(); diff --git a/backend/src/modules/crm/costSheets.controller.ts b/backend/src/modules/crm/costSheets.controller.ts new file mode 100644 index 0000000..26bd98c --- /dev/null +++ b/backend/src/modules/crm/costSheets.controller.ts @@ -0,0 +1,55 @@ +import { Response, NextFunction } from 'express'; +import { AuthRequest } from '../../shared/middleware/auth'; +import { costSheetsService } from './costSheets.service'; +import { ResponseFormatter } from '../../shared/utils/responseFormatter'; + +export class CostSheetsController { + async create(req: AuthRequest, res: Response, next: NextFunction) { + try { + const cs = await costSheetsService.create(req.body, req.user!.id); + res.status(201).json( + ResponseFormatter.success(cs, 'تم إنشاء كشف التكلفة - Cost sheet created') + ); + } catch (error) { + next(error); + } + } + + async findById(req: AuthRequest, res: Response, next: NextFunction) { + try { + const cs = await costSheetsService.findById(req.params.id); + res.json(ResponseFormatter.success(cs)); + } catch (error) { + next(error); + } + } + + async findByDeal(req: AuthRequest, res: Response, next: NextFunction) { + try { + const list = await costSheetsService.findByDeal(req.params.dealId); + res.json(ResponseFormatter.success(list)); + } catch (error) { + next(error); + } + } + + async approve(req: AuthRequest, res: Response, next: NextFunction) { + try { + const cs = await costSheetsService.approve(req.params.id, req.user!.id, req.user!.id); + res.json(ResponseFormatter.success(cs, 'تمت الموافقة على كشف التكلفة - Cost sheet approved')); + } catch (error) { + next(error); + } + } + + async reject(req: AuthRequest, res: Response, next: NextFunction) { + try { + const cs = await costSheetsService.reject(req.params.id, req.user!.id); + res.json(ResponseFormatter.success(cs, 'تم رفض كشف التكلفة - Cost sheet rejected')); + } catch (error) { + next(error); + } + } +} + +export const costSheetsController = new CostSheetsController(); diff --git a/backend/src/modules/crm/costSheets.service.ts b/backend/src/modules/crm/costSheets.service.ts new file mode 100644 index 0000000..809c3f2 --- /dev/null +++ b/backend/src/modules/crm/costSheets.service.ts @@ -0,0 +1,113 @@ +import prisma from '../../config/database'; +import { AppError } from '../../shared/middleware/errorHandler'; +import { AuditLogger } from '../../shared/utils/auditLogger'; + +interface CreateCostSheetData { + dealId: string; + items: any[]; + totalCost: number; + suggestedPrice: number; + profitMargin: number; +} + +class CostSheetsService { + async create(data: CreateCostSheetData, userId: string) { + const latest = await prisma.costSheet.findFirst({ + where: { dealId: data.dealId }, + orderBy: { version: 'desc' }, + select: { version: true }, + }); + const version = latest ? latest.version + 1 : 1; + const costSheetNumber = await this.generateCostSheetNumber(); + + const costSheet = await prisma.costSheet.create({ + data: { + costSheetNumber, + dealId: data.dealId, + version, + items: data.items, + totalCost: data.totalCost, + suggestedPrice: data.suggestedPrice, + profitMargin: data.profitMargin, + status: 'DRAFT', + }, + include: { + deal: { include: { contact: true, owner: true } }, + }, + }); + + await AuditLogger.log({ + entityType: 'COST_SHEET', + entityId: costSheet.id, + action: 'CREATE', + userId, + }); + return costSheet; + } + + async findById(id: string) { + const cs = await prisma.costSheet.findUnique({ + where: { id }, + include: { + deal: { include: { contact: true, owner: true } }, + }, + }); + if (!cs) throw new AppError(404, 'كشف التكلفة غير موجود - Cost sheet not found'); + return cs; + } + + async findByDeal(dealId: string) { + return prisma.costSheet.findMany({ + where: { dealId }, + orderBy: { version: 'desc' }, + }); + } + + async approve(id: string, approvedBy: string, userId: string) { + const cs = await prisma.costSheet.update({ + where: { id }, + data: { status: 'APPROVED', approvedBy, approvedAt: new Date() }, + include: { deal: true }, + }); + await AuditLogger.log({ + entityType: 'COST_SHEET', + entityId: id, + action: 'APPROVE', + userId, + }); + return cs; + } + + async reject(id: string, userId: string) { + const cs = await prisma.costSheet.update({ + where: { id }, + data: { status: 'REJECTED', approvedBy: null, approvedAt: null }, + include: { deal: true }, + }); + await AuditLogger.log({ + entityType: 'COST_SHEET', + entityId: id, + action: 'REJECT', + userId, + }); + return cs; + } + + private async generateCostSheetNumber(): Promise { + const year = new Date().getFullYear(); + const prefix = `CS-${year}-`; + const last = await prisma.costSheet.findFirst({ + where: { costSheetNumber: { startsWith: prefix } }, + orderBy: { createdAt: 'desc' }, + select: { costSheetNumber: true }, + }); + let next = 1; + if (last) { + const parts = last.costSheetNumber.split('-'); + next = parseInt(parts[2] || '0') + 1; + } + return `${prefix}${next.toString().padStart(6, '0')}`; + } +} + +export const costSheetsService = new CostSheetsService(); diff --git a/backend/src/modules/crm/crm.routes.ts b/backend/src/modules/crm/crm.routes.ts index e220869..51802f3 100644 --- a/backend/src/modules/crm/crm.routes.ts +++ b/backend/src/modules/crm/crm.routes.ts @@ -1,6 +1,9 @@ import { Router } from 'express'; import { body, param } from 'express-validator'; import { pipelinesController, dealsController, quotesController } from './crm.controller'; +import { costSheetsController } from './costSheets.controller'; +import { contractsController } from './contracts.controller'; +import { invoicesController } from './invoices.controller'; import { authenticate, authorize } from '../../shared/middleware/auth'; import { validate } from '../../shared/middleware/validation'; @@ -171,5 +174,153 @@ router.post( quotesController.send ); +// ============= COST SHEETS ============= + +router.get( + '/deals/:dealId/cost-sheets', + authorize('crm', 'deals', 'read'), + param('dealId').isUUID(), + validate, + costSheetsController.findByDeal +); + +router.get( + '/cost-sheets/:id', + authorize('crm', 'deals', 'read'), + param('id').isUUID(), + validate, + costSheetsController.findById +); + +router.post( + '/cost-sheets', + authorize('crm', 'deals', 'create'), + [ + body('dealId').isUUID(), + body('items').isArray(), + body('totalCost').isNumeric(), + body('suggestedPrice').isNumeric(), + body('profitMargin').isNumeric(), + validate, + ], + costSheetsController.create +); + +router.post( + '/cost-sheets/:id/approve', + authorize('crm', 'deals', 'update'), + param('id').isUUID(), + validate, + costSheetsController.approve +); + +router.post( + '/cost-sheets/:id/reject', + authorize('crm', 'deals', 'update'), + param('id').isUUID(), + validate, + costSheetsController.reject +); + +// ============= CONTRACTS ============= + +router.get( + '/deals/:dealId/contracts', + authorize('crm', 'deals', 'read'), + param('dealId').isUUID(), + validate, + contractsController.findByDeal +); + +router.get( + '/contracts/:id', + authorize('crm', 'deals', 'read'), + param('id').isUUID(), + validate, + contractsController.findById +); + +router.post( + '/contracts', + authorize('crm', 'deals', 'create'), + [ + body('dealId').isUUID(), + body('title').notEmpty().trim(), + body('type').notEmpty().trim(), + body('startDate').isISO8601(), + body('value').isNumeric(), + body('terms').notEmpty().trim(), + validate, + ], + contractsController.create +); + +router.put( + '/contracts/:id', + authorize('crm', 'deals', 'update'), + param('id').isUUID(), + validate, + contractsController.update +); + +router.post( + '/contracts/:id/sign', + authorize('crm', 'deals', 'update'), + param('id').isUUID(), + validate, + contractsController.markSigned +); + +// ============= INVOICES ============= + +router.get( + '/deals/:dealId/invoices', + authorize('crm', 'deals', 'read'), + param('dealId').isUUID(), + validate, + invoicesController.findByDeal +); + +router.get( + '/invoices/:id', + authorize('crm', 'deals', 'read'), + param('id').isUUID(), + validate, + invoicesController.findById +); + +router.post( + '/invoices', + authorize('crm', 'deals', 'create'), + [ + body('items').isArray(), + body('subtotal').isNumeric(), + body('taxAmount').isNumeric(), + body('total').isNumeric(), + body('dueDate').isISO8601(), + validate, + ], + invoicesController.create +); + +router.put( + '/invoices/:id', + authorize('crm', 'deals', 'update'), + param('id').isUUID(), + validate, + invoicesController.update +); + +router.post( + '/invoices/:id/record-payment', + authorize('crm', 'deals', 'update'), + [ + param('id').isUUID(), + body('paidAmount').isNumeric(), + validate, + ], + invoicesController.recordPayment +); + export default router; diff --git a/backend/src/modules/crm/invoices.controller.ts b/backend/src/modules/crm/invoices.controller.ts new file mode 100644 index 0000000..9367500 --- /dev/null +++ b/backend/src/modules/crm/invoices.controller.ts @@ -0,0 +1,61 @@ +import { Response, NextFunction } from 'express'; +import { AuthRequest } from '../../shared/middleware/auth'; +import { invoicesService } from './invoices.service'; +import { ResponseFormatter } from '../../shared/utils/responseFormatter'; + +export class InvoicesController { + async create(req: AuthRequest, res: Response, next: NextFunction) { + try { + const invoice = await invoicesService.create(req.body, req.user!.id); + res.status(201).json( + ResponseFormatter.success(invoice, 'تم إنشاء الفاتورة - Invoice created') + ); + } catch (error) { + next(error); + } + } + + async findById(req: AuthRequest, res: Response, next: NextFunction) { + try { + const invoice = await invoicesService.findById(req.params.id); + res.json(ResponseFormatter.success(invoice)); + } catch (error) { + next(error); + } + } + + async findByDeal(req: AuthRequest, res: Response, next: NextFunction) { + try { + const list = await invoicesService.findByDeal(req.params.dealId); + res.json(ResponseFormatter.success(list)); + } catch (error) { + next(error); + } + } + + async update(req: AuthRequest, res: Response, next: NextFunction) { + try { + const invoice = await invoicesService.update(req.params.id, req.body, req.user!.id); + res.json(ResponseFormatter.success(invoice, 'تم تحديث الفاتورة - Invoice updated')); + } catch (error) { + next(error); + } + } + + async recordPayment(req: AuthRequest, res: Response, next: NextFunction) { + try { + const { paidAmount, paidDate } = req.body; + const invoice = await invoicesService.recordPayment( + req.params.id, + paidAmount, + paidDate ? new Date(paidDate) : new Date(), + req.user!.id + ); + res.json(ResponseFormatter.success(invoice, 'تم تسجيل الدفع - Payment recorded')); + } catch (error) { + next(error); + } + } +} + +export const invoicesController = new InvoicesController(); diff --git a/backend/src/modules/crm/invoices.service.ts b/backend/src/modules/crm/invoices.service.ts new file mode 100644 index 0000000..b1af086 --- /dev/null +++ b/backend/src/modules/crm/invoices.service.ts @@ -0,0 +1,130 @@ +import prisma from '../../config/database'; +import { AppError } from '../../shared/middleware/errorHandler'; +import { AuditLogger } from '../../shared/utils/auditLogger'; + +interface CreateInvoiceData { + dealId?: string; + items: any[]; + subtotal: number; + taxAmount: number; + total: number; + dueDate: Date; +} + +class InvoicesService { + async create(data: CreateInvoiceData, userId: string) { + const invoiceNumber = await this.generateInvoiceNumber(); + const invoice = await prisma.invoice.create({ + data: { + invoiceNumber, + dealId: data.dealId || null, + items: data.items, + subtotal: data.subtotal, + taxAmount: data.taxAmount, + total: data.total, + dueDate: new Date(data.dueDate), + status: 'DRAFT', + }, + include: { + deal: { include: { contact: true, owner: true } }, + }, + }); + + await AuditLogger.log({ + entityType: 'INVOICE', + entityId: invoice.id, + action: 'CREATE', + userId, + }); + return invoice; + } + + async findById(id: string) { + const invoice = await prisma.invoice.findUnique({ + where: { id }, + include: { + deal: { include: { contact: true, owner: true } }, + }, + }); + if (!invoice) throw new AppError(404, 'الفاتورة غير موجودة - Invoice not found'); + return invoice; + } + + async findByDeal(dealId: string) { + return prisma.invoice.findMany({ + where: { dealId }, + orderBy: { createdAt: 'desc' }, + }); + } + + async updateStatus(id: string, status: string, userId: string) { + const invoice = await prisma.invoice.update({ + where: { id }, + data: { status }, + include: { deal: true }, + }); + await AuditLogger.log({ + entityType: 'INVOICE', + entityId: id, + action: 'STATUS_CHANGE', + userId, + changes: { status }, + }); + return invoice; + } + + async recordPayment(id: string, paidAmount: number, paidDate: Date, userId: string) { + const invoice = await prisma.invoice.update({ + where: { id }, + data: { + status: 'PAID', + paidAmount, + paidDate: new Date(paidDate), + }, + include: { deal: true }, + }); + await AuditLogger.log({ + entityType: 'INVOICE', + entityId: id, + action: 'PAYMENT_RECORDED', + userId, + changes: { paidAmount, paidDate }, + }); + return invoice; + } + + async update(id: string, data: Partial, userId: string) { + const updateData: Record = { ...data }; + if (updateData.dueDate) updateData.dueDate = new Date(updateData.dueDate); + const invoice = await prisma.invoice.update({ + where: { id }, + data: updateData, + include: { deal: { include: { contact: true, owner: true } } }, + }); + await AuditLogger.log({ + entityType: 'INVOICE', + entityId: id, + action: 'UPDATE', + userId, + }); + return invoice; + } + + private async generateInvoiceNumber(): Promise { + const year = new Date().getFullYear(); + const prefix = `INV-${year}-`; + const last = await prisma.invoice.findFirst({ + where: { invoiceNumber: { startsWith: prefix } }, + orderBy: { createdAt: 'desc' }, + select: { invoiceNumber: true }, + }); + let next = 1; + if (last) { + const parts = last.invoiceNumber.split('-'); + next = parseInt(parts[2] || '0') + 1; + } + return `${prefix}${next.toString().padStart(6, '0')}`; + } +} + +export const invoicesService = new InvoicesService(); diff --git a/frontend/src/app/crm/deals/[id]/page.tsx b/frontend/src/app/crm/deals/[id]/page.tsx index c538a22..6fdb7f3 100644 --- a/frontend/src/app/crm/deals/[id]/page.tsx +++ b/frontend/src/app/crm/deals/[id]/page.tsx @@ -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(null) const [quotes, setQuotes] = useState([]) + const [costSheets, setCostSheets] = useState([]) + const [contracts, setContracts] = useState([]) + const [invoices, setInvoices] = useState([]) const [history, setHistory] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(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(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({ + 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 = { ACTIVE: 'bg-green-100 text-green-700', @@ -272,18 +459,18 @@ function DealDetailContent() {
-
)} + {activeTab === 'costSheets' && ( +
+
+

{t('crm.costSheets')}

+ +
+ {costSheets.length === 0 ? ( +
+ +

{t('common.noData')}

+
+ ) : ( +
+ {costSheets.map((cs) => ( +
+
+
+

{cs.costSheetNumber}

+

v{cs.version} · {cs.status}

+

+ {Number(cs.totalCost)?.toLocaleString()} SAR cost · {Number(cs.suggestedPrice)?.toLocaleString()} SAR suggested · {Number(cs.profitMargin)}% margin +

+
+ {cs.status === 'DRAFT' && ( +
+ + +
+ )} +
+

{formatDate(cs.createdAt)}

+
+ ))} +
+ )} +
+ )} + + {activeTab === 'contracts' && ( +
+
+

{t('crm.contracts')}

+ +
+ {contracts.length === 0 ? ( +
+ +

{t('common.noData')}

+
+ ) : ( +
+ {contracts.map((c) => ( +
+
+
+

{c.contractNumber} · {c.title}

+

{c.type} · {c.status}

+

+ {Number(c.value)?.toLocaleString()} SAR · {formatDate(c.startDate)} {c.endDate ? `– ${formatDate(c.endDate)}` : ''} +

+
+ {c.status === 'PENDING_SIGNATURE' && ( + + )} +
+

{formatDate(c.createdAt)}

+
+ ))} +
+ )} +
+ )} + + {activeTab === 'invoices' && ( +
+
+

{t('crm.invoices')}

+ +
+ {invoices.length === 0 ? ( +
+ +

{t('common.noData')}

+
+ ) : ( +
+ {invoices.map((inv) => ( +
+
+
+

{inv.invoiceNumber}

+

{inv.status}

+

+ {Number(inv.total)?.toLocaleString()} SAR · {inv.paidAmount ? `${Number(inv.paidAmount)?.toLocaleString()} paid` : ''} · due {formatDate(inv.dueDate)} +

+
+ {(inv.status === 'SENT' || inv.status === 'OVERDUE') && ( + + )} +
+

{formatDate(inv.createdAt)}

+
+ ))} +
+ )} +
+ )} + {activeTab === 'history' && (
{history.length === 0 ? ( @@ -527,6 +843,179 @@ function DealDetailContent() {
)} + + {/* Cost Sheet Modal */} + setShowCostSheetModal(false)} title={t('crm.addCostSheet')} size="xl"> +
+

{t('crm.costSheetItems')}

+ {costSheetForm.items.map((item, idx) => ( +
+ { + 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" /> + { + 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" /> + { + 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" /> + { + 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" /> + +
+ ))} + +
+
+ + setCostSheetForm({ ...costSheetForm, totalCost: parseFloat(e.target.value) || 0 })} className="w-full px-3 py-2 border rounded-lg" /> +
+
+ + setCostSheetForm({ ...costSheetForm, suggestedPrice: parseFloat(e.target.value) || 0 })} className="w-full px-3 py-2 border rounded-lg" /> +
+
+ + setCostSheetForm({ ...costSheetForm, profitMargin: parseFloat(e.target.value) || 0 })} className="w-full px-3 py-2 border rounded-lg" /> +
+
+
+ + +
+
+
+ + {/* Contract Modal */} + setShowContractModal(false)} title={t('crm.addContract')} size="xl"> +
+
+ + setContractForm({ ...contractForm, title: e.target.value })} className="w-full px-3 py-2 border rounded-lg" /> +
+
+
+ + +
+
+ + setContractForm({ ...contractForm, value: parseFloat(e.target.value) || 0 })} className="w-full px-3 py-2 border rounded-lg" /> +
+
+
+
+ + setContractForm({ ...contractForm, startDate: e.target.value })} className="w-full px-3 py-2 border rounded-lg" /> +
+
+ + setContractForm({ ...contractForm, endDate: e.target.value })} className="w-full px-3 py-2 border rounded-lg" /> +
+
+
+ + setContractForm({ ...contractForm, paymentTerms: { description: e.target.value } })} className="w-full px-3 py-2 border rounded-lg" /> +
+
+ + setContractForm({ ...contractForm, deliveryTerms: { description: e.target.value } })} className="w-full px-3 py-2 border rounded-lg" /> +
+
+ +