594 lines
19 KiB
TypeScript
594 lines
19 KiB
TypeScript
import type {
|
|
ApprovalActionPayload,
|
|
ApprovalFilters,
|
|
ContactSharePayload,
|
|
CrmDashboardData,
|
|
CrmReferenceData,
|
|
Customer,
|
|
CustomerDetailResponse,
|
|
CustomerFilters,
|
|
CustomerMutationPayload,
|
|
Enquiry,
|
|
EnquiryDetailResponse,
|
|
EnquiryFilters,
|
|
PaginatedResponse,
|
|
Quotation,
|
|
QuotationDetailResponse,
|
|
QuotationFilters
|
|
} from './types';
|
|
import { initialCrmState, type CrmMockState } from '../data/mock-crm';
|
|
|
|
function clone<T>(value: T): T {
|
|
return JSON.parse(JSON.stringify(value)) as T;
|
|
}
|
|
|
|
let crmState: CrmMockState = clone(initialCrmState);
|
|
|
|
function sortItems<T>(items: T[], sort?: string) {
|
|
if (!sort) return items;
|
|
|
|
try {
|
|
const parsed = JSON.parse(sort) as Array<{ id: string; desc?: boolean }>;
|
|
const [rule] = parsed;
|
|
|
|
if (!rule) return items;
|
|
|
|
return [...items].sort((a, b) => {
|
|
const av = String((a as Record<string, unknown>)[rule.id] ?? '');
|
|
const bv = String((b as Record<string, unknown>)[rule.id] ?? '');
|
|
return rule.desc ? bv.localeCompare(av) : av.localeCompare(bv);
|
|
});
|
|
} catch {
|
|
return items;
|
|
}
|
|
}
|
|
|
|
function paginate<T>(items: T[], page = 1, limit = 10): PaginatedResponse<T> {
|
|
const start = (page - 1) * limit;
|
|
return {
|
|
items: items.slice(start, start + limit),
|
|
totalItems: items.length
|
|
};
|
|
}
|
|
|
|
function findBranch(branchId: string) {
|
|
return crmState.branches.find((branch) => branch.id === branchId);
|
|
}
|
|
|
|
function findSalesperson(id: string) {
|
|
return crmState.salespersons.find((sales) => sales.id === id);
|
|
}
|
|
|
|
export async function getCrmReferenceData(): Promise<CrmReferenceData> {
|
|
return clone({
|
|
branches: crmState.branches,
|
|
salespersons: crmState.salespersons,
|
|
masterOptions: crmState.masterOptions,
|
|
sequences: crmState.sequences,
|
|
templates: crmState.templates
|
|
});
|
|
}
|
|
|
|
export async function getCrmDashboardData(): Promise<CrmDashboardData> {
|
|
const openEnquiries = crmState.enquiries.filter((enquiry) =>
|
|
['new', 'qualifying', 'requirement', 'follow_up'].includes(enquiry.status)
|
|
);
|
|
const pendingQuotations = crmState.quotations.filter(
|
|
(quotation) => quotation.status === 'pending_approval'
|
|
);
|
|
const wonQuotations = crmState.quotations.filter((quotation) => quotation.status === 'accepted');
|
|
const lostQuotations = crmState.quotations.filter((quotation) => quotation.status === 'lost');
|
|
const hotQuotations = crmState.quotations.filter((quotation) => quotation.isHotProject);
|
|
const dueFollowUps = crmState.quotations.flatMap((quotation) => quotation.followUps).slice(0, 5);
|
|
const conversionRate = Math.round(
|
|
(wonQuotations.length / Math.max(1, crmState.enquiries.length)) * 100
|
|
);
|
|
|
|
return clone({
|
|
metrics: [
|
|
{
|
|
id: 'm-1',
|
|
label: 'Total Enquiries',
|
|
value: String(crmState.enquiries.length),
|
|
change: '+12%',
|
|
trend: 'up'
|
|
},
|
|
{
|
|
id: 'm-2',
|
|
label: 'Open Enquiries',
|
|
value: String(openEnquiries.length),
|
|
change: '+3',
|
|
trend: 'up'
|
|
},
|
|
{
|
|
id: 'm-3',
|
|
label: 'Pending Quotations',
|
|
value: String(pendingQuotations.length),
|
|
change: '+2',
|
|
trend: 'up'
|
|
},
|
|
{
|
|
id: 'm-4',
|
|
label: 'Pending Approval',
|
|
value: String(crmState.approvals.filter((item) => item.status === 'pending').length),
|
|
change: 'Needs review',
|
|
trend: 'flat'
|
|
},
|
|
{
|
|
id: 'm-5',
|
|
label: 'Won Amount',
|
|
value: `${Math.round(wonQuotations.reduce((sum, item) => sum + item.totalAmount, 0) / 1000000)}M`,
|
|
change: '+18%',
|
|
trend: 'up'
|
|
},
|
|
{
|
|
id: 'm-6',
|
|
label: 'Lost Amount',
|
|
value: `${Math.round(lostQuotations.reduce((sum, item) => sum + item.totalAmount, 0) / 1000000)}M`,
|
|
change: '-6%',
|
|
trend: 'down'
|
|
},
|
|
{
|
|
id: 'm-7',
|
|
label: 'Conversion Rate',
|
|
value: `${conversionRate}%`,
|
|
change: '+4pts',
|
|
trend: 'up'
|
|
},
|
|
{
|
|
id: 'm-8',
|
|
label: 'Hot Projects',
|
|
value: String(hotQuotations.length),
|
|
change: 'Focus',
|
|
trend: 'flat'
|
|
},
|
|
{
|
|
id: 'm-9',
|
|
label: 'Follow-up Due Today',
|
|
value: String(dueFollowUps.length),
|
|
change: 'Today',
|
|
trend: 'flat'
|
|
}
|
|
],
|
|
enquiryPipeline: [
|
|
{ label: 'New', value: crmState.enquiries.filter((item) => item.status === 'new').length },
|
|
{
|
|
label: 'Qualifying',
|
|
value: crmState.enquiries.filter((item) => item.status === 'qualifying').length
|
|
},
|
|
{
|
|
label: 'Requirement',
|
|
value: crmState.enquiries.filter((item) => item.status === 'requirement').length
|
|
},
|
|
{
|
|
label: 'Follow Up',
|
|
value: crmState.enquiries.filter((item) => item.status === 'follow_up').length
|
|
},
|
|
{
|
|
label: 'Converted',
|
|
value: crmState.enquiries.filter((item) => item.status === 'converted').length
|
|
}
|
|
],
|
|
quotationPipeline: [
|
|
{
|
|
label: 'Draft',
|
|
value: crmState.quotations.filter((item) => item.status === 'draft').length
|
|
},
|
|
{
|
|
label: 'Pending',
|
|
value: crmState.quotations.filter((item) => item.status === 'pending_approval').length
|
|
},
|
|
{ label: 'Sent', value: crmState.quotations.filter((item) => item.status === 'sent').length },
|
|
{
|
|
label: 'Accepted',
|
|
value: crmState.quotations.filter((item) => item.status === 'accepted').length
|
|
},
|
|
{ label: 'Lost', value: crmState.quotations.filter((item) => item.status === 'lost').length }
|
|
],
|
|
salesPerformance: crmState.salespersons.map((salesperson) => ({
|
|
label: salesperson.nickname,
|
|
value: crmState.quotations.filter((quotation) => quotation.salesmanId === salesperson.id)
|
|
.length,
|
|
secondaryValue: crmState.quotations
|
|
.filter((quotation) => quotation.salesmanId === salesperson.id)
|
|
.reduce((sum, quotation) => sum + quotation.totalAmount, 0)
|
|
})),
|
|
competitorAnalysis: [
|
|
{ label: 'Apex Dock', value: 3 },
|
|
{ label: 'LiftPro', value: 2 },
|
|
{ label: 'SunNorth', value: 1 },
|
|
{ label: 'Others', value: 2 }
|
|
],
|
|
wonLostTrend: [
|
|
{ label: 'Jan', value: 1, secondaryValue: 0 },
|
|
{ label: 'Feb', value: 2, secondaryValue: 1 },
|
|
{ label: 'Mar', value: 1, secondaryValue: 1 },
|
|
{ label: 'Apr', value: 3, secondaryValue: 1 },
|
|
{ label: 'May', value: 2, secondaryValue: 2 },
|
|
{ label: 'Jun', value: 2, secondaryValue: 1 }
|
|
],
|
|
pendingApprovals: crmState.approvals.filter((item) => item.status === 'pending'),
|
|
hotQuotations,
|
|
dueFollowUps
|
|
});
|
|
}
|
|
|
|
export async function getCustomers(filters: CustomerFilters): Promise<PaginatedResponse<Customer>> {
|
|
let items = [...crmState.customers];
|
|
|
|
if (filters.search) {
|
|
const search = filters.search.toLowerCase();
|
|
items = items.filter(
|
|
(customer) =>
|
|
customer.name.toLowerCase().includes(search) || customer.code.toLowerCase().includes(search)
|
|
);
|
|
}
|
|
|
|
if (filters.status) {
|
|
items = items.filter((customer) => customer.customerStatus === filters.status);
|
|
}
|
|
|
|
if (filters.branch) {
|
|
items = items.filter((customer) => customer.branchId === filters.branch);
|
|
}
|
|
|
|
items = sortItems(items, filters.sort);
|
|
return clone(paginate(items, filters.page, filters.limit));
|
|
}
|
|
|
|
export async function getCustomerById(id: string): Promise<CustomerDetailResponse> {
|
|
const customer = crmState.customers.find((item) => item.id === id);
|
|
if (!customer) {
|
|
throw new Error('Customer not found');
|
|
}
|
|
|
|
return clone({
|
|
customer,
|
|
contacts: crmState.contacts.filter((contact) => contact.customerId === id),
|
|
shares: crmState.contactShares.filter((share) =>
|
|
crmState.contacts.some(
|
|
(contact) => contact.id === share.contactId && contact.customerId === id
|
|
)
|
|
),
|
|
shareLogs: crmState.contactShareLogs.filter((log) =>
|
|
crmState.contacts.some((contact) => contact.id === log.contactId && contact.customerId === id)
|
|
),
|
|
enquiries: crmState.enquiries.filter((enquiry) => enquiry.customerId === id),
|
|
quotations: crmState.quotations.filter((quotation) =>
|
|
quotation.customerRoles.some((role) => role.customerId === id)
|
|
),
|
|
auditEvents: crmState.auditEvents.filter((event) => event.entityId === id),
|
|
branch: findBranch(customer.branchId)!
|
|
});
|
|
}
|
|
|
|
export async function getEnquiries(filters: EnquiryFilters): Promise<PaginatedResponse<Enquiry>> {
|
|
let items = [...crmState.enquiries];
|
|
|
|
if (filters.search) {
|
|
const search = filters.search.toLowerCase();
|
|
items = items.filter(
|
|
(item) =>
|
|
item.code.toLowerCase().includes(search) || item.title.toLowerCase().includes(search)
|
|
);
|
|
}
|
|
if (filters.status) items = items.filter((item) => item.status === filters.status);
|
|
if (filters.productType) items = items.filter((item) => item.productType === filters.productType);
|
|
if (filters.salesman) items = items.filter((item) => item.salesmanId === filters.salesman);
|
|
if (filters.dateFrom) items = items.filter((item) => item.createdAt >= filters.dateFrom!);
|
|
if (filters.dateTo)
|
|
items = items.filter((item) => item.createdAt <= `${filters.dateTo!}T23:59:59.999Z`);
|
|
|
|
items = sortItems(items, filters.sort);
|
|
return clone(paginate(items, filters.page, filters.limit));
|
|
}
|
|
|
|
export async function getEnquiryById(id: string): Promise<EnquiryDetailResponse> {
|
|
const enquiry = crmState.enquiries.find((item) => item.id === id);
|
|
if (!enquiry) {
|
|
throw new Error('Enquiry not found');
|
|
}
|
|
|
|
const customer = crmState.customers.find((item) => item.id === enquiry.customerId)!;
|
|
const contact = crmState.contacts.find((item) => item.id === enquiry.contactId)!;
|
|
return clone({
|
|
enquiry,
|
|
customer,
|
|
contact,
|
|
quotations: crmState.quotations.filter((quotation) => quotation.enquiryId === id),
|
|
branch: findBranch(enquiry.branchId)!,
|
|
salesman: findSalesperson(enquiry.salesmanId)!
|
|
});
|
|
}
|
|
|
|
export async function getQuotations(
|
|
filters: QuotationFilters
|
|
): Promise<PaginatedResponse<Quotation>> {
|
|
let items = [...crmState.quotations];
|
|
|
|
if (filters.search) {
|
|
const search = filters.search.toLowerCase();
|
|
items = items.filter(
|
|
(quotation) =>
|
|
quotation.code.toLowerCase().includes(search) ||
|
|
quotation.project.toLowerCase().includes(search)
|
|
);
|
|
}
|
|
if (filters.status) items = items.filter((quotation) => quotation.status === filters.status);
|
|
if (filters.quotationType)
|
|
items = items.filter((quotation) => quotation.quotationType === filters.quotationType);
|
|
if (filters.salesman)
|
|
items = items.filter((quotation) => quotation.salesmanId === filters.salesman);
|
|
if (filters.hot)
|
|
items = items.filter((quotation) =>
|
|
filters.hot === 'yes' ? quotation.isHotProject : !quotation.isHotProject
|
|
);
|
|
if (filters.dateFrom)
|
|
items = items.filter((quotation) => quotation.quotationDate >= filters.dateFrom!);
|
|
if (filters.dateTo)
|
|
items = items.filter((quotation) => quotation.quotationDate <= filters.dateTo!);
|
|
|
|
items = sortItems(items, filters.sort);
|
|
return clone(paginate(items, filters.page, filters.limit));
|
|
}
|
|
|
|
export async function getQuotationById(id: string): Promise<QuotationDetailResponse> {
|
|
const quotation = crmState.quotations.find((item) => item.id === id);
|
|
if (!quotation) {
|
|
throw new Error('Quotation not found');
|
|
}
|
|
|
|
const enquiry = crmState.enquiries.find((item) => item.id === quotation.enquiryId)!;
|
|
const customerIds = [...new Set(quotation.customerRoles.map((role) => role.customerId))];
|
|
const contactIds = [
|
|
...new Set(quotation.customerRoles.map((role) => role.contactId).filter(Boolean))
|
|
] as string[];
|
|
|
|
return clone({
|
|
quotation,
|
|
enquiry,
|
|
customers: crmState.customers.filter((item) => customerIds.includes(item.id)),
|
|
contacts: crmState.contacts.filter((item) => contactIds.includes(item.id)),
|
|
branch: findBranch(quotation.branchId)!,
|
|
salesman: findSalesperson(quotation.salesmanId)!,
|
|
saleAdmin: findSalesperson(quotation.saleAdminId)!,
|
|
auditEvents: crmState.auditEvents.filter((event) => quotation.auditEventIds.includes(event.id))
|
|
});
|
|
}
|
|
|
|
export async function getApprovals(filters: ApprovalFilters) {
|
|
let items = [...crmState.approvals];
|
|
if (filters.branch) items = items.filter((item) => item.branchId === filters.branch);
|
|
if (filters.status) items = items.filter((item) => item.status === filters.status);
|
|
return clone(paginate(items, filters.page, filters.limit));
|
|
}
|
|
|
|
export async function createCustomer(values: CustomerMutationPayload) {
|
|
const customer: Customer = { id: `cus-${Date.now()}`, contactIds: [], ...values };
|
|
crmState.customers.unshift(customer);
|
|
return clone(customer);
|
|
}
|
|
|
|
export async function updateCustomer(id: string, values: CustomerMutationPayload) {
|
|
crmState.customers = crmState.customers.map((customer) =>
|
|
customer.id === id ? { ...customer, ...values } : customer
|
|
);
|
|
return getCustomerById(id);
|
|
}
|
|
|
|
export async function convertEnquiryToQuotation(enquiryId: string) {
|
|
const enquiry = crmState.enquiries.find((item) => item.id === enquiryId);
|
|
if (!enquiry) throw new Error('Enquiry not found');
|
|
|
|
const nextId = `qt-${Date.now()}`;
|
|
const newQuotation: Quotation = {
|
|
id: nextId,
|
|
code: `QT2606-${String(crmState.quotations.length + 1).padStart(3, '0')}`,
|
|
enquiryId,
|
|
quotationDate: '2026-06-11',
|
|
validUntil: '2026-07-11',
|
|
quotationType: 'official',
|
|
project: enquiry.title,
|
|
siteLocation: enquiry.projectLocation,
|
|
customerRoles: [
|
|
{ role: 'owner', customerId: enquiry.customerId, contactId: enquiry.contactId }
|
|
],
|
|
salesmanId: enquiry.salesmanId,
|
|
saleAdminId: 'sp-2',
|
|
branchId: enquiry.branchId,
|
|
status: 'draft',
|
|
revision: 0,
|
|
totalAmount: enquiry.expectedValue,
|
|
chancePercent: enquiry.chancePercent,
|
|
isHotProject: enquiry.chancePercent >= 70,
|
|
items: [
|
|
{
|
|
id: `${nextId}-item-1`,
|
|
topic: 'Scope',
|
|
description: enquiry.requirementSummary,
|
|
quantity: 1,
|
|
unit: 'lot',
|
|
unitPrice: enquiry.expectedValue,
|
|
amount: enquiry.expectedValue
|
|
}
|
|
],
|
|
topics: [
|
|
{
|
|
id: `${nextId}-topic-1`,
|
|
type: 'scope',
|
|
label: 'Scope',
|
|
items: [enquiry.requirementSummary]
|
|
},
|
|
{
|
|
id: `${nextId}-topic-2`,
|
|
type: 'exclusion',
|
|
label: 'Exclusion',
|
|
items: ['To be confirmed']
|
|
},
|
|
{ id: `${nextId}-topic-3`, type: 'payment', label: 'Payment', items: ['Pending setup'] }
|
|
],
|
|
followUps: [],
|
|
attachments: [],
|
|
approvalSteps: [],
|
|
auditEventIds: []
|
|
};
|
|
|
|
crmState.quotations.unshift(newQuotation);
|
|
crmState.enquiries = crmState.enquiries.map((item) =>
|
|
item.id === enquiryId
|
|
? {
|
|
...item,
|
|
status: 'converted',
|
|
quotationIds: [...item.quotationIds, nextId],
|
|
activities: [
|
|
...item.activities,
|
|
{
|
|
id: `${enquiryId}-activity-${Date.now()}`,
|
|
type: 'CONVERT',
|
|
title: 'Convert to quotation',
|
|
detail: `สร้าง ${newQuotation.code}`,
|
|
actorName: 'CRM Demo',
|
|
createdAt: '2026-06-11T10:00:00.000Z'
|
|
}
|
|
]
|
|
}
|
|
: item
|
|
);
|
|
|
|
return clone(newQuotation);
|
|
}
|
|
|
|
export async function submitQuotationApproval(quotationId: string) {
|
|
crmState.quotations = crmState.quotations.map((quotation) =>
|
|
quotation.id === quotationId
|
|
? {
|
|
...quotation,
|
|
status: 'pending_approval',
|
|
approvalSteps:
|
|
quotation.approvalSteps.length > 0
|
|
? quotation.approvalSteps
|
|
: [
|
|
{
|
|
id: `${quotationId}-ap-1`,
|
|
level: 1,
|
|
approverName: 'Sales Director',
|
|
approverPosition: 'Director',
|
|
status: 'pending'
|
|
}
|
|
]
|
|
}
|
|
: quotation
|
|
);
|
|
}
|
|
|
|
export async function approveQuotation(payload: ApprovalActionPayload) {
|
|
crmState.quotations = crmState.quotations.map((quotation) => {
|
|
if (quotation.id !== payload.quotationId) return quotation;
|
|
|
|
const nextSteps: typeof quotation.approvalSteps = quotation.approvalSteps.map((step, index) =>
|
|
index === quotation.approvalSteps.findIndex((item) => item.status === 'pending')
|
|
? {
|
|
...step,
|
|
status: 'approved' as const,
|
|
actedAt: '2026-06-11T11:00:00.000Z',
|
|
comment: payload.comment
|
|
}
|
|
: step
|
|
);
|
|
|
|
const hasPending = nextSteps.some((step) => step.status === 'pending');
|
|
return {
|
|
...quotation,
|
|
approvalSteps: nextSteps,
|
|
status: hasPending ? 'pending_approval' : 'approved',
|
|
approvedPdfUrl: hasPending
|
|
? quotation.approvedPdfUrl
|
|
: `/mock/${quotation.code.toLowerCase()}.pdf`
|
|
};
|
|
});
|
|
|
|
crmState.approvals = crmState.approvals.map((item) =>
|
|
item.quotationId === payload.quotationId ? { ...item, status: 'approved' } : item
|
|
);
|
|
}
|
|
|
|
export async function rejectQuotation(payload: ApprovalActionPayload) {
|
|
crmState.quotations = crmState.quotations.map((quotation) =>
|
|
quotation.id === payload.quotationId
|
|
? {
|
|
...quotation,
|
|
status: 'rejected' as const,
|
|
approvalSteps: quotation.approvalSteps.map((step, index) =>
|
|
index === quotation.approvalSteps.findIndex((item) => item.status === 'pending')
|
|
? {
|
|
...step,
|
|
status: 'rejected' as const,
|
|
actedAt: '2026-06-11T11:30:00.000Z',
|
|
comment: payload.comment
|
|
}
|
|
: step
|
|
)
|
|
}
|
|
: quotation
|
|
);
|
|
crmState.approvals = crmState.approvals.map((item) =>
|
|
item.quotationId === payload.quotationId ? { ...item, status: 'rejected' } : item
|
|
);
|
|
}
|
|
|
|
export async function createQuotationRevision(quotationId: string) {
|
|
const quotation = crmState.quotations.find((item) => item.id === quotationId);
|
|
if (!quotation) throw new Error('Quotation not found');
|
|
|
|
const revisionNumber = quotation.revision + 1;
|
|
const newQuotation: Quotation = {
|
|
...clone(quotation),
|
|
id: `${quotationId}-rev-${revisionNumber}`,
|
|
code: `${quotation.code}-R${String(revisionNumber).padStart(2, '0')}`,
|
|
revision: revisionNumber,
|
|
status: 'revised',
|
|
totalAmount: quotation.totalAmount + 125000
|
|
};
|
|
|
|
crmState.quotations.unshift(newQuotation);
|
|
return clone(newQuotation);
|
|
}
|
|
|
|
export async function markQuotationStatus(quotationId: string, status: Quotation['status']) {
|
|
crmState.quotations = crmState.quotations.map((quotation) =>
|
|
quotation.id === quotationId ? { ...quotation, status } : quotation
|
|
);
|
|
}
|
|
|
|
export async function shareContact(payload: ContactSharePayload) {
|
|
const share = {
|
|
id: `share-${Date.now()}`,
|
|
createdAt: '2026-06-11T12:00:00.000Z',
|
|
...payload
|
|
};
|
|
crmState.contactShares.unshift(share);
|
|
crmState.contactShareLogs.unshift({
|
|
id: `share-log-${Date.now()}`,
|
|
contactId: payload.contactId,
|
|
action: 'SHARE',
|
|
targetUserName: payload.sharedWithUserName,
|
|
actorName: 'CRM Demo',
|
|
createdAt: '2026-06-11T12:00:00.000Z'
|
|
});
|
|
}
|
|
|
|
export async function revokeContactShare(contactId: string, shareId: string) {
|
|
const current = crmState.contactShares.find((share) => share.id === shareId);
|
|
crmState.contactShares = crmState.contactShares.filter((share) => share.id !== shareId);
|
|
if (current) {
|
|
crmState.contactShareLogs.unshift({
|
|
id: `share-log-${Date.now()}`,
|
|
contactId,
|
|
action: 'REVOKE',
|
|
targetUserName: current.sharedWithUserName,
|
|
actorName: 'CRM Demo',
|
|
createdAt: '2026-06-11T12:30:00.000Z'
|
|
});
|
|
}
|
|
}
|