feat(crm): add contracts, cost sheets, invoices modules and API clients

Made-with: Cursor
This commit is contained in:
Talal Sharabi
2026-03-11 16:40:25 +04:00
parent 8a20927044
commit 18c13cdf7c
12 changed files with 1483 additions and 10 deletions

View File

@@ -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();

View File

@@ -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<CreateContractData>, userId: string) {
const updateData: Record<string, any> = { ...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<string> {
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();

View File

@@ -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();

View File

@@ -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<string> {
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();

View File

@@ -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;

View File

@@ -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();

View File

@@ -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<CreateInvoiceData>, userId: string) {
const updateData: Record<string, any> = { ...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<string> {
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();

View File

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

View File

@@ -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: 'استيراد جهات الاتصال',

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

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

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