lead menu
nquiry menu
This commit is contained in:
@@ -32,11 +32,13 @@ export interface CrmDashboardSummary {
|
||||
unassignedLeads: number;
|
||||
averageLeadAgingDays: number;
|
||||
};
|
||||
opportunity: {
|
||||
opportunityCount: number;
|
||||
openOpportunities: number;
|
||||
hotOpportunities: number;
|
||||
opportunityValue: number;
|
||||
enquiry: {
|
||||
enquiryCount: number;
|
||||
activeEnquiries: number;
|
||||
wonCount: number;
|
||||
lostCount: number;
|
||||
hotEnquiries: number;
|
||||
enquiryValue: number;
|
||||
};
|
||||
quotation: {
|
||||
draftQuotations: number;
|
||||
@@ -73,7 +75,7 @@ export interface CrmDashboardSalesRankingRow {
|
||||
salesPersonId: string;
|
||||
salesPersonName: string;
|
||||
leadCount: number;
|
||||
opportunityCount: number;
|
||||
enquiryCount: number;
|
||||
quotationCount: number;
|
||||
approvedQuotations: number;
|
||||
wonRevenue: number;
|
||||
@@ -92,7 +94,7 @@ export interface CrmDashboardFollowupRow {
|
||||
sourceType: 'enquiry' | 'quotation';
|
||||
followupDate: string;
|
||||
customerName: string;
|
||||
opportunityName: string;
|
||||
enquiryName: string;
|
||||
assignedSalesName: string | null;
|
||||
outcome: string | null;
|
||||
}
|
||||
@@ -148,4 +150,8 @@ export interface CrmDashboardResponse {
|
||||
myPendingApprovals: CrmDashboardApprovalRow[];
|
||||
};
|
||||
hotProjects: CrmDashboardHotProjectRow[];
|
||||
visibility: {
|
||||
canViewCommercialData: boolean;
|
||||
canViewApprovalData: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,16 +36,29 @@ export function CrmDashboard({ canExport }: { canExport: boolean }) {
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<DashboardFilters referenceData={data.referenceData} canExport={canExport} />
|
||||
<DashboardSummaryCards summary={data.summary} />
|
||||
<div className='grid gap-6 2xl:grid-cols-[1.35fr_1fr]'>
|
||||
<DashboardFunnel steps={data.funnel} />
|
||||
<DashboardHotProjects rows={data.hotProjects} />
|
||||
</div>
|
||||
<DashboardRevenueCards revenueAnalytics={data.revenueAnalytics} />
|
||||
<div className='grid gap-6 2xl:grid-cols-[1.2fr_1fr]'>
|
||||
<DashboardSalesRanking rows={data.salesRanking} />
|
||||
<DashboardApprovalMetrics approvals={data.approvals} />
|
||||
</div>
|
||||
<DashboardSummaryCards
|
||||
summary={data.summary}
|
||||
canViewCommercialData={data.visibility.canViewCommercialData}
|
||||
/>
|
||||
{data.visibility.canViewCommercialData ? (
|
||||
<div className='grid gap-6 2xl:grid-cols-[1.35fr_1fr]'>
|
||||
<DashboardFunnel steps={data.funnel} />
|
||||
<DashboardHotProjects rows={data.hotProjects} />
|
||||
</div>
|
||||
) : null}
|
||||
{data.visibility.canViewCommercialData ? (
|
||||
<DashboardRevenueCards revenueAnalytics={data.revenueAnalytics} />
|
||||
) : null}
|
||||
{data.visibility.canViewCommercialData || data.visibility.canViewApprovalData ? (
|
||||
<div className='grid gap-6 2xl:grid-cols-[1.2fr_1fr]'>
|
||||
{data.visibility.canViewCommercialData ? (
|
||||
<DashboardSalesRanking rows={data.salesRanking} />
|
||||
) : null}
|
||||
{data.visibility.canViewApprovalData ? (
|
||||
<DashboardApprovalMetrics approvals={data.approvals} />
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<DashboardFollowups followups={data.followups} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -36,8 +36,8 @@ export function DashboardFollowups({
|
||||
<TableRow>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Customer</TableHead>
|
||||
<TableHead>Opportunity</TableHead>
|
||||
<TableHead>Assigned Sales</TableHead>
|
||||
<TableHead>Enquiry</TableHead>
|
||||
<TableHead>Enquiry Owner</TableHead>
|
||||
<TableHead>Source</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -53,7 +53,7 @@ export function DashboardFollowups({
|
||||
<TableRow key={`${row.sourceType}-${row.id}`}>
|
||||
<TableCell>{new Date(row.followupDate).toLocaleDateString()}</TableCell>
|
||||
<TableCell>{row.customerName}</TableCell>
|
||||
<TableCell>{row.opportunityName}</TableCell>
|
||||
<TableCell>{row.enquiryName}</TableCell>
|
||||
<TableCell>{row.assignedSalesName ?? '-'}</TableCell>
|
||||
<TableCell className='capitalize'>{row.sourceType}</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -14,7 +14,7 @@ export function DashboardFunnel({ steps }: { steps: CrmDashboardFunnelStep[] })
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sales Pipeline Funnel</CardTitle>
|
||||
<CardDescription>Frozen lead, opportunity, quotation, approval, and won stages.</CardDescription>
|
||||
<CardDescription>Lead, enquiry, quotation, approval, and won stages from the current pipeline model.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
{steps.map((step) => (
|
||||
|
||||
@@ -12,8 +12,8 @@ export function DashboardHotProjects({ rows }: { rows: CrmDashboardHotProjectRow
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Top Hot Opportunities</CardTitle>
|
||||
<CardDescription>Production hot projects from enquiries, ranked by commercial value.</CardDescription>
|
||||
<CardTitle>Top Active Enquiries</CardTitle>
|
||||
<CardDescription>Sales-owned hot enquiries ranked by estimated commercial value.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
@@ -22,7 +22,7 @@ export function DashboardHotProjects({ rows }: { rows: CrmDashboardHotProjectRow
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead>Customer</TableHead>
|
||||
<TableHead className='text-right'>Value</TableHead>
|
||||
<TableHead>Assigned Sales</TableHead>
|
||||
<TableHead>Enquiry Owner</TableHead>
|
||||
<TableHead className='text-right'>Probability</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
@@ -13,7 +13,7 @@ export function DashboardSalesRanking({ rows }: { rows: CrmDashboardSalesRanking
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sales Ranking</CardTitle>
|
||||
<CardDescription>Lead and opportunity ownership follow assignment rules. Quotation ownership uses salesman.</CardDescription>
|
||||
<CardDescription>Lead and enquiry ownership follow assignment rules. Quotation ownership uses salesman.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
@@ -21,7 +21,7 @@ export function DashboardSalesRanking({ rows }: { rows: CrmDashboardSalesRanking
|
||||
<TableRow>
|
||||
<TableHead>Sales Person</TableHead>
|
||||
<TableHead className='text-right'>Lead</TableHead>
|
||||
<TableHead className='text-right'>Opportunity</TableHead>
|
||||
<TableHead className='text-right'>Enquiry</TableHead>
|
||||
<TableHead className='text-right'>Quotation</TableHead>
|
||||
<TableHead className='text-right'>Approved</TableHead>
|
||||
<TableHead className='text-right'>Won Revenue</TableHead>
|
||||
@@ -40,7 +40,7 @@ export function DashboardSalesRanking({ rows }: { rows: CrmDashboardSalesRanking
|
||||
<TableRow key={row.salesPersonId}>
|
||||
<TableCell className='font-medium'>{row.salesPersonName}</TableCell>
|
||||
<TableCell className='text-right'>{formatNumber(row.leadCount)}</TableCell>
|
||||
<TableCell className='text-right'>{formatNumber(row.opportunityCount)}</TableCell>
|
||||
<TableCell className='text-right'>{formatNumber(row.enquiryCount)}</TableCell>
|
||||
<TableCell className='text-right'>{formatNumber(row.quotationCount)}</TableCell>
|
||||
<TableCell className='text-right'>{formatNumber(row.approvedQuotations)}</TableCell>
|
||||
<TableCell className='text-right'>{formatNumber(row.wonRevenue)}</TableCell>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import type { CrmDashboardSummary } from '../api/types';
|
||||
|
||||
@@ -10,13 +11,15 @@ function formatNumber(value: number) {
|
||||
function SummaryCard({
|
||||
title,
|
||||
description,
|
||||
items
|
||||
items,
|
||||
href
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
items: Array<{ label: string; value: number; suffix?: string }>;
|
||||
href?: string;
|
||||
}) {
|
||||
return (
|
||||
const content = (
|
||||
<Card className='h-full'>
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
@@ -35,14 +38,23 @@ function SummaryCard({
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return href ? <Link href={href} className='block'>{content}</Link> : content;
|
||||
}
|
||||
|
||||
export function DashboardSummaryCards({ summary }: { summary: CrmDashboardSummary }) {
|
||||
export function DashboardSummaryCards({
|
||||
summary,
|
||||
canViewCommercialData
|
||||
}: {
|
||||
summary: CrmDashboardSummary;
|
||||
canViewCommercialData: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className='grid gap-4 xl:grid-cols-2 2xl:grid-cols-4'>
|
||||
<div className={`grid gap-4 ${canViewCommercialData ? 'xl:grid-cols-2 2xl:grid-cols-4' : 'xl:grid-cols-2'}`}>
|
||||
<SummaryCard
|
||||
title='Lead'
|
||||
description='Lead-stage enquiries based on the frozen KPI rules.'
|
||||
description='Marketing-owned lead-stage records based on pipeline stage.'
|
||||
href='/dashboard/crm/leads'
|
||||
items={[
|
||||
{ label: 'Lead Count', value: summary.lead.leadCount },
|
||||
{ label: 'New Leads', value: summary.lead.newLeads },
|
||||
@@ -51,34 +63,39 @@ export function DashboardSummaryCards({ summary }: { summary: CrmDashboardSummar
|
||||
]}
|
||||
/>
|
||||
<SummaryCard
|
||||
title='Opportunity'
|
||||
description='Assigned enquiries that remain active in the pipeline.'
|
||||
title='Enquiry'
|
||||
description='Sales-owned enquiry-stage records plus closed outcomes.'
|
||||
href='/dashboard/crm/enquiries'
|
||||
items={[
|
||||
{ label: 'Opportunity Count', value: summary.opportunity.opportunityCount },
|
||||
{ label: 'Open Opportunities', value: summary.opportunity.openOpportunities },
|
||||
{ label: 'Hot Opportunities', value: summary.opportunity.hotOpportunities },
|
||||
{ label: 'Opportunity Value', value: summary.opportunity.opportunityValue }
|
||||
]}
|
||||
/>
|
||||
<SummaryCard
|
||||
title='Quotation'
|
||||
description='Live quotation status counts from production data.'
|
||||
items={[
|
||||
{ label: 'Draft Quotations', value: summary.quotation.draftQuotations },
|
||||
{ label: 'Pending Approval', value: summary.quotation.pendingApproval },
|
||||
{ label: 'Approved Quotations', value: summary.quotation.approvedQuotations },
|
||||
{ label: 'Sent Quotations', value: summary.quotation.sentQuotations }
|
||||
]}
|
||||
/>
|
||||
<SummaryCard
|
||||
title='Revenue'
|
||||
description='Quotation, won, and lost values using frozen dashboard rules.'
|
||||
items={[
|
||||
{ label: 'Quotation Value', value: summary.revenue.quotationValue },
|
||||
{ label: 'Won Value', value: summary.revenue.wonValue },
|
||||
{ label: 'Lost Value', value: summary.revenue.lostValue }
|
||||
{ label: 'Enquiry Count', value: summary.enquiry.enquiryCount },
|
||||
{ label: 'Active Enquiries', value: summary.enquiry.activeEnquiries },
|
||||
{ label: 'Closed Won', value: summary.enquiry.wonCount },
|
||||
{ label: 'Closed Lost', value: summary.enquiry.lostCount }
|
||||
]}
|
||||
/>
|
||||
{canViewCommercialData ? (
|
||||
<SummaryCard
|
||||
title='Quotation'
|
||||
description='Live quotation status counts from production data.'
|
||||
items={[
|
||||
{ label: 'Draft Quotations', value: summary.quotation.draftQuotations },
|
||||
{ label: 'Pending Approval', value: summary.quotation.pendingApproval },
|
||||
{ label: 'Approved Quotations', value: summary.quotation.approvedQuotations },
|
||||
{ label: 'Sent Quotations', value: summary.quotation.sentQuotations }
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
{canViewCommercialData ? (
|
||||
<SummaryCard
|
||||
title='Revenue'
|
||||
description='Quotation, won, and lost values using governed revenue rules.'
|
||||
items={[
|
||||
{ label: 'Quotation Value', value: summary.revenue.quotationValue },
|
||||
{ label: 'Won Value', value: summary.revenue.wonValue },
|
||||
{ label: 'Lost Value', value: summary.revenue.lostValue }
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
users
|
||||
} from '@/db/schema';
|
||||
import { db } from '@/lib/db';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { getUserBranches } from '@/features/foundation/branch-scope/service';
|
||||
import { listApprovalRequests } from '@/features/foundation/approval/server/service';
|
||||
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||
@@ -54,6 +55,7 @@ type DashboardContext = {
|
||||
userId: string;
|
||||
membershipRole: string;
|
||||
businessRole: string;
|
||||
permissions: string[];
|
||||
};
|
||||
|
||||
type NormalizedFilters = {
|
||||
@@ -70,6 +72,15 @@ type StatusMaps = {
|
||||
quotationStatusById: Map<string, string>;
|
||||
};
|
||||
|
||||
const DASHBOARD_FULL_ACCESS_ROLES = new Set([
|
||||
'marketing',
|
||||
'sales_manager',
|
||||
'department_manager',
|
||||
'top_manager'
|
||||
]);
|
||||
|
||||
const DASHBOARD_OWNED_SCOPE_ROLES = new Set(['sales', 'sales_support']);
|
||||
|
||||
function normalizeDashboardFilters(filters: CrmDashboardFilters): NormalizedFilters {
|
||||
const now = new Date();
|
||||
const monthStart = startOfDay(new Date(now.getFullYear(), now.getMonth(), 1));
|
||||
@@ -103,6 +114,54 @@ function average(values: number[]) {
|
||||
return round2(values.reduce((sum, value) => sum + value, 0) / values.length);
|
||||
}
|
||||
|
||||
function canViewCommercialData(context: DashboardContext) {
|
||||
return (
|
||||
context.membershipRole === 'admin' || context.permissions.includes(PERMISSIONS.crmQuotationRead)
|
||||
);
|
||||
}
|
||||
|
||||
function canViewApprovalData(context: DashboardContext) {
|
||||
return (
|
||||
context.membershipRole === 'admin' || context.permissions.includes(PERMISSIONS.crmApprovalRead)
|
||||
);
|
||||
}
|
||||
|
||||
function hasDashboardFullScope(context: DashboardContext) {
|
||||
return (
|
||||
context.membershipRole === 'admin' || DASHBOARD_FULL_ACCESS_ROLES.has(context.businessRole)
|
||||
);
|
||||
}
|
||||
|
||||
function canAccessDashboardEnquiry(
|
||||
row: typeof crmEnquiries.$inferSelect,
|
||||
context: DashboardContext
|
||||
) {
|
||||
if (hasDashboardFullScope(context)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (DASHBOARD_OWNED_SCOPE_ROLES.has(context.businessRole)) {
|
||||
return row.createdBy === context.userId || row.assignedToUserId === context.userId;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function canAccessDashboardQuotation(
|
||||
row: typeof crmQuotations.$inferSelect,
|
||||
context: DashboardContext
|
||||
) {
|
||||
if (hasDashboardFullScope(context)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (DASHBOARD_OWNED_SCOPE_ROLES.has(context.businessRole)) {
|
||||
return row.salesmanId === context.userId;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function getStatusMaps(organizationId: string): Promise<StatusMaps> {
|
||||
const [enquiryOptions, quotationOptions] = await Promise.all([
|
||||
getActiveOptionsByCategory(ENQUIRY_STATUS_CATEGORY, { organizationId }),
|
||||
@@ -172,35 +231,39 @@ function matchesQuotationFilters(
|
||||
return true;
|
||||
}
|
||||
|
||||
function isLeadStatus(statusCode: string | undefined, enquiry: typeof crmEnquiries.$inferSelect) {
|
||||
return enquiry.assignedToUserId === null && !['closed_lost', 'cancelled'].includes(statusCode ?? '');
|
||||
}
|
||||
|
||||
function isOpportunityStatus(
|
||||
statusCode: string | undefined,
|
||||
enquiry: typeof crmEnquiries.$inferSelect
|
||||
function isPipelineStage(
|
||||
enquiry: typeof crmEnquiries.$inferSelect,
|
||||
stage: 'lead' | 'enquiry' | 'closed_won' | 'closed_lost'
|
||||
) {
|
||||
return enquiry.assignedToUserId !== null && !['closed_lost', 'cancelled'].includes(statusCode ?? '');
|
||||
return enquiry.pipelineStage === stage;
|
||||
}
|
||||
|
||||
async function loadScopedRows(
|
||||
organizationId: string,
|
||||
context: DashboardContext,
|
||||
filters: NormalizedFilters
|
||||
) {
|
||||
const [enquiries, quotations] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(crmEnquiries)
|
||||
.where(and(eq(crmEnquiries.organizationId, organizationId), isNull(crmEnquiries.deletedAt))),
|
||||
.where(
|
||||
and(eq(crmEnquiries.organizationId, context.organizationId), isNull(crmEnquiries.deletedAt))
|
||||
),
|
||||
db
|
||||
.select()
|
||||
.from(crmQuotations)
|
||||
.where(and(eq(crmQuotations.organizationId, organizationId), isNull(crmQuotations.deletedAt)))
|
||||
.where(
|
||||
and(eq(crmQuotations.organizationId, context.organizationId), isNull(crmQuotations.deletedAt))
|
||||
)
|
||||
]);
|
||||
|
||||
return {
|
||||
enquiries: enquiries.filter((row) => matchesEnquiryFilters(row, filters)),
|
||||
quotations: quotations.filter((row) => matchesQuotationFilters(row, filters))
|
||||
enquiries: enquiries
|
||||
.filter((row) => canAccessDashboardEnquiry(row, context))
|
||||
.filter((row) => matchesEnquiryFilters(row, filters)),
|
||||
quotations: quotations
|
||||
.filter((row) => canAccessDashboardQuotation(row, context))
|
||||
.filter((row) => matchesQuotationFilters(row, filters))
|
||||
};
|
||||
}
|
||||
|
||||
@@ -210,12 +273,10 @@ function buildSummary(
|
||||
statusMaps: StatusMaps
|
||||
): CrmDashboardSummary {
|
||||
const now = new Date();
|
||||
const leadRows = scopedEnquiries.filter((row) =>
|
||||
isLeadStatus(statusMaps.enquiryStatusById.get(row.status), row)
|
||||
);
|
||||
const opportunityRows = scopedEnquiries.filter((row) =>
|
||||
isOpportunityStatus(statusMaps.enquiryStatusById.get(row.status), row)
|
||||
);
|
||||
const leadRows = scopedEnquiries.filter((row) => isPipelineStage(row, 'lead'));
|
||||
const enquiryRows = scopedEnquiries.filter((row) => isPipelineStage(row, 'enquiry'));
|
||||
const wonRows = scopedEnquiries.filter((row) => isPipelineStage(row, 'closed_won'));
|
||||
const lostRows = scopedEnquiries.filter((row) => isPipelineStage(row, 'closed_lost'));
|
||||
|
||||
const leadAges = leadRows.map((row) => (now.getTime() - row.createdAt.getTime()) / (1000 * 60 * 60 * 24));
|
||||
const quotationStatusCodes = scopedQuotations.map((row) => statusMaps.quotationStatusById.get(row.status) ?? '');
|
||||
@@ -224,14 +285,16 @@ function buildSummary(
|
||||
lead: {
|
||||
leadCount: leadRows.length,
|
||||
newLeads: leadRows.length,
|
||||
unassignedLeads: leadRows.length,
|
||||
unassignedLeads: leadRows.filter((row) => row.assignedToUserId === null).length,
|
||||
averageLeadAgingDays: average(leadAges)
|
||||
},
|
||||
opportunity: {
|
||||
opportunityCount: opportunityRows.length,
|
||||
openOpportunities: opportunityRows.length,
|
||||
hotOpportunities: opportunityRows.filter((row) => row.isHotProject).length,
|
||||
opportunityValue: round2(opportunityRows.reduce((sum, row) => sum + (row.estimatedValue ?? 0), 0))
|
||||
enquiry: {
|
||||
enquiryCount: enquiryRows.length,
|
||||
activeEnquiries: enquiryRows.length,
|
||||
wonCount: wonRows.length,
|
||||
lostCount: lostRows.length,
|
||||
hotEnquiries: enquiryRows.filter((row) => row.isHotProject).length,
|
||||
enquiryValue: round2(enquiryRows.reduce((sum, row) => sum + (row.estimatedValue ?? 0), 0))
|
||||
},
|
||||
quotation: {
|
||||
draftQuotations: quotationStatusCodes.filter((code) => code === 'draft').length,
|
||||
@@ -274,10 +337,10 @@ function buildFunnel(
|
||||
value: 0
|
||||
},
|
||||
{
|
||||
key: 'opportunity',
|
||||
label: 'Opportunity',
|
||||
count: summary.opportunity.opportunityCount,
|
||||
value: summary.opportunity.opportunityValue
|
||||
key: 'enquiry',
|
||||
label: 'Enquiry',
|
||||
count: summary.enquiry.enquiryCount,
|
||||
value: summary.enquiry.enquiryValue
|
||||
},
|
||||
{
|
||||
key: 'quotation',
|
||||
@@ -312,9 +375,7 @@ function buildFunnel(
|
||||
{
|
||||
key: 'closed_won',
|
||||
label: 'Closed Won',
|
||||
count: scopedQuotations.filter(
|
||||
(row) => (statusMaps.quotationStatusById.get(row.status) ?? '') === 'accepted'
|
||||
).length,
|
||||
count: summary.enquiry.wonCount,
|
||||
value: scopedQuotations
|
||||
.filter((row) => (statusMaps.quotationStatusById.get(row.status) ?? '') === 'accepted')
|
||||
.reduce((sum, row) => sum + row.totalAmount, 0)
|
||||
@@ -415,19 +476,19 @@ async function buildSalesRanking(
|
||||
salesPersonId: row.assignedToUserId,
|
||||
salesPersonName: userMap.get(row.assignedToUserId) ?? 'Unknown',
|
||||
leadCount: 0,
|
||||
opportunityCount: 0,
|
||||
enquiryCount: 0,
|
||||
quotationCount: 0,
|
||||
approvedQuotations: 0,
|
||||
wonRevenue: 0,
|
||||
conversionRate: 0
|
||||
};
|
||||
|
||||
if (isLeadStatus(statusMaps.enquiryStatusById.get(row.status), row)) {
|
||||
if (isPipelineStage(row, 'lead')) {
|
||||
current.leadCount += 1;
|
||||
}
|
||||
|
||||
if (isOpportunityStatus(statusMaps.enquiryStatusById.get(row.status), row)) {
|
||||
current.opportunityCount += 1;
|
||||
if (isPipelineStage(row, 'enquiry')) {
|
||||
current.enquiryCount += 1;
|
||||
}
|
||||
|
||||
rankingMap.set(row.assignedToUserId, current);
|
||||
@@ -441,7 +502,7 @@ async function buildSalesRanking(
|
||||
salesPersonId: row.salesmanId,
|
||||
salesPersonName: userMap.get(row.salesmanId) ?? 'Unknown',
|
||||
leadCount: 0,
|
||||
opportunityCount: 0,
|
||||
enquiryCount: 0,
|
||||
quotationCount: 0,
|
||||
approvedQuotations: 0,
|
||||
wonRevenue: 0,
|
||||
@@ -480,20 +541,33 @@ async function buildSalesRanking(
|
||||
}
|
||||
|
||||
async function buildFollowups(
|
||||
organizationId: string,
|
||||
context: DashboardContext,
|
||||
filters: NormalizedFilters
|
||||
): Promise<{ summary: CrmDashboardFollowupSummary; upcoming: CrmDashboardFollowupRow[] }> {
|
||||
const allowQuotationFollowups = canViewCommercialData(context);
|
||||
const [enquiryFollowups, quotationFollowups] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(crmEnquiryFollowups)
|
||||
.where(and(eq(crmEnquiryFollowups.organizationId, organizationId), isNull(crmEnquiryFollowups.deletedAt)))
|
||||
.where(
|
||||
and(
|
||||
eq(crmEnquiryFollowups.organizationId, context.organizationId),
|
||||
isNull(crmEnquiryFollowups.deletedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(crmEnquiryFollowups.followupDate)),
|
||||
db
|
||||
.select()
|
||||
.from(crmQuotationFollowups)
|
||||
.where(and(eq(crmQuotationFollowups.organizationId, organizationId), isNull(crmQuotationFollowups.deletedAt)))
|
||||
.orderBy(asc(crmQuotationFollowups.followupDate))
|
||||
allowQuotationFollowups
|
||||
? db
|
||||
.select()
|
||||
.from(crmQuotationFollowups)
|
||||
.where(
|
||||
and(
|
||||
eq(crmQuotationFollowups.organizationId, context.organizationId),
|
||||
isNull(crmQuotationFollowups.deletedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(crmQuotationFollowups.followupDate))
|
||||
: []
|
||||
]);
|
||||
|
||||
const { from, to } = toDayBounds(filters);
|
||||
@@ -522,6 +596,10 @@ async function buildFollowups(
|
||||
const salesMap = new Map(salesUsers.map((row) => [row.id, row.name]));
|
||||
|
||||
const enquiryRows = enquiryFollowups
|
||||
.filter((row) => {
|
||||
const enquiry = enquiryMap.get(row.enquiryId);
|
||||
return enquiry ? canAccessDashboardEnquiry(enquiry, context) : false;
|
||||
})
|
||||
.filter((row) => !isBefore(row.followupDate, from) && !isAfter(row.followupDate, to))
|
||||
.flatMap<CrmDashboardFollowupRow>((row) => {
|
||||
const enquiry = enquiryMap.get(row.enquiryId);
|
||||
@@ -536,7 +614,7 @@ async function buildFollowups(
|
||||
sourceType: 'enquiry',
|
||||
followupDate: row.followupDate.toISOString(),
|
||||
customerName: customerMap.get(enquiry.customerId) ?? 'Unknown customer',
|
||||
opportunityName: enquiry.projectName ?? enquiry.title,
|
||||
enquiryName: enquiry.projectName ?? enquiry.title,
|
||||
assignedSalesName: enquiry.assignedToUserId
|
||||
? (salesMap.get(enquiry.assignedToUserId) ?? null)
|
||||
: null,
|
||||
@@ -545,6 +623,10 @@ async function buildFollowups(
|
||||
];
|
||||
});
|
||||
const quotationRows = quotationFollowups
|
||||
.filter((row) => {
|
||||
const quotation = quotationMap.get(row.quotationId);
|
||||
return quotation ? canAccessDashboardQuotation(quotation, context) : false;
|
||||
})
|
||||
.filter((row) => !isBefore(row.followupDate, from) && !isAfter(row.followupDate, to))
|
||||
.flatMap<CrmDashboardFollowupRow>((row) => {
|
||||
const quotation = quotationMap.get(row.quotationId);
|
||||
@@ -559,7 +641,7 @@ async function buildFollowups(
|
||||
sourceType: 'quotation',
|
||||
followupDate: row.followupDate.toISOString(),
|
||||
customerName: customerMap.get(quotation.customerId) ?? 'Unknown customer',
|
||||
opportunityName: quotation.projectName ?? quotation.code,
|
||||
enquiryName: quotation.projectName ?? quotation.code,
|
||||
assignedSalesName: quotation.salesmanId
|
||||
? (salesMap.get(quotation.salesmanId) ?? null)
|
||||
: null,
|
||||
@@ -636,18 +718,25 @@ async function buildApprovalAnalytics(
|
||||
}
|
||||
|
||||
async function buildHotProjects(
|
||||
organizationId: string,
|
||||
context: DashboardContext,
|
||||
filters: NormalizedFilters,
|
||||
statusMaps: StatusMaps
|
||||
): Promise<CrmDashboardHotProjectRow[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(crmEnquiries)
|
||||
.where(and(eq(crmEnquiries.organizationId, organizationId), isNull(crmEnquiries.deletedAt), eq(crmEnquiries.isHotProject, true)))
|
||||
.where(
|
||||
and(
|
||||
eq(crmEnquiries.organizationId, context.organizationId),
|
||||
isNull(crmEnquiries.deletedAt),
|
||||
eq(crmEnquiries.isHotProject, true)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(crmEnquiries.updatedAt));
|
||||
const scoped = rows
|
||||
.filter((row) => canAccessDashboardEnquiry(row, context))
|
||||
.filter((row) => matchesEnquiryFilters(row, filters))
|
||||
.filter((row) => isOpportunityStatus(statusMaps.enquiryStatusById.get(row.status), row))
|
||||
.filter((row) => isPipelineStage(row, 'enquiry'))
|
||||
.slice(0, 10);
|
||||
const customerIds = [...new Set(scoped.map((row) => row.customerId))];
|
||||
const salesIds = [...new Set(scoped.map((row) => row.assignedToUserId).filter(Boolean))] as string[];
|
||||
@@ -674,18 +763,42 @@ export async function getCrmDashboardData(
|
||||
rawFilters: CrmDashboardFilters
|
||||
): Promise<CrmDashboardResponse> {
|
||||
const filters = normalizeDashboardFilters(rawFilters);
|
||||
const allowCommercialData = canViewCommercialData(context);
|
||||
const allowApprovalData = canViewApprovalData(context);
|
||||
const [referenceData, statusMaps, scoped] = await Promise.all([
|
||||
getReferenceData(context.organizationId),
|
||||
getStatusMaps(context.organizationId),
|
||||
loadScopedRows(context.organizationId, filters)
|
||||
loadScopedRows(context, filters)
|
||||
]);
|
||||
const summary = buildSummary(scoped.enquiries, scoped.quotations, statusMaps);
|
||||
const [revenueAnalytics, salesRanking, followups, approvals, hotProjects] = await Promise.all([
|
||||
buildRevenueAnalytics(context.organizationId, filters),
|
||||
buildSalesRanking(scoped.enquiries, scoped.quotations, statusMaps),
|
||||
buildFollowups(context.organizationId, filters),
|
||||
buildApprovalAnalytics(context),
|
||||
buildHotProjects(context.organizationId, filters, statusMaps)
|
||||
allowCommercialData
|
||||
? buildRevenueAnalytics(context.organizationId, filters)
|
||||
: Promise.resolve({
|
||||
topEndCustomers: [],
|
||||
topBillingCustomers: [],
|
||||
topContractors: [],
|
||||
topConsultants: []
|
||||
}),
|
||||
allowCommercialData
|
||||
? buildSalesRanking(scoped.enquiries, scoped.quotations, statusMaps)
|
||||
: Promise.resolve([]),
|
||||
buildFollowups(context, filters),
|
||||
allowApprovalData
|
||||
? buildApprovalAnalytics(context)
|
||||
: Promise.resolve({
|
||||
summary: {
|
||||
pendingApprovals: 0,
|
||||
approvedToday: 0,
|
||||
rejectedToday: 0,
|
||||
returnedToday: 0,
|
||||
averageApprovalTimeHours: 0
|
||||
},
|
||||
myPendingApprovals: []
|
||||
}),
|
||||
allowCommercialData
|
||||
? buildHotProjects(context, filters, statusMaps)
|
||||
: Promise.resolve([])
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -700,6 +813,10 @@ export async function getCrmDashboardData(
|
||||
salesRanking,
|
||||
followups,
|
||||
approvals,
|
||||
hotProjects
|
||||
hotProjects,
|
||||
visibility: {
|
||||
canViewCommercialData: allowCommercialData,
|
||||
canViewApprovalData: allowApprovalData
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,11 +16,16 @@ import type {
|
||||
EnquiryFollowupMutationPayload,
|
||||
EnquiryMutationPayload
|
||||
} from './types';
|
||||
import { crmDashboardKeys } from '@/features/crm/dashboard/api/queries';
|
||||
|
||||
async function invalidateEnquiryLists() {
|
||||
await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.lists() });
|
||||
}
|
||||
|
||||
async function invalidateDashboardQueries() {
|
||||
await getQueryClient().invalidateQueries({ queryKey: crmDashboardKeys.all });
|
||||
}
|
||||
|
||||
async function invalidateEnquiryDetail(id: string) {
|
||||
await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.detail(id) });
|
||||
}
|
||||
@@ -37,7 +42,8 @@ export async function invalidateEnquiryMutationQueries(id: string) {
|
||||
await Promise.all([
|
||||
invalidateEnquiryLists(),
|
||||
invalidateEnquiryDetail(id),
|
||||
invalidateEnquiryProjectParties(id)
|
||||
invalidateEnquiryProjectParties(id),
|
||||
invalidateDashboardQueries()
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -45,7 +51,8 @@ export async function invalidateEnquiryFollowupMutationQueries(enquiryId: string
|
||||
await Promise.all([
|
||||
invalidateEnquiryLists(),
|
||||
invalidateEnquiryDetail(enquiryId),
|
||||
invalidateEnquiryFollowups(enquiryId)
|
||||
invalidateEnquiryFollowups(enquiryId),
|
||||
invalidateDashboardQueries()
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -53,7 +60,7 @@ export const createEnquiryMutation = mutationOptions({
|
||||
mutationFn: (data: EnquiryMutationPayload) => createEnquiry(data),
|
||||
onSettled: async (_data, error) => {
|
||||
if (!error) {
|
||||
await invalidateEnquiryLists();
|
||||
await Promise.all([invalidateEnquiryLists(), invalidateDashboardQueries()]);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -75,7 +82,7 @@ export const deleteEnquiryMutation = mutationOptions({
|
||||
getQueryClient().removeQueries({ queryKey: enquiryKeys.detail(id) });
|
||||
getQueryClient().removeQueries({ queryKey: enquiryKeys.projectParties(id) });
|
||||
getQueryClient().removeQueries({ queryKey: enquiryKeys.followups(id) });
|
||||
await invalidateEnquiryLists();
|
||||
await Promise.all([invalidateEnquiryLists(), invalidateDashboardQueries()]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ export async function getEnquiries(filters: EnquiryFilters): Promise<EnquiryList
|
||||
if (filters.page) searchParams.set('page', String(filters.page));
|
||||
if (filters.limit) searchParams.set('limit', String(filters.limit));
|
||||
if (filters.search) searchParams.set('search', filters.search);
|
||||
if (filters.pipelineStage) searchParams.set('pipelineStage', filters.pipelineStage);
|
||||
if (filters.status) searchParams.set('status', filters.status);
|
||||
if (filters.productType) searchParams.set('productType', filters.productType);
|
||||
if (filters.priority) searchParams.set('priority', filters.priority);
|
||||
|
||||
@@ -54,6 +54,8 @@ export interface EnquiryAssignableUserLookup {
|
||||
businessRole: string;
|
||||
}
|
||||
|
||||
export type EnquiryPipelineStage = 'lead' | 'enquiry' | 'closed_won' | 'closed_lost';
|
||||
|
||||
export interface EnquiryRecord {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
@@ -78,6 +80,7 @@ export interface EnquiryRecord {
|
||||
notes: string | null;
|
||||
isHotProject: boolean;
|
||||
isActive: boolean;
|
||||
pipelineStage: EnquiryPipelineStage;
|
||||
assignedToUserId: string | null;
|
||||
assignedToName: string | null;
|
||||
assignedAt: string | null;
|
||||
@@ -95,6 +98,9 @@ export interface EnquiryListItem extends EnquiryRecord {
|
||||
customerName: string;
|
||||
contactName: string | null;
|
||||
followupCount: number;
|
||||
quotationCount: number;
|
||||
nextFollowupDate: string | null;
|
||||
createdByName: string | null;
|
||||
}
|
||||
|
||||
export interface EnquiryFollowupRecord {
|
||||
@@ -142,6 +148,7 @@ export interface EnquiryFilters {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
pipelineStage?: EnquiryPipelineStage;
|
||||
status?: string;
|
||||
productType?: string;
|
||||
priority?: string;
|
||||
|
||||
@@ -13,20 +13,22 @@ import { getEnquiryColumns } from './enquiry-columns';
|
||||
|
||||
export function EnquiriesTable({
|
||||
referenceData,
|
||||
workspace,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
canAssign,
|
||||
canReassign
|
||||
}: {
|
||||
referenceData: EnquiryReferenceData;
|
||||
workspace: 'lead' | 'enquiry';
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
canAssign: boolean;
|
||||
canReassign: boolean;
|
||||
}) {
|
||||
const columns = useMemo(
|
||||
() => getEnquiryColumns({ referenceData, canUpdate, canDelete, canAssign, canReassign }),
|
||||
[referenceData, canUpdate, canDelete, canAssign, canReassign]
|
||||
() => getEnquiryColumns({ referenceData, workspace, canUpdate, canDelete, canAssign, canReassign }),
|
||||
[referenceData, workspace, canUpdate, canDelete, canAssign, canReassign]
|
||||
);
|
||||
const columnIds = columns.map((column) => column.id).filter(Boolean) as string[];
|
||||
const [params] = useQueryStates({
|
||||
@@ -44,6 +46,7 @@ export function EnquiriesTable({
|
||||
const filters = {
|
||||
page: params.page,
|
||||
limit: params.perPage,
|
||||
pipelineStage: workspace,
|
||||
...(params.name && { search: params.name }),
|
||||
...(params.status && { status: params.status }),
|
||||
...(params.productType && { productType: params.productType }),
|
||||
|
||||
@@ -57,7 +57,7 @@ export function EnquiryAssignmentDialog({
|
||||
const assignMutation = useMutation({
|
||||
...assignEnquiryMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Enquiry assigned successfully');
|
||||
toast.success('Lead converted to enquiry successfully');
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
@@ -109,10 +109,10 @@ export function EnquiryAssignmentDialog({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{mode === 'assign' ? 'Assign Sales' : 'Reassign Sales'}</DialogTitle>
|
||||
<DialogTitle>{mode === 'assign' ? 'Convert To Enquiry' : 'Reassign Enquiry Owner'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{mode === 'assign'
|
||||
? 'Select the sales owner who should take this enquiry forward.'
|
||||
? 'Assign this lead to sales and convert it into an enquiry.'
|
||||
: 'Move this enquiry to a different sales owner and keep the handoff note visible.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -127,7 +127,7 @@ export function EnquiryAssignmentDialog({
|
||||
<>
|
||||
<FormSelectField
|
||||
name='assignedToUserId'
|
||||
label='Sales User'
|
||||
label='Enquiry Owner'
|
||||
required
|
||||
options={referenceData.assignableUsers.map((user) => ({
|
||||
value: user.id,
|
||||
@@ -156,7 +156,7 @@ export function EnquiryAssignmentDialog({
|
||||
disabled={noUsersAvailable}
|
||||
>
|
||||
<Icons.userPen className='mr-2 h-4 w-4' />
|
||||
{mode === 'assign' ? 'Assign Sales' : 'Reassign Sales'}
|
||||
{mode === 'assign' ? 'Convert To Enquiry' : 'Reassign Owner'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { EnquiryFormSheet } from './enquiry-form-sheet';
|
||||
export function EnquiryCellAction({
|
||||
data,
|
||||
referenceData,
|
||||
workspace,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
canAssign: _canAssign,
|
||||
@@ -28,6 +29,7 @@ export function EnquiryCellAction({
|
||||
}: {
|
||||
data: EnquiryListItem;
|
||||
referenceData: EnquiryReferenceData;
|
||||
workspace: 'lead' | 'enquiry';
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
canAssign: boolean;
|
||||
@@ -40,11 +42,15 @@ export function EnquiryCellAction({
|
||||
const deleteMutation = useMutation({
|
||||
...deleteEnquiryMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Enquiry deleted successfully');
|
||||
toast.success(`${workspace === 'lead' ? 'Lead' : 'Enquiry'} deleted successfully`);
|
||||
setDeleteOpen(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to delete enquiry')
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: `Failed to delete ${workspace === 'lead' ? 'lead' : 'enquiry'}`
|
||||
)
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -60,6 +66,7 @@ export function EnquiryCellAction({
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
referenceData={referenceData}
|
||||
workspace={workspace}
|
||||
/>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -70,11 +77,19 @@ export function EnquiryCellAction({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={() => router.push(`/dashboard/crm/enquiries/${data.id}`)}>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
router.push(
|
||||
workspace === 'lead'
|
||||
? `/dashboard/crm/leads/${data.id}`
|
||||
: `/dashboard/crm/enquiries/${data.id}`
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icons.arrowRight className='mr-2 h-4 w-4' /> View
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setEditOpen(true)} disabled={!canUpdate}>
|
||||
<Icons.edit className='mr-2 h-4 w-4' /> Edit
|
||||
<Icons.edit className='mr-2 h-4 w-4' /> {workspace === 'lead' ? 'Edit Lead' : 'Edit Enquiry'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setDeleteOpen(true)} disabled={!canDelete}>
|
||||
<Icons.trash className='mr-2 h-4 w-4' /> Delete
|
||||
|
||||
@@ -9,52 +9,116 @@ import type { EnquiryListItem, EnquiryReferenceData } from '../api/types';
|
||||
import { EnquiryCellAction } from './enquiry-cell-action';
|
||||
import { EnquiryStatusBadge } from './enquiry-status-badge';
|
||||
|
||||
export function getEnquiryColumns({
|
||||
function getLeadColumns({
|
||||
referenceData,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
canAssign,
|
||||
canReassign
|
||||
}: {
|
||||
referenceData: EnquiryReferenceData;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
canAssign: boolean;
|
||||
canReassign: boolean;
|
||||
}): ColumnDef<EnquiryListItem>[] {
|
||||
}: SharedColumnsConfig): ColumnDef<EnquiryListItem>[] {
|
||||
const statusMap = new Map(referenceData.statuses.map((item) => [item.id, item]));
|
||||
const productTypeMap = new Map(referenceData.productTypes.map((item) => [item.id, item]));
|
||||
const priorityMap = new Map(referenceData.priorities.map((item) => [item.id, item]));
|
||||
const branchMap = new Map(referenceData.branches.map((item) => [item.id, item]));
|
||||
const leadChannelMap = new Map(referenceData.leadChannels.map((item) => [item.id, item.label]));
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'title',
|
||||
accessorKey: 'title',
|
||||
id: 'code',
|
||||
accessorKey: 'code',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Enquiry' />
|
||||
<DataTableColumnHeader column={column} title='Code' />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className='flex flex-col'>
|
||||
<Link
|
||||
href={`/dashboard/crm/enquiries/${row.original.id}`}
|
||||
href={`/dashboard/crm/leads/${row.original.id}`}
|
||||
className='font-medium hover:underline'
|
||||
>
|
||||
{row.original.title}
|
||||
{row.original.code}
|
||||
</Link>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{row.original.code} • {row.original.customerName}
|
||||
</span>
|
||||
<span className='text-muted-foreground text-xs'>{row.original.title}</span>
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
label: 'Enquiry',
|
||||
placeholder: 'Search enquiries...',
|
||||
label: 'Code',
|
||||
placeholder: 'Search leads...',
|
||||
variant: 'text' as const,
|
||||
icon: Icons.search
|
||||
},
|
||||
enableColumnFilter: true
|
||||
},
|
||||
{
|
||||
id: 'leadChannel',
|
||||
accessorFn: (row) => row.leadChannel ?? '',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Lead Source' />
|
||||
),
|
||||
cell: ({ row }) => <span>{leadChannelMap.get(row.original.leadChannel ?? '') ?? '-'}</span>
|
||||
},
|
||||
{
|
||||
id: 'customer',
|
||||
accessorFn: (row) => row.customerId,
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Company' />
|
||||
),
|
||||
cell: ({ row }) => <span>{row.original.customerName}</span>,
|
||||
meta: {
|
||||
label: 'Company',
|
||||
variant: 'multiSelect' as const,
|
||||
options: referenceData.customers.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.name
|
||||
}))
|
||||
},
|
||||
enableColumnFilter: true
|
||||
},
|
||||
{
|
||||
id: 'contact',
|
||||
accessorFn: (row) => row.contactId ?? '',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Contact' />
|
||||
),
|
||||
cell: ({ row }) => <span>{row.original.contactName ?? '-'}</span>
|
||||
},
|
||||
{
|
||||
id: 'assignedSales',
|
||||
accessorFn: (row) => row.assignedToName ?? '',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Assigned Sales' />
|
||||
),
|
||||
cell: ({ row }) => <span>{row.original.assignedToName ?? 'Unassigned'}</span>
|
||||
},
|
||||
{
|
||||
id: 'createdBy',
|
||||
accessorFn: (row) => row.createdByName ?? row.createdBy,
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Created By' />
|
||||
),
|
||||
cell: ({ row }) => <span>{row.original.createdByName ?? row.original.createdBy}</span>
|
||||
},
|
||||
{
|
||||
id: 'createdAt',
|
||||
accessorKey: 'createdAt',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Created At' />
|
||||
),
|
||||
cell: ({ row }) => <span>{new Date(row.original.createdAt).toLocaleDateString()}</span>
|
||||
},
|
||||
{
|
||||
id: 'age',
|
||||
accessorFn: (row) => row.createdAt,
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Age' />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const ageInDays = Math.max(
|
||||
0,
|
||||
Math.floor(
|
||||
(Date.now() - new Date(row.original.createdAt).getTime()) / (1000 * 60 * 60 * 24)
|
||||
)
|
||||
);
|
||||
|
||||
return <span>{ageInDays} days</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
accessorKey: 'status',
|
||||
@@ -76,79 +140,74 @@ export function getEnquiryColumns({
|
||||
enableColumnFilter: true
|
||||
},
|
||||
{
|
||||
id: 'productType',
|
||||
accessorKey: 'productType',
|
||||
id: 'actions',
|
||||
cell: ({ row }) => (
|
||||
<EnquiryCellAction
|
||||
data={row.original}
|
||||
referenceData={referenceData}
|
||||
workspace='lead'
|
||||
canUpdate={canUpdate}
|
||||
canDelete={canDelete}
|
||||
canAssign={canAssign}
|
||||
canReassign={canReassign}
|
||||
/>
|
||||
)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function getEnquiryWorkspaceColumns({
|
||||
referenceData,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
canAssign,
|
||||
canReassign
|
||||
}: SharedColumnsConfig): ColumnDef<EnquiryListItem>[] {
|
||||
const statusMap = new Map(referenceData.statuses.map((item) => [item.id, item]));
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'code',
|
||||
accessorKey: 'code',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Product Type' />
|
||||
<DataTableColumnHeader column={column} title='Code' />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Badge variant='outline'>
|
||||
{productTypeMap.get(row.original.productType)?.label ?? 'Unknown'}
|
||||
</Badge>
|
||||
<div className='flex flex-col'>
|
||||
<Link
|
||||
href={`/dashboard/crm/enquiries/${row.original.id}`}
|
||||
className='font-medium hover:underline'
|
||||
>
|
||||
{row.original.code}
|
||||
</Link>
|
||||
<span className='text-muted-foreground text-xs'>{row.original.title}</span>
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
label: 'Product Type',
|
||||
variant: 'multiSelect' as const,
|
||||
options: referenceData.productTypes.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label
|
||||
}))
|
||||
label: 'Code',
|
||||
placeholder: 'Search enquiries...',
|
||||
variant: 'text' as const,
|
||||
icon: Icons.search
|
||||
},
|
||||
enableColumnFilter: true
|
||||
},
|
||||
{
|
||||
id: 'priority',
|
||||
accessorKey: 'priority',
|
||||
id: 'projectName',
|
||||
accessorFn: (row) => row.projectName ?? row.title,
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Priority' />
|
||||
<DataTableColumnHeader column={column} title='Project' />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Badge variant='secondary'>
|
||||
{priorityMap.get(row.original.priority)?.label ?? 'Unknown'}
|
||||
</Badge>
|
||||
),
|
||||
meta: {
|
||||
label: 'Priority',
|
||||
variant: 'multiSelect' as const,
|
||||
options: referenceData.priorities.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label
|
||||
}))
|
||||
},
|
||||
enableColumnFilter: true
|
||||
},
|
||||
{
|
||||
id: 'branch',
|
||||
accessorFn: (row) => row.branchId ?? '',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Branch' />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span>
|
||||
{row.original.branchId
|
||||
? (branchMap.get(row.original.branchId)?.name ?? 'Unknown')
|
||||
: 'Unassigned'}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
label: 'Branch',
|
||||
variant: 'multiSelect' as const,
|
||||
options: referenceData.branches.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.name
|
||||
}))
|
||||
},
|
||||
enableColumnFilter: true
|
||||
cell: ({ row }) => <span>{row.original.projectName ?? row.original.title}</span>
|
||||
},
|
||||
{
|
||||
id: 'customer',
|
||||
accessorFn: (row) => row.customerId,
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Customer' />
|
||||
<DataTableColumnHeader column={column} title='Billing Customer' />
|
||||
),
|
||||
cell: ({ row }) => <span>{row.original.customerName}</span>,
|
||||
meta: {
|
||||
label: 'Customer',
|
||||
label: 'Billing Customer',
|
||||
variant: 'multiSelect' as const,
|
||||
options: referenceData.customers.map((item) => ({
|
||||
value: item.id,
|
||||
@@ -159,25 +218,77 @@ export function getEnquiryColumns({
|
||||
},
|
||||
{
|
||||
id: 'assignedSales',
|
||||
header: 'Assigned Sales',
|
||||
accessorFn: (row) => row.assignedToName ?? '',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Enquiry Owner' />
|
||||
),
|
||||
cell: ({ row }) => <span>{row.original.assignedToName ?? '-'}</span>
|
||||
},
|
||||
{
|
||||
id: 'chancePercent',
|
||||
accessorKey: 'chancePercent',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Chance %' />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className='flex flex-col'>
|
||||
<span>{row.original.assignedToName ?? 'Unassigned'}</span>
|
||||
{row.original.assignedAt ? (
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{new Date(row.original.assignedAt).toLocaleDateString()}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<span>{row.original.chancePercent !== null ? `${row.original.chancePercent}%` : '-'}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'followupCount',
|
||||
accessorKey: 'followupCount',
|
||||
id: 'estimatedValue',
|
||||
accessorKey: 'estimatedValue',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Follow-ups' />
|
||||
<DataTableColumnHeader column={column} title='Estimated Value' />
|
||||
),
|
||||
cell: ({ row }) => <span>{row.original.followupCount}</span>
|
||||
cell: ({ row }) => (
|
||||
<span>
|
||||
{row.original.estimatedValue !== null
|
||||
? row.original.estimatedValue.toLocaleString()
|
||||
: '-'}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'quotationCount',
|
||||
accessorKey: 'quotationCount',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Quotation Count' />
|
||||
),
|
||||
cell: ({ row }) => <span>{row.original.quotationCount}</span>
|
||||
},
|
||||
{
|
||||
id: 'nextFollowupDate',
|
||||
accessorKey: 'nextFollowupDate',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Next Follow-up' />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span>
|
||||
{row.original.nextFollowupDate
|
||||
? new Date(row.original.nextFollowupDate).toLocaleDateString()
|
||||
: '-'}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
accessorKey: 'status',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Status' />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const status = statusMap.get(row.original.status);
|
||||
return <EnquiryStatusBadge code={status?.code} label={status?.label ?? 'Unknown'} />;
|
||||
},
|
||||
meta: {
|
||||
label: 'Status',
|
||||
variant: 'multiSelect' as const,
|
||||
options: referenceData.statuses.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label
|
||||
}))
|
||||
},
|
||||
enableColumnFilter: true
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
@@ -185,6 +296,7 @@ export function getEnquiryColumns({
|
||||
<EnquiryCellAction
|
||||
data={row.original}
|
||||
referenceData={referenceData}
|
||||
workspace='enquiry'
|
||||
canUpdate={canUpdate}
|
||||
canDelete={canDelete}
|
||||
canAssign={canAssign}
|
||||
@@ -194,3 +306,34 @@ export function getEnquiryColumns({
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
type SharedColumnsConfig = {
|
||||
referenceData: EnquiryReferenceData;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
canAssign: boolean;
|
||||
canReassign: boolean;
|
||||
};
|
||||
|
||||
export function getEnquiryColumns({
|
||||
referenceData,
|
||||
workspace,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
canAssign,
|
||||
canReassign
|
||||
}: SharedColumnsConfig & {
|
||||
workspace: 'lead' | 'enquiry';
|
||||
}): ColumnDef<EnquiryListItem>[] {
|
||||
const sharedConfig = {
|
||||
referenceData,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
canAssign,
|
||||
canReassign
|
||||
};
|
||||
|
||||
return workspace === 'lead'
|
||||
? getLeadColumns(sharedConfig)
|
||||
: getEnquiryWorkspaceColumns(sharedConfig);
|
||||
}
|
||||
|
||||
@@ -9,12 +9,12 @@ import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { enquiryByIdOptions, enquiryProjectPartiesOptions } from '../api/queries';
|
||||
import type { EnquiryReferenceData } from '../api/types';
|
||||
import type { QuotationRelationItem } from '@/features/crm/quotations/api/types';
|
||||
import { enquiryByIdOptions, enquiryProjectPartiesOptions } from '../api/queries';
|
||||
import type { EnquiryRecord, EnquiryReferenceData } from '../api/types';
|
||||
import { EnquiryAssignmentDialog } from './enquiry-assignment-dialog';
|
||||
import { EnquiryFormSheet } from './enquiry-form-sheet';
|
||||
import { EnquiryFollowupsTab } from './enquiry-followups-tab';
|
||||
import { EnquiryFormSheet } from './enquiry-form-sheet';
|
||||
import { EnquiryStatusBadge } from './enquiry-status-badge';
|
||||
|
||||
function FieldItem({ label, value }: { label: string; value: string | null | undefined }) {
|
||||
@@ -26,27 +26,48 @@ function FieldItem({ label, value }: { label: string; value: string | null | und
|
||||
);
|
||||
}
|
||||
|
||||
export function EnquiryDetail({
|
||||
enquiryId,
|
||||
referenceData,
|
||||
relatedQuotations,
|
||||
canUpdate,
|
||||
canAssign,
|
||||
canReassign,
|
||||
canManageFollowups
|
||||
}: {
|
||||
function getPipelineStageLabel(stage: EnquiryRecord['pipelineStage']) {
|
||||
switch (stage) {
|
||||
case 'lead':
|
||||
return 'Lead';
|
||||
case 'enquiry':
|
||||
return 'Enquiry';
|
||||
case 'closed_won':
|
||||
return 'Closed Won';
|
||||
case 'closed_lost':
|
||||
return 'Closed Lost';
|
||||
default:
|
||||
return stage;
|
||||
}
|
||||
}
|
||||
|
||||
interface EnquiryDetailProps {
|
||||
enquiryId: string;
|
||||
referenceData: EnquiryReferenceData;
|
||||
relatedQuotations: QuotationRelationItem[];
|
||||
workspace: 'lead' | 'enquiry';
|
||||
canUpdate: boolean;
|
||||
canAssign: boolean;
|
||||
canReassign: boolean;
|
||||
canViewRelatedQuotations: boolean;
|
||||
canManageFollowups: {
|
||||
create: boolean;
|
||||
update: boolean;
|
||||
delete: boolean;
|
||||
};
|
||||
}) {
|
||||
}
|
||||
|
||||
export function EnquiryDetail({
|
||||
enquiryId,
|
||||
referenceData,
|
||||
relatedQuotations,
|
||||
workspace,
|
||||
canUpdate,
|
||||
canAssign,
|
||||
canReassign,
|
||||
canViewRelatedQuotations,
|
||||
canManageFollowups
|
||||
}: EnquiryDetailProps) {
|
||||
const { data } = useSuspenseQuery(enquiryByIdOptions(enquiryId));
|
||||
const { data: projectPartiesData } = useSuspenseQuery(enquiryProjectPartiesOptions(enquiryId));
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
@@ -85,6 +106,29 @@ export function EnquiryDetail({
|
||||
const status = statusMap.get(enquiry.status);
|
||||
const canManageAssignment = enquiry.assignedToUserId ? canReassign : canAssign;
|
||||
const assignmentMode = enquiry.assignedToUserId ? 'reassign' : 'assign';
|
||||
const pipelineStageLabel = getPipelineStageLabel(enquiry.pipelineStage);
|
||||
const isLeadWorkspace = workspace === 'lead';
|
||||
const backHref = isLeadWorkspace ? '/dashboard/crm/leads' : '/dashboard/crm/enquiries';
|
||||
const recordLabel = isLeadWorkspace ? 'Lead' : 'Enquiry';
|
||||
const ownerLabel = isLeadWorkspace ? 'Assigned Sales' : 'Enquiry Owner';
|
||||
const infoCardTitle = isLeadWorkspace ? 'Lead Information' : 'Enquiry Information';
|
||||
const infoCardDescription = isLeadWorkspace
|
||||
? 'Lead source, qualification context, and sales handoff readiness.'
|
||||
: 'Project scope, ownership context, and active sales execution details.';
|
||||
const assignmentButtonLabel =
|
||||
assignmentMode === 'assign'
|
||||
? isLeadWorkspace
|
||||
? 'Assign To Sales'
|
||||
: 'Assign Enquiry Owner'
|
||||
: isLeadWorkspace
|
||||
? 'Reassign Sales Owner'
|
||||
: 'Reassign Enquiry Owner';
|
||||
const projectSectionLabel = isLeadWorkspace ? 'Lead Scope' : 'Project Information';
|
||||
const followupTabLabel = isLeadWorkspace ? 'Lead Follow-ups' : 'Follow-ups';
|
||||
const relatedTabLabel = isLeadWorkspace ? 'Related Quotations' : 'Quotations';
|
||||
const snapshotDescription = isLeadWorkspace
|
||||
? 'Operational metadata and lead ownership handoff state for this record.'
|
||||
: 'Operational metadata and assignment state for this enquiry record.';
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
@@ -92,7 +136,7 @@ export function EnquiryDetail({
|
||||
<div className='space-y-3'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button asChild variant='ghost' size='icon'>
|
||||
<Link href='/dashboard/crm/enquiries'>
|
||||
<Link href={backHref}>
|
||||
<Icons.chevronLeft className='h-4 w-4' />
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -103,8 +147,8 @@ export function EnquiryDetail({
|
||||
<div>
|
||||
<h2 className='text-2xl font-semibold'>{enquiry.title}</h2>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{customer?.name ?? 'Unknown customer'}
|
||||
{contact ? ` • ${contact.name}` : ''}
|
||||
{pipelineStageLabel} | {customer?.name ?? 'Unknown customer'}
|
||||
{contact ? ` | ${contact.name}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -112,12 +156,13 @@ export function EnquiryDetail({
|
||||
{canManageAssignment ? (
|
||||
<Button variant='outline' onClick={() => setAssignmentOpen(true)}>
|
||||
<Icons.userPen className='mr-2 h-4 w-4' />
|
||||
{assignmentMode === 'assign' ? 'Assign Sales' : 'Reassign Sales'}
|
||||
{assignmentButtonLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
{canUpdate ? (
|
||||
<Button variant='outline' onClick={() => setEditOpen(true)}>
|
||||
<Icons.edit className='mr-2 h-4 w-4' /> Edit Enquiry
|
||||
<Icons.edit className='mr-2 h-4 w-4' />
|
||||
{`Edit ${recordLabel}`}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -130,20 +175,23 @@ export function EnquiryDetail({
|
||||
<Tabs defaultValue='overview' className='gap-4'>
|
||||
<TabsList>
|
||||
<TabsTrigger value='overview'>Overview</TabsTrigger>
|
||||
<TabsTrigger value='followups'>Follow-ups</TabsTrigger>
|
||||
<TabsTrigger value='followups'>{followupTabLabel}</TabsTrigger>
|
||||
<TabsTrigger value='activity'>Activity</TabsTrigger>
|
||||
<TabsTrigger value='related'>Related Quotations</TabsTrigger>
|
||||
{canViewRelatedQuotations ? (
|
||||
<TabsTrigger value='related'>{relatedTabLabel}</TabsTrigger>
|
||||
) : null}
|
||||
</TabsList>
|
||||
<TabsContent value='overview'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Overview</CardTitle>
|
||||
<CardDescription>
|
||||
Opportunity scope, project context, and commercial direction.
|
||||
</CardDescription>
|
||||
<CardTitle>{infoCardTitle}</CardTitle>
|
||||
<CardDescription>{infoCardDescription}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
||||
<FieldItem label='Billing Customer' value={customer?.name} />
|
||||
<FieldItem
|
||||
label={isLeadWorkspace ? 'Company' : 'Billing Customer'}
|
||||
value={customer?.name}
|
||||
/>
|
||||
<FieldItem label='Contact' value={contact?.name} />
|
||||
<FieldItem
|
||||
label='Branch'
|
||||
@@ -159,7 +207,7 @@ export function EnquiryDetail({
|
||||
value={priorityMap.get(enquiry.priority)?.label}
|
||||
/>
|
||||
<FieldItem
|
||||
label='Lead Channel'
|
||||
label='Lead Source'
|
||||
value={leadChannelMap.get(enquiry.leadChannel ?? '')?.label}
|
||||
/>
|
||||
<FieldItem
|
||||
@@ -184,7 +232,8 @@ export function EnquiryDetail({
|
||||
/>
|
||||
<FieldItem label='Competitor' value={enquiry.competitor} />
|
||||
<FieldItem label='Source' value={enquiry.source} />
|
||||
<FieldItem label='Assigned Sales' value={enquiry.assignedToName} />
|
||||
<FieldItem label='Pipeline Stage' value={pipelineStageLabel} />
|
||||
<FieldItem label={ownerLabel} value={enquiry.assignedToName} />
|
||||
<FieldItem
|
||||
label='Assigned At'
|
||||
value={
|
||||
@@ -195,10 +244,14 @@ export function EnquiryDetail({
|
||||
<FieldItem label='Assignment Remark' value={enquiry.assignmentRemark} />
|
||||
<div className='md:col-span-2 xl:col-span-4'>
|
||||
<div className='space-y-3'>
|
||||
<div className='text-muted-foreground text-xs'>Project Parties</div>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{isLeadWorkspace ? 'Lead Parties' : 'Project Parties'}
|
||||
</div>
|
||||
{!projectPartiesData.items.length ? (
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-4 text-sm'>
|
||||
No project parties have been added yet.
|
||||
{isLeadWorkspace
|
||||
? 'No lead parties have been added yet.'
|
||||
: 'No project parties have been added yet.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-3'>
|
||||
@@ -217,11 +270,10 @@ export function EnquiryDetail({
|
||||
</div>
|
||||
<div className='md:col-span-2 xl:col-span-4'>
|
||||
<FieldItem
|
||||
label='Project'
|
||||
label={projectSectionLabel}
|
||||
value={
|
||||
[enquiry.projectName, enquiry.projectLocation]
|
||||
.filter(Boolean)
|
||||
.join(' • ') || null
|
||||
[enquiry.projectName, enquiry.projectLocation].filter(Boolean).join(' | ') ||
|
||||
null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -250,9 +302,9 @@ export function EnquiryDetail({
|
||||
<TabsContent value='activity'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Activity</CardTitle>
|
||||
<CardTitle>{isLeadWorkspace ? 'Lead Activity' : 'Activity'}</CardTitle>
|
||||
<CardDescription>
|
||||
Audit events for the enquiry and its follow-up records.
|
||||
Audit events for this record and its follow-up history.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
@@ -289,43 +341,45 @@ export function EnquiryDetail({
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value='related'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Related Quotations</CardTitle>
|
||||
<CardDescription>
|
||||
Production quotations already linked to this enquiry.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!relatedQuotations.length ? (
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
||||
No quotations have been linked to this enquiry yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-3'>
|
||||
{relatedQuotations.map((quotation) => (
|
||||
<Link
|
||||
key={quotation.id}
|
||||
href={`/dashboard/crm/quotations/${quotation.id}`}
|
||||
className='block rounded-lg border p-4 transition-colors hover:bg-muted/40'
|
||||
>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div className='space-y-1'>
|
||||
<div className='font-medium'>{quotation.code}</div>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
{quotation.totalAmount.toLocaleString()}
|
||||
{canViewRelatedQuotations ? (
|
||||
<TabsContent value='related'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{relatedTabLabel}</CardTitle>
|
||||
<CardDescription>
|
||||
Production quotations already linked to this record.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!relatedQuotations.length ? (
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
||||
No quotations have been linked to this record yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-3'>
|
||||
{relatedQuotations.map((quotation) => (
|
||||
<Link
|
||||
key={quotation.id}
|
||||
href={`/dashboard/crm/quotations/${quotation.id}`}
|
||||
className='block rounded-lg border p-4 transition-colors hover:bg-muted/40'
|
||||
>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div className='space-y-1'>
|
||||
<div className='font-medium'>{quotation.code}</div>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
{quotation.totalAmount.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<Icons.arrowRight className='text-muted-foreground h-4 w-4' />
|
||||
</div>
|
||||
<Icons.arrowRight className='text-muted-foreground h-4 w-4' />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -335,12 +389,11 @@ export function EnquiryDetail({
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Record Snapshot</CardTitle>
|
||||
<CardDescription>
|
||||
Operational metadata and assignment state for this enquiry row.
|
||||
</CardDescription>
|
||||
<CardDescription>{snapshotDescription}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<FieldItem label='Assigned Sales' value={enquiry.assignedToName} />
|
||||
<FieldItem label='Pipeline Stage' value={pipelineStageLabel} />
|
||||
<FieldItem label={ownerLabel} value={enquiry.assignedToName} />
|
||||
<FieldItem label='Assigned By' value={enquiry.assignedByName} />
|
||||
<FieldItem label='Created By' value={enquiry.createdBy} />
|
||||
<FieldItem label='Updated By' value={enquiry.updatedBy} />
|
||||
@@ -367,6 +420,7 @@ export function EnquiryDetail({
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
referenceData={referenceData}
|
||||
workspace={workspace}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -64,12 +64,14 @@ export function EnquiryFormSheet({
|
||||
enquiry,
|
||||
open,
|
||||
onOpenChange,
|
||||
referenceData
|
||||
referenceData,
|
||||
workspace = 'enquiry'
|
||||
}: {
|
||||
enquiry?: EnquiryRecord;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
referenceData: EnquiryReferenceData;
|
||||
workspace?: 'lead' | 'enquiry';
|
||||
}) {
|
||||
const isEdit = !!enquiry;
|
||||
const defaultValues = useMemo(
|
||||
@@ -88,21 +90,21 @@ export function EnquiryFormSheet({
|
||||
const createMutation = useMutation({
|
||||
...createEnquiryMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Enquiry created successfully');
|
||||
toast.success(`${recordLabel} created successfully`);
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to create enquiry')
|
||||
toast.error(error instanceof Error ? error.message : `Failed to create ${recordLabel.toLowerCase()}`)
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
...updateEnquiryMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Enquiry updated successfully');
|
||||
toast.success(`${recordLabel} updated successfully`);
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to update enquiry')
|
||||
toast.error(error instanceof Error ? error.message : `Failed to update ${recordLabel.toLowerCase()}`)
|
||||
});
|
||||
|
||||
const contactsForCustomer = referenceData.contacts.filter(
|
||||
@@ -177,13 +179,17 @@ export function EnquiryFormSheet({
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
const recordLabel = workspace === 'lead' ? 'Lead' : 'Enquiry';
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className='flex flex-col sm:max-w-4xl'>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{isEdit ? 'Edit Enquiry' : 'New Enquiry'}</SheetTitle>
|
||||
<SheetTitle>{isEdit ? `Edit ${recordLabel}` : `New ${recordLabel}`}</SheetTitle>
|
||||
<SheetDescription>
|
||||
Capture the opportunity details before quotation and approval stages begin.
|
||||
{workspace === 'lead'
|
||||
? 'Capture marketing lead details before assignment to sales.'
|
||||
: 'Capture sales enquiry details before quotation work begins.'}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
@@ -261,7 +267,7 @@ export function EnquiryFormSheet({
|
||||
name='title'
|
||||
label='Title'
|
||||
required
|
||||
placeholder='Warehouse crane upgrade opportunity'
|
||||
placeholder='Warehouse crane upgrade enquiry'
|
||||
/>
|
||||
<FormTextField
|
||||
name='projectName'
|
||||
@@ -338,7 +344,7 @@ export function EnquiryFormSheet({
|
||||
<FormTextareaField
|
||||
name='description'
|
||||
label='Description'
|
||||
placeholder='High-level summary of the opportunity'
|
||||
placeholder='High-level summary of the lead or enquiry'
|
||||
/>
|
||||
</div>
|
||||
<div className='md:col-span-2'>
|
||||
@@ -352,19 +358,19 @@ export function EnquiryFormSheet({
|
||||
<FormTextareaField
|
||||
name='notes'
|
||||
label='Notes'
|
||||
placeholder='Internal notes, blockers, or guidance for the sales team'
|
||||
placeholder='Internal notes, blockers, or handoff guidance'
|
||||
/>
|
||||
</div>
|
||||
<div className='md:col-span-2 grid gap-4 md:grid-cols-2'>
|
||||
<FormSwitchField
|
||||
name='isHotProject'
|
||||
label='Hot Project'
|
||||
description='Use for urgent or highly strategic opportunities.'
|
||||
description='Use for urgent or highly strategic enquiries.'
|
||||
/>
|
||||
<FormSwitchField
|
||||
name='isActive'
|
||||
label='Active Record'
|
||||
description='Inactive enquiries stay in history but should not progress further.'
|
||||
description='Inactive leads and enquiries stay in history but should not progress further.'
|
||||
/>
|
||||
</div>
|
||||
<div className='md:col-span-2'>
|
||||
@@ -385,7 +391,7 @@ export function EnquiryFormSheet({
|
||||
</Button>
|
||||
<Button type='submit' form='enquiry-form-sheet' isLoading={isPending}>
|
||||
<Icons.check className='mr-2 h-4 w-4' />
|
||||
{isEdit ? 'Update Enquiry' : 'Create Enquiry'}
|
||||
{isEdit ? `Update ${recordLabel}` : `Create ${recordLabel}`}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
@@ -394,18 +400,25 @@ export function EnquiryFormSheet({
|
||||
}
|
||||
|
||||
export function EnquiryFormSheetTrigger({
|
||||
referenceData
|
||||
referenceData,
|
||||
workspace = 'enquiry'
|
||||
}: {
|
||||
referenceData: EnquiryReferenceData;
|
||||
workspace?: 'lead' | 'enquiry';
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setOpen(true)}>
|
||||
<Icons.add className='mr-2 h-4 w-4' /> Add Enquiry
|
||||
<Icons.add className='mr-2 h-4 w-4' /> {workspace === 'lead' ? 'Add Lead' : 'Add Enquiry'}
|
||||
</Button>
|
||||
<EnquiryFormSheet open={open} onOpenChange={setOpen} referenceData={referenceData} />
|
||||
<EnquiryFormSheet
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
referenceData={referenceData}
|
||||
workspace={workspace}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,12 +7,14 @@ import { EnquiriesTable } from './enquiries-table';
|
||||
|
||||
export default function EnquiryListing({
|
||||
referenceData,
|
||||
workspace,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
canAssign,
|
||||
canReassign
|
||||
}: {
|
||||
referenceData: EnquiryReferenceData;
|
||||
workspace: 'lead' | 'enquiry';
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
canAssign: boolean;
|
||||
@@ -30,6 +32,7 @@ export default function EnquiryListing({
|
||||
const filters = {
|
||||
page,
|
||||
limit,
|
||||
pipelineStage: workspace,
|
||||
...(search && { search }),
|
||||
...(status && { status }),
|
||||
...(productType && { productType }),
|
||||
@@ -46,6 +49,7 @@ export default function EnquiryListing({
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<EnquiriesTable
|
||||
referenceData={referenceData}
|
||||
workspace={workspace}
|
||||
canUpdate={canUpdate}
|
||||
canDelete={canDelete}
|
||||
canAssign={canAssign}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
crmEnquiries,
|
||||
crmEnquiryCustomers,
|
||||
crmEnquiryFollowups,
|
||||
crmQuotations,
|
||||
memberships,
|
||||
users
|
||||
} from '@/db/schema';
|
||||
@@ -29,6 +30,7 @@ import type {
|
||||
EnquiryProjectPartyListItem,
|
||||
EnquiryProjectPartyMutationPayload,
|
||||
EnquiryProjectPartyRecord,
|
||||
EnquiryPipelineStage,
|
||||
EnquiryRecord,
|
||||
EnquiryReferenceData
|
||||
} from '../api/types';
|
||||
@@ -48,6 +50,30 @@ const ENQUIRY_OPTION_CATEGORIES = {
|
||||
} as const;
|
||||
|
||||
const ASSIGNABLE_BUSINESS_ROLES = new Set(['sales_manager', 'sales', 'sales_support']);
|
||||
const SALES_OWNED_SCOPE_ROLES = new Set(['sales', 'sales_support']);
|
||||
const ENQUIRY_FULL_ACCESS_ROLES = new Set([
|
||||
'marketing',
|
||||
'sales_manager',
|
||||
'department_manager',
|
||||
'top_manager'
|
||||
]);
|
||||
const SALES_CREATED_ENQUIRY_ROLES = new Set([
|
||||
'sales',
|
||||
'sales_support',
|
||||
'sales_manager',
|
||||
'department_manager',
|
||||
'top_manager'
|
||||
]);
|
||||
const CLOSED_LOST_STATUS_CODES = new Set(['closed_lost', 'cancelled']);
|
||||
const CLOSED_WON_QUOTATION_STATUS_CODES = new Set(['accepted']);
|
||||
const CLOSED_LOST_QUOTATION_STATUS_CODES = new Set(['lost', 'rejected']);
|
||||
|
||||
export interface EnquiryAccessContext {
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
membershipRole: string;
|
||||
businessRole: string;
|
||||
}
|
||||
|
||||
function mapOption(
|
||||
option: Awaited<ReturnType<typeof getActiveOptionsByCategory>>[number]
|
||||
@@ -99,6 +125,7 @@ function mapEnquiryRecord(
|
||||
notes: row.notes,
|
||||
isHotProject: row.isHotProject,
|
||||
isActive: row.isActive,
|
||||
pipelineStage: row.pipelineStage as EnquiryPipelineStage,
|
||||
assignedToUserId: row.assignedToUserId,
|
||||
assignedToName: options?.assignedToName ?? null,
|
||||
assignedAt: row.assignedAt?.toISOString() ?? null,
|
||||
@@ -113,6 +140,41 @@ function mapEnquiryRecord(
|
||||
};
|
||||
}
|
||||
|
||||
function hasEnquiryFullAccess(context: EnquiryAccessContext) {
|
||||
return (
|
||||
context.membershipRole === 'admin' || ENQUIRY_FULL_ACCESS_ROLES.has(context.businessRole)
|
||||
);
|
||||
}
|
||||
|
||||
function canAccessEnquiryRow(
|
||||
row: typeof crmEnquiries.$inferSelect,
|
||||
context: EnquiryAccessContext
|
||||
) {
|
||||
if (hasEnquiryFullAccess(context)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (SALES_OWNED_SCOPE_ROLES.has(context.businessRole)) {
|
||||
return row.createdBy === context.userId || row.assignedToUserId === context.userId;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildAccessScopedFilters(context: EnquiryAccessContext): SQL[] {
|
||||
if (hasEnquiryFullAccess(context)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (SALES_OWNED_SCOPE_ROLES.has(context.businessRole)) {
|
||||
return [
|
||||
or(eq(crmEnquiries.createdBy, context.userId), eq(crmEnquiries.assignedToUserId, context.userId))!
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function mapFollowupRecord(row: typeof crmEnquiryFollowups.$inferSelect): EnquiryFollowupRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -218,6 +280,19 @@ async function assertMasterOptionValue(
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveOptionCodeById(
|
||||
organizationId: string,
|
||||
category: string,
|
||||
optionId?: string | null
|
||||
) {
|
||||
if (!optionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const options = await getActiveOptionsByCategory(category, { organizationId });
|
||||
return options.find((option) => option.id === optionId)?.code ?? null;
|
||||
}
|
||||
|
||||
export async function assertCustomerBelongsToOrganization(id: string, organizationId: string) {
|
||||
const [customer] = await db
|
||||
.select()
|
||||
@@ -309,7 +384,11 @@ export async function assertAssignableUserBelongsToOrganization(
|
||||
return membership;
|
||||
}
|
||||
|
||||
async function assertEnquiryBelongsToOrganization(id: string, organizationId: string) {
|
||||
async function assertEnquiryBelongsToOrganization(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
accessContext?: EnquiryAccessContext
|
||||
) {
|
||||
const [enquiry] = await db
|
||||
.select()
|
||||
.from(crmEnquiries)
|
||||
@@ -326,6 +405,10 @@ async function assertEnquiryBelongsToOrganization(id: string, organizationId: st
|
||||
throw new AuthError('Enquiry not found', 404);
|
||||
}
|
||||
|
||||
if (accessContext && !canAccessEnquiryRow(enquiry, accessContext)) {
|
||||
throw new AuthError('Forbidden', 403);
|
||||
}
|
||||
|
||||
return enquiry;
|
||||
}
|
||||
|
||||
@@ -395,9 +478,14 @@ async function validateEnquiryPayload(organizationId: string, payload: EnquiryMu
|
||||
async function validateFollowupPayload(
|
||||
organizationId: string,
|
||||
enquiryId: string,
|
||||
payload: EnquiryFollowupMutationPayload
|
||||
payload: EnquiryFollowupMutationPayload,
|
||||
accessContext?: EnquiryAccessContext
|
||||
) {
|
||||
const enquiry = await assertEnquiryBelongsToOrganization(enquiryId, organizationId);
|
||||
const enquiry = await assertEnquiryBelongsToOrganization(
|
||||
enquiryId,
|
||||
organizationId,
|
||||
accessContext
|
||||
);
|
||||
|
||||
await assertMasterOptionValue(
|
||||
organizationId,
|
||||
@@ -410,7 +498,8 @@ async function validateFollowupPayload(
|
||||
}
|
||||
}
|
||||
|
||||
function buildEnquiryFilters(organizationId: string, filters: EnquiryFilters): SQL[] {
|
||||
function buildEnquiryFilters(context: EnquiryAccessContext, filters: EnquiryFilters): SQL[] {
|
||||
const pipelineStages = splitFilterValue(filters.pipelineStage);
|
||||
const statuses = splitFilterValue(filters.status);
|
||||
const productTypes = splitFilterValue(filters.productType);
|
||||
const priorities = splitFilterValue(filters.priority);
|
||||
@@ -418,8 +507,10 @@ function buildEnquiryFilters(organizationId: string, filters: EnquiryFilters): S
|
||||
const customers = splitFilterValue(filters.customer);
|
||||
|
||||
return [
|
||||
eq(crmEnquiries.organizationId, organizationId),
|
||||
eq(crmEnquiries.organizationId, context.organizationId),
|
||||
isNull(crmEnquiries.deletedAt),
|
||||
...buildAccessScopedFilters(context),
|
||||
...(pipelineStages.length ? [inArray(crmEnquiries.pipelineStage, pipelineStages)] : []),
|
||||
...(statuses.length ? [inArray(crmEnquiries.status, statuses)] : []),
|
||||
...(productTypes.length ? [inArray(crmEnquiries.productType, productTypes)] : []),
|
||||
...(priorities.length ? [inArray(crmEnquiries.priority, priorities)] : []),
|
||||
@@ -438,6 +529,54 @@ function buildEnquiryFilters(organizationId: string, filters: EnquiryFilters): S
|
||||
];
|
||||
}
|
||||
|
||||
function derivePipelineStageFromRole(businessRole: string): EnquiryPipelineStage {
|
||||
return SALES_CREATED_ENQUIRY_ROLES.has(businessRole) ? 'enquiry' : 'lead';
|
||||
}
|
||||
|
||||
async function resolvePipelineStageForCreate(
|
||||
organizationId: string,
|
||||
businessRole: string,
|
||||
statusId: string
|
||||
): Promise<EnquiryPipelineStage> {
|
||||
const statusCode = await resolveOptionCodeById(
|
||||
organizationId,
|
||||
ENQUIRY_OPTION_CATEGORIES.status,
|
||||
statusId
|
||||
);
|
||||
|
||||
if (statusCode && CLOSED_LOST_STATUS_CODES.has(statusCode)) {
|
||||
return 'closed_lost';
|
||||
}
|
||||
|
||||
return derivePipelineStageFromRole(businessRole);
|
||||
}
|
||||
|
||||
async function resolvePipelineStageForUpdate(
|
||||
organizationId: string,
|
||||
current: typeof crmEnquiries.$inferSelect,
|
||||
payload: EnquiryMutationPayload
|
||||
): Promise<EnquiryPipelineStage> {
|
||||
const statusCode = await resolveOptionCodeById(
|
||||
organizationId,
|
||||
ENQUIRY_OPTION_CATEGORIES.status,
|
||||
payload.status
|
||||
);
|
||||
|
||||
if (statusCode && CLOSED_LOST_STATUS_CODES.has(statusCode)) {
|
||||
return 'closed_lost';
|
||||
}
|
||||
|
||||
if (current.pipelineStage === 'closed_won') {
|
||||
return 'closed_won';
|
||||
}
|
||||
|
||||
if (current.pipelineStage === 'enquiry' || current.assignedToUserId) {
|
||||
return 'enquiry';
|
||||
}
|
||||
|
||||
return 'lead';
|
||||
}
|
||||
|
||||
export async function getEnquiryReferenceData(
|
||||
organizationId: string
|
||||
): Promise<EnquiryReferenceData> {
|
||||
@@ -505,12 +644,12 @@ export async function getEnquiryReferenceData(
|
||||
}
|
||||
|
||||
export async function listEnquiries(
|
||||
organizationId: string,
|
||||
context: EnquiryAccessContext,
|
||||
filters: EnquiryFilters
|
||||
): Promise<{ items: EnquiryListItem[]; totalItems: number }> {
|
||||
const page = filters.page ?? 1;
|
||||
const limit = filters.limit ?? 10;
|
||||
const whereFilters = buildEnquiryFilters(organizationId, filters);
|
||||
const whereFilters = buildEnquiryFilters(context, filters);
|
||||
const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters);
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
@@ -528,16 +667,17 @@ export async function listEnquiries(
|
||||
const assignedUserIds = [
|
||||
...new Set(rows.map((row) => row.assignedToUserId).filter(Boolean) as string[])
|
||||
];
|
||||
const createdByUserIds = [...new Set(rows.map((row) => row.createdBy).filter(Boolean) as string[])];
|
||||
const enquiryIds = rows.map((row) => row.id);
|
||||
|
||||
const [customers, contacts, assignedUsers, followupCounts] = await Promise.all([
|
||||
const [customers, contacts, assignedUsers, createdByUsers, followupCounts, quotationCounts, nextFollowups] = await Promise.all([
|
||||
customerIds.length
|
||||
? db
|
||||
.select()
|
||||
.from(crmCustomers)
|
||||
.where(
|
||||
and(
|
||||
eq(crmCustomers.organizationId, organizationId),
|
||||
eq(crmCustomers.organizationId, context.organizationId),
|
||||
inArray(crmCustomers.id, customerIds)
|
||||
)
|
||||
)
|
||||
@@ -548,7 +688,7 @@ export async function listEnquiries(
|
||||
.from(crmCustomerContacts)
|
||||
.where(
|
||||
and(
|
||||
eq(crmCustomerContacts.organizationId, organizationId),
|
||||
eq(crmCustomerContacts.organizationId, context.organizationId),
|
||||
inArray(crmCustomerContacts.id, contactIds)
|
||||
)
|
||||
)
|
||||
@@ -559,6 +699,12 @@ export async function listEnquiries(
|
||||
.from(users)
|
||||
.where(inArray(users.id, assignedUserIds))
|
||||
: [],
|
||||
createdByUserIds.length
|
||||
? db
|
||||
.select({ id: users.id, name: users.name })
|
||||
.from(users)
|
||||
.where(inArray(users.id, createdByUserIds))
|
||||
: [],
|
||||
enquiryIds.length
|
||||
? db
|
||||
.select({
|
||||
@@ -568,19 +714,68 @@ export async function listEnquiries(
|
||||
.from(crmEnquiryFollowups)
|
||||
.where(
|
||||
and(
|
||||
eq(crmEnquiryFollowups.organizationId, organizationId),
|
||||
eq(crmEnquiryFollowups.organizationId, context.organizationId),
|
||||
inArray(crmEnquiryFollowups.enquiryId, enquiryIds),
|
||||
isNull(crmEnquiryFollowups.deletedAt)
|
||||
)
|
||||
)
|
||||
.groupBy(crmEnquiryFollowups.enquiryId)
|
||||
: [],
|
||||
enquiryIds.length
|
||||
? db
|
||||
.select({
|
||||
enquiryId: crmQuotations.enquiryId,
|
||||
value: count()
|
||||
})
|
||||
.from(crmQuotations)
|
||||
.where(
|
||||
and(
|
||||
eq(crmQuotations.organizationId, context.organizationId),
|
||||
inArray(crmQuotations.enquiryId, enquiryIds),
|
||||
isNull(crmQuotations.deletedAt)
|
||||
)
|
||||
)
|
||||
.groupBy(crmQuotations.enquiryId)
|
||||
: [],
|
||||
enquiryIds.length
|
||||
? db
|
||||
.select({
|
||||
enquiryId: crmEnquiryFollowups.enquiryId,
|
||||
nextFollowupDate: crmEnquiryFollowups.nextFollowupDate
|
||||
})
|
||||
.from(crmEnquiryFollowups)
|
||||
.where(
|
||||
and(
|
||||
eq(crmEnquiryFollowups.organizationId, context.organizationId),
|
||||
inArray(crmEnquiryFollowups.enquiryId, enquiryIds),
|
||||
isNull(crmEnquiryFollowups.deletedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(crmEnquiryFollowups.nextFollowupDate))
|
||||
: []
|
||||
]);
|
||||
|
||||
const customerMap = new Map(customers.map((customer) => [customer.id, customer.name]));
|
||||
const contactMap = new Map(contacts.map((contact) => [contact.id, contact.name]));
|
||||
const assignedUserMap = new Map(assignedUsers.map((user) => [user.id, user.name]));
|
||||
const createdByUserMap = new Map(createdByUsers.map((user) => [user.id, user.name]));
|
||||
const followupCountMap = new Map(followupCounts.map((item) => [item.enquiryId, item.value]));
|
||||
const quotationCountMap = new Map(
|
||||
quotationCounts
|
||||
.filter((item) => item.enquiryId !== null)
|
||||
.map((item) => [item.enquiryId as string, item.value])
|
||||
);
|
||||
const nextFollowupMap = new Map<string, string | null>();
|
||||
|
||||
for (const item of nextFollowups) {
|
||||
if (!item.nextFollowupDate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!nextFollowupMap.has(item.enquiryId)) {
|
||||
nextFollowupMap.set(item.enquiryId, item.nextFollowupDate.toISOString());
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
items: rows.map((row) => ({
|
||||
@@ -591,14 +786,21 @@ export async function listEnquiries(
|
||||
}),
|
||||
customerName: customerMap.get(row.customerId) ?? 'Unknown customer',
|
||||
contactName: row.contactId ? (contactMap.get(row.contactId) ?? null) : null,
|
||||
followupCount: followupCountMap.get(row.id) ?? 0
|
||||
followupCount: followupCountMap.get(row.id) ?? 0,
|
||||
quotationCount: quotationCountMap.get(row.id) ?? 0,
|
||||
nextFollowupDate: nextFollowupMap.get(row.id) ?? null,
|
||||
createdByName: createdByUserMap.get(row.createdBy) ?? null
|
||||
})),
|
||||
totalItems: totalResult?.value ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
export async function getEnquiryDetail(id: string, organizationId: string): Promise<EnquiryRecord> {
|
||||
const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId);
|
||||
export async function getEnquiryDetail(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
accessContext?: EnquiryAccessContext
|
||||
): Promise<EnquiryRecord> {
|
||||
const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
|
||||
const relatedUserIds = [enquiry.assignedToUserId, enquiry.assignedBy].filter(Boolean) as string[];
|
||||
const userRows = relatedUserIds.length
|
||||
? await db
|
||||
@@ -711,9 +913,10 @@ async function syncEnquiryProjectParties(
|
||||
|
||||
export async function listEnquiryProjectParties(
|
||||
id: string,
|
||||
organizationId: string
|
||||
organizationId: string,
|
||||
accessContext?: EnquiryAccessContext
|
||||
): Promise<EnquiryProjectPartyListItem[]> {
|
||||
await assertEnquiryBelongsToOrganization(id, organizationId);
|
||||
await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
|
||||
const [relations, customers, roleOptions] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
@@ -754,8 +957,10 @@ export async function listEnquiryProjectParties(
|
||||
|
||||
export async function getEnquiryActivity(
|
||||
id: string,
|
||||
organizationId: string
|
||||
organizationId: string,
|
||||
accessContext?: EnquiryAccessContext
|
||||
): Promise<EnquiryActivityRecord[]> {
|
||||
await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
|
||||
const auditLogs = await listAuditLogs({
|
||||
organizationId,
|
||||
entityId: id,
|
||||
@@ -791,9 +996,15 @@ export async function getEnquiryActivity(
|
||||
export async function createEnquiry(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
businessRole: string,
|
||||
payload: EnquiryMutationPayload
|
||||
) {
|
||||
await validateEnquiryPayload(organizationId, payload);
|
||||
const pipelineStage = await resolvePipelineStageForCreate(
|
||||
organizationId,
|
||||
businessRole,
|
||||
payload.status
|
||||
);
|
||||
|
||||
const documentCode = await generateNextDocumentCode({
|
||||
organizationId,
|
||||
@@ -828,6 +1039,7 @@ export async function createEnquiry(
|
||||
notes: payload.notes?.trim() || null,
|
||||
isHotProject: payload.isHotProject ?? false,
|
||||
isActive: payload.isActive ?? true,
|
||||
pipelineStage,
|
||||
createdBy: userId,
|
||||
updatedBy: userId
|
||||
})
|
||||
@@ -849,10 +1061,12 @@ export async function updateEnquiry(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: EnquiryMutationPayload
|
||||
payload: EnquiryMutationPayload,
|
||||
accessContext?: EnquiryAccessContext
|
||||
) {
|
||||
await validateEnquiryPayload(organizationId, payload);
|
||||
await assertEnquiryBelongsToOrganization(id, organizationId);
|
||||
const current = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
|
||||
const pipelineStage = await resolvePipelineStageForUpdate(organizationId, current, payload);
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
const [updated] = await tx
|
||||
@@ -878,6 +1092,7 @@ export async function updateEnquiry(
|
||||
notes: payload.notes?.trim() || null,
|
||||
isHotProject: payload.isHotProject ?? false,
|
||||
isActive: payload.isActive ?? true,
|
||||
pipelineStage,
|
||||
updatedBy: userId,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
@@ -896,8 +1111,13 @@ export async function updateEnquiry(
|
||||
});
|
||||
}
|
||||
|
||||
export async function softDeleteEnquiry(id: string, organizationId: string, userId: string) {
|
||||
await assertEnquiryBelongsToOrganization(id, organizationId);
|
||||
export async function softDeleteEnquiry(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
accessContext?: EnquiryAccessContext
|
||||
) {
|
||||
await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
|
||||
const now = new Date();
|
||||
|
||||
const [updated] = await db
|
||||
@@ -964,6 +1184,7 @@ async function updateEnquiryAssignment(
|
||||
const [updated] = await db
|
||||
.update(crmEnquiries)
|
||||
.set({
|
||||
pipelineStage: 'enquiry',
|
||||
assignedToUserId: payload.assignedToUserId,
|
||||
assignedAt: new Date(),
|
||||
assignedBy: userId,
|
||||
@@ -995,8 +1216,12 @@ export async function reassignEnquiryToUser(
|
||||
return updateEnquiryAssignment(id, organizationId, userId, payload, 'reassign');
|
||||
}
|
||||
|
||||
export async function listEnquiryFollowups(id: string, organizationId: string) {
|
||||
await assertEnquiryBelongsToOrganization(id, organizationId);
|
||||
export async function listEnquiryFollowups(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
accessContext?: EnquiryAccessContext
|
||||
) {
|
||||
await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(crmEnquiryFollowups)
|
||||
@@ -1016,9 +1241,10 @@ export async function createEnquiryFollowup(
|
||||
enquiryId: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: EnquiryFollowupMutationPayload
|
||||
payload: EnquiryFollowupMutationPayload,
|
||||
accessContext?: EnquiryAccessContext
|
||||
) {
|
||||
await validateFollowupPayload(organizationId, enquiryId, payload);
|
||||
await validateFollowupPayload(organizationId, enquiryId, payload, accessContext);
|
||||
|
||||
const [created] = await db
|
||||
.insert(crmEnquiryFollowups)
|
||||
@@ -1046,9 +1272,10 @@ export async function updateEnquiryFollowup(
|
||||
followupId: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: EnquiryFollowupMutationPayload
|
||||
payload: EnquiryFollowupMutationPayload,
|
||||
accessContext?: EnquiryAccessContext
|
||||
) {
|
||||
await validateFollowupPayload(organizationId, enquiryId, payload);
|
||||
await validateFollowupPayload(organizationId, enquiryId, payload, accessContext);
|
||||
await assertFollowupBelongsToEnquiry(followupId, enquiryId, organizationId);
|
||||
|
||||
const [updated] = await db
|
||||
@@ -1074,8 +1301,10 @@ export async function softDeleteEnquiryFollowup(
|
||||
enquiryId: string,
|
||||
followupId: string,
|
||||
organizationId: string,
|
||||
userId: string
|
||||
userId: string,
|
||||
accessContext?: EnquiryAccessContext
|
||||
) {
|
||||
await assertEnquiryBelongsToOrganization(enquiryId, organizationId, accessContext);
|
||||
await assertFollowupBelongsToEnquiry(followupId, enquiryId, organizationId);
|
||||
|
||||
const [updated] = await db
|
||||
@@ -1117,3 +1346,39 @@ export async function listCustomerEnquiryRelations(
|
||||
updatedAt: row.updatedAt.toISOString()
|
||||
}));
|
||||
}
|
||||
|
||||
export async function syncEnquiryPipelineStageFromQuotationStatus(
|
||||
enquiryId: string,
|
||||
organizationId: string,
|
||||
quotationStatusCode: string | null
|
||||
) {
|
||||
if (!quotationStatusCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextStage: EnquiryPipelineStage | null = CLOSED_WON_QUOTATION_STATUS_CODES.has(
|
||||
quotationStatusCode
|
||||
)
|
||||
? 'closed_won'
|
||||
: CLOSED_LOST_QUOTATION_STATUS_CODES.has(quotationStatusCode)
|
||||
? 'closed_lost'
|
||||
: null;
|
||||
|
||||
if (!nextStage) {
|
||||
return;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(crmEnquiries)
|
||||
.set({
|
||||
pipelineStage: nextStage,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(crmEnquiries.id, enquiryId),
|
||||
eq(crmEnquiries.organizationId, organizationId),
|
||||
isNull(crmEnquiries.deletedAt)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { listAuditLogs } from '@/features/foundation/audit-log/service';
|
||||
import { getUserBranches, validateBranchAccess } from '@/features/foundation/branch-scope/service';
|
||||
import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service';
|
||||
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||
import { syncEnquiryPipelineStageFromQuotationStatus } from '@/features/crm/enquiries/server/service';
|
||||
import type {
|
||||
QuotationActivityRecord,
|
||||
QuotationAttachmentMutationPayload,
|
||||
@@ -1215,6 +1216,11 @@ export async function createQuotation(
|
||||
payload: QuotationMutationPayload
|
||||
) {
|
||||
await validateQuotationPayload(organizationId, payload);
|
||||
const quotationStatusCode = await resolveOptionCodeById(
|
||||
organizationId,
|
||||
QUOTATION_OPTION_CATEGORIES.status,
|
||||
payload.status
|
||||
);
|
||||
|
||||
const enquiry = payload.enquiryId
|
||||
? await assertEnquiryBelongsToOrganization(payload.enquiryId, organizationId)
|
||||
@@ -1277,6 +1283,14 @@ export async function createQuotation(
|
||||
payload.projectParties ?? []
|
||||
);
|
||||
|
||||
if (created.enquiryId) {
|
||||
await syncEnquiryPipelineStageFromQuotationStatus(
|
||||
created.enquiryId,
|
||||
organizationId,
|
||||
quotationStatusCode
|
||||
);
|
||||
}
|
||||
|
||||
return created;
|
||||
});
|
||||
}
|
||||
@@ -1288,6 +1302,11 @@ export async function updateQuotation(
|
||||
payload: QuotationMutationPayload
|
||||
) {
|
||||
await validateQuotationPayload(organizationId, payload);
|
||||
const quotationStatusCode = await resolveOptionCodeById(
|
||||
organizationId,
|
||||
QUOTATION_OPTION_CATEGORIES.status,
|
||||
payload.status
|
||||
);
|
||||
const existing = await assertQuotationBelongsToOrganization(id, organizationId);
|
||||
const enquiry = payload.enquiryId
|
||||
? await assertEnquiryBelongsToOrganization(payload.enquiryId, organizationId)
|
||||
@@ -1336,6 +1355,14 @@ export async function updateQuotation(
|
||||
payload.projectParties ?? []
|
||||
);
|
||||
|
||||
if (row.enquiryId) {
|
||||
await syncEnquiryPipelineStageFromQuotationStatus(
|
||||
row.enquiryId,
|
||||
organizationId,
|
||||
quotationStatusCode
|
||||
);
|
||||
}
|
||||
|
||||
return row;
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user