823 lines
27 KiB
TypeScript
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
|
|
}
|
|
};
|
|
}
|