Files
alla-allaos-fullstack/src/features/crm/dashboard/server/service.ts
phaichayon c0cc880be2 lead menu
nquiry menu
2026-06-19 10:39:11 +07:00

823 lines
27 KiB
TypeScript

import { and, asc, desc, eq, inArray, isNull } from 'drizzle-orm';
import {
endOfDay,
endOfWeek,
formatISO,
isAfter,
isBefore,
isSameDay,
startOfDay,
startOfWeek
} from 'date-fns';
import {
crmApprovalRequests,
crmCustomers,
crmEnquiries,
crmEnquiryFollowups,
crmQuotationFollowups,
crmQuotations,
memberships,
users
} from '@/db/schema';
import { db } from '@/lib/db';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { getUserBranches } from '@/features/foundation/branch-scope/service';
import { listApprovalRequests } from '@/features/foundation/approval/server/service';
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
import {
getRevenueByBillingCustomer,
getRevenueByConsultant,
getRevenueByContractor,
getRevenueByEndCustomer
} from '@/features/crm/reporting/server/service';
import type {
CrmDashboardApprovalRow,
CrmDashboardApprovalSummary,
CrmDashboardFilters,
CrmDashboardFollowupRow,
CrmDashboardFollowupSummary,
CrmDashboardFunnelStep,
CrmDashboardHotProjectRow,
CrmDashboardReferenceData,
CrmDashboardResponse,
CrmDashboardRevenueRow,
CrmDashboardSalesRankingRow,
CrmDashboardSummary
} from '../api/types';
const ENQUIRY_STATUS_CATEGORY = 'crm_enquiry_status';
const QUOTATION_STATUS_CATEGORY = 'crm_quotation_status';
const PRODUCT_TYPE_CATEGORY = 'crm_product_type';
const PROJECT_PARTY_ROLE_CATEGORY = 'crm_project_party_role';
type DashboardContext = {
organizationId: string;
userId: string;
membershipRole: string;
businessRole: string;
permissions: string[];
};
type NormalizedFilters = {
dateFrom: string;
dateTo: string;
branch: string | null;
salesman: string | null;
projectPartyRole: string | null;
productType: string | null;
};
type StatusMaps = {
enquiryStatusById: Map<string, string>;
quotationStatusById: Map<string, string>;
};
const DASHBOARD_FULL_ACCESS_ROLES = new Set([
'marketing',
'sales_manager',
'department_manager',
'top_manager'
]);
const DASHBOARD_OWNED_SCOPE_ROLES = new Set(['sales', 'sales_support']);
function normalizeDashboardFilters(filters: CrmDashboardFilters): NormalizedFilters {
const now = new Date();
const monthStart = startOfDay(new Date(now.getFullYear(), now.getMonth(), 1));
return {
dateFrom: filters.dateFrom ?? formatISO(monthStart, { representation: 'date' }),
dateTo: filters.dateTo ?? formatISO(now, { representation: 'date' }),
branch: filters.branch ?? null,
salesman: filters.salesman ?? null,
projectPartyRole: filters.projectPartyRole ?? null,
productType: filters.productType ?? null
};
}
function toDayBounds(filters: NormalizedFilters) {
return {
from: startOfDay(new Date(filters.dateFrom)),
to: endOfDay(new Date(filters.dateTo))
};
}
function round2(value: number) {
return Math.round(value * 100) / 100;
}
function average(values: number[]) {
if (values.length === 0) {
return 0;
}
return round2(values.reduce((sum, value) => sum + value, 0) / values.length);
}
function canViewCommercialData(context: DashboardContext) {
return (
context.membershipRole === 'admin' || context.permissions.includes(PERMISSIONS.crmQuotationRead)
);
}
function canViewApprovalData(context: DashboardContext) {
return (
context.membershipRole === 'admin' || context.permissions.includes(PERMISSIONS.crmApprovalRead)
);
}
function hasDashboardFullScope(context: DashboardContext) {
return (
context.membershipRole === 'admin' || DASHBOARD_FULL_ACCESS_ROLES.has(context.businessRole)
);
}
function canAccessDashboardEnquiry(
row: typeof crmEnquiries.$inferSelect,
context: DashboardContext
) {
if (hasDashboardFullScope(context)) {
return true;
}
if (DASHBOARD_OWNED_SCOPE_ROLES.has(context.businessRole)) {
return row.createdBy === context.userId || row.assignedToUserId === context.userId;
}
return true;
}
function canAccessDashboardQuotation(
row: typeof crmQuotations.$inferSelect,
context: DashboardContext
) {
if (hasDashboardFullScope(context)) {
return true;
}
if (DASHBOARD_OWNED_SCOPE_ROLES.has(context.businessRole)) {
return row.salesmanId === context.userId;
}
return true;
}
async function getStatusMaps(organizationId: string): Promise<StatusMaps> {
const [enquiryOptions, quotationOptions] = await Promise.all([
getActiveOptionsByCategory(ENQUIRY_STATUS_CATEGORY, { organizationId }),
getActiveOptionsByCategory(QUOTATION_STATUS_CATEGORY, { organizationId })
]);
return {
enquiryStatusById: new Map(enquiryOptions.map((item) => [item.id, item.code])),
quotationStatusById: new Map(quotationOptions.map((item) => [item.id, item.code]))
};
}
async function getReferenceData(organizationId: string): Promise<CrmDashboardReferenceData> {
const [branches, salesmen, productTypes, projectPartyRoles] = await Promise.all([
getUserBranches(),
db
.select({ id: users.id, name: users.name })
.from(memberships)
.innerJoin(users, eq(memberships.userId, users.id))
.where(eq(memberships.organizationId, organizationId))
.orderBy(asc(users.name)),
getActiveOptionsByCategory(PRODUCT_TYPE_CATEGORY, { organizationId }),
getActiveOptionsByCategory(PROJECT_PARTY_ROLE_CATEGORY, { organizationId })
]);
return {
branches: branches.map((item) => ({ id: item.id, code: item.code, label: item.name })),
salesmen: salesmen.map((item) => ({ id: item.id, name: item.name })),
productTypes: productTypes.map((item) => ({
id: item.id,
code: item.code,
label: item.label
})),
projectPartyRoles: projectPartyRoles.map((item) => ({
id: item.id,
code: item.code,
label: item.label
}))
};
}
function matchesEnquiryFilters(
row: typeof crmEnquiries.$inferSelect,
filters: NormalizedFilters
) {
const { from, to } = toDayBounds(filters);
if (filters.branch && row.branchId !== filters.branch) return false;
if (filters.salesman && row.assignedToUserId !== filters.salesman) return false;
if (filters.productType && row.productType !== filters.productType) return false;
if (isBefore(row.createdAt, from) || isAfter(row.createdAt, to)) return false;
return true;
}
function matchesQuotationFilters(
row: typeof crmQuotations.$inferSelect,
filters: NormalizedFilters
) {
const { from, to } = toDayBounds(filters);
if (filters.branch && row.branchId !== filters.branch) return false;
if (filters.salesman && row.salesmanId !== filters.salesman) return false;
if (filters.productType && row.quotationType !== filters.productType) return false;
if (isBefore(row.quotationDate, from) || isAfter(row.quotationDate, to)) return false;
return true;
}
function isPipelineStage(
enquiry: typeof crmEnquiries.$inferSelect,
stage: 'lead' | 'enquiry' | 'closed_won' | 'closed_lost'
) {
return enquiry.pipelineStage === stage;
}
async function loadScopedRows(
context: DashboardContext,
filters: NormalizedFilters
) {
const [enquiries, quotations] = await Promise.all([
db
.select()
.from(crmEnquiries)
.where(
and(eq(crmEnquiries.organizationId, context.organizationId), isNull(crmEnquiries.deletedAt))
),
db
.select()
.from(crmQuotations)
.where(
and(eq(crmQuotations.organizationId, context.organizationId), isNull(crmQuotations.deletedAt))
)
]);
return {
enquiries: enquiries
.filter((row) => canAccessDashboardEnquiry(row, context))
.filter((row) => matchesEnquiryFilters(row, filters)),
quotations: quotations
.filter((row) => canAccessDashboardQuotation(row, context))
.filter((row) => matchesQuotationFilters(row, filters))
};
}
function buildSummary(
scopedEnquiries: Array<typeof crmEnquiries.$inferSelect>,
scopedQuotations: Array<typeof crmQuotations.$inferSelect>,
statusMaps: StatusMaps
): CrmDashboardSummary {
const now = new Date();
const leadRows = scopedEnquiries.filter((row) => isPipelineStage(row, 'lead'));
const enquiryRows = scopedEnquiries.filter((row) => isPipelineStage(row, 'enquiry'));
const wonRows = scopedEnquiries.filter((row) => isPipelineStage(row, 'closed_won'));
const lostRows = scopedEnquiries.filter((row) => isPipelineStage(row, 'closed_lost'));
const leadAges = leadRows.map((row) => (now.getTime() - row.createdAt.getTime()) / (1000 * 60 * 60 * 24));
const quotationStatusCodes = scopedQuotations.map((row) => statusMaps.quotationStatusById.get(row.status) ?? '');
return {
lead: {
leadCount: leadRows.length,
newLeads: leadRows.length,
unassignedLeads: leadRows.filter((row) => row.assignedToUserId === null).length,
averageLeadAgingDays: average(leadAges)
},
enquiry: {
enquiryCount: enquiryRows.length,
activeEnquiries: enquiryRows.length,
wonCount: wonRows.length,
lostCount: lostRows.length,
hotEnquiries: enquiryRows.filter((row) => row.isHotProject).length,
enquiryValue: round2(enquiryRows.reduce((sum, row) => sum + (row.estimatedValue ?? 0), 0))
},
quotation: {
draftQuotations: quotationStatusCodes.filter((code) => code === 'draft').length,
pendingApproval: quotationStatusCodes.filter((code) => code === 'pending_approval').length,
approvedQuotations: quotationStatusCodes.filter((code) => code === 'approved').length,
sentQuotations: quotationStatusCodes.filter((code) => code === 'sent').length
},
revenue: {
quotationValue: round2(
scopedQuotations
.filter((row) => (statusMaps.quotationStatusById.get(row.status) ?? '') !== 'cancelled')
.reduce((sum, row) => sum + row.totalAmount, 0)
),
wonValue: round2(
scopedQuotations
.filter((row) => (statusMaps.quotationStatusById.get(row.status) ?? '') === 'accepted')
.reduce((sum, row) => sum + row.totalAmount, 0)
),
lostValue: round2(
scopedQuotations
.filter((row) =>
['lost', 'rejected'].includes(statusMaps.quotationStatusById.get(row.status) ?? '')
)
.reduce((sum, row) => sum + row.totalAmount, 0)
)
}
};
}
function buildFunnel(
summary: CrmDashboardSummary,
scopedQuotations: Array<typeof crmQuotations.$inferSelect>,
statusMaps: StatusMaps
): CrmDashboardFunnelStep[] {
const steps = [
{
key: 'lead',
label: 'Lead',
count: summary.lead.leadCount,
value: 0
},
{
key: 'enquiry',
label: 'Enquiry',
count: summary.enquiry.enquiryCount,
value: summary.enquiry.enquiryValue
},
{
key: 'quotation',
label: 'Quotation',
count: scopedQuotations.filter(
(row) => (statusMaps.quotationStatusById.get(row.status) ?? '') !== 'cancelled'
).length,
value: scopedQuotations
.filter((row) => (statusMaps.quotationStatusById.get(row.status) ?? '') !== 'cancelled')
.reduce((sum, row) => sum + row.totalAmount, 0)
},
{
key: 'pending_approval',
label: 'Pending Approval',
count: scopedQuotations.filter(
(row) => (statusMaps.quotationStatusById.get(row.status) ?? '') === 'pending_approval'
).length,
value: scopedQuotations
.filter((row) => (statusMaps.quotationStatusById.get(row.status) ?? '') === 'pending_approval')
.reduce((sum, row) => sum + row.totalAmount, 0)
},
{
key: 'approved',
label: 'Approved',
count: scopedQuotations.filter(
(row) => (statusMaps.quotationStatusById.get(row.status) ?? '') === 'approved'
).length,
value: scopedQuotations
.filter((row) => (statusMaps.quotationStatusById.get(row.status) ?? '') === 'approved')
.reduce((sum, row) => sum + row.totalAmount, 0)
},
{
key: 'closed_won',
label: 'Closed Won',
count: summary.enquiry.wonCount,
value: scopedQuotations
.filter((row) => (statusMaps.quotationStatusById.get(row.status) ?? '') === 'accepted')
.reduce((sum, row) => sum + row.totalAmount, 0)
}
];
return steps.map((step, index) => ({
...step,
value: round2(step.value),
conversionRate:
index === 0 || steps[index - 1].count === 0
? null
: round2((step.count / steps[index - 1].count) * 100)
}));
}
async function buildRevenueAnalytics(
organizationId: string,
filters: NormalizedFilters
) {
const revenueFilters = {
dateFrom: filters.dateFrom,
dateTo: filters.dateTo,
...(filters.branch ? { branch: filters.branch } : {}),
...(filters.salesman ? { salesman: filters.salesman } : {}),
...(filters.productType ? { quotationType: filters.productType } : {})
};
const [
endCustomers,
billingCustomers,
contractors,
consultants,
wonEnd,
wonBilling,
wonContractor,
wonConsultant
] = await Promise.all([
getRevenueByEndCustomer(organizationId, revenueFilters),
getRevenueByBillingCustomer(organizationId, revenueFilters),
getRevenueByContractor(organizationId, revenueFilters),
getRevenueByConsultant(organizationId, revenueFilters),
getRevenueByEndCustomer(organizationId, { ...revenueFilters, status: 'accepted' }),
getRevenueByBillingCustomer(organizationId, { ...revenueFilters, status: 'accepted' }),
getRevenueByContractor(organizationId, { ...revenueFilters, status: 'accepted' }),
getRevenueByConsultant(organizationId, { ...revenueFilters, status: 'accepted' })
]);
function withWonRevenue(
rows: Awaited<ReturnType<typeof getRevenueByEndCustomer>>,
wonRows: Awaited<ReturnType<typeof getRevenueByEndCustomer>>
): CrmDashboardRevenueRow[] {
const wonMap = new Map(wonRows.map((row) => [row.customerId, row.revenue]));
return rows.slice(0, 5).map((row) => ({
customerId: row.customerId,
customerCode: row.customerCode,
customerName: row.customerName,
roleCode: row.roleCode,
revenue: row.revenue,
quotationCount: row.quotationCount,
wonRevenue: wonMap.get(row.customerId) ?? 0
}));
}
return {
topEndCustomers: withWonRevenue(endCustomers, wonEnd),
topBillingCustomers: withWonRevenue(billingCustomers, wonBilling),
topContractors: withWonRevenue(contractors, wonContractor),
topConsultants: withWonRevenue(consultants, wonConsultant)
};
}
async function buildSalesRanking(
scopedEnquiries: Array<typeof crmEnquiries.$inferSelect>,
scopedQuotations: Array<typeof crmQuotations.$inferSelect>,
statusMaps: StatusMaps
): Promise<CrmDashboardSalesRankingRow[]> {
const userIds = [
...new Set(
[
...scopedEnquiries.map((row) => row.assignedToUserId).filter(Boolean),
...scopedQuotations.map((row) => row.salesmanId).filter(Boolean)
] as string[]
)
];
const salesUsers = userIds.length
? await db.select({ id: users.id, name: users.name }).from(users).where(inArray(users.id, userIds))
: [];
const userMap = new Map(salesUsers.map((row) => [row.id, row.name]));
const rankingMap = new Map<string, CrmDashboardSalesRankingRow>();
for (const row of scopedEnquiries) {
if (!row.assignedToUserId) continue;
const current =
rankingMap.get(row.assignedToUserId) ??
{
salesPersonId: row.assignedToUserId,
salesPersonName: userMap.get(row.assignedToUserId) ?? 'Unknown',
leadCount: 0,
enquiryCount: 0,
quotationCount: 0,
approvedQuotations: 0,
wonRevenue: 0,
conversionRate: 0
};
if (isPipelineStage(row, 'lead')) {
current.leadCount += 1;
}
if (isPipelineStage(row, 'enquiry')) {
current.enquiryCount += 1;
}
rankingMap.set(row.assignedToUserId, current);
}
for (const row of scopedQuotations) {
if (!row.salesmanId) continue;
const current =
rankingMap.get(row.salesmanId) ??
{
salesPersonId: row.salesmanId,
salesPersonName: userMap.get(row.salesmanId) ?? 'Unknown',
leadCount: 0,
enquiryCount: 0,
quotationCount: 0,
approvedQuotations: 0,
wonRevenue: 0,
conversionRate: 0
};
const statusCode = statusMaps.quotationStatusById.get(row.status) ?? '';
if (statusCode !== 'cancelled') {
current.quotationCount += 1;
}
if (statusCode === 'approved') {
current.approvedQuotations += 1;
}
if (statusCode === 'accepted') {
current.wonRevenue += row.totalAmount;
}
rankingMap.set(row.salesmanId, current);
}
return [...rankingMap.values()]
.map((row) => ({
...row,
wonRevenue: round2(row.wonRevenue),
conversionRate:
row.quotationCount > 0 ? round2((row.approvedQuotations / row.quotationCount) * 100) : 0
}))
.sort((a, b) => {
if (b.wonRevenue !== a.wonRevenue) return b.wonRevenue - a.wonRevenue;
if (b.quotationCount !== a.quotationCount) return b.quotationCount - a.quotationCount;
return a.salesPersonName.localeCompare(b.salesPersonName);
})
.slice(0, 10);
}
async function buildFollowups(
context: DashboardContext,
filters: NormalizedFilters
): Promise<{ summary: CrmDashboardFollowupSummary; upcoming: CrmDashboardFollowupRow[] }> {
const allowQuotationFollowups = canViewCommercialData(context);
const [enquiryFollowups, quotationFollowups] = await Promise.all([
db
.select()
.from(crmEnquiryFollowups)
.where(
and(
eq(crmEnquiryFollowups.organizationId, context.organizationId),
isNull(crmEnquiryFollowups.deletedAt)
)
)
.orderBy(asc(crmEnquiryFollowups.followupDate)),
allowQuotationFollowups
? db
.select()
.from(crmQuotationFollowups)
.where(
and(
eq(crmQuotationFollowups.organizationId, context.organizationId),
isNull(crmQuotationFollowups.deletedAt)
)
)
.orderBy(asc(crmQuotationFollowups.followupDate))
: []
]);
const { from, to } = toDayBounds(filters);
const now = new Date();
const weekStart = startOfWeek(now, { weekStartsOn: 1 });
const weekEnd = endOfWeek(now, { weekStartsOn: 1 });
const enquiryIds = [...new Set(enquiryFollowups.map((row) => row.enquiryId))];
const quotationIds = [...new Set(quotationFollowups.map((row) => row.quotationId))];
const [enquiries, quotations] = await Promise.all([
enquiryIds.length ? db.select().from(crmEnquiries).where(inArray(crmEnquiries.id, enquiryIds)) : [],
quotationIds.length ? db.select().from(crmQuotations).where(inArray(crmQuotations.id, quotationIds)) : []
]);
const customerIds = [...new Set([...enquiries.map((row) => row.customerId), ...quotations.map((row) => row.customerId)])];
const salesIds = [
...new Set(
[...enquiries.map((row) => row.assignedToUserId).filter(Boolean), ...quotations.map((row) => row.salesmanId).filter(Boolean)] as string[]
)
];
const [customers, salesUsers] = await Promise.all([
customerIds.length ? db.select().from(crmCustomers).where(inArray(crmCustomers.id, customerIds)) : [],
salesIds.length ? db.select({ id: users.id, name: users.name }).from(users).where(inArray(users.id, salesIds)) : []
]);
const enquiryMap = new Map(enquiries.map((row) => [row.id, row]));
const quotationMap = new Map(quotations.map((row) => [row.id, row]));
const customerMap = new Map(customers.map((row) => [row.id, row.name]));
const salesMap = new Map(salesUsers.map((row) => [row.id, row.name]));
const enquiryRows = enquiryFollowups
.filter((row) => {
const enquiry = enquiryMap.get(row.enquiryId);
return enquiry ? canAccessDashboardEnquiry(enquiry, context) : false;
})
.filter((row) => !isBefore(row.followupDate, from) && !isAfter(row.followupDate, to))
.flatMap<CrmDashboardFollowupRow>((row) => {
const enquiry = enquiryMap.get(row.enquiryId);
if (!enquiry) {
return [];
}
return [
{
id: row.id,
sourceType: 'enquiry',
followupDate: row.followupDate.toISOString(),
customerName: customerMap.get(enquiry.customerId) ?? 'Unknown customer',
enquiryName: enquiry.projectName ?? enquiry.title,
assignedSalesName: enquiry.assignedToUserId
? (salesMap.get(enquiry.assignedToUserId) ?? null)
: null,
outcome: row.outcome
}
];
});
const quotationRows = quotationFollowups
.filter((row) => {
const quotation = quotationMap.get(row.quotationId);
return quotation ? canAccessDashboardQuotation(quotation, context) : false;
})
.filter((row) => !isBefore(row.followupDate, from) && !isAfter(row.followupDate, to))
.flatMap<CrmDashboardFollowupRow>((row) => {
const quotation = quotationMap.get(row.quotationId);
if (!quotation) {
return [];
}
return [
{
id: row.id,
sourceType: 'quotation',
followupDate: row.followupDate.toISOString(),
customerName: customerMap.get(quotation.customerId) ?? 'Unknown customer',
enquiryName: quotation.projectName ?? quotation.code,
assignedSalesName: quotation.salesmanId
? (salesMap.get(quotation.salesmanId) ?? null)
: null,
outcome: row.outcome
}
];
});
const rows: CrmDashboardFollowupRow[] = [...enquiryRows, ...quotationRows];
return {
summary: {
dueToday: rows.filter((row) => isSameDay(new Date(row.followupDate), now)).length,
dueThisWeek: rows.filter((row) => {
const date = new Date(row.followupDate);
return !isBefore(date, weekStart) && !isAfter(date, weekEnd);
}).length,
overdue: rows.filter((row) => isBefore(new Date(row.followupDate), startOfDay(now)) && !row.outcome).length,
completed: rows.filter((row) => !!row.outcome).length
},
upcoming: rows
.filter((row) => !isBefore(new Date(row.followupDate), startOfDay(now)))
.sort((a, b) => new Date(a.followupDate).getTime() - new Date(b.followupDate).getTime())
.slice(0, 10)
};
}
async function buildApprovalAnalytics(
context: DashboardContext
): Promise<{ summary: CrmDashboardApprovalSummary; myPendingApprovals: CrmDashboardApprovalRow[] }> {
const pending = await listApprovalRequests(context.organizationId, {
page: 1,
limit: 50,
status: 'pending'
});
const todayStart = startOfDay(new Date());
const todayEnd = endOfDay(new Date());
const rows = await db
.select()
.from(crmApprovalRequests)
.where(and(eq(crmApprovalRequests.organizationId, context.organizationId), isNull(crmApprovalRequests.deletedAt)))
.orderBy(desc(crmApprovalRequests.updatedAt));
const approvedRows = rows.filter(
(row) => row.status === 'approved' && row.completedAt && !isBefore(row.completedAt, todayStart) && !isAfter(row.completedAt, todayEnd)
);
const rejectedRows = rows.filter(
(row) => row.status === 'rejected' && row.completedAt && !isBefore(row.completedAt, todayStart) && !isAfter(row.completedAt, todayEnd)
);
const returnedRows = rows.filter(
(row) => row.status === 'returned' && row.completedAt && !isBefore(row.completedAt, todayStart) && !isAfter(row.completedAt, todayEnd)
);
const approvedDurations = approvedRows.map((row) => (row.completedAt!.getTime() - row.requestedAt.getTime()) / (1000 * 60 * 60));
return {
summary: {
pendingApprovals: pending.totalItems,
approvedToday: approvedRows.length,
rejectedToday: rejectedRows.length,
returnedToday: returnedRows.length,
averageApprovalTimeHours: average(approvedDurations)
},
myPendingApprovals: pending.items
.filter((item) => item.currentStepRoleCode === context.businessRole || context.membershipRole === 'admin')
.slice(0, 10)
.map((item) => ({
id: item.id,
entityCode: item.entityCode,
entityTitle: item.entityTitle,
workflowName: item.workflowName,
currentStepRoleName: item.currentStepRoleName,
requestedAt: item.requestedAt
}))
};
}
async function buildHotProjects(
context: DashboardContext,
filters: NormalizedFilters,
statusMaps: StatusMaps
): Promise<CrmDashboardHotProjectRow[]> {
const rows = await db
.select()
.from(crmEnquiries)
.where(
and(
eq(crmEnquiries.organizationId, context.organizationId),
isNull(crmEnquiries.deletedAt),
eq(crmEnquiries.isHotProject, true)
)
)
.orderBy(desc(crmEnquiries.updatedAt));
const scoped = rows
.filter((row) => canAccessDashboardEnquiry(row, context))
.filter((row) => matchesEnquiryFilters(row, filters))
.filter((row) => isPipelineStage(row, 'enquiry'))
.slice(0, 10);
const customerIds = [...new Set(scoped.map((row) => row.customerId))];
const salesIds = [...new Set(scoped.map((row) => row.assignedToUserId).filter(Boolean))] as string[];
const [customers, salesUsers] = await Promise.all([
customerIds.length ? db.select().from(crmCustomers).where(inArray(crmCustomers.id, customerIds)) : [],
salesIds.length ? db.select({ id: users.id, name: users.name }).from(users).where(inArray(users.id, salesIds)) : []
]);
const customerMap = new Map(customers.map((row) => [row.id, row.name]));
const salesMap = new Map(salesUsers.map((row) => [row.id, row.name]));
return scoped.map((row) => ({
enquiryId: row.id,
enquiryCode: row.code,
projectName: row.projectName ?? row.title,
customerName: customerMap.get(row.customerId) ?? 'Unknown customer',
value: row.estimatedValue ?? 0,
assignedSalesName: row.assignedToUserId ? (salesMap.get(row.assignedToUserId) ?? null) : null,
probability: row.chancePercent
}));
}
export async function getCrmDashboardData(
context: DashboardContext,
rawFilters: CrmDashboardFilters
): Promise<CrmDashboardResponse> {
const filters = normalizeDashboardFilters(rawFilters);
const allowCommercialData = canViewCommercialData(context);
const allowApprovalData = canViewApprovalData(context);
const [referenceData, statusMaps, scoped] = await Promise.all([
getReferenceData(context.organizationId),
getStatusMaps(context.organizationId),
loadScopedRows(context, filters)
]);
const summary = buildSummary(scoped.enquiries, scoped.quotations, statusMaps);
const [revenueAnalytics, salesRanking, followups, approvals, hotProjects] = await Promise.all([
allowCommercialData
? buildRevenueAnalytics(context.organizationId, filters)
: Promise.resolve({
topEndCustomers: [],
topBillingCustomers: [],
topContractors: [],
topConsultants: []
}),
allowCommercialData
? buildSalesRanking(scoped.enquiries, scoped.quotations, statusMaps)
: Promise.resolve([]),
buildFollowups(context, filters),
allowApprovalData
? buildApprovalAnalytics(context)
: Promise.resolve({
summary: {
pendingApprovals: 0,
approvedToday: 0,
rejectedToday: 0,
returnedToday: 0,
averageApprovalTimeHours: 0
},
myPendingApprovals: []
}),
allowCommercialData
? buildHotProjects(context, filters, statusMaps)
: Promise.resolve([])
]);
return {
success: true,
time: new Date().toISOString(),
message: 'CRM dashboard loaded successfully',
filters,
referenceData,
summary,
funnel: buildFunnel(summary, scoped.quotations, statusMaps),
revenueAnalytics,
salesRanking,
followups,
approvals,
hotProjects,
visibility: {
canViewCommercialData: allowCommercialData,
canViewApprovalData: allowApprovalData
}
};
}