task-b.1
complere
This commit is contained in:
phaichayon
2026-06-15 11:19:31 +07:00
parent 89b39cad38
commit b8cd39eaa4
55 changed files with 4295 additions and 1848 deletions

View File

@@ -0,0 +1,593 @@
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'
});
}
}