feat(crm): add contracts, cost sheets, invoices modules and API clients
Made-with: Cursor
This commit is contained in:
65
backend/src/modules/crm/contracts.controller.ts
Normal file
65
backend/src/modules/crm/contracts.controller.ts
Normal 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();
|
||||
136
backend/src/modules/crm/contracts.service.ts
Normal file
136
backend/src/modules/crm/contracts.service.ts
Normal 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();
|
||||
55
backend/src/modules/crm/costSheets.controller.ts
Normal file
55
backend/src/modules/crm/costSheets.controller.ts
Normal 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();
|
||||
113
backend/src/modules/crm/costSheets.service.ts
Normal file
113
backend/src/modules/crm/costSheets.service.ts
Normal 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();
|
||||
@@ -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;
|
||||
|
||||
|
||||
61
backend/src/modules/crm/invoices.controller.ts
Normal file
61
backend/src/modules/crm/invoices.controller.ts
Normal 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();
|
||||
130
backend/src/modules/crm/invoices.service.ts
Normal file
130
backend/src/modules/crm/invoices.service.ts
Normal 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();
|
||||
@@ -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