lead menu

nquiry menu
This commit is contained in:
phaichayon
2026-06-19 10:39:11 +07:00
parent 0650cf6297
commit c0cc880be2
46 changed files with 5985 additions and 420 deletions

View File

@@ -20,6 +20,7 @@ import {
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';
@@ -54,6 +55,7 @@ type DashboardContext = {
userId: string;
membershipRole: string;
businessRole: string;
permissions: string[];
};
type NormalizedFilters = {
@@ -70,6 +72,15 @@ type StatusMaps = {
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));
@@ -103,6 +114,54 @@ function average(values: number[]) {
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 }),
@@ -172,35 +231,39 @@ function matchesQuotationFilters(
return true;
}
function isLeadStatus(statusCode: string | undefined, enquiry: typeof crmEnquiries.$inferSelect) {
return enquiry.assignedToUserId === null && !['closed_lost', 'cancelled'].includes(statusCode ?? '');
}
function isOpportunityStatus(
statusCode: string | undefined,
enquiry: typeof crmEnquiries.$inferSelect
function isPipelineStage(
enquiry: typeof crmEnquiries.$inferSelect,
stage: 'lead' | 'enquiry' | 'closed_won' | 'closed_lost'
) {
return enquiry.assignedToUserId !== null && !['closed_lost', 'cancelled'].includes(statusCode ?? '');
return enquiry.pipelineStage === stage;
}
async function loadScopedRows(
organizationId: string,
context: DashboardContext,
filters: NormalizedFilters
) {
const [enquiries, quotations] = await Promise.all([
db
.select()
.from(crmEnquiries)
.where(and(eq(crmEnquiries.organizationId, organizationId), isNull(crmEnquiries.deletedAt))),
.where(
and(eq(crmEnquiries.organizationId, context.organizationId), isNull(crmEnquiries.deletedAt))
),
db
.select()
.from(crmQuotations)
.where(and(eq(crmQuotations.organizationId, organizationId), isNull(crmQuotations.deletedAt)))
.where(
and(eq(crmQuotations.organizationId, context.organizationId), isNull(crmQuotations.deletedAt))
)
]);
return {
enquiries: enquiries.filter((row) => matchesEnquiryFilters(row, filters)),
quotations: quotations.filter((row) => matchesQuotationFilters(row, filters))
enquiries: enquiries
.filter((row) => canAccessDashboardEnquiry(row, context))
.filter((row) => matchesEnquiryFilters(row, filters)),
quotations: quotations
.filter((row) => canAccessDashboardQuotation(row, context))
.filter((row) => matchesQuotationFilters(row, filters))
};
}
@@ -210,12 +273,10 @@ function buildSummary(
statusMaps: StatusMaps
): CrmDashboardSummary {
const now = new Date();
const leadRows = scopedEnquiries.filter((row) =>
isLeadStatus(statusMaps.enquiryStatusById.get(row.status), row)
);
const opportunityRows = scopedEnquiries.filter((row) =>
isOpportunityStatus(statusMaps.enquiryStatusById.get(row.status), row)
);
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) ?? '');
@@ -224,14 +285,16 @@ function buildSummary(
lead: {
leadCount: leadRows.length,
newLeads: leadRows.length,
unassignedLeads: leadRows.length,
unassignedLeads: leadRows.filter((row) => row.assignedToUserId === null).length,
averageLeadAgingDays: average(leadAges)
},
opportunity: {
opportunityCount: opportunityRows.length,
openOpportunities: opportunityRows.length,
hotOpportunities: opportunityRows.filter((row) => row.isHotProject).length,
opportunityValue: round2(opportunityRows.reduce((sum, row) => sum + (row.estimatedValue ?? 0), 0))
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,
@@ -274,10 +337,10 @@ function buildFunnel(
value: 0
},
{
key: 'opportunity',
label: 'Opportunity',
count: summary.opportunity.opportunityCount,
value: summary.opportunity.opportunityValue
key: 'enquiry',
label: 'Enquiry',
count: summary.enquiry.enquiryCount,
value: summary.enquiry.enquiryValue
},
{
key: 'quotation',
@@ -312,9 +375,7 @@ function buildFunnel(
{
key: 'closed_won',
label: 'Closed Won',
count: scopedQuotations.filter(
(row) => (statusMaps.quotationStatusById.get(row.status) ?? '') === 'accepted'
).length,
count: summary.enquiry.wonCount,
value: scopedQuotations
.filter((row) => (statusMaps.quotationStatusById.get(row.status) ?? '') === 'accepted')
.reduce((sum, row) => sum + row.totalAmount, 0)
@@ -415,19 +476,19 @@ async function buildSalesRanking(
salesPersonId: row.assignedToUserId,
salesPersonName: userMap.get(row.assignedToUserId) ?? 'Unknown',
leadCount: 0,
opportunityCount: 0,
enquiryCount: 0,
quotationCount: 0,
approvedQuotations: 0,
wonRevenue: 0,
conversionRate: 0
};
if (isLeadStatus(statusMaps.enquiryStatusById.get(row.status), row)) {
if (isPipelineStage(row, 'lead')) {
current.leadCount += 1;
}
if (isOpportunityStatus(statusMaps.enquiryStatusById.get(row.status), row)) {
current.opportunityCount += 1;
if (isPipelineStage(row, 'enquiry')) {
current.enquiryCount += 1;
}
rankingMap.set(row.assignedToUserId, current);
@@ -441,7 +502,7 @@ async function buildSalesRanking(
salesPersonId: row.salesmanId,
salesPersonName: userMap.get(row.salesmanId) ?? 'Unknown',
leadCount: 0,
opportunityCount: 0,
enquiryCount: 0,
quotationCount: 0,
approvedQuotations: 0,
wonRevenue: 0,
@@ -480,20 +541,33 @@ async function buildSalesRanking(
}
async function buildFollowups(
organizationId: string,
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, organizationId), isNull(crmEnquiryFollowups.deletedAt)))
.where(
and(
eq(crmEnquiryFollowups.organizationId, context.organizationId),
isNull(crmEnquiryFollowups.deletedAt)
)
)
.orderBy(asc(crmEnquiryFollowups.followupDate)),
db
.select()
.from(crmQuotationFollowups)
.where(and(eq(crmQuotationFollowups.organizationId, organizationId), isNull(crmQuotationFollowups.deletedAt)))
.orderBy(asc(crmQuotationFollowups.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);
@@ -522,6 +596,10 @@ async function buildFollowups(
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);
@@ -536,7 +614,7 @@ async function buildFollowups(
sourceType: 'enquiry',
followupDate: row.followupDate.toISOString(),
customerName: customerMap.get(enquiry.customerId) ?? 'Unknown customer',
opportunityName: enquiry.projectName ?? enquiry.title,
enquiryName: enquiry.projectName ?? enquiry.title,
assignedSalesName: enquiry.assignedToUserId
? (salesMap.get(enquiry.assignedToUserId) ?? null)
: null,
@@ -545,6 +623,10 @@ async function buildFollowups(
];
});
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);
@@ -559,7 +641,7 @@ async function buildFollowups(
sourceType: 'quotation',
followupDate: row.followupDate.toISOString(),
customerName: customerMap.get(quotation.customerId) ?? 'Unknown customer',
opportunityName: quotation.projectName ?? quotation.code,
enquiryName: quotation.projectName ?? quotation.code,
assignedSalesName: quotation.salesmanId
? (salesMap.get(quotation.salesmanId) ?? null)
: null,
@@ -636,18 +718,25 @@ async function buildApprovalAnalytics(
}
async function buildHotProjects(
organizationId: string,
context: DashboardContext,
filters: NormalizedFilters,
statusMaps: StatusMaps
): Promise<CrmDashboardHotProjectRow[]> {
const rows = await db
.select()
.from(crmEnquiries)
.where(and(eq(crmEnquiries.organizationId, organizationId), isNull(crmEnquiries.deletedAt), eq(crmEnquiries.isHotProject, true)))
.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) => isOpportunityStatus(statusMaps.enquiryStatusById.get(row.status), row))
.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[];
@@ -674,18 +763,42 @@ export async function getCrmDashboardData(
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.organizationId, filters)
loadScopedRows(context, filters)
]);
const summary = buildSummary(scoped.enquiries, scoped.quotations, statusMaps);
const [revenueAnalytics, salesRanking, followups, approvals, hotProjects] = await Promise.all([
buildRevenueAnalytics(context.organizationId, filters),
buildSalesRanking(scoped.enquiries, scoped.quotations, statusMaps),
buildFollowups(context.organizationId, filters),
buildApprovalAnalytics(context),
buildHotProjects(context.organizationId, filters, statusMaps)
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 {
@@ -700,6 +813,10 @@ export async function getCrmDashboardData(
salesRanking,
followups,
approvals,
hotProjects
hotProjects,
visibility: {
canViewCommercialData: allowCommercialData,
canViewApprovalData: allowApprovalData
}
};
}