lead menu

nquiry menu
This commit is contained in:
phaichayon
2026-06-19 10:39:11 +07:00
parent 0650cf6297
commit c0cc880be2
46 changed files with 5985 additions and 420 deletions

View File

@@ -39,7 +39,8 @@ export async function GET(request: NextRequest) {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
businessRole: membership.businessRole,
permissions: membership.permissions
},
{
dateFrom: searchParams.get('dateFrom'),
@@ -84,11 +85,11 @@ export async function GET(request: NextRequest) {
];
} else {
rows = [
['Sales Person', 'Lead Count', 'Opportunity Count', 'Quotation Count', 'Approved Quotations', 'Won Revenue', 'Conversion Rate'],
['Sales Person', 'Lead Count', 'Enquiry Count', 'Quotation Count', 'Approved Quotations', 'Won Revenue', 'Conversion Rate'],
...data.salesRanking.map((row) => [
row.salesPersonName,
String(row.leadCount),
String(row.opportunityCount),
String(row.enquiryCount),
String(row.quotationCount),
String(row.approvedQuotations),
String(row.wonRevenue),

View File

@@ -14,7 +14,8 @@ export async function GET(request: NextRequest) {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
businessRole: membership.businessRole,
permissions: membership.permissions
},
{
dateFrom: searchParams.get('dateFrom'),

View File

@@ -13,11 +13,16 @@ type Params = {
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryAssign
});
const payload = enquiryAssignmentRequestSchema.parse(await request.json());
const before = await getEnquiryDetail(id, organization.id);
const before = await getEnquiryDetail(id, organization.id, {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
});
const updated = await assignEnquiryToUser(id, organization.id, session.user.id, payload);
await auditAction({
@@ -26,19 +31,21 @@ export async function POST(request: NextRequest, { params }: Params) {
userId: session.user.id,
entityType: 'crm_enquiry',
entityId: id,
action: 'assign',
action: 'lead_to_enquiry',
beforeData: {
oldAssignedToUserId: before.assignedToUserId
oldAssignedToUserId: before.assignedToUserId,
oldPipelineStage: before.pipelineStage
},
afterData: {
newAssignedToUserId: updated.assignedToUserId,
newPipelineStage: updated.pipelineStage,
remark: updated.assignmentRemark
}
});
return NextResponse.json({
success: true,
message: 'Enquiry assigned successfully',
message: 'Lead converted to enquiry successfully',
enquiry: updated
});
} catch (error) {

View File

@@ -10,10 +10,15 @@ type Params = {
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization } = await requireOrganizationAccess({
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryRead
});
const items = await listEnquiryProjectParties(id, organization.id);
const items = await listEnquiryProjectParties(id, organization.id, {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
});
return NextResponse.json({
success: true,

View File

@@ -22,11 +22,18 @@ const followupRequestSchema = enquiryFollowupSchema.extend({
export async function PATCH(request: NextRequest, { params }: Params) {
try {
const { id, followupId } = await params;
const { organization, session } = await requireOrganizationAccess({
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryFollowupUpdate
});
const payload = followupRequestSchema.parse(await request.json());
const before = (await listEnquiryFollowups(id, organization.id)).find(
const before = (
await listEnquiryFollowups(id, organization.id, {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
})
).find(
(item) => item.id === followupId
);
@@ -39,7 +46,13 @@ export async function PATCH(request: NextRequest, { params }: Params) {
followupId,
organization.id,
session.user.id,
payload
payload,
{
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
}
);
await auditUpdate({
@@ -79,10 +92,17 @@ export async function PATCH(request: NextRequest, { params }: Params) {
export async function DELETE(_request: NextRequest, { params }: Params) {
try {
const { id, followupId } = await params;
const { organization, session } = await requireOrganizationAccess({
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryFollowupDelete
});
const before = (await listEnquiryFollowups(id, organization.id)).find(
const before = (
await listEnquiryFollowups(id, organization.id, {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
})
).find(
(item) => item.id === followupId
);
@@ -94,7 +114,13 @@ export async function DELETE(_request: NextRequest, { params }: Params) {
id,
followupId,
organization.id,
session.user.id
session.user.id,
{
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
}
);
await auditDelete({

View File

@@ -21,10 +21,15 @@ const followupRequestSchema = enquiryFollowupSchema.extend({
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization } = await requireOrganizationAccess({
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryFollowupRead
});
const items = await listEnquiryFollowups(id, organization.id);
const items = await listEnquiryFollowups(id, organization.id, {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
});
return NextResponse.json({
success: true,
@@ -48,11 +53,22 @@ export async function GET(_request: NextRequest, { params }: Params) {
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryFollowupCreate
});
const payload = followupRequestSchema.parse(await request.json());
const created = await createEnquiryFollowup(id, organization.id, session.user.id, payload);
const created = await createEnquiryFollowup(
id,
organization.id,
session.user.id,
payload,
{
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
}
);
await auditCreate({
organizationId: organization.id,

View File

@@ -13,11 +13,16 @@ type Params = {
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryReassign
});
const payload = enquiryAssignmentRequestSchema.parse(await request.json());
const before = await getEnquiryDetail(id, organization.id);
const before = await getEnquiryDetail(id, organization.id, {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
});
const updated = await reassignEnquiryToUser(id, organization.id, session.user.id, payload);
await auditAction({
@@ -28,10 +33,12 @@ export async function POST(request: NextRequest, { params }: Params) {
entityId: id,
action: 'reassign',
beforeData: {
oldAssignedToUserId: before.assignedToUserId
oldAssignedToUserId: before.assignedToUserId,
oldPipelineStage: before.pipelineStage
},
afterData: {
newAssignedToUserId: updated.assignedToUserId,
newPipelineStage: updated.pipelineStage,
remark: updated.assignmentRemark
}
});

View File

@@ -25,12 +25,18 @@ const enquiryRequestSchema = enquirySchema.extend({
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization } = await requireOrganizationAccess({
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryRead
});
const accessContext = {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
};
const [enquiry, activity] = await Promise.all([
getEnquiryDetail(id, organization.id),
getEnquiryActivity(id, organization.id)
getEnquiryDetail(id, organization.id, accessContext),
getEnquiryActivity(id, organization.id, accessContext)
]);
return NextResponse.json({
@@ -56,12 +62,22 @@ export async function GET(_request: NextRequest, { params }: Params) {
export async function PATCH(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryUpdate
});
const payload = enquiryRequestSchema.parse(await request.json());
const before = await getEnquiryDetail(id, organization.id);
const updated = await updateEnquiry(id, organization.id, session.user.id, payload);
const before = await getEnquiryDetail(id, organization.id, {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
});
const updated = await updateEnquiry(id, organization.id, session.user.id, payload, {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
});
await auditUpdate({
organizationId: organization.id,
@@ -101,11 +117,21 @@ export async function PATCH(request: NextRequest, { params }: Params) {
export async function DELETE(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryDelete
});
const before = await getEnquiryDetail(id, organization.id);
const updated = await softDeleteEnquiry(id, organization.id, session.user.id);
const before = await getEnquiryDetail(id, organization.id, {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
});
const updated = await softDeleteEnquiry(id, organization.id, session.user.id, {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
});
await auditDelete({
organizationId: organization.id,

View File

@@ -15,30 +15,40 @@ const enquiryRequestSchema = enquirySchema.extend({
export async function GET(request: NextRequest) {
try {
const { organization } = await requireOrganizationAccess({
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryRead
});
const { searchParams } = request.nextUrl;
const page = Number(searchParams.get('page') ?? 1);
const limit = Number(searchParams.get('limit') ?? 10);
const search = searchParams.get('search') ?? undefined;
const pipelineStage = searchParams.get('pipelineStage') ?? undefined;
const status = searchParams.get('status') ?? undefined;
const productType = searchParams.get('productType') ?? undefined;
const priority = searchParams.get('priority') ?? undefined;
const branch = searchParams.get('branch') ?? undefined;
const customer = searchParams.get('customer') ?? undefined;
const sort = searchParams.get('sort') ?? undefined;
const result = await listEnquiries(organization.id, {
page,
limit,
search,
status,
productType,
priority,
branch,
customer,
sort
});
const result = await listEnquiries(
{
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
},
{
page,
limit,
search,
pipelineStage: pipelineStage as 'lead' | 'enquiry' | 'closed_won' | 'closed_lost' | undefined,
status,
productType,
priority,
branch,
customer,
sort
}
);
const offset = (page - 1) * limit;
return NextResponse.json({
@@ -65,11 +75,16 @@ export async function GET(request: NextRequest) {
export async function POST(request: NextRequest) {
try {
const { organization, session } = await requireOrganizationAccess({
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryCreate
});
const payload = enquiryRequestSchema.parse(await request.json());
const created = await createEnquiry(organization.id, session.user.id, payload);
const created = await createEnquiry(
organization.id,
session.user.id,
membership.businessRole,
payload
);
await auditCreate({
organizationId: organization.id,

View File

@@ -54,6 +54,11 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryReassign)));
const canReadQuotation =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationRead)));
const queryClient = getQueryClient();
if (canRead) {
@@ -67,14 +72,14 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
? await getEnquiryReferenceData(session.user.activeOrganizationId)
: null;
const relatedQuotations =
canRead && session?.user?.activeOrganizationId
canRead && canReadQuotation && session?.user?.activeOrganizationId
? await listEnquiryQuotationRelations(id, session.user.activeOrganizationId)
: [];
return (
<PageContainer
pageTitle='Enquiry Detail'
pageDescription='Opportunity context, follow-up timeline, and future quotation handoff.'
pageDescription='Lead or enquiry context, follow-up timeline, and ownership lifecycle.'
access={canRead}
accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
@@ -88,9 +93,11 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
enquiryId={id}
referenceData={referenceData}
relatedQuotations={relatedQuotations}
workspace='enquiry'
canUpdate={canUpdate}
canAssign={canAssign}
canReassign={canReassign}
canViewRelatedQuotations={canReadQuotation}
canManageFollowups={{
create: canFollowupCreate,
update: canFollowupUpdate,

View File

@@ -50,6 +50,7 @@ export default async function EnquiriesRoute(props: PageProps) {
session.user.activePermissions.includes(
PERMISSIONS.crmEnquiryReassign,
)));
const businessRole = session?.user?.activeBusinessRole ?? null;
searchParamsCache.parse(searchParams);
@@ -60,8 +61,8 @@ export default async function EnquiriesRoute(props: PageProps) {
return (
<PageContainer
pageTitle="Enquiries (Lead)"
pageDescription="Production opportunity registry with customer linkage, follow-up workflow, and audit-backed activity."
pageTitle="Enquiries"
pageDescription="Sales workspace for active enquiries, follow-up workflow, and quotation preparation."
access={canRead}
accessFallback={
<div className="text-muted-foreground rounded-lg border border-dashed p-8 text-center">
@@ -70,13 +71,14 @@ export default async function EnquiriesRoute(props: PageProps) {
}
pageHeaderAction={
canCreate && referenceData ? (
<EnquiryFormSheetTrigger referenceData={referenceData} />
<EnquiryFormSheetTrigger referenceData={referenceData} workspace='enquiry' />
) : null
}
>
{referenceData ? (
<EnquiryListing
referenceData={referenceData}
workspace='enquiry'
canUpdate={canUpdate}
canDelete={canDelete}
canAssign={canAssign}

View File

@@ -0,0 +1,99 @@
import { auth } from '@/auth';
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { notFound, redirect } from 'next/navigation';
import PageContainer from '@/components/layout/page-container';
import {
enquiryByIdOptions,
enquiryFollowupsOptions,
enquiryProjectPartiesOptions
} from '@/features/crm/enquiries/api/queries';
import { EnquiryDetail } from '@/features/crm/enquiries/components/enquiry-detail';
import { getEnquiryDetail, getEnquiryReferenceData } from '@/features/crm/enquiries/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { getQueryClient } from '@/lib/query-client';
type PageProps = {
params: Promise<{ id: string }>;
};
export default async function LeadDetailRoute({ params }: PageProps) {
const { id } = await params;
const session = await auth();
const canRead =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmLeadRead)));
const canUpdate =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmLeadUpdate)));
const canAssign =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmLeadAssign)));
const queryClient = getQueryClient();
if (!session?.user?.activeOrganizationId) {
notFound();
}
const enquiry = canRead
? await getEnquiryDetail(id, session.user.activeOrganizationId, {
organizationId: session.user.activeOrganizationId,
userId: session.user.id,
membershipRole: session.user.activeMembershipRole ?? 'user',
businessRole: session.user.activeBusinessRole ?? 'sales_support'
})
: null;
if (enquiry && enquiry.pipelineStage !== 'lead') {
redirect(`/dashboard/crm/enquiries/${id}`);
}
if (canRead) {
void queryClient.prefetchQuery(enquiryByIdOptions(id));
void queryClient.prefetchQuery(enquiryProjectPartiesOptions(id));
void queryClient.prefetchQuery(enquiryFollowupsOptions(id));
}
const referenceData =
canRead && session.user.activeOrganizationId
? await getEnquiryReferenceData(session.user.activeOrganizationId)
: null;
return (
<PageContainer
pageTitle='Lead Detail'
pageDescription='Marketing lead information and assignment handoff context.'
access={canRead}
accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
You do not have access to this lead.
</div>
}
>
{referenceData ? (
<HydrationBoundary state={dehydrate(queryClient)}>
<EnquiryDetail
enquiryId={id}
referenceData={referenceData}
relatedQuotations={[]}
workspace='lead'
canUpdate={canUpdate}
canAssign={canAssign}
canReassign={false}
canViewRelatedQuotations={false}
canManageFollowups={{
create: false,
update: false,
delete: false
}}
/>
</HydrationBoundary>
) : null}
</PageContainer>
);
}

View File

@@ -0,0 +1,82 @@
import { auth } from '@/auth';
import PageContainer from '@/components/layout/page-container';
import EnquiryListing from '@/features/crm/enquiries/components/enquiry-listing';
import { EnquiryFormSheetTrigger } from '@/features/crm/enquiries/components/enquiry-form-sheet';
import { getEnquiryReferenceData } from '@/features/crm/enquiries/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { searchParamsCache } from '@/lib/searchparams';
import type { SearchParams } from 'nuqs/server';
export const metadata = {
title: 'Dashboard: CRM Leads'
};
type PageProps = {
searchParams: Promise<SearchParams>;
};
export default async function LeadsRoute(props: PageProps) {
const searchParams = await props.searchParams;
const session = await auth();
const canRead =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmLeadRead)));
const canCreate =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmLeadCreate)));
const canUpdate =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmLeadUpdate)));
const canDelete =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmLeadDelete)));
const canAssign =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmLeadAssign)));
searchParamsCache.parse(searchParams);
const referenceData =
canRead && session?.user?.activeOrganizationId
? await getEnquiryReferenceData(session.user.activeOrganizationId)
: null;
return (
<PageContainer
pageTitle='Leads'
pageDescription='Marketing workspace for lead collection, qualification, nurturing, and sales assignment.'
access={canRead}
accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
You do not have access to CRM leads.
</div>
}
pageHeaderAction={
canCreate && referenceData ? (
<EnquiryFormSheetTrigger referenceData={referenceData} workspace='lead' />
) : null
}
>
{referenceData ? (
<EnquiryListing
referenceData={referenceData}
workspace='lead'
canUpdate={canUpdate}
canDelete={canDelete}
canAssign={canAssign}
canReassign={false}
/>
) : null}
</PageContainer>
);
}

View File

@@ -100,7 +100,15 @@ export const navGroups: NavGroup[] = [
access: { requireOrg: true, permission: "crm.customer.read" },
},
{
title: "Enquiries (Lead)",
title: "Leads",
url: "/dashboard/crm/leads",
icon: "forms",
isActive: false,
items: [],
access: { requireOrg: true, permission: "crm.lead.read" },
},
{
title: "Enquiries",
url: "/dashboard/crm/enquiries",
icon: "forms",
isActive: false,

View File

@@ -216,6 +216,7 @@ export const crmEnquiries = pgTable(
notes: text('notes'),
isHotProject: boolean('is_hot_project').default(false).notNull(),
isActive: boolean('is_active').default(true).notNull(),
pipelineStage: text('pipeline_stage').default('lead').notNull(),
assignedToUserId: text('assigned_to_user_id'),
assignedAt: timestamp('assigned_at', { withTimezone: true }),
assignedBy: text('assigned_by'),

View File

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

View File

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

View File

@@ -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>

View File

@@ -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) => (

View File

@@ -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>

View File

@@ -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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>

View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -47,6 +47,7 @@ const membershipRoleOptions = [
] as const;
const businessRoleOptions = [
{ value: 'marketing', label: 'Marketing' },
{ value: 'sales_manager', label: 'Sales Manager' },
{ value: 'sales', label: 'Sales' },
{ value: 'sales_support', label: 'Sales Support' },

View File

@@ -1,6 +1,7 @@
export const SYSTEM_ROLES = ['super_admin', 'user'] as const;
export const MEMBERSHIP_ROLES = ['admin', 'user'] as const;
export const BUSINESS_ROLES = [
'marketing',
'sales',
'sales_support',
'sales_manager',
@@ -18,6 +19,11 @@ export const PERMISSIONS = {
organizationManage: 'organization:manage',
usersManage: 'users:manage',
reportRead: 'report:read',
crmLeadRead: 'crm.lead.read',
crmLeadCreate: 'crm.lead.create',
crmLeadUpdate: 'crm.lead.update',
crmLeadAssign: 'crm.lead.assign',
crmLeadDelete: 'crm.lead.delete',
crmCustomerRead: 'crm.customer.read',
crmCustomerCreate: 'crm.customer.create',
crmCustomerUpdate: 'crm.customer.update',
@@ -76,6 +82,50 @@ export const PERMISSIONS = {
export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
const MANAGER_PERMISSIONS: Permission[] = [
PERMISSIONS.crmLeadRead,
PERMISSIONS.crmLeadCreate,
PERMISSIONS.crmLeadUpdate,
PERMISSIONS.crmLeadAssign,
PERMISSIONS.crmLeadDelete,
PERMISSIONS.crmCustomerRead,
PERMISSIONS.crmCustomerCreate,
PERMISSIONS.crmCustomerUpdate,
PERMISSIONS.crmContactRead,
PERMISSIONS.crmContactCreate,
PERMISSIONS.crmContactUpdate,
PERMISSIONS.crmEnquiryRead,
PERMISSIONS.crmEnquiryCreate,
PERMISSIONS.crmEnquiryUpdate,
PERMISSIONS.crmEnquiryAssign,
PERMISSIONS.crmEnquiryReassign,
PERMISSIONS.crmEnquiryFollowupRead,
PERMISSIONS.crmEnquiryFollowupCreate,
PERMISSIONS.crmEnquiryFollowupUpdate,
PERMISSIONS.crmQuotationRead,
PERMISSIONS.crmQuotationCreate,
PERMISSIONS.crmQuotationUpdate,
PERMISSIONS.crmQuotationItemManage,
PERMISSIONS.crmQuotationCustomerManage,
PERMISSIONS.crmQuotationTopicManage,
PERMISSIONS.crmQuotationFollowupManage,
PERMISSIONS.crmQuotationAttachmentManage,
PERMISSIONS.crmQuotationRevisionCreate,
PERMISSIONS.crmApprovalRead,
PERMISSIONS.crmApprovalSubmit,
PERMISSIONS.crmApprovalWorkflowRead,
PERMISSIONS.crmDashboardRead,
PERMISSIONS.crmDashboardExport,
PERMISSIONS.crmDocumentTemplateRead,
PERMISSIONS.crmDocumentSequenceRead,
PERMISSIONS.crmDocumentArtifactRead,
PERMISSIONS.crmDocumentArtifactDownload,
PERMISSIONS.crmDocumentArtifactVoid,
PERMISSIONS.crmQuotationDocumentPreview,
PERMISSIONS.crmQuotationPdfPreview,
PERMISSIONS.crmQuotationPdfDownload
];
function getMembershipBasePermissions(role: MembershipRole): Permission[] {
if (role === 'admin') {
return [
@@ -83,6 +133,11 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] {
PERMISSIONS.productsWrite,
PERMISSIONS.organizationManage,
PERMISSIONS.usersManage,
PERMISSIONS.crmLeadRead,
PERMISSIONS.crmLeadCreate,
PERMISSIONS.crmLeadUpdate,
PERMISSIONS.crmLeadAssign,
PERMISSIONS.crmLeadDelete,
PERMISSIONS.crmCustomerRead,
PERMISSIONS.crmCustomerCreate,
PERMISSIONS.crmCustomerUpdate,
@@ -141,8 +196,9 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] {
}
return [
PERMISSIONS.productsRead,
PERMISSIONS.crmCustomerRead,
PERMISSIONS.productsRead,
PERMISSIONS.crmLeadRead,
PERMISSIONS.crmCustomerRead,
PERMISSIONS.crmContactRead,
PERMISSIONS.crmEnquiryRead,
PERMISSIONS.crmEnquiryFollowupRead,
@@ -162,45 +218,23 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] {
export function getBusinessRolePermissions(role: BusinessRole): Permission[] {
switch (role) {
case 'sales_manager':
case 'marketing':
return [
PERMISSIONS.crmLeadRead,
PERMISSIONS.crmLeadCreate,
PERMISSIONS.crmLeadUpdate,
PERMISSIONS.crmLeadAssign,
PERMISSIONS.crmLeadDelete,
PERMISSIONS.crmCustomerRead,
PERMISSIONS.crmCustomerCreate,
PERMISSIONS.crmCustomerUpdate,
PERMISSIONS.crmContactRead,
PERMISSIONS.crmContactCreate,
PERMISSIONS.crmContactUpdate,
PERMISSIONS.crmEnquiryRead,
PERMISSIONS.crmEnquiryCreate,
PERMISSIONS.crmEnquiryUpdate,
PERMISSIONS.crmEnquiryAssign,
PERMISSIONS.crmEnquiryReassign,
PERMISSIONS.crmEnquiryFollowupRead,
PERMISSIONS.crmEnquiryFollowupCreate,
PERMISSIONS.crmEnquiryFollowupUpdate,
PERMISSIONS.crmQuotationRead,
PERMISSIONS.crmQuotationCreate,
PERMISSIONS.crmQuotationUpdate,
PERMISSIONS.crmQuotationItemManage,
PERMISSIONS.crmQuotationCustomerManage,
PERMISSIONS.crmQuotationTopicManage,
PERMISSIONS.crmQuotationFollowupManage,
PERMISSIONS.crmQuotationAttachmentManage,
PERMISSIONS.crmQuotationRevisionCreate,
PERMISSIONS.crmApprovalRead,
PERMISSIONS.crmApprovalSubmit,
PERMISSIONS.crmApprovalWorkflowRead,
PERMISSIONS.crmDashboardRead,
PERMISSIONS.crmDashboardExport,
PERMISSIONS.crmDocumentTemplateRead,
PERMISSIONS.crmDocumentSequenceRead,
PERMISSIONS.crmDocumentArtifactRead,
PERMISSIONS.crmDocumentArtifactDownload,
PERMISSIONS.crmDocumentArtifactVoid,
PERMISSIONS.crmQuotationDocumentPreview,
PERMISSIONS.crmQuotationPdfPreview,
PERMISSIONS.crmQuotationPdfDownload
PERMISSIONS.crmDashboardRead
];
case 'sales_manager':
return MANAGER_PERMISSIONS;
case 'sales':
return [
PERMISSIONS.crmCustomerRead,
@@ -264,6 +298,7 @@ export function getBusinessRolePermissions(role: BusinessRole): Permission[] {
];
case 'department_manager':
case 'top_manager':
return MANAGER_PERMISSIONS;
default:
return [];
}