This commit is contained in:
phaichayon
2026-06-22 16:57:02 +07:00
parent 3f28fde39f
commit ffa5de8311
33 changed files with 6706 additions and 101 deletions

View File

@@ -22,6 +22,7 @@ export async function POST(request: NextRequest, { params }: Params) {
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole,
permissions: membership.permissions ?? [],
branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',

View File

@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import {
enquiryMarkLostSchema
} from '@/features/crm/enquiries/schemas/enquiry.schema';
import { getEnquiryDetail, markEnquiryAsLost } from '@/features/crm/enquiries/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryMarkLost
});
const payload = enquiryMarkLostSchema.parse(await request.json());
const accessContext = {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole,
permissions: membership.permissions ?? [],
branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const before = await getEnquiryDetail(id, organization.id, accessContext);
const updated = await markEnquiryAsLost(id, organization.id, session.user.id, payload, accessContext);
const after = await getEnquiryDetail(id, organization.id, accessContext);
return NextResponse.json({
success: true,
message: 'Enquiry marked as lost successfully',
before,
enquiry: updated,
after
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ message: error.issues[0]?.message ?? 'Invalid payload' },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to mark enquiry as lost' }, { status: 500 });
}
}

View File

@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import {
enquiryMarkWonSchema
} from '@/features/crm/enquiries/schemas/enquiry.schema';
import { getEnquiryDetail, markEnquiryAsWon } from '@/features/crm/enquiries/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryMarkWon
});
const payload = enquiryMarkWonSchema.parse(await request.json());
const accessContext = {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole,
permissions: membership.permissions ?? [],
branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const before = await getEnquiryDetail(id, organization.id, accessContext);
const updated = await markEnquiryAsWon(id, organization.id, session.user.id, payload, accessContext);
const after = await getEnquiryDetail(id, organization.id, accessContext);
return NextResponse.json({
success: true,
message: 'Enquiry marked as won successfully',
before,
enquiry: updated,
after
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ message: error.issues[0]?.message ?? 'Invalid payload' },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to mark enquiry as won' }, { status: 500 });
}
}

View File

@@ -0,0 +1,83 @@
import { NextRequest, NextResponse } from 'next/server';
import { getEnquiryAttachment } from '@/features/crm/enquiries/server/service';
import { getStorageProvider } from '@/features/foundation/storage/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string; attachmentId: string }>;
};
function buildAccessContext(input: {
organizationId: string;
userId: string;
membership: {
role: string;
businessRole: string;
permissions?: string[] | null;
branchScopeIds?: string[] | null;
productTypeScopeIds?: string[] | null;
ownershipScope?: string | null;
branchScopeMode?: string | null;
productScopeMode?: string | null;
};
}) {
return {
organizationId: input.organizationId,
userId: input.userId,
membershipRole: input.membership.role,
businessRole: input.membership.businessRole,
permissions: input.membership.permissions ?? [],
branchScopeIds: input.membership.branchScopeIds ?? [],
productTypeScopeIds: input.membership.productTypeScopeIds ?? [],
ownershipScope: input.membership.ownershipScope ?? 'organization',
branchScopeMode: input.membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: input.membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
}
export async function GET(request: NextRequest, { params }: Params) {
try {
const { id, attachmentId } = await params;
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryRead
});
if (
membership.role !== 'admin' &&
!(membership.permissions ?? []).includes(PERMISSIONS.crmQuotationPricingRead)
) {
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
}
const attachment = await getEnquiryAttachment(
id,
attachmentId,
organization.id,
buildAccessContext({
organizationId: organization.id,
userId: session.user.id,
membership
})
);
const object = await getStorageProvider().getObject({ key: attachment.storageKey });
const download = request.nextUrl.searchParams.get('download') === '1';
return new NextResponse(object.body, {
headers: {
'Content-Type': attachment.fileType ?? object.contentType,
'Content-Disposition': `${download ? 'attachment' : 'inline'}; filename="${attachment.originalFileName}"`
}
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to load PO attachment' }, { status: 500 });
}
}

View File

@@ -0,0 +1,141 @@
import { NextRequest, NextResponse } from 'next/server';
import {
listEnquiryAttachments,
uploadPurchaseOrderAttachment
} from '@/features/crm/enquiries/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
function buildAccessContext(input: {
organizationId: string;
userId: string;
membership: {
role: string;
businessRole: string;
permissions?: string[] | null;
branchScopeIds?: string[] | null;
productTypeScopeIds?: string[] | null;
ownershipScope?: string | null;
branchScopeMode?: string | null;
productScopeMode?: string | null;
};
}) {
return {
organizationId: input.organizationId,
userId: input.userId,
membershipRole: input.membership.role,
businessRole: input.membership.businessRole,
permissions: input.membership.permissions ?? [],
branchScopeIds: input.membership.branchScopeIds ?? [],
productTypeScopeIds: input.membership.productTypeScopeIds ?? [],
ownershipScope: input.membership.ownershipScope ?? 'organization',
branchScopeMode: input.membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: input.membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
}
function canViewCommercialData(membership: { role: string; permissions?: string[] | null }) {
return (
membership.role === 'admin' ||
(membership.permissions ?? []).includes(PERMISSIONS.crmQuotationPricingRead)
);
}
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryRead
});
if (!canViewCommercialData(membership)) {
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
}
const items = await listEnquiryAttachments(
id,
organization.id,
buildAccessContext({
organizationId: organization.id,
userId: session.user.id,
membership
})
);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Enquiry purchase order attachments loaded successfully',
items
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to load PO attachments' }, { status: 500 });
}
}
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryMarkWon
});
if (!canViewCommercialData(membership)) {
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
}
const formData = await request.formData();
const file = formData.get('file');
if (!(file instanceof File)) {
return NextResponse.json({ message: 'PO attachment file is required' }, { status: 400 });
}
const created = await uploadPurchaseOrderAttachment({
enquiryId: id,
organizationId: organization.id,
userId: session.user.id,
description:
typeof formData.get('description') === 'string' ? (formData.get('description') as string) : null,
file: {
name: file.name,
type: file.type,
size: file.size,
buffer: Buffer.from(await file.arrayBuffer())
},
accessContext: buildAccessContext({
organizationId: organization.id,
userId: session.user.id,
membership
})
});
return NextResponse.json({
success: true,
message: 'PO attachment uploaded successfully',
attachment: created
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to upload PO attachment' }, { status: 500 });
}
}

View File

@@ -22,6 +22,7 @@ export async function POST(request: NextRequest, { params }: Params) {
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole,
permissions: membership.permissions ?? [],
branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',

View File

@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { enquiryReopenSchema } from '@/features/crm/enquiries/schemas/enquiry.schema';
import { getEnquiryDetail, reopenLostEnquiry } from '@/features/crm/enquiries/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryReopen
});
const payload = enquiryReopenSchema.parse(await request.json());
const accessContext = {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole,
permissions: membership.permissions ?? [],
branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const before = await getEnquiryDetail(id, organization.id, accessContext);
const updated = await reopenLostEnquiry(id, organization.id, session.user.id, payload, accessContext);
const after = await getEnquiryDetail(id, organization.id, accessContext);
return NextResponse.json({
success: true,
message: 'Enquiry reopened successfully',
before,
enquiry: updated,
after
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof z.ZodError) {
return NextResponse.json(
{ message: error.issues[0]?.message ?? 'Invalid payload' },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to reopen enquiry' }, { status: 500 });
}
}

View File

@@ -33,6 +33,7 @@ export async function GET(_request: NextRequest, { params }: Params) {
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole,
permissions: membership.permissions ?? [],
branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',
@@ -76,6 +77,7 @@ export async function PATCH(request: NextRequest, { params }: Params) {
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole,
permissions: membership.permissions ?? [],
branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',
@@ -131,6 +133,7 @@ export async function DELETE(_request: NextRequest, { params }: Params) {
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole,
permissions: membership.permissions ?? [],
branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',

View File

@@ -59,6 +59,26 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationRead)));
const canViewOutcomeCommercialData =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationPricingRead)));
const canMarkWon =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryMarkWon)));
const canMarkLost =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryMarkLost)));
const canReopen =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryReopen)));
const queryClient = getQueryClient();
if (canRead) {
@@ -98,6 +118,10 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
canAssign={canAssign}
canReassign={canReassign}
canViewRelatedQuotations={canReadQuotation}
canViewOutcomeCommercialData={canViewOutcomeCommercialData}
canMarkWon={canMarkWon}
canMarkLost={canMarkLost}
canReopen={canReopen}
canManageFollowups={{
create: canFollowupCreate,
update: canFollowupUpdate,

View File

@@ -46,6 +46,7 @@ export default async function LeadDetailRoute({ params }: PageProps) {
userId: session.user.id,
membershipRole: session.user.activeMembershipRole ?? 'user',
businessRole: session.user.activeBusinessRole ?? 'sales_support',
permissions: session.user.activePermissions ?? [],
branchScopeIds: session.user.activeBranchScopeIds ?? [],
productTypeScopeIds: session.user.activeProductTypeScopeIds ?? [],
ownershipScope: session.user.activeOwnershipScope ?? 'organization',
@@ -91,6 +92,10 @@ export default async function LeadDetailRoute({ params }: PageProps) {
canAssign={canAssign}
canReassign={false}
canViewRelatedQuotations={false}
canViewOutcomeCommercialData={false}
canMarkWon={false}
canMarkLost={false}
canReopen={false}
canManageFollowups={{
create: false,
update: false,

View File

@@ -317,6 +317,16 @@ export const crmEnquiries = pgTable(
isHotProject: boolean('is_hot_project').default(false).notNull(),
isActive: boolean('is_active').default(true).notNull(),
pipelineStage: text('pipeline_stage').default('lead').notNull(),
closedWonAt: timestamp('closed_won_at', { withTimezone: true }),
closedLostAt: timestamp('closed_lost_at', { withTimezone: true }),
closedByUserId: text('closed_by_user_id'),
poNumber: text('po_number'),
poDate: timestamp('po_date', { withTimezone: true }),
poAmount: doublePrecision('po_amount'),
poCurrency: text('po_currency'),
lostReason: text('lost_reason'),
lostCompetitor: text('lost_competitor'),
lostRemark: text('lost_remark'),
assignedToUserId: text('assigned_to_user_id'),
assignedAt: timestamp('assigned_at', { withTimezone: true }),
assignedBy: text('assigned_by'),
@@ -365,6 +375,25 @@ export const crmEnquiryCustomers = pgTable('crm_enquiry_customers', {
deletedAt: timestamp('deleted_at', { withTimezone: true })
});
export const crmEnquiryAttachments = pgTable('crm_enquiry_attachments', {
id: text('id').primaryKey(),
organizationId: text('organization_id').notNull(),
enquiryId: text('enquiry_id').notNull(),
category: text('category').notNull(),
fileName: text('file_name').notNull(),
originalFileName: text('original_file_name').notNull(),
storageProvider: text('storage_provider').notNull(),
storageKey: text('storage_key').notNull(),
fileSize: integer('file_size'),
fileType: text('file_type'),
description: text('description'),
uploadedAt: timestamp('uploaded_at', { withTimezone: true }).defaultNow().notNull(),
uploadedBy: text('uploaded_by').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
deletedAt: timestamp('deleted_at', { withTimezone: true })
});
export const crmQuotations = pgTable(
'crm_quotations',
{

View File

@@ -285,6 +285,36 @@ const FOUNDATION_OPTIONS = {
{ code: 'email', label: 'Email', value: 'email', sortOrder: 4 },
{ code: 'line', label: 'LINE', value: 'line', sortOrder: 5 }
],
crm_lost_reason: [
{ code: 'competitor_won', label: 'Competitor Won', value: 'competitor_won', sortOrder: 1 },
{
code: 'budget_not_approved',
label: 'Budget Not Approved',
value: 'budget_not_approved',
sortOrder: 2
},
{
code: 'project_cancelled',
label: 'Project Cancelled',
value: 'project_cancelled',
sortOrder: 3
},
{
code: 'customer_no_response',
label: 'Customer No Response',
value: 'customer_no_response',
sortOrder: 4
},
{
code: 'technical_requirement_mismatch',
label: 'Technical Requirement Mismatch',
value: 'technical_requirement_mismatch',
sortOrder: 5
},
{ code: 'price_too_high', label: 'Price Too High', value: 'price_too_high', sortOrder: 6 },
{ code: 'timeline_not_fit', label: 'Timeline Not Fit', value: 'timeline_not_fit', sortOrder: 7 },
{ code: 'other', label: 'Other', value: 'other', sortOrder: 8 }
],
crm_job_title: [
{ code: 'sales', label: 'Sales Engineer', value: 'sales', sortOrder: 1 },
{ code: 'sales_support', label: 'Sales Support', value: 'sales_support', sortOrder: 2 },

View File

@@ -37,6 +37,7 @@ export interface CrmDashboardSummary {
activeEnquiries: number;
wonCount: number;
lostCount: number;
winRate: number;
hotEnquiries: number;
enquiryValue: number;
};
@@ -150,6 +151,11 @@ export interface CrmDashboardResponse {
myPendingApprovals: CrmDashboardApprovalRow[];
};
hotProjects: CrmDashboardHotProjectRow[];
outcomeAnalytics: {
winRate: number;
lostByReason: Array<{ key: string; label: string; count: number; revenue: number }>;
lostByCompetitor: Array<{ key: string; label: string; count: number; revenue: number }>;
};
visibility: {
canViewCommercialData: boolean;
canViewApprovalData: boolean;

View File

@@ -70,7 +70,7 @@ export function DashboardSummaryCards({
{ label: 'จำนวนโอกาสขาย', value: summary.enquiry.enquiryCount },
{ label: 'โอกาสขายที่กำลังดำเนินการ', value: summary.enquiry.activeEnquiries },
{ label: 'จำนวนงานที่ชนะ', value: summary.enquiry.wonCount },
{ label: 'จำนวนงานที่แพ้', value: summary.enquiry.lostCount }
{ label: 'Win Rate', value: summary.enquiry.winRate, suffix: '%' }
]}
/>
{canViewCommercialData ? (

View File

@@ -30,7 +30,12 @@ import {
getRevenueByBillingCustomer,
getRevenueByConsultant,
getRevenueByContractor,
getRevenueByEndCustomer
getRevenueByEndCustomer,
getLostByCompetitor,
getLostByReason,
getLostRevenue,
getWinRate,
getWonRevenue
} from '@/features/crm/reporting/server/service';
import { getPipelineStageThaiLabel, getProjectPartyRoleThaiLabel } from '@/features/crm/shared/terminology';
import type {
@@ -281,7 +286,8 @@ async function loadScopedRows(
function buildSummary(
scopedEnquiries: Array<typeof crmEnquiries.$inferSelect>,
scopedQuotations: Array<typeof crmQuotations.$inferSelect>,
statusMaps: StatusMaps
statusMaps: StatusMaps,
outcomeMetrics: { wonValue: number; lostValue: number; winRate: number }
): CrmDashboardSummary {
const now = new Date();
const leadRows = scopedEnquiries.filter((row) => isPipelineStage(row, 'lead'));
@@ -304,6 +310,7 @@ function buildSummary(
activeEnquiries: enquiryRows.length,
wonCount: wonRows.length,
lostCount: lostRows.length,
winRate: outcomeMetrics.winRate,
hotEnquiries: enquiryRows.filter((row) => row.isHotProject).length,
enquiryValue: round2(enquiryRows.reduce((sum, row) => sum + (row.estimatedValue ?? 0), 0))
},
@@ -319,18 +326,8 @@ function buildSummary(
.filter((row) => (statusMaps.quotationStatusById.get(row.status) ?? '') !== 'cancelled')
.reduce((sum, row) => sum + row.totalAmount, 0)
),
wonValue: round2(
scopedQuotations
.filter((row) => (statusMaps.quotationStatusById.get(row.status) ?? '') === 'accepted')
.reduce((sum, row) => sum + row.totalAmount, 0)
),
lostValue: round2(
scopedQuotations
.filter((row) =>
['lost', 'rejected'].includes(statusMaps.quotationStatusById.get(row.status) ?? '')
)
.reduce((sum, row) => sum + row.totalAmount, 0)
)
wonValue: round2(outcomeMetrics.wonValue),
lostValue: round2(outcomeMetrics.lostValue)
}
};
}
@@ -387,9 +384,7 @@ function buildFunnel(
key: 'closed_won',
label: getPipelineStageThaiLabel('closed_won'),
count: summary.enquiry.wonCount,
value: scopedQuotations
.filter((row) => (statusMaps.quotationStatusById.get(row.status) ?? '') === 'accepted')
.reduce((sum, row) => sum + row.totalAmount, 0)
value: summary.revenue.wonValue
}
];
@@ -463,7 +458,8 @@ async function buildRevenueAnalytics(
async function buildSalesRanking(
scopedEnquiries: Array<typeof crmEnquiries.$inferSelect>,
scopedQuotations: Array<typeof crmQuotations.$inferSelect>,
statusMaps: StatusMaps
statusMaps: StatusMaps,
wonRevenueRows: Array<{ assignedToUserId: string | null; revenue: number }>
): Promise<CrmDashboardSalesRankingRow[]> {
const userIds = [
...new Set(
@@ -529,11 +525,29 @@ async function buildSalesRanking(
current.approvedQuotations += 1;
}
if (statusCode === 'accepted') {
current.wonRevenue += row.totalAmount;
rankingMap.set(row.salesmanId, current);
}
for (const row of wonRevenueRows) {
if (!row.assignedToUserId) {
continue;
}
rankingMap.set(row.salesmanId, current);
const current =
rankingMap.get(row.assignedToUserId) ??
{
salesPersonId: row.assignedToUserId,
salesPersonName: userMap.get(row.assignedToUserId) ?? 'Unknown',
leadCount: 0,
enquiryCount: 0,
quotationCount: 0,
approvedQuotations: 0,
wonRevenue: 0,
conversionRate: 0
};
current.wonRevenue += row.revenue;
rankingMap.set(row.assignedToUserId, current);
}
return [...rankingMap.values()]
@@ -776,12 +790,33 @@ export async function getCrmDashboardData(
const filters = normalizeDashboardFilters(rawFilters);
const allowCommercialData = canViewCommercialData(context);
const allowApprovalData = canViewApprovalData(context);
const outcomeFilters = {
dateFrom: filters.dateFrom,
dateTo: filters.dateTo,
branch: filters.branch,
salesman: filters.salesman,
productType: filters.productType
};
const [referenceData, statusMaps, scoped] = await Promise.all([
getReferenceData(context.organizationId),
getStatusMaps(context.organizationId),
loadScopedRows(context, filters)
]);
const summary = buildSummary(scoped.enquiries, scoped.quotations, statusMaps);
const [wonRevenueRows, lostRevenueRows, winRate, lostByReasonRows, lostByCompetitorRows] =
allowCommercialData
? await Promise.all([
getWonRevenue(context.organizationId, outcomeFilters),
getLostRevenue(context.organizationId, outcomeFilters),
getWinRate(context.organizationId, outcomeFilters),
getLostByReason(context.organizationId, outcomeFilters),
getLostByCompetitor(context.organizationId, outcomeFilters)
])
: [[], [], 0, [], []];
const summary = buildSummary(scoped.enquiries, scoped.quotations, statusMaps, {
wonValue: wonRevenueRows.reduce((sum, row) => sum + row.revenue, 0),
lostValue: lostRevenueRows.reduce((sum, row) => sum + row.revenue, 0),
winRate
});
const [revenueAnalytics, salesRanking, followups, approvals, hotProjects] = await Promise.all([
allowCommercialData
? buildRevenueAnalytics(context.organizationId, filters)
@@ -792,7 +827,7 @@ export async function getCrmDashboardData(
topConsultants: []
}),
allowCommercialData
? buildSalesRanking(scoped.enquiries, scoped.quotations, statusMaps)
? buildSalesRanking(scoped.enquiries, scoped.quotations, statusMaps, wonRevenueRows)
: Promise.resolve([]),
buildFollowups(context, filters),
allowApprovalData
@@ -825,6 +860,11 @@ export async function getCrmDashboardData(
followups,
approvals,
hotProjects,
outcomeAnalytics: {
winRate,
lostByReason: lostByReasonRows,
lostByCompetitor: lostByCompetitorRows
},
visibility: {
canViewCommercialData: allowCommercialData,
canViewApprovalData: allowApprovalData

View File

@@ -6,7 +6,10 @@ import {
createEnquiryFollowup,
deleteEnquiry,
deleteEnquiryFollowup,
markEnquiryLost,
markEnquiryWon,
reassignEnquiry,
reopenEnquiry,
updateEnquiry,
updateEnquiryFollowup
} from './service';
@@ -14,6 +17,8 @@ import { enquiryKeys } from './queries';
import type {
EnquiryAssignmentMutationPayload,
EnquiryFollowupMutationPayload,
EnquiryMarkLostPayload,
EnquiryMarkWonPayload,
EnquiryMutationPayload
} from './types';
import { crmDashboardKeys } from '@/features/crm/dashboard/api/queries';
@@ -38,11 +43,16 @@ async function invalidateEnquiryFollowups(id: string) {
await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.followups(id) });
}
async function invalidateEnquiryAttachments(id: string) {
await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.attachments(id) });
}
export async function invalidateEnquiryMutationQueries(id: string) {
await Promise.all([
invalidateEnquiryLists(),
invalidateEnquiryDetail(id),
invalidateEnquiryProjectParties(id),
invalidateEnquiryAttachments(id),
invalidateDashboardQueries()
]);
}
@@ -148,3 +158,33 @@ export const deleteEnquiryFollowupMutation = mutationOptions({
}
}
});
export const markEnquiryWonMutation = mutationOptions({
mutationFn: ({ id, values }: { id: string; values: EnquiryMarkWonPayload }) =>
markEnquiryWon(id, values),
onSettled: async (_data, error, variables) => {
if (!error) {
await invalidateEnquiryMutationQueries(variables.id);
}
}
});
export const markEnquiryLostMutation = mutationOptions({
mutationFn: ({ id, values }: { id: string; values: EnquiryMarkLostPayload }) =>
markEnquiryLost(id, values),
onSettled: async (_data, error, variables) => {
if (!error) {
await invalidateEnquiryMutationQueries(variables.id);
}
}
});
export const reopenEnquiryMutation = mutationOptions({
mutationFn: ({ id, reason }: { id: string; reason: string }) =>
reopenEnquiry(id, { reopenReason: reason }),
onSettled: async (_data, error, variables) => {
if (!error) {
await invalidateEnquiryMutationQueries(variables.id);
}
}
});

View File

@@ -1,5 +1,6 @@
import { queryOptions } from '@tanstack/react-query';
import {
getEnquiryAttachments,
getEnquiries,
getEnquiryById,
getEnquiryFollowups,
@@ -16,7 +17,9 @@ export const enquiryKeys = {
projectPartiesRoot: () => [...enquiryKeys.all, 'project-parties'] as const,
projectParties: (id: string) => [...enquiryKeys.projectPartiesRoot(), id] as const,
followupsRoot: () => [...enquiryKeys.all, 'followups'] as const,
followups: (id: string) => [...enquiryKeys.followupsRoot(), id] as const
followups: (id: string) => [...enquiryKeys.followupsRoot(), id] as const,
attachmentsRoot: () => [...enquiryKeys.all, 'attachments'] as const,
attachments: (id: string) => [...enquiryKeys.attachmentsRoot(), id] as const
};
export const enquiriesQueryOptions = (filters: EnquiryFilters) =>
@@ -42,3 +45,9 @@ export const enquiryFollowupsOptions = (id: string) =>
queryKey: enquiryKeys.followups(id),
queryFn: () => getEnquiryFollowups(id)
});
export const enquiryAttachmentsOptions = (id: string) =>
queryOptions({
queryKey: enquiryKeys.attachments(id),
queryFn: () => getEnquiryAttachments(id)
});

View File

@@ -1,13 +1,17 @@
import { apiClient } from '@/lib/api-client';
import type {
EnquiryAttachmentsResponse,
EnquiryAssignmentMutationPayload,
EnquiryDetailResponse,
EnquiryFilters,
EnquiryFollowupMutationPayload,
EnquiryFollowupsResponse,
EnquiryListResponse,
EnquiryMarkLostPayload,
EnquiryMarkWonPayload,
EnquiryMutationPayload,
EnquiryProjectPartiesResponse,
EnquiryReopenPayload,
MutationSuccessResponse
} from './types';
@@ -76,6 +80,10 @@ export async function getEnquiryFollowups(id: string): Promise<EnquiryFollowupsR
return apiClient<EnquiryFollowupsResponse>(`/crm/enquiries/${id}/followups`);
}
export async function getEnquiryAttachments(id: string): Promise<EnquiryAttachmentsResponse> {
return apiClient<EnquiryAttachmentsResponse>(`/crm/enquiries/${id}/po-attachments`);
}
export async function createEnquiryFollowup(
enquiryId: string,
data: EnquiryFollowupMutationPayload
@@ -102,3 +110,24 @@ export async function deleteEnquiryFollowup(enquiryId: string, followupId: strin
method: 'DELETE'
});
}
export async function markEnquiryWon(id: string, data: EnquiryMarkWonPayload) {
return apiClient<MutationSuccessResponse>(`/crm/enquiries/${id}/mark-won`, {
method: 'POST',
body: JSON.stringify(data)
});
}
export async function markEnquiryLost(id: string, data: EnquiryMarkLostPayload) {
return apiClient<MutationSuccessResponse>(`/crm/enquiries/${id}/mark-lost`, {
method: 'POST',
body: JSON.stringify(data)
});
}
export async function reopenEnquiry(id: string, data: EnquiryReopenPayload) {
return apiClient<MutationSuccessResponse>(`/crm/enquiries/${id}/reopen`, {
method: 'POST',
body: JSON.stringify(data)
});
}

View File

@@ -56,6 +56,25 @@ export interface EnquiryAssignableUserLookup {
businessRole: string;
}
export interface EnquiryAttachmentRecord {
id: string;
organizationId: string;
enquiryId: string;
category: string;
fileName: string;
originalFileName: string;
storageProvider: string;
storageKey: string;
fileSize: number | null;
fileType: string | null;
description: string | null;
uploadedAt: string;
uploadedBy: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
export type EnquiryPipelineStage = 'lead' | 'enquiry' | 'closed_won' | 'closed_lost';
export interface EnquiryRecord {
@@ -83,6 +102,19 @@ export interface EnquiryRecord {
isHotProject: boolean;
isActive: boolean;
pipelineStage: EnquiryPipelineStage;
closedWonAt: string | null;
closedLostAt: string | null;
closedByUserId: string | null;
closedByName: string | null;
poNumber: string | null;
poDate: string | null;
poAmount: number | null;
poCurrency: string | null;
lostReason: string | null;
lostReasonCode: string | null;
lostReasonLabel: string | null;
lostCompetitor: string | null;
lostRemark: string | null;
assignedToUserId: string | null;
assignedToName: string | null;
assignedAt: string | null;
@@ -140,6 +172,7 @@ export interface EnquiryReferenceData {
priorities: EnquiryOption[];
leadChannels: EnquiryOption[];
followupTypes: EnquiryOption[];
lostReasons: EnquiryOption[];
projectPartyRoles: EnquiryOption[];
customers: EnquiryCustomerLookup[];
contacts: EnquiryContactLookup[];
@@ -191,6 +224,13 @@ export interface EnquiryFollowupsResponse {
items: EnquiryFollowupRecord[];
}
export interface EnquiryAttachmentsResponse {
success: boolean;
time: string;
message: string;
items: EnquiryAttachmentRecord[];
}
export interface EnquiryProjectPartyMutationPayload {
customerId: string;
role: string;
@@ -237,6 +277,24 @@ export interface EnquiryAssignmentMutationPayload {
remark?: string;
}
export interface EnquiryMarkWonPayload {
poNumber: string;
poDate: string;
poAmount?: number | null;
poCurrency?: string | null;
remark?: string | null;
}
export interface EnquiryMarkLostPayload {
lostReason: string;
lostCompetitor?: string | null;
lostRemark?: string | null;
}
export interface EnquiryReopenPayload {
reopenReason: string;
}
export interface EnquiryCustomerRelationItem {
id: string;
code: string;

View File

@@ -16,6 +16,7 @@ import type { EnquiryRecord, EnquiryReferenceData } from '../api/types';
import { EnquiryAssignmentDialog } from './enquiry-assignment-dialog';
import { EnquiryFollowupsTab } from './enquiry-followups-tab';
import { EnquiryFormSheet } from './enquiry-form-sheet';
import { EnquiryOutcomeCard } from './enquiry-outcome-card';
import { EnquiryStatusBadge } from './enquiry-status-badge';
function FieldItem({ label, value }: { label: string; value: string | null | undefined }) {
@@ -40,6 +41,10 @@ interface EnquiryDetailProps {
canAssign: boolean;
canReassign: boolean;
canViewRelatedQuotations: boolean;
canViewOutcomeCommercialData: boolean;
canMarkWon: boolean;
canMarkLost: boolean;
canReopen: boolean;
canManageFollowups: {
create: boolean;
update: boolean;
@@ -56,6 +61,10 @@ export function EnquiryDetail({
canAssign,
canReassign,
canViewRelatedQuotations,
canViewOutcomeCommercialData,
canMarkWon,
canMarkLost,
canReopen,
canManageFollowups
}: EnquiryDetailProps) {
const { data } = useSuspenseQuery(enquiryByIdOptions(enquiryId));
@@ -374,6 +383,14 @@ export function EnquiryDetail({
</div>
<div className='space-y-6'>
<EnquiryOutcomeCard
enquiryId={enquiryId}
referenceData={referenceData}
canViewCommercialData={canViewOutcomeCommercialData}
canMarkWon={canMarkWon}
canMarkLost={canMarkLost}
canReopen={canReopen}
/>
<Card>
<CardHeader>
<CardTitle></CardTitle>

View File

@@ -0,0 +1,465 @@
'use client';
import Link from 'next/link';
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Icons } from '@/components/icons';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import {
invalidateEnquiryMutationQueries,
markEnquiryLostMutation,
markEnquiryWonMutation,
reopenEnquiryMutation
} from '../api/mutations';
import { enquiryAttachmentsOptions, enquiryByIdOptions } from '../api/queries';
import type { EnquiryReferenceData } from '../api/types';
function FieldItem({ label, value }: { label: string; value: string | null | undefined }) {
return (
<div className='space-y-1'>
<div className='text-muted-foreground text-xs'>{label}</div>
<div className='text-sm'>{value || '-'}</div>
</div>
);
}
function getOutcomeLabel(stage: string) {
if (stage === 'closed_won') return 'Won';
if (stage === 'closed_lost') return 'Lost';
return 'Open';
}
type EnquiryOutcomeCardProps = {
enquiryId: string;
referenceData: EnquiryReferenceData;
canViewCommercialData: boolean;
canMarkWon: boolean;
canMarkLost: boolean;
canReopen: boolean;
};
export function EnquiryOutcomeCard({
enquiryId,
referenceData,
canViewCommercialData,
canMarkWon,
canMarkLost,
canReopen
}: EnquiryOutcomeCardProps) {
const { data } = useSuspenseQuery(enquiryByIdOptions(enquiryId));
const attachmentsQuery = useQuery({
...enquiryAttachmentsOptions(enquiryId),
enabled: canViewCommercialData
});
const enquiry = data.enquiry;
const [wonOpen, setWonOpen] = useState(false);
const [lostOpen, setLostOpen] = useState(false);
const [reopenOpen, setReopenOpen] = useState(false);
const [poNumber, setPoNumber] = useState('');
const [poDate, setPoDate] = useState('');
const [poAmount, setPoAmount] = useState('');
const [poCurrency, setPoCurrency] = useState('THB');
const [wonRemark, setWonRemark] = useState('');
const [lostReason, setLostReason] = useState('');
const [lostCompetitor, setLostCompetitor] = useState('');
const [lostRemark, setLostRemark] = useState('');
const [reopenReason, setReopenReason] = useState('');
const [poFile, setPoFile] = useState<File | null>(null);
const [poFileDescription, setPoFileDescription] = useState('');
const wonMutation = useMutation({
...markEnquiryWonMutation,
onSuccess: async () => {
toast.success('Marked enquiry as won');
setWonOpen(false);
await invalidateEnquiryMutationQueries(enquiryId);
},
onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to mark won')
});
const lostMutation = useMutation({
...markEnquiryLostMutation,
onSuccess: async () => {
toast.success('Marked enquiry as lost');
setLostOpen(false);
await invalidateEnquiryMutationQueries(enquiryId);
},
onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to mark lost')
});
const reopenMutation = useMutation({
...reopenEnquiryMutation,
onSuccess: async () => {
toast.success('Reopened enquiry');
setReopenOpen(false);
await invalidateEnquiryMutationQueries(enquiryId);
},
onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to reopen enquiry')
});
const uploadMutation = useMutation({
mutationFn: async () => {
if (!poFile) {
throw new Error('Please choose a PO attachment file');
}
const formData = new FormData();
formData.append('file', poFile);
formData.append('description', poFileDescription);
const response = await fetch(`/api/crm/enquiries/${enquiryId}/po-attachments`, {
method: 'POST',
body: formData
});
if (!response.ok) {
const payload = (await response.json().catch(() => null)) as { message?: string } | null;
throw new Error(payload?.message ?? 'Failed to upload PO attachment');
}
return response.json();
},
onSuccess: async () => {
toast.success('PO attachment uploaded');
setPoFile(null);
setPoFileDescription('');
await invalidateEnquiryMutationQueries(enquiryId);
},
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to upload PO attachment')
});
const closedDate = enquiry.closedWonAt ?? enquiry.closedLostAt;
const lostReasonLabel = useMemo(() => {
if (!enquiry.lostReason) {
return null;
}
return (
enquiry.lostReasonLabel ??
referenceData.lostReasons.find(
(item) => item.id === enquiry.lostReason || item.code === enquiry.lostReason
)?.label ??
enquiry.lostReason
);
}, [enquiry.lostReason, enquiry.lostReasonLabel, referenceData.lostReasons]);
const attachmentItems = attachmentsQuery.data?.items ?? [];
return (
<>
<Card>
<CardHeader>
<CardTitle>Outcome</CardTitle>
<CardDescription>Official won/lost lifecycle and purchase order tracking.</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<div className='flex items-center justify-between gap-3'>
<Badge variant={enquiry.pipelineStage === 'closed_won' ? 'default' : enquiry.pipelineStage === 'closed_lost' ? 'destructive' : 'secondary'}>
{getOutcomeLabel(enquiry.pipelineStage)}
</Badge>
<div className='flex flex-wrap gap-2'>
{enquiry.pipelineStage === 'enquiry' && canMarkWon ? (
<Button size='sm' onClick={() => {
setPoNumber(enquiry.poNumber ?? '');
setPoDate(enquiry.poDate ? enquiry.poDate.slice(0, 10) : '');
setPoAmount(enquiry.poAmount !== null && enquiry.poAmount !== undefined ? String(enquiry.poAmount) : '');
setPoCurrency(enquiry.poCurrency ?? 'THB');
setWonRemark('');
setWonOpen(true);
}}>
<Icons.check className='mr-2 h-4 w-4' />
Mark Won
</Button>
) : null}
{enquiry.pipelineStage === 'enquiry' && canMarkLost ? (
<Button variant='destructive' size='sm' onClick={() => {
setLostReason(enquiry.lostReason ?? '');
setLostCompetitor(enquiry.lostCompetitor ?? '');
setLostRemark(enquiry.lostRemark ?? '');
setLostOpen(true);
}}>
<Icons.xCircle className='mr-2 h-4 w-4' />
Mark Lost
</Button>
) : null}
{enquiry.pipelineStage === 'closed_lost' && canReopen ? (
<Button variant='outline' size='sm' onClick={() => setReopenOpen(true)}>
Reopen
</Button>
) : null}
</div>
</div>
<div className='grid gap-4 md:grid-cols-2'>
<FieldItem label='Closed By' value={enquiry.closedByName} />
<FieldItem
label='Closed Date'
value={closedDate ? new Date(closedDate).toLocaleString() : null}
/>
<FieldItem label='PO Number' value={enquiry.poNumber} />
<FieldItem
label='PO Date'
value={enquiry.poDate ? new Date(enquiry.poDate).toLocaleDateString() : null}
/>
<FieldItem
label='PO Amount'
value={
canViewCommercialData && enquiry.poAmount !== null
? `${enquiry.poAmount.toLocaleString()} ${enquiry.poCurrency ?? ''}`.trim()
: canViewCommercialData
? null
: 'Restricted'
}
/>
<FieldItem label='Lost Reason' value={lostReasonLabel} />
<FieldItem label='Lost Competitor' value={enquiry.lostCompetitor} />
<FieldItem label='Remark' value={enquiry.lostRemark} />
</div>
{canViewCommercialData && enquiry.pipelineStage === 'closed_won' ? (
<div className='space-y-4 rounded-lg border p-4'>
<div className='space-y-1'>
<div className='font-medium'>Purchase Order Attachment</div>
<div className='text-muted-foreground text-sm'>
Upload PDF, image, or Excel files for the received PO.
</div>
</div>
<div className='grid gap-3 md:grid-cols-2'>
<div className='space-y-2'>
<Label htmlFor='po-file'>PO file</Label>
<Input
id='po-file'
type='file'
accept='.pdf,.png,.jpg,.jpeg,.webp,.xls,.xlsx'
onChange={(event) => setPoFile(event.target.files?.[0] ?? null)}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='po-file-description'>Description</Label>
<Input
id='po-file-description'
value={poFileDescription}
onChange={(event) => setPoFileDescription(event.target.value)}
placeholder='Optional note for this file'
/>
</div>
</div>
<Button
type='button'
variant='outline'
onClick={() => uploadMutation.mutate()}
isLoading={uploadMutation.isPending}
disabled={!poFile}
>
<Icons.upload className='mr-2 h-4 w-4' />
Upload PO
</Button>
{!attachmentItems.length ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-4 text-sm'>
No PO attachments uploaded yet.
</div>
) : (
<div className='space-y-2'>
{attachmentItems.map((attachment) => (
<div
key={attachment.id}
className='flex flex-col gap-2 rounded-lg border p-3 md:flex-row md:items-center md:justify-between'
>
<div className='space-y-1'>
<div className='font-medium'>{attachment.originalFileName}</div>
<div className='text-muted-foreground text-xs'>
{attachment.fileType ?? 'Unknown type'}
{attachment.fileSize ? `${attachment.fileSize.toLocaleString()} bytes` : ''}
</div>
</div>
<div className='flex gap-2'>
<Button asChild variant='outline' size='sm'>
<Link href={`/api/crm/enquiries/${enquiryId}/po-attachments/${attachment.id}`}>
View
</Link>
</Button>
<Button asChild variant='outline' size='sm'>
<Link href={`/api/crm/enquiries/${enquiryId}/po-attachments/${attachment.id}?download=1`}>
Download
</Link>
</Button>
</div>
</div>
))}
</div>
)}
</div>
) : null}
</CardContent>
</Card>
<Dialog open={wonOpen} onOpenChange={setWonOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Mark Won</DialogTitle>
<DialogDescription>PO received is the official trigger for closed won.</DialogDescription>
</DialogHeader>
<div className='grid gap-4 py-2'>
<div className='space-y-2'>
<Label htmlFor='po-number'>PO Number</Label>
<Input id='po-number' value={poNumber} onChange={(e) => setPoNumber(e.target.value)} />
</div>
<div className='space-y-2'>
<Label htmlFor='po-date'>PO Date</Label>
<Input id='po-date' type='date' value={poDate} onChange={(e) => setPoDate(e.target.value)} />
</div>
<div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-2'>
<Label htmlFor='po-amount'>PO Amount</Label>
<Input id='po-amount' value={poAmount} onChange={(e) => setPoAmount(e.target.value)} />
</div>
<div className='space-y-2'>
<Label>PO Currency</Label>
<Select value={poCurrency} onValueChange={setPoCurrency}>
<SelectTrigger>
<SelectValue placeholder='Select currency' />
</SelectTrigger>
<SelectContent>
<SelectItem value='THB'>THB</SelectItem>
<SelectItem value='USD'>USD</SelectItem>
<SelectItem value='EUR'>EUR</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className='space-y-2'>
<Label htmlFor='won-remark'>Remark</Label>
<Textarea id='won-remark' value={wonRemark} onChange={(e) => setWonRemark(e.target.value)} />
</div>
</div>
<DialogFooter>
<Button variant='outline' onClick={() => setWonOpen(false)}>
Cancel
</Button>
<Button
isLoading={wonMutation.isPending}
onClick={() =>
wonMutation.mutate({
id: enquiryId,
values: {
poNumber,
poDate,
poAmount: poAmount.trim() ? Number(poAmount) : null,
poCurrency,
remark: wonRemark
}
})
}
>
Confirm Won
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={lostOpen} onOpenChange={setLostOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Mark Lost</DialogTitle>
<DialogDescription>Lost reason is required for every closed lost outcome.</DialogDescription>
</DialogHeader>
<div className='grid gap-4 py-2'>
<div className='space-y-2'>
<Label>Lost Reason</Label>
<Select value={lostReason} onValueChange={setLostReason}>
<SelectTrigger>
<SelectValue placeholder='Select lost reason' />
</SelectTrigger>
<SelectContent>
{referenceData.lostReasons.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label htmlFor='lost-competitor'>Lost Competitor</Label>
<Input
id='lost-competitor'
value={lostCompetitor}
onChange={(e) => setLostCompetitor(e.target.value)}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='lost-remark'>Lost Remark</Label>
<Textarea id='lost-remark' value={lostRemark} onChange={(e) => setLostRemark(e.target.value)} />
</div>
</div>
<DialogFooter>
<Button variant='outline' onClick={() => setLostOpen(false)}>
Cancel
</Button>
<Button
variant='destructive'
isLoading={lostMutation.isPending}
onClick={() =>
lostMutation.mutate({
id: enquiryId,
values: {
lostReason,
lostCompetitor,
lostRemark
}
})
}
>
Confirm Lost
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={reopenOpen} onOpenChange={setReopenOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Reopen Opportunity</DialogTitle>
<DialogDescription>Only lost opportunities can be reopened.</DialogDescription>
</DialogHeader>
<div className='space-y-2 py-2'>
<Label htmlFor='reopen-reason'>Reopen Reason</Label>
<Textarea
id='reopen-reason'
value={reopenReason}
onChange={(e) => setReopenReason(e.target.value)}
/>
</div>
<DialogFooter>
<Button variant='outline' onClick={() => setReopenOpen(false)}>
Cancel
</Button>
<Button
isLoading={reopenMutation.isPending}
onClick={() => reopenMutation.mutate({ id: enquiryId, reason: reopenReason })}
>
Reopen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -73,5 +73,29 @@ export const enquiryFollowupSchema = z.object({
nextAction: z.string().optional()
});
export const enquiryMarkWonSchema = z.object({
poNumber: z.string().min(1, 'PO number is required'),
poDate: z.string().min(1, 'PO date is required'),
poAmount: coerceOptionalNumber('PO amount must be a number').refine(
(value) => value === undefined || value >= 0,
'PO amount must be 0 or greater'
),
poCurrency: z.string().optional().nullable(),
remark: z.string().optional().nullable()
});
export const enquiryMarkLostSchema = z.object({
lostReason: z.string().min(1, 'Lost reason is required'),
lostCompetitor: z.string().optional().nullable(),
lostRemark: z.string().optional().nullable()
});
export const enquiryReopenSchema = z.object({
reopenReason: z.string().min(3, 'Reopen reason is required')
});
export type EnquiryFormValues = z.input<typeof enquirySchema>;
export type EnquiryFollowupFormValues = z.input<typeof enquiryFollowupSchema>;
export type EnquiryMarkWonFormValues = z.input<typeof enquiryMarkWonSchema>;
export type EnquiryMarkLostFormValues = z.input<typeof enquiryMarkLostSchema>;
export type EnquiryReopenFormValues = z.input<typeof enquiryReopenSchema>;

View File

@@ -3,6 +3,7 @@ import {
crmCustomerContacts,
crmCustomers,
crmEnquiries,
crmEnquiryAttachments,
crmEnquiryCustomers,
crmEnquiryFollowups,
crmQuotations,
@@ -12,13 +13,15 @@ import {
import { db } from '@/lib/db';
import { AuthError } from '@/lib/auth/session';
import { hasBranchScopeAccess, hasProductScopeAccess } from '@/lib/auth/crm-access';
import { listAuditLogs } from '@/features/foundation/audit-log/service';
import { auditAction, 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 { buildOrganizationStorageKey, getStorageProvider } from '@/features/foundation/storage/service';
import type {
EnquiryActivityRecord,
EnquiryAssignableUserLookup,
EnquiryAttachmentRecord,
EnquiryBranchOption,
EnquiryAssignmentMutationPayload,
EnquiryCustomerRelationItem,
@@ -26,12 +29,15 @@ import type {
EnquiryFollowupMutationPayload,
EnquiryFollowupRecord,
EnquiryListItem,
EnquiryMarkLostPayload,
EnquiryMarkWonPayload,
EnquiryMutationPayload,
EnquiryOption,
EnquiryProjectPartyListItem,
EnquiryProjectPartyMutationPayload,
EnquiryProjectPartyRecord,
EnquiryPipelineStage,
EnquiryReopenPayload,
EnquiryRecord,
EnquiryReferenceData
} from '../api/types';
@@ -40,6 +46,7 @@ import {
PROJECT_PARTY_OPTION_CATEGORY,
resolveProjectPartyRole
} from '@/features/crm/shared/project-party';
import { PERMISSIONS } from '@/lib/auth/rbac';
const ENQUIRY_OPTION_CATEGORIES = {
status: 'crm_enquiry_status',
@@ -47,6 +54,7 @@ const ENQUIRY_OPTION_CATEGORIES = {
priority: 'crm_priority',
leadChannel: 'crm_lead_channel',
followupType: 'crm_followup_type',
lostReason: 'crm_lost_reason',
projectPartyRole: PROJECT_PARTY_OPTION_CATEGORY
} as const;
@@ -58,15 +66,22 @@ const SALES_CREATED_ENQUIRY_ROLES = new Set([
'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']);
const PURCHASE_ORDER_ATTACHMENT_CATEGORY = 'purchase_order';
const ALLOWED_PO_CONTENT_TYPES = new Set([
'application/pdf',
'image/jpeg',
'image/png',
'image/webp',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
]);
export interface EnquiryAccessContext {
organizationId: string;
userId: string;
membershipRole: string;
businessRole: string;
permissions?: string[];
branchScopeIds: string[];
productTypeScopeIds: string[];
ownershipScope: string;
@@ -98,7 +113,13 @@ function mapBranch(
function mapEnquiryRecord(
row: typeof crmEnquiries.$inferSelect,
options?: { assignedToName?: string | null; assignedByName?: string | null }
options?: {
assignedToName?: string | null;
assignedByName?: string | null;
closedByName?: string | null;
lostReason?: { code: string; label: string } | null;
canViewCommercialData?: boolean;
}
): EnquiryRecord {
return {
id: row.id,
@@ -125,6 +146,19 @@ function mapEnquiryRecord(
isHotProject: row.isHotProject,
isActive: row.isActive,
pipelineStage: row.pipelineStage as EnquiryPipelineStage,
closedWonAt: row.closedWonAt?.toISOString() ?? null,
closedLostAt: row.closedLostAt?.toISOString() ?? null,
closedByUserId: row.closedByUserId,
closedByName: options?.closedByName ?? null,
poNumber: row.poNumber,
poDate: row.poDate?.toISOString() ?? null,
poAmount: options?.canViewCommercialData === false ? null : row.poAmount,
poCurrency: row.poCurrency,
lostReason: row.lostReason,
lostReasonCode: options?.lostReason?.code ?? null,
lostReasonLabel: options?.lostReason?.label ?? null,
lostCompetitor: row.lostCompetitor,
lostRemark: row.lostRemark,
assignedToUserId: row.assignedToUserId,
assignedToName: options?.assignedToName ?? null,
assignedAt: row.assignedAt?.toISOString() ?? null,
@@ -139,6 +173,38 @@ function mapEnquiryRecord(
};
}
function mapAttachmentRecord(row: typeof crmEnquiryAttachments.$inferSelect): EnquiryAttachmentRecord {
return {
id: row.id,
organizationId: row.organizationId,
enquiryId: row.enquiryId,
category: row.category,
fileName: row.fileName,
originalFileName: row.originalFileName,
storageProvider: row.storageProvider,
storageKey: row.storageKey,
fileSize: row.fileSize,
fileType: row.fileType,
description: row.description,
uploadedAt: row.uploadedAt.toISOString(),
uploadedBy: row.uploadedBy,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
deletedAt: row.deletedAt?.toISOString() ?? null
};
}
function canViewOutcomeCommercialData(context?: EnquiryAccessContext) {
if (!context) {
return true;
}
return (
context.membershipRole === 'admin' ||
(context.permissions ?? []).includes(PERMISSIONS.crmQuotationPricingRead)
);
}
function hasEnquiryFullAccess(context: EnquiryAccessContext) {
return context.membershipRole === 'admin' || context.ownershipScope !== 'own';
}
@@ -561,40 +627,16 @@ function derivePipelineStageFromRole(businessRole: string): EnquiryPipelineStage
}
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 === 'closed_won' || current.pipelineStage === 'closed_lost') {
return current.pipelineStage as EnquiryPipelineStage;
}
if (current.pipelineStage === 'enquiry' || current.assignedToUserId) {
@@ -614,6 +656,7 @@ export async function getEnquiryReferenceData(
priorities,
leadChannels,
followupTypes,
lostReasons,
projectPartyRoles,
customers,
contacts,
@@ -625,6 +668,7 @@ export async function getEnquiryReferenceData(
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.priority, { organizationId }),
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.leadChannel, { organizationId }),
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.followupType, { organizationId }),
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.lostReason, { organizationId }),
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.projectPartyRole, { organizationId }),
db
.select({
@@ -665,6 +709,7 @@ export async function getEnquiryReferenceData(
priorities: priorities.map(mapOption),
leadChannels: leadChannels.map(mapOption),
followupTypes: followupTypes.map(mapOption),
lostReasons: lostReasons.map(mapOption),
projectPartyRoles: projectPartyRoles.map(mapOption),
customers: customers.map((customer) => ({
id: customer.id,
@@ -844,20 +889,32 @@ export async function getEnquiryDetail(
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
.select({ id: users.id, name: users.name })
.from(users)
.where(inArray(users.id, relatedUserIds))
: [];
const relatedUserIds = [
enquiry.assignedToUserId,
enquiry.assignedBy,
enquiry.closedByUserId
].filter(Boolean) as string[];
const [userRows, lostReasonOptions] = await Promise.all([
relatedUserIds.length
? db.select({ id: users.id, name: users.name }).from(users).where(inArray(users.id, relatedUserIds))
: [],
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.lostReason, { organizationId })
]);
const userMap = new Map(userRows.map((row) => [row.id, row.name]));
const lostReasonOption =
lostReasonOptions.find((option) => option.id === enquiry.lostReason || option.code === enquiry.lostReason) ??
null;
return mapEnquiryRecord(enquiry, {
assignedToName: enquiry.assignedToUserId
? (userMap.get(enquiry.assignedToUserId) ?? null)
: null,
assignedByName: enquiry.assignedBy ? (userMap.get(enquiry.assignedBy) ?? null) : null
assignedByName: enquiry.assignedBy ? (userMap.get(enquiry.assignedBy) ?? null) : null,
closedByName: enquiry.closedByUserId ? (userMap.get(enquiry.closedByUserId) ?? null) : null,
lostReason: lostReasonOption
? { code: lostReasonOption.code, label: lostReasonOption.label }
: null,
canViewCommercialData: canViewOutcomeCommercialData(accessContext)
});
}
@@ -1043,11 +1100,7 @@ export async function createEnquiry(
payload: EnquiryMutationPayload
) {
await validateEnquiryPayload(organizationId, payload, accessContext);
const pipelineStage = await resolvePipelineStageForCreate(
organizationId,
accessContext.businessRole,
payload.status
);
const pipelineStage = await resolvePipelineStageForCreate(accessContext.businessRole);
const documentCode = await generateNextDocumentCode({
organizationId,
@@ -1113,7 +1166,7 @@ export async function updateEnquiry(
) {
await validateEnquiryPayload(organizationId, payload, accessContext);
const current = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
const pipelineStage = await resolvePipelineStageForUpdate(organizationId, current, payload);
const pipelineStage = await resolvePipelineStageForUpdate(current);
return db.transaction(async (tx) => {
const [updated] = await tx
@@ -1370,6 +1423,296 @@ export async function softDeleteEnquiryFollowup(
return updated;
}
function ensureEnquiryStageForOutcome(
enquiry: typeof crmEnquiries.$inferSelect,
expectedStage: EnquiryPipelineStage
) {
if (enquiry.pipelineStage !== expectedStage) {
throw new AuthError(`Enquiry must be in ${expectedStage} stage`, 400);
}
}
export async function markEnquiryAsWon(
id: string,
organizationId: string,
userId: string,
payload: EnquiryMarkWonPayload,
accessContext?: EnquiryAccessContext
) {
const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
ensureEnquiryStageForOutcome(enquiry, 'enquiry');
if (!payload.poNumber.trim()) {
throw new AuthError('PO number is required', 400);
}
const now = new Date();
const [updated] = await db
.update(crmEnquiries)
.set({
pipelineStage: 'closed_won',
closedWonAt: now,
closedLostAt: null,
closedByUserId: userId,
poNumber: payload.poNumber.trim(),
poDate: new Date(payload.poDate),
poAmount: payload.poAmount ?? null,
poCurrency: payload.poCurrency?.trim() || null,
lostReason: null,
lostCompetitor: null,
lostRemark: null,
updatedAt: now,
updatedBy: userId
})
.where(eq(crmEnquiries.id, id))
.returning();
await auditAction({
organizationId,
branchId: updated.branchId,
userId,
entityType: 'crm_enquiry',
entityId: id,
action: 'mark_won',
afterData: {
poNumber: updated.poNumber,
poAmount: updated.poAmount,
poCurrency: updated.poCurrency,
remark: payload.remark?.trim() || null,
closedBy: userId,
closedAt: updated.closedWonAt?.toISOString() ?? null
}
});
return updated;
}
export async function markEnquiryAsLost(
id: string,
organizationId: string,
userId: string,
payload: EnquiryMarkLostPayload,
accessContext?: EnquiryAccessContext
) {
const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
ensureEnquiryStageForOutcome(enquiry, 'enquiry');
await assertMasterOptionValue(organizationId, ENQUIRY_OPTION_CATEGORIES.lostReason, payload.lostReason);
const lostReasonCode =
(await resolveOptionCodeById(organizationId, ENQUIRY_OPTION_CATEGORIES.lostReason, payload.lostReason)) ??
payload.lostReason;
const now = new Date();
const [updated] = await db
.update(crmEnquiries)
.set({
pipelineStage: 'closed_lost',
closedWonAt: null,
closedLostAt: now,
closedByUserId: userId,
poNumber: null,
poDate: null,
poAmount: null,
poCurrency: null,
lostReason: payload.lostReason,
lostCompetitor: payload.lostCompetitor?.trim() || null,
lostRemark: payload.lostRemark?.trim() || null,
updatedAt: now,
updatedBy: userId
})
.where(eq(crmEnquiries.id, id))
.returning();
await auditAction({
organizationId,
branchId: updated.branchId,
userId,
entityType: 'crm_enquiry',
entityId: id,
action: 'mark_lost',
afterData: {
lostReason: lostReasonCode,
lostCompetitor: updated.lostCompetitor,
closedBy: userId,
closedAt: updated.closedLostAt?.toISOString() ?? null
}
});
return updated;
}
export async function reopenLostEnquiry(
id: string,
organizationId: string,
userId: string,
payload: EnquiryReopenPayload,
accessContext?: EnquiryAccessContext
) {
const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
if (enquiry.pipelineStage === 'closed_won') {
throw new AuthError('Won enquiries cannot be reopened', 400);
}
ensureEnquiryStageForOutcome(enquiry, 'closed_lost');
const now = new Date();
const [updated] = await db
.update(crmEnquiries)
.set({
pipelineStage: 'enquiry',
closedLostAt: null,
closedByUserId: null,
lostReason: null,
lostCompetitor: null,
lostRemark: null,
updatedAt: now,
updatedBy: userId
})
.where(eq(crmEnquiries.id, id))
.returning();
await auditAction({
organizationId,
branchId: updated.branchId,
userId,
entityType: 'crm_enquiry',
entityId: id,
action: 'reopen',
afterData: {
reopenReason: payload.reopenReason.trim(),
previousStage: enquiry.pipelineStage,
nextStage: updated.pipelineStage
}
});
return updated;
}
export async function listEnquiryAttachments(
enquiryId: string,
organizationId: string,
accessContext?: EnquiryAccessContext
) {
await assertEnquiryBelongsToOrganization(enquiryId, organizationId, accessContext);
const rows = await db
.select()
.from(crmEnquiryAttachments)
.where(
and(
eq(crmEnquiryAttachments.organizationId, organizationId),
eq(crmEnquiryAttachments.enquiryId, enquiryId),
eq(crmEnquiryAttachments.category, PURCHASE_ORDER_ATTACHMENT_CATEGORY),
isNull(crmEnquiryAttachments.deletedAt)
)
)
.orderBy(desc(crmEnquiryAttachments.uploadedAt));
return rows.map(mapAttachmentRecord);
}
export async function getEnquiryAttachment(
enquiryId: string,
attachmentId: string,
organizationId: string,
accessContext?: EnquiryAccessContext
) {
await assertEnquiryBelongsToOrganization(enquiryId, organizationId, accessContext);
const [attachment] = await db
.select()
.from(crmEnquiryAttachments)
.where(
and(
eq(crmEnquiryAttachments.id, attachmentId),
eq(crmEnquiryAttachments.organizationId, organizationId),
eq(crmEnquiryAttachments.enquiryId, enquiryId),
isNull(crmEnquiryAttachments.deletedAt)
)
)
.limit(1);
if (!attachment) {
throw new AuthError('Attachment not found', 404);
}
return attachment;
}
export async function uploadPurchaseOrderAttachment(input: {
enquiryId: string;
organizationId: string;
userId: string;
file: {
name: string;
type: string;
size: number;
buffer: Buffer;
};
description?: string | null;
accessContext?: EnquiryAccessContext;
}) {
const enquiry = await assertEnquiryBelongsToOrganization(
input.enquiryId,
input.organizationId,
input.accessContext
);
if (enquiry.pipelineStage !== 'closed_won') {
throw new AuthError('PO attachment is available only for won enquiries', 400);
}
if (!ALLOWED_PO_CONTENT_TYPES.has(input.file.type)) {
throw new AuthError('Unsupported PO attachment file type', 400);
}
const storageProvider = getStorageProvider();
const safeFileName = input.file.name.replace(/[^a-zA-Z0-9._-]/g, '-');
const storageKey = buildOrganizationStorageKey(input.organizationId, [
'enquiries',
input.enquiryId,
'purchase-orders',
`${Date.now()}-${safeFileName}`
]);
const stored = await storageProvider.putObject({
key: storageKey,
body: input.file.buffer,
contentType: input.file.type,
fileName: safeFileName
});
const [created] = await db
.insert(crmEnquiryAttachments)
.values({
id: crypto.randomUUID(),
organizationId: input.organizationId,
enquiryId: input.enquiryId,
category: PURCHASE_ORDER_ATTACHMENT_CATEGORY,
fileName: safeFileName,
originalFileName: input.file.name,
storageProvider: stored.provider,
storageKey: stored.key,
fileSize: stored.size,
fileType: input.file.type,
description: input.description?.trim() || null,
uploadedBy: input.userId
})
.returning();
await auditAction({
organizationId: input.organizationId,
branchId: enquiry.branchId,
userId: input.userId,
entityType: 'crm_enquiry',
entityId: input.enquiryId,
action: 'upload_po',
afterData: {
attachmentId: created.id,
poNumber: enquiry.poNumber,
fileName: created.fileName,
fileType: created.fileType
}
});
return created;
}
export async function listCustomerEnquiryRelations(
customerId: string,
organizationId: string,
@@ -1405,33 +1748,7 @@ export async function syncEnquiryPipelineStageFromQuotationStatus(
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)
)
);
void enquiryId;
void organizationId;
void quotationStatusCode;
}

View File

@@ -14,6 +14,7 @@ import {
} from 'drizzle-orm';
import {
crmCustomers,
crmEnquiries,
crmEnquiryCustomers,
crmQuotationCustomers,
crmQuotations
@@ -24,7 +25,13 @@ import {
PROJECT_PARTY_OPTION_CATEGORY,
resolveProjectPartyRole
} from '@/features/crm/shared/project-party';
import type { RevenueAttributionFilters, RevenueAttributionSummary } from './types';
import type {
OutcomeBreakdownRow,
OutcomeRevenueFilters,
OutcomeRevenueRecord,
RevenueAttributionFilters,
RevenueAttributionSummary
} from './types';
type SupportedRevenueRole = 'end_customer' | 'billing_customer' | 'contractor' | 'consultant';
@@ -378,3 +385,174 @@ export async function getRevenueByConsultant(
) {
return getRevenueByRole(organizationId, 'consultant', filters);
}
function buildOutcomeRevenueFilters(
organizationId: string,
stage: 'closed_won' | 'closed_lost',
filters: OutcomeRevenueFilters
): SQL[] {
const dateColumn = stage === 'closed_won' ? crmEnquiries.closedWonAt : crmEnquiries.closedLostAt;
return [
eq(crmEnquiries.organizationId, organizationId),
isNull(crmEnquiries.deletedAt),
eq(crmEnquiries.pipelineStage, stage),
...(filters.branch ? [eq(crmEnquiries.branchId, filters.branch)] : []),
...(filters.salesman ? [eq(crmEnquiries.assignedToUserId, filters.salesman)] : []),
...(filters.productType ? [eq(crmEnquiries.productType, filters.productType)] : []),
...(filters.dateFrom ? [gte(dateColumn, new Date(filters.dateFrom))] : []),
...(filters.dateTo ? [lte(dateColumn, new Date(filters.dateTo))] : [])
];
}
async function getOutcomeRevenueRecords(
organizationId: string,
stage: 'closed_won' | 'closed_lost',
filters: OutcomeRevenueFilters = {}
): Promise<OutcomeRevenueRecord[]> {
const whereFilters = buildOutcomeRevenueFilters(organizationId, stage, filters);
const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters);
const enquiries = await db.select().from(crmEnquiries).where(where);
if (!enquiries.length) {
return [];
}
const enquiryIds = enquiries.map((row) => row.id);
const quotationRows = await db
.select({
enquiryId: crmQuotations.enquiryId,
totalAmount: crmQuotations.totalAmount
})
.from(crmQuotations)
.where(
and(
eq(crmQuotations.organizationId, organizationId),
inArray(crmQuotations.enquiryId, enquiryIds),
isNull(crmQuotations.deletedAt)
)
);
const quotationTotals = new Map<string, number>();
for (const row of quotationRows) {
if (!row.enquiryId) {
continue;
}
quotationTotals.set(row.enquiryId, (quotationTotals.get(row.enquiryId) ?? 0) + row.totalAmount);
}
return enquiries.map((row) => ({
enquiryId: row.id,
customerId: row.customerId,
branchId: row.branchId,
productType: row.productType,
assignedToUserId: row.assignedToUserId,
revenue: stage === 'closed_won' ? row.poAmount ?? quotationTotals.get(row.id) ?? 0 : quotationTotals.get(row.id) ?? 0,
closedAt:
(stage === 'closed_won' ? row.closedWonAt : row.closedLostAt)?.toISOString() ?? null
}));
}
export async function getWonRevenue(
organizationId: string,
filters: OutcomeRevenueFilters = {}
) {
return getOutcomeRevenueRecords(organizationId, 'closed_won', filters);
}
export async function getLostRevenue(
organizationId: string,
filters: OutcomeRevenueFilters = {}
) {
return getOutcomeRevenueRecords(organizationId, 'closed_lost', filters);
}
export async function getLostByReason(
organizationId: string,
filters: OutcomeRevenueFilters = {}
): Promise<OutcomeBreakdownRow[]> {
const records = await getLostRevenue(organizationId, filters);
const enquiryIds = records.map((row) => row.enquiryId);
if (!enquiryIds.length) {
return [];
}
const [enquiries, lostReasonOptions] = await Promise.all([
db
.select({ id: crmEnquiries.id, lostReason: crmEnquiries.lostReason })
.from(crmEnquiries)
.where(inArray(crmEnquiries.id, enquiryIds)),
getActiveOptionsByCategory('crm_lost_reason', { organizationId })
]);
const lostReasonMap = new Map(
lostReasonOptions.flatMap((item) => [
[item.id, item.label] as const,
[item.code, item.label] as const
])
);
const grouped = new Map<string, OutcomeBreakdownRow>();
for (const row of records) {
const enquiry = enquiries.find((item) => item.id === row.enquiryId);
const key = enquiry?.lostReason ?? 'unknown';
const current = grouped.get(key) ?? {
key,
label: lostReasonMap.get(key) ?? key,
count: 0,
revenue: 0
};
current.count += 1;
current.revenue += row.revenue;
grouped.set(key, current);
}
return [...grouped.values()].sort((a, b) => b.count - a.count || b.revenue - a.revenue);
}
export async function getLostByCompetitor(
organizationId: string,
filters: OutcomeRevenueFilters = {}
): Promise<OutcomeBreakdownRow[]> {
const records = await getLostRevenue(organizationId, filters);
const enquiryIds = records.map((row) => row.enquiryId);
if (!enquiryIds.length) {
return [];
}
const enquiries = await db
.select({ id: crmEnquiries.id, lostCompetitor: crmEnquiries.lostCompetitor })
.from(crmEnquiries)
.where(inArray(crmEnquiries.id, enquiryIds));
const grouped = new Map<string, OutcomeBreakdownRow>();
for (const row of records) {
const enquiry = enquiries.find((item) => item.id === row.enquiryId);
const key = enquiry?.lostCompetitor?.trim() || 'unknown';
const current = grouped.get(key) ?? { key, label: key, count: 0, revenue: 0 };
current.count += 1;
current.revenue += row.revenue;
grouped.set(key, current);
}
return [...grouped.values()].sort((a, b) => b.count - a.count || b.revenue - a.revenue);
}
export async function getWinRate(
organizationId: string,
filters: OutcomeRevenueFilters = {}
) {
const [wonRows, lostRows] = await Promise.all([
getWonRevenue(organizationId, filters),
getLostRevenue(organizationId, filters)
]);
const denominator = wonRows.length + lostRows.length;
if (denominator === 0) {
return 0;
}
return Math.round((wonRows.length / denominator) * 10000) / 100;
}

View File

@@ -20,3 +20,28 @@ export interface RevenueAttributionSummary {
quotationCount: number;
quotationIds: string[];
}
export interface OutcomeRevenueFilters {
dateFrom?: string | null;
dateTo?: string | null;
branch?: string | null;
salesman?: string | null;
productType?: string | null;
}
export interface OutcomeRevenueRecord {
enquiryId: string;
customerId: string;
branchId: string | null;
productType: string;
assignedToUserId: string | null;
revenue: number;
closedAt: string | null;
}
export interface OutcomeBreakdownRow {
key: string;
label: string;
count: number;
revenue: number;
}

View File

@@ -54,6 +54,9 @@ export const PERMISSIONS = {
crmEnquiryDelete: 'crm.enquiry.delete',
crmEnquiryAssign: 'crm.enquiry.assign',
crmEnquiryReassign: 'crm.enquiry.reassign',
crmEnquiryMarkWon: 'crm.enquiry.mark_won',
crmEnquiryMarkLost: 'crm.enquiry.mark_lost',
crmEnquiryReopen: 'crm.enquiry.reopen',
crmEnquiryFollowupRead: 'crm.enquiry.followup.read',
crmEnquiryFollowupCreate: 'crm.enquiry.followup.create',
crmEnquiryFollowupUpdate: 'crm.enquiry.followup.update',
@@ -167,6 +170,8 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
PERMISSIONS.crmEnquiryRead,
PERMISSIONS.crmEnquiryCreate,
PERMISSIONS.crmEnquiryUpdate,
PERMISSIONS.crmEnquiryMarkWon,
PERMISSIONS.crmEnquiryMarkLost,
PERMISSIONS.crmEnquiryFollowupRead,
PERMISSIONS.crmEnquiryFollowupCreate,
PERMISSIONS.crmEnquiryFollowupUpdate,
@@ -204,6 +209,8 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
PERMISSIONS.crmContactRead,
PERMISSIONS.crmEnquiryRead,
PERMISSIONS.crmEnquiryUpdate,
PERMISSIONS.crmEnquiryMarkWon,
PERMISSIONS.crmEnquiryMarkLost,
PERMISSIONS.crmEnquiryFollowupRead,
PERMISSIONS.crmEnquiryFollowupCreate,
PERMISSIONS.crmEnquiryFollowupUpdate,
@@ -256,6 +263,9 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
PERMISSIONS.crmEnquiryUpdate,
PERMISSIONS.crmEnquiryAssign,
PERMISSIONS.crmEnquiryReassign,
PERMISSIONS.crmEnquiryMarkWon,
PERMISSIONS.crmEnquiryMarkLost,
PERMISSIONS.crmEnquiryReopen,
PERMISSIONS.crmEnquiryFollowupRead,
PERMISSIONS.crmEnquiryFollowupCreate,
PERMISSIONS.crmEnquiryFollowupUpdate,
@@ -302,6 +312,9 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
PERMISSIONS.crmEnquiryRead,
PERMISSIONS.crmEnquiryAssign,
PERMISSIONS.crmEnquiryReassign,
PERMISSIONS.crmEnquiryMarkWon,
PERMISSIONS.crmEnquiryMarkLost,
PERMISSIONS.crmEnquiryReopen,
PERMISSIONS.crmEnquiryFollowupRead,
PERMISSIONS.crmQuotationRead,
PERMISSIONS.crmQuotationPricingRead,
@@ -365,6 +378,9 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
description: 'CRM configuration authority for templates, workflows, sequences, and permissions.',
permissions: [
PERMISSIONS.crmDashboardRead,
PERMISSIONS.crmEnquiryMarkWon,
PERMISSIONS.crmEnquiryMarkLost,
PERMISSIONS.crmEnquiryReopen,
PERMISSIONS.crmCustomerOwnerRead,
PERMISSIONS.crmCustomerOwnerManage,
PERMISSIONS.crmContactShareRead,
@@ -466,6 +482,9 @@ export const CRM_PERMISSION_GROUPS: PermissionGroup[] = [
{ key: PERMISSIONS.crmEnquiryDelete, label: 'Delete enquiries' },
{ key: PERMISSIONS.crmEnquiryAssign, label: 'Assign enquiries' },
{ key: PERMISSIONS.crmEnquiryReassign, label: 'Reassign enquiries' },
{ key: PERMISSIONS.crmEnquiryMarkWon, label: 'Mark enquiries as won' },
{ key: PERMISSIONS.crmEnquiryMarkLost, label: 'Mark enquiries as lost' },
{ key: PERMISSIONS.crmEnquiryReopen, label: 'Reopen lost enquiries' },
{ key: PERMISSIONS.crmEnquiryFollowupRead, label: 'Read enquiry follow-ups' },
{ key: PERMISSIONS.crmEnquiryFollowupCreate, label: 'Create enquiry follow-ups' },
{ key: PERMISSIONS.crmEnquiryFollowupUpdate, label: 'Update enquiry follow-ups' },