lead menu
nquiry menu
This commit is contained in:
@@ -32,11 +32,13 @@ export interface CrmDashboardSummary {
|
||||
unassignedLeads: number;
|
||||
averageLeadAgingDays: number;
|
||||
};
|
||||
opportunity: {
|
||||
opportunityCount: number;
|
||||
openOpportunities: number;
|
||||
hotOpportunities: number;
|
||||
opportunityValue: number;
|
||||
enquiry: {
|
||||
enquiryCount: number;
|
||||
activeEnquiries: number;
|
||||
wonCount: number;
|
||||
lostCount: number;
|
||||
hotEnquiries: number;
|
||||
enquiryValue: number;
|
||||
};
|
||||
quotation: {
|
||||
draftQuotations: number;
|
||||
@@ -73,7 +75,7 @@ export interface CrmDashboardSalesRankingRow {
|
||||
salesPersonId: string;
|
||||
salesPersonName: string;
|
||||
leadCount: number;
|
||||
opportunityCount: number;
|
||||
enquiryCount: number;
|
||||
quotationCount: number;
|
||||
approvedQuotations: number;
|
||||
wonRevenue: number;
|
||||
@@ -92,7 +94,7 @@ export interface CrmDashboardFollowupRow {
|
||||
sourceType: 'enquiry' | 'quotation';
|
||||
followupDate: string;
|
||||
customerName: string;
|
||||
opportunityName: string;
|
||||
enquiryName: string;
|
||||
assignedSalesName: string | null;
|
||||
outcome: string | null;
|
||||
}
|
||||
@@ -148,4 +150,8 @@ export interface CrmDashboardResponse {
|
||||
myPendingApprovals: CrmDashboardApprovalRow[];
|
||||
};
|
||||
hotProjects: CrmDashboardHotProjectRow[];
|
||||
visibility: {
|
||||
canViewCommercialData: boolean;
|
||||
canViewApprovalData: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,16 +36,29 @@ export function CrmDashboard({ canExport }: { canExport: boolean }) {
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<DashboardFilters referenceData={data.referenceData} canExport={canExport} />
|
||||
<DashboardSummaryCards summary={data.summary} />
|
||||
<div className='grid gap-6 2xl:grid-cols-[1.35fr_1fr]'>
|
||||
<DashboardFunnel steps={data.funnel} />
|
||||
<DashboardHotProjects rows={data.hotProjects} />
|
||||
</div>
|
||||
<DashboardRevenueCards revenueAnalytics={data.revenueAnalytics} />
|
||||
<div className='grid gap-6 2xl:grid-cols-[1.2fr_1fr]'>
|
||||
<DashboardSalesRanking rows={data.salesRanking} />
|
||||
<DashboardApprovalMetrics approvals={data.approvals} />
|
||||
</div>
|
||||
<DashboardSummaryCards
|
||||
summary={data.summary}
|
||||
canViewCommercialData={data.visibility.canViewCommercialData}
|
||||
/>
|
||||
{data.visibility.canViewCommercialData ? (
|
||||
<div className='grid gap-6 2xl:grid-cols-[1.35fr_1fr]'>
|
||||
<DashboardFunnel steps={data.funnel} />
|
||||
<DashboardHotProjects rows={data.hotProjects} />
|
||||
</div>
|
||||
) : null}
|
||||
{data.visibility.canViewCommercialData ? (
|
||||
<DashboardRevenueCards revenueAnalytics={data.revenueAnalytics} />
|
||||
) : null}
|
||||
{data.visibility.canViewCommercialData || data.visibility.canViewApprovalData ? (
|
||||
<div className='grid gap-6 2xl:grid-cols-[1.2fr_1fr]'>
|
||||
{data.visibility.canViewCommercialData ? (
|
||||
<DashboardSalesRanking rows={data.salesRanking} />
|
||||
) : null}
|
||||
{data.visibility.canViewApprovalData ? (
|
||||
<DashboardApprovalMetrics approvals={data.approvals} />
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<DashboardFollowups followups={data.followups} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -36,8 +36,8 @@ export function DashboardFollowups({
|
||||
<TableRow>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Customer</TableHead>
|
||||
<TableHead>Opportunity</TableHead>
|
||||
<TableHead>Assigned Sales</TableHead>
|
||||
<TableHead>Enquiry</TableHead>
|
||||
<TableHead>Enquiry Owner</TableHead>
|
||||
<TableHead>Source</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -53,7 +53,7 @@ export function DashboardFollowups({
|
||||
<TableRow key={`${row.sourceType}-${row.id}`}>
|
||||
<TableCell>{new Date(row.followupDate).toLocaleDateString()}</TableCell>
|
||||
<TableCell>{row.customerName}</TableCell>
|
||||
<TableCell>{row.opportunityName}</TableCell>
|
||||
<TableCell>{row.enquiryName}</TableCell>
|
||||
<TableCell>{row.assignedSalesName ?? '-'}</TableCell>
|
||||
<TableCell className='capitalize'>{row.sourceType}</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -14,7 +14,7 @@ export function DashboardFunnel({ steps }: { steps: CrmDashboardFunnelStep[] })
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sales Pipeline Funnel</CardTitle>
|
||||
<CardDescription>Frozen lead, opportunity, quotation, approval, and won stages.</CardDescription>
|
||||
<CardDescription>Lead, enquiry, quotation, approval, and won stages from the current pipeline model.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
{steps.map((step) => (
|
||||
|
||||
@@ -12,8 +12,8 @@ export function DashboardHotProjects({ rows }: { rows: CrmDashboardHotProjectRow
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Top Hot Opportunities</CardTitle>
|
||||
<CardDescription>Production hot projects from enquiries, ranked by commercial value.</CardDescription>
|
||||
<CardTitle>Top Active Enquiries</CardTitle>
|
||||
<CardDescription>Sales-owned hot enquiries ranked by estimated commercial value.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
@@ -22,7 +22,7 @@ export function DashboardHotProjects({ rows }: { rows: CrmDashboardHotProjectRow
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead>Customer</TableHead>
|
||||
<TableHead className='text-right'>Value</TableHead>
|
||||
<TableHead>Assigned Sales</TableHead>
|
||||
<TableHead>Enquiry Owner</TableHead>
|
||||
<TableHead className='text-right'>Probability</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
@@ -13,7 +13,7 @@ export function DashboardSalesRanking({ rows }: { rows: CrmDashboardSalesRanking
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sales Ranking</CardTitle>
|
||||
<CardDescription>Lead and opportunity ownership follow assignment rules. Quotation ownership uses salesman.</CardDescription>
|
||||
<CardDescription>Lead and enquiry ownership follow assignment rules. Quotation ownership uses salesman.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
@@ -21,7 +21,7 @@ export function DashboardSalesRanking({ rows }: { rows: CrmDashboardSalesRanking
|
||||
<TableRow>
|
||||
<TableHead>Sales Person</TableHead>
|
||||
<TableHead className='text-right'>Lead</TableHead>
|
||||
<TableHead className='text-right'>Opportunity</TableHead>
|
||||
<TableHead className='text-right'>Enquiry</TableHead>
|
||||
<TableHead className='text-right'>Quotation</TableHead>
|
||||
<TableHead className='text-right'>Approved</TableHead>
|
||||
<TableHead className='text-right'>Won Revenue</TableHead>
|
||||
@@ -40,7 +40,7 @@ export function DashboardSalesRanking({ rows }: { rows: CrmDashboardSalesRanking
|
||||
<TableRow key={row.salesPersonId}>
|
||||
<TableCell className='font-medium'>{row.salesPersonName}</TableCell>
|
||||
<TableCell className='text-right'>{formatNumber(row.leadCount)}</TableCell>
|
||||
<TableCell className='text-right'>{formatNumber(row.opportunityCount)}</TableCell>
|
||||
<TableCell className='text-right'>{formatNumber(row.enquiryCount)}</TableCell>
|
||||
<TableCell className='text-right'>{formatNumber(row.quotationCount)}</TableCell>
|
||||
<TableCell className='text-right'>{formatNumber(row.approvedQuotations)}</TableCell>
|
||||
<TableCell className='text-right'>{formatNumber(row.wonRevenue)}</TableCell>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import type { CrmDashboardSummary } from '../api/types';
|
||||
|
||||
@@ -10,13 +11,15 @@ function formatNumber(value: number) {
|
||||
function SummaryCard({
|
||||
title,
|
||||
description,
|
||||
items
|
||||
items,
|
||||
href
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
items: Array<{ label: string; value: number; suffix?: string }>;
|
||||
href?: string;
|
||||
}) {
|
||||
return (
|
||||
const content = (
|
||||
<Card className='h-full'>
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
@@ -35,14 +38,23 @@ function SummaryCard({
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return href ? <Link href={href} className='block'>{content}</Link> : content;
|
||||
}
|
||||
|
||||
export function DashboardSummaryCards({ summary }: { summary: CrmDashboardSummary }) {
|
||||
export function DashboardSummaryCards({
|
||||
summary,
|
||||
canViewCommercialData
|
||||
}: {
|
||||
summary: CrmDashboardSummary;
|
||||
canViewCommercialData: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className='grid gap-4 xl:grid-cols-2 2xl:grid-cols-4'>
|
||||
<div className={`grid gap-4 ${canViewCommercialData ? 'xl:grid-cols-2 2xl:grid-cols-4' : 'xl:grid-cols-2'}`}>
|
||||
<SummaryCard
|
||||
title='Lead'
|
||||
description='Lead-stage enquiries based on the frozen KPI rules.'
|
||||
description='Marketing-owned lead-stage records based on pipeline stage.'
|
||||
href='/dashboard/crm/leads'
|
||||
items={[
|
||||
{ label: 'Lead Count', value: summary.lead.leadCount },
|
||||
{ label: 'New Leads', value: summary.lead.newLeads },
|
||||
@@ -51,34 +63,39 @@ export function DashboardSummaryCards({ summary }: { summary: CrmDashboardSummar
|
||||
]}
|
||||
/>
|
||||
<SummaryCard
|
||||
title='Opportunity'
|
||||
description='Assigned enquiries that remain active in the pipeline.'
|
||||
title='Enquiry'
|
||||
description='Sales-owned enquiry-stage records plus closed outcomes.'
|
||||
href='/dashboard/crm/enquiries'
|
||||
items={[
|
||||
{ label: 'Opportunity Count', value: summary.opportunity.opportunityCount },
|
||||
{ label: 'Open Opportunities', value: summary.opportunity.openOpportunities },
|
||||
{ label: 'Hot Opportunities', value: summary.opportunity.hotOpportunities },
|
||||
{ label: 'Opportunity Value', value: summary.opportunity.opportunityValue }
|
||||
]}
|
||||
/>
|
||||
<SummaryCard
|
||||
title='Quotation'
|
||||
description='Live quotation status counts from production data.'
|
||||
items={[
|
||||
{ label: 'Draft Quotations', value: summary.quotation.draftQuotations },
|
||||
{ label: 'Pending Approval', value: summary.quotation.pendingApproval },
|
||||
{ label: 'Approved Quotations', value: summary.quotation.approvedQuotations },
|
||||
{ label: 'Sent Quotations', value: summary.quotation.sentQuotations }
|
||||
]}
|
||||
/>
|
||||
<SummaryCard
|
||||
title='Revenue'
|
||||
description='Quotation, won, and lost values using frozen dashboard rules.'
|
||||
items={[
|
||||
{ label: 'Quotation Value', value: summary.revenue.quotationValue },
|
||||
{ label: 'Won Value', value: summary.revenue.wonValue },
|
||||
{ label: 'Lost Value', value: summary.revenue.lostValue }
|
||||
{ label: 'Enquiry Count', value: summary.enquiry.enquiryCount },
|
||||
{ label: 'Active Enquiries', value: summary.enquiry.activeEnquiries },
|
||||
{ label: 'Closed Won', value: summary.enquiry.wonCount },
|
||||
{ label: 'Closed Lost', value: summary.enquiry.lostCount }
|
||||
]}
|
||||
/>
|
||||
{canViewCommercialData ? (
|
||||
<SummaryCard
|
||||
title='Quotation'
|
||||
description='Live quotation status counts from production data.'
|
||||
items={[
|
||||
{ label: 'Draft Quotations', value: summary.quotation.draftQuotations },
|
||||
{ label: 'Pending Approval', value: summary.quotation.pendingApproval },
|
||||
{ label: 'Approved Quotations', value: summary.quotation.approvedQuotations },
|
||||
{ label: 'Sent Quotations', value: summary.quotation.sentQuotations }
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
{canViewCommercialData ? (
|
||||
<SummaryCard
|
||||
title='Revenue'
|
||||
description='Quotation, won, and lost values using governed revenue rules.'
|
||||
items={[
|
||||
{ label: 'Quotation Value', value: summary.revenue.quotationValue },
|
||||
{ label: 'Won Value', value: summary.revenue.wonValue },
|
||||
{ label: 'Lost Value', value: summary.revenue.lostValue }
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user