task-j1
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user