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(value: T): T { return JSON.parse(JSON.stringify(value)) as T; } let crmState: CrmMockState = clone(initialCrmState); function sortItems(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)[rule.id] ?? ''); const bv = String((b as Record)[rule.id] ?? ''); return rule.desc ? bv.localeCompare(av) : av.localeCompare(bv); }); } catch { return items; } } function paginate(items: T[], page = 1, limit = 10): PaginatedResponse { 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 { return clone({ branches: crmState.branches, salespersons: crmState.salespersons, masterOptions: crmState.masterOptions, sequences: crmState.sequences, templates: crmState.templates }); } export async function getCrmDashboardData(): Promise { 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> { 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 { 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> { 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 { 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> { 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 { 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' }); } }