This commit is contained in:
phaichayon
2026-06-17 16:04:19 +07:00
parent 5be6c54272
commit 04a886ea26
57 changed files with 6477 additions and 105 deletions

View 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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}