task-j1
This commit is contained in:
14
src/features/crm/dashboard/api/queries.ts
Normal file
14
src/features/crm/dashboard/api/queries.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
import { getCrmDashboard } from './service';
|
||||
import type { CrmDashboardFilters } from './types';
|
||||
|
||||
export const crmDashboardKeys = {
|
||||
all: ['crm-dashboard'] as const,
|
||||
data: (filters: CrmDashboardFilters) => [...crmDashboardKeys.all, filters] as const
|
||||
};
|
||||
|
||||
export const crmDashboardQueryOptions = (filters: CrmDashboardFilters) =>
|
||||
queryOptions({
|
||||
queryKey: crmDashboardKeys.data(filters),
|
||||
queryFn: () => getCrmDashboard(filters)
|
||||
});
|
||||
16
src/features/crm/dashboard/api/service.ts
Normal file
16
src/features/crm/dashboard/api/service.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import type { CrmDashboardFilters, CrmDashboardResponse } from './types';
|
||||
|
||||
export async function getCrmDashboard(filters: CrmDashboardFilters): Promise<CrmDashboardResponse> {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (filters.dateFrom) searchParams.set('dateFrom', filters.dateFrom);
|
||||
if (filters.dateTo) searchParams.set('dateTo', filters.dateTo);
|
||||
if (filters.branch) searchParams.set('branch', filters.branch);
|
||||
if (filters.salesman) searchParams.set('salesman', filters.salesman);
|
||||
if (filters.projectPartyRole) searchParams.set('projectPartyRole', filters.projectPartyRole);
|
||||
if (filters.productType) searchParams.set('productType', filters.productType);
|
||||
|
||||
const query = searchParams.toString();
|
||||
return apiClient<CrmDashboardResponse>(`/crm/dashboard${query ? `?${query}` : ''}`);
|
||||
}
|
||||
151
src/features/crm/dashboard/api/types.ts
Normal file
151
src/features/crm/dashboard/api/types.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
export interface CrmDashboardFilters {
|
||||
dateFrom?: string | null;
|
||||
dateTo?: string | null;
|
||||
branch?: string | null;
|
||||
salesman?: string | null;
|
||||
projectPartyRole?: string | null;
|
||||
productType?: string | null;
|
||||
}
|
||||
|
||||
export interface CrmDashboardOption {
|
||||
id: string;
|
||||
code: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface CrmDashboardSalesOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface CrmDashboardReferenceData {
|
||||
branches: CrmDashboardOption[];
|
||||
salesmen: CrmDashboardSalesOption[];
|
||||
productTypes: CrmDashboardOption[];
|
||||
projectPartyRoles: CrmDashboardOption[];
|
||||
}
|
||||
|
||||
export interface CrmDashboardSummary {
|
||||
lead: {
|
||||
leadCount: number;
|
||||
newLeads: number;
|
||||
unassignedLeads: number;
|
||||
averageLeadAgingDays: number;
|
||||
};
|
||||
opportunity: {
|
||||
opportunityCount: number;
|
||||
openOpportunities: number;
|
||||
hotOpportunities: number;
|
||||
opportunityValue: number;
|
||||
};
|
||||
quotation: {
|
||||
draftQuotations: number;
|
||||
pendingApproval: number;
|
||||
approvedQuotations: number;
|
||||
sentQuotations: number;
|
||||
};
|
||||
revenue: {
|
||||
quotationValue: number;
|
||||
wonValue: number;
|
||||
lostValue: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CrmDashboardFunnelStep {
|
||||
key: string;
|
||||
label: string;
|
||||
count: number;
|
||||
value: number;
|
||||
conversionRate: number | null;
|
||||
}
|
||||
|
||||
export interface CrmDashboardRevenueRow {
|
||||
customerId: string;
|
||||
customerCode: string;
|
||||
customerName: string;
|
||||
roleCode: string;
|
||||
revenue: number;
|
||||
quotationCount: number;
|
||||
wonRevenue: number;
|
||||
}
|
||||
|
||||
export interface CrmDashboardSalesRankingRow {
|
||||
salesPersonId: string;
|
||||
salesPersonName: string;
|
||||
leadCount: number;
|
||||
opportunityCount: number;
|
||||
quotationCount: number;
|
||||
approvedQuotations: number;
|
||||
wonRevenue: number;
|
||||
conversionRate: number;
|
||||
}
|
||||
|
||||
export interface CrmDashboardFollowupSummary {
|
||||
dueToday: number;
|
||||
dueThisWeek: number;
|
||||
overdue: number;
|
||||
completed: number;
|
||||
}
|
||||
|
||||
export interface CrmDashboardFollowupRow {
|
||||
id: string;
|
||||
sourceType: 'enquiry' | 'quotation';
|
||||
followupDate: string;
|
||||
customerName: string;
|
||||
opportunityName: string;
|
||||
assignedSalesName: string | null;
|
||||
outcome: string | null;
|
||||
}
|
||||
|
||||
export interface CrmDashboardApprovalSummary {
|
||||
pendingApprovals: number;
|
||||
approvedToday: number;
|
||||
rejectedToday: number;
|
||||
returnedToday: number;
|
||||
averageApprovalTimeHours: number;
|
||||
}
|
||||
|
||||
export interface CrmDashboardApprovalRow {
|
||||
id: string;
|
||||
entityCode: string | null;
|
||||
entityTitle: string | null;
|
||||
workflowName: string;
|
||||
currentStepRoleName: string | null;
|
||||
requestedAt: string;
|
||||
}
|
||||
|
||||
export interface CrmDashboardHotProjectRow {
|
||||
enquiryId: string;
|
||||
enquiryCode: string;
|
||||
projectName: string;
|
||||
customerName: string;
|
||||
value: number;
|
||||
assignedSalesName: string | null;
|
||||
probability: number | null;
|
||||
}
|
||||
|
||||
export interface CrmDashboardResponse {
|
||||
success: boolean;
|
||||
time: string;
|
||||
message: string;
|
||||
filters: Required<CrmDashboardFilters>;
|
||||
referenceData: CrmDashboardReferenceData;
|
||||
summary: CrmDashboardSummary;
|
||||
funnel: CrmDashboardFunnelStep[];
|
||||
revenueAnalytics: {
|
||||
topEndCustomers: CrmDashboardRevenueRow[];
|
||||
topBillingCustomers: CrmDashboardRevenueRow[];
|
||||
topContractors: CrmDashboardRevenueRow[];
|
||||
topConsultants: CrmDashboardRevenueRow[];
|
||||
};
|
||||
salesRanking: CrmDashboardSalesRankingRow[];
|
||||
followups: {
|
||||
summary: CrmDashboardFollowupSummary;
|
||||
upcoming: CrmDashboardFollowupRow[];
|
||||
};
|
||||
approvals: {
|
||||
summary: CrmDashboardApprovalSummary;
|
||||
myPendingApprovals: CrmDashboardApprovalRow[];
|
||||
};
|
||||
hotProjects: CrmDashboardHotProjectRow[];
|
||||
}
|
||||
52
src/features/crm/dashboard/components/crm-dashboard.tsx
Normal file
52
src/features/crm/dashboard/components/crm-dashboard.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { parseAsString, useQueryStates } from 'nuqs';
|
||||
import { crmDashboardQueryOptions } from '../api/queries';
|
||||
import { DashboardApprovalMetrics } from './dashboard-approval-metrics';
|
||||
import { DashboardFilters } from './dashboard-filters';
|
||||
import { DashboardFollowups } from './dashboard-followups';
|
||||
import { DashboardFunnel } from './dashboard-funnel';
|
||||
import { DashboardHotProjects } from './dashboard-hot-projects';
|
||||
import { DashboardRevenueCards } from './dashboard-revenue-cards';
|
||||
import { DashboardSalesRanking } from './dashboard-sales-ranking';
|
||||
import { DashboardSummaryCards } from './dashboard-summary-cards';
|
||||
|
||||
export function CrmDashboard({ canExport }: { canExport: boolean }) {
|
||||
const [params] = useQueryStates({
|
||||
dateFrom: parseAsString,
|
||||
dateTo: parseAsString,
|
||||
branch: parseAsString,
|
||||
salesman: parseAsString,
|
||||
projectPartyRole: parseAsString,
|
||||
productType: parseAsString
|
||||
});
|
||||
|
||||
const filters = {
|
||||
dateFrom: params.dateFrom,
|
||||
dateTo: params.dateTo,
|
||||
branch: params.branch,
|
||||
salesman: params.salesman,
|
||||
projectPartyRole: params.projectPartyRole,
|
||||
productType: params.productType
|
||||
};
|
||||
|
||||
const { data } = useSuspenseQuery(crmDashboardQueryOptions(filters));
|
||||
|
||||
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>
|
||||
<DashboardFollowups followups={data.followups} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import type { CrmDashboardResponse } from '../api/types';
|
||||
|
||||
function Stat({ label, value, suffix }: { label: string; value: number; suffix?: string }) {
|
||||
return (
|
||||
<div className='rounded-lg border bg-muted/20 p-4'>
|
||||
<div className='text-muted-foreground text-xs'>{label}</div>
|
||||
<div className='mt-2 text-2xl font-semibold'>
|
||||
{value}
|
||||
{suffix ? <span className='ml-1 text-sm font-normal'>{suffix}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardApprovalMetrics({
|
||||
approvals
|
||||
}: {
|
||||
approvals: CrmDashboardResponse['approvals'];
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Approval Analytics</CardTitle>
|
||||
<CardDescription>Live workflow counts plus the current approver queue.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-6'>
|
||||
<div className='grid gap-4 sm:grid-cols-2 xl:grid-cols-5'>
|
||||
<Stat label='Pending Approvals' value={approvals.summary.pendingApprovals} />
|
||||
<Stat label='Approved Today' value={approvals.summary.approvedToday} />
|
||||
<Stat label='Rejected Today' value={approvals.summary.rejectedToday} />
|
||||
<Stat label='Returned Today' value={approvals.summary.returnedToday} />
|
||||
<Stat
|
||||
label='Average Approval Time'
|
||||
value={approvals.summary.averageApprovalTimeHours}
|
||||
suffix='hrs'
|
||||
/>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Document</TableHead>
|
||||
<TableHead>Workflow</TableHead>
|
||||
<TableHead>Current Step</TableHead>
|
||||
<TableHead>Requested At</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{approvals.myPendingApprovals.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className='text-muted-foreground text-center'>
|
||||
No pending approvals are assigned to your current approver scope.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
approvals.myPendingApprovals.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell>
|
||||
<div className='font-medium'>{row.entityCode ?? 'Approval'}</div>
|
||||
<div className='text-muted-foreground text-xs'>{row.entityTitle ?? '-'}</div>
|
||||
</TableCell>
|
||||
<TableCell>{row.workflowName}</TableCell>
|
||||
<TableCell>{row.currentStepRoleName ?? '-'}</TableCell>
|
||||
<TableCell>{new Date(row.requestedAt).toLocaleString()}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
177
src/features/crm/dashboard/components/dashboard-filters.tsx
Normal file
177
src/features/crm/dashboard/components/dashboard-filters.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { parseAsString, useQueryStates } from 'nuqs';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select';
|
||||
import type { CrmDashboardReferenceData } from '../api/types';
|
||||
|
||||
export function DashboardFilters({
|
||||
referenceData,
|
||||
canExport
|
||||
}: {
|
||||
referenceData: CrmDashboardReferenceData;
|
||||
canExport: boolean;
|
||||
}) {
|
||||
const [params, setParams] = useQueryStates({
|
||||
dateFrom: parseAsString,
|
||||
dateTo: parseAsString,
|
||||
branch: parseAsString,
|
||||
salesman: parseAsString,
|
||||
projectPartyRole: parseAsString,
|
||||
productType: parseAsString
|
||||
});
|
||||
|
||||
const exportQuery = useMemo(() => {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (params.dateFrom) searchParams.set('dateFrom', params.dateFrom);
|
||||
if (params.dateTo) searchParams.set('dateTo', params.dateTo);
|
||||
if (params.branch) searchParams.set('branch', params.branch);
|
||||
if (params.salesman) searchParams.set('salesman', params.salesman);
|
||||
if (params.projectPartyRole) searchParams.set('projectPartyRole', params.projectPartyRole);
|
||||
if (params.productType) searchParams.set('productType', params.productType);
|
||||
|
||||
return searchParams.toString();
|
||||
}, [params]);
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-4 rounded-xl border bg-card p-4'>
|
||||
<div className='grid gap-3 md:grid-cols-2 xl:grid-cols-6'>
|
||||
<Input
|
||||
type='date'
|
||||
value={params.dateFrom ?? ''}
|
||||
onChange={(event) => void setParams({ dateFrom: event.target.value || null })}
|
||||
/>
|
||||
<Input
|
||||
type='date'
|
||||
value={params.dateTo ?? ''}
|
||||
onChange={(event) => void setParams({ dateTo: event.target.value || null })}
|
||||
/>
|
||||
<Select
|
||||
value={params.branch ?? '__all__'}
|
||||
onValueChange={(value) => void setParams({ branch: value === '__all__' ? null : value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Branch' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='__all__'>All Branches</SelectItem>
|
||||
{referenceData.branches.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={params.salesman ?? '__all__'}
|
||||
onValueChange={(value) => void setParams({ salesman: value === '__all__' ? null : value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Sales Person' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='__all__'>All Sales</SelectItem>
|
||||
{referenceData.salesmen.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={params.projectPartyRole ?? '__all__'}
|
||||
onValueChange={(value) =>
|
||||
void setParams({ projectPartyRole: value === '__all__' ? null : value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Project Party Role' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='__all__'>All Party Roles</SelectItem>
|
||||
{referenceData.projectPartyRoles.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={params.productType ?? '__all__'}
|
||||
onValueChange={(value) => void setParams({ productType: value === '__all__' ? null : value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder='Product Type' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='__all__'>All Product Types</SelectItem>
|
||||
{referenceData.productTypes.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() =>
|
||||
void setParams({
|
||||
dateFrom: null,
|
||||
dateTo: null,
|
||||
branch: null,
|
||||
salesman: null,
|
||||
projectPartyRole: null,
|
||||
productType: null
|
||||
})
|
||||
}
|
||||
>
|
||||
Reset Filters
|
||||
</Button>
|
||||
{canExport ? (
|
||||
<>
|
||||
<Button asChild variant='outline'>
|
||||
<Link
|
||||
href={`/api/crm/dashboard/export?section=sales-ranking&format=csv${
|
||||
exportQuery ? `&${exportQuery}` : ''
|
||||
}`}
|
||||
>
|
||||
<Icons.download className='mr-2 h-4 w-4' /> Export Sales CSV
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant='outline'>
|
||||
<Link
|
||||
href={`/api/crm/dashboard/export?section=revenue-analytics&format=xls${
|
||||
exportQuery ? `&${exportQuery}` : ''
|
||||
}`}
|
||||
>
|
||||
<Icons.download className='mr-2 h-4 w-4' /> Export Revenue Excel
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant='outline'>
|
||||
<Link
|
||||
href={`/api/crm/dashboard/export?section=pipeline-funnel&format=csv${
|
||||
exportQuery ? `&${exportQuery}` : ''
|
||||
}`}
|
||||
>
|
||||
<Icons.download className='mr-2 h-4 w-4' /> Export Funnel CSV
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import type { CrmDashboardResponse } from '../api/types';
|
||||
|
||||
function Stat({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className='rounded-lg border bg-muted/20 p-4'>
|
||||
<div className='text-muted-foreground text-xs'>{label}</div>
|
||||
<div className='mt-2 text-2xl font-semibold'>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardFollowups({
|
||||
followups
|
||||
}: {
|
||||
followups: CrmDashboardResponse['followups'];
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Follow-up Dashboard</CardTitle>
|
||||
<CardDescription>Upcoming and completed CRM follow-ups across enquiries and quotations.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-6'>
|
||||
<div className='grid gap-4 sm:grid-cols-2 xl:grid-cols-4'>
|
||||
<Stat label='Due Today' value={followups.summary.dueToday} />
|
||||
<Stat label='Due This Week' value={followups.summary.dueThisWeek} />
|
||||
<Stat label='Overdue' value={followups.summary.overdue} />
|
||||
<Stat label='Completed' value={followups.summary.completed} />
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Customer</TableHead>
|
||||
<TableHead>Opportunity</TableHead>
|
||||
<TableHead>Assigned Sales</TableHead>
|
||||
<TableHead>Source</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{followups.upcoming.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className='text-muted-foreground text-center'>
|
||||
No upcoming follow-ups match the current filters.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
followups.upcoming.map((row) => (
|
||||
<TableRow key={`${row.sourceType}-${row.id}`}>
|
||||
<TableCell>{new Date(row.followupDate).toLocaleDateString()}</TableCell>
|
||||
<TableCell>{row.customerName}</TableCell>
|
||||
<TableCell>{row.opportunityName}</TableCell>
|
||||
<TableCell>{row.assignedSalesName ?? '-'}</TableCell>
|
||||
<TableCell className='capitalize'>{row.sourceType}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
45
src/features/crm/dashboard/components/dashboard-funnel.tsx
Normal file
45
src/features/crm/dashboard/components/dashboard-funnel.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import type { CrmDashboardFunnelStep } from '../api/types';
|
||||
|
||||
function formatNumber(value: number) {
|
||||
return new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 }).format(value);
|
||||
}
|
||||
|
||||
export function DashboardFunnel({ steps }: { steps: CrmDashboardFunnelStep[] }) {
|
||||
const maxCount = Math.max(...steps.map((step) => step.count), 1);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sales Pipeline Funnel</CardTitle>
|
||||
<CardDescription>Frozen lead, opportunity, quotation, approval, and won stages.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
{steps.map((step) => (
|
||||
<div key={step.key} className='space-y-2'>
|
||||
<div className='flex items-center justify-between gap-4'>
|
||||
<div>
|
||||
<div className='font-medium'>{step.label}</div>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{formatNumber(step.count)} items, value {formatNumber(step.value)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='text-right text-sm'>
|
||||
<div>{step.conversionRate === null ? '-' : `${formatNumber(step.conversionRate)}%`}</div>
|
||||
<div className='text-muted-foreground text-xs'>conversion</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='bg-muted h-2 overflow-hidden rounded-full'>
|
||||
<div
|
||||
className='h-full rounded-full bg-primary'
|
||||
style={{ width: `${Math.max((step.count / maxCount) * 100, step.count > 0 ? 8 : 0)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import type { CrmDashboardHotProjectRow } from '../api/types';
|
||||
|
||||
function formatNumber(value: number) {
|
||||
return new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 }).format(value);
|
||||
}
|
||||
|
||||
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>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead>Customer</TableHead>
|
||||
<TableHead className='text-right'>Value</TableHead>
|
||||
<TableHead>Assigned Sales</TableHead>
|
||||
<TableHead className='text-right'>Probability</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className='text-muted-foreground text-center'>
|
||||
No hot opportunities match the current filters.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<TableRow key={row.enquiryId}>
|
||||
<TableCell>
|
||||
<div className='font-medium'>{row.projectName}</div>
|
||||
<div className='text-muted-foreground text-xs'>{row.enquiryCode}</div>
|
||||
</TableCell>
|
||||
<TableCell>{row.customerName}</TableCell>
|
||||
<TableCell className='text-right'>{formatNumber(row.value)}</TableCell>
|
||||
<TableCell>{row.assignedSalesName ?? '-'}</TableCell>
|
||||
<TableCell className='text-right'>{row.probability ?? 0}%</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import type { CrmDashboardRevenueRow, CrmDashboardResponse } from '../api/types';
|
||||
|
||||
function formatNumber(value: number) {
|
||||
return new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 }).format(value);
|
||||
}
|
||||
|
||||
function RevenueTable({
|
||||
title,
|
||||
description,
|
||||
rows
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
rows: CrmDashboardRevenueRow[];
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Customer</TableHead>
|
||||
<TableHead className='text-right'>Revenue</TableHead>
|
||||
<TableHead className='text-right'>Quotations</TableHead>
|
||||
<TableHead className='text-right'>Won Revenue</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className='text-muted-foreground text-center'>
|
||||
No revenue rows match the current filters.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<TableRow key={`${row.roleCode}-${row.customerId}`}>
|
||||
<TableCell>
|
||||
<div className='font-medium'>{row.customerName}</div>
|
||||
<div className='text-muted-foreground text-xs'>{row.customerCode}</div>
|
||||
</TableCell>
|
||||
<TableCell className='text-right'>{formatNumber(row.revenue)}</TableCell>
|
||||
<TableCell className='text-right'>{formatNumber(row.quotationCount)}</TableCell>
|
||||
<TableCell className='text-right'>{formatNumber(row.wonRevenue)}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardRevenueCards({
|
||||
revenueAnalytics
|
||||
}: {
|
||||
revenueAnalytics: CrmDashboardResponse['revenueAnalytics'];
|
||||
}) {
|
||||
return (
|
||||
<div className='grid gap-4 xl:grid-cols-2'>
|
||||
<RevenueTable
|
||||
title='Top End Customers'
|
||||
description='Revenue owner attribution with billing-customer fallback when end-customer is missing.'
|
||||
rows={revenueAnalytics.topEndCustomers}
|
||||
/>
|
||||
<RevenueTable
|
||||
title='Top Billing Customers'
|
||||
description='Commercial relationship view grouped by billing customer.'
|
||||
rows={revenueAnalytics.topBillingCustomers}
|
||||
/>
|
||||
<RevenueTable
|
||||
title='Top Contractors'
|
||||
description='Relationship analytics with full attribution for each contractor role.'
|
||||
rows={revenueAnalytics.topContractors}
|
||||
/>
|
||||
<RevenueTable
|
||||
title='Top Consultants'
|
||||
description='Consultant-side revenue relationships from project parties.'
|
||||
rows={revenueAnalytics.topConsultants}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import type { CrmDashboardSalesRankingRow } from '../api/types';
|
||||
|
||||
function formatNumber(value: number) {
|
||||
return new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 }).format(value);
|
||||
}
|
||||
|
||||
export function DashboardSalesRanking({ rows }: { rows: CrmDashboardSalesRankingRow[] }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sales Ranking</CardTitle>
|
||||
<CardDescription>Lead and opportunity ownership follow assignment rules. Quotation ownership uses salesman.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Sales Person</TableHead>
|
||||
<TableHead className='text-right'>Lead</TableHead>
|
||||
<TableHead className='text-right'>Opportunity</TableHead>
|
||||
<TableHead className='text-right'>Quotation</TableHead>
|
||||
<TableHead className='text-right'>Approved</TableHead>
|
||||
<TableHead className='text-right'>Won Revenue</TableHead>
|
||||
<TableHead className='text-right'>Conversion</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className='text-muted-foreground text-center'>
|
||||
No sales ranking rows match the current filters.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<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.quotationCount)}</TableCell>
|
||||
<TableCell className='text-right'>{formatNumber(row.approvedQuotations)}</TableCell>
|
||||
<TableCell className='text-right'>{formatNumber(row.wonRevenue)}</TableCell>
|
||||
<TableCell className='text-right'>{formatNumber(row.conversionRate)}%</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import type { CrmDashboardSummary } from '../api/types';
|
||||
|
||||
function formatNumber(value: number) {
|
||||
return new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 }).format(value);
|
||||
}
|
||||
|
||||
function SummaryCard({
|
||||
title,
|
||||
description,
|
||||
items
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
items: Array<{ label: string; value: number; suffix?: string }>;
|
||||
}) {
|
||||
return (
|
||||
<Card className='h-full'>
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='grid gap-4 sm:grid-cols-2'>
|
||||
{items.map((item) => (
|
||||
<div key={item.label} className='rounded-lg border bg-muted/20 p-4'>
|
||||
<div className='text-muted-foreground text-xs'>{item.label}</div>
|
||||
<div className='mt-2 text-2xl font-semibold'>
|
||||
{formatNumber(item.value)}
|
||||
{item.suffix ? <span className='ml-1 text-sm font-normal'>{item.suffix}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardSummaryCards({ summary }: { summary: CrmDashboardSummary }) {
|
||||
return (
|
||||
<div className='grid gap-4 xl:grid-cols-2 2xl:grid-cols-4'>
|
||||
<SummaryCard
|
||||
title='Lead'
|
||||
description='Lead-stage enquiries based on the frozen KPI rules.'
|
||||
items={[
|
||||
{ label: 'Lead Count', value: summary.lead.leadCount },
|
||||
{ label: 'New Leads', value: summary.lead.newLeads },
|
||||
{ label: 'Unassigned Leads', value: summary.lead.unassignedLeads },
|
||||
{ label: 'Lead Aging Avg', value: summary.lead.averageLeadAgingDays, suffix: 'days' }
|
||||
]}
|
||||
/>
|
||||
<SummaryCard
|
||||
title='Opportunity'
|
||||
description='Assigned enquiries that remain active in the pipeline.'
|
||||
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 }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
705
src/features/crm/dashboard/server/service.ts
Normal file
705
src/features/crm/dashboard/server/service.ts
Normal file
@@ -0,0 +1,705 @@
|
||||
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 { 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;
|
||||
};
|
||||
|
||||
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>;
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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 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
|
||||
) {
|
||||
return enquiry.assignedToUserId !== null && !['closed_lost', 'cancelled'].includes(statusCode ?? '');
|
||||
}
|
||||
|
||||
async function loadScopedRows(
|
||||
organizationId: string,
|
||||
filters: NormalizedFilters
|
||||
) {
|
||||
const [enquiries, quotations] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(crmEnquiries)
|
||||
.where(and(eq(crmEnquiries.organizationId, organizationId), isNull(crmEnquiries.deletedAt))),
|
||||
db
|
||||
.select()
|
||||
.from(crmQuotations)
|
||||
.where(and(eq(crmQuotations.organizationId, organizationId), isNull(crmQuotations.deletedAt)))
|
||||
]);
|
||||
|
||||
return {
|
||||
enquiries: enquiries.filter((row) => matchesEnquiryFilters(row, filters)),
|
||||
quotations: quotations.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) =>
|
||||
isLeadStatus(statusMaps.enquiryStatusById.get(row.status), row)
|
||||
);
|
||||
const opportunityRows = scopedEnquiries.filter((row) =>
|
||||
isOpportunityStatus(statusMaps.enquiryStatusById.get(row.status), row)
|
||||
);
|
||||
|
||||
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.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))
|
||||
},
|
||||
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: 'opportunity',
|
||||
label: 'Opportunity',
|
||||
count: summary.opportunity.opportunityCount,
|
||||
value: summary.opportunity.opportunityValue
|
||||
},
|
||||
{
|
||||
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: scopedQuotations.filter(
|
||||
(row) => (statusMaps.quotationStatusById.get(row.status) ?? '') === 'accepted'
|
||||
).length,
|
||||
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,
|
||||
opportunityCount: 0,
|
||||
quotationCount: 0,
|
||||
approvedQuotations: 0,
|
||||
wonRevenue: 0,
|
||||
conversionRate: 0
|
||||
};
|
||||
|
||||
if (isLeadStatus(statusMaps.enquiryStatusById.get(row.status), row)) {
|
||||
current.leadCount += 1;
|
||||
}
|
||||
|
||||
if (isOpportunityStatus(statusMaps.enquiryStatusById.get(row.status), row)) {
|
||||
current.opportunityCount += 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,
|
||||
opportunityCount: 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(
|
||||
organizationId: string,
|
||||
filters: NormalizedFilters
|
||||
): Promise<{ summary: CrmDashboardFollowupSummary; upcoming: CrmDashboardFollowupRow[] }> {
|
||||
const [enquiryFollowups, quotationFollowups] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(crmEnquiryFollowups)
|
||||
.where(and(eq(crmEnquiryFollowups.organizationId, 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))
|
||||
]);
|
||||
|
||||
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) => !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',
|
||||
opportunityName: enquiry.projectName ?? enquiry.title,
|
||||
assignedSalesName: enquiry.assignedToUserId
|
||||
? (salesMap.get(enquiry.assignedToUserId) ?? null)
|
||||
: null,
|
||||
outcome: row.outcome
|
||||
}
|
||||
];
|
||||
});
|
||||
const quotationRows = quotationFollowups
|
||||
.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',
|
||||
opportunityName: 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(
|
||||
organizationId: string,
|
||||
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)))
|
||||
.orderBy(desc(crmEnquiries.updatedAt));
|
||||
const scoped = rows
|
||||
.filter((row) => matchesEnquiryFilters(row, filters))
|
||||
.filter((row) => isOpportunityStatus(statusMaps.enquiryStatusById.get(row.status), row))
|
||||
.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 [referenceData, statusMaps, scoped] = await Promise.all([
|
||||
getReferenceData(context.organizationId),
|
||||
getStatusMaps(context.organizationId),
|
||||
loadScopedRows(context.organizationId, 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)
|
||||
]);
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user