-
+
View
-
+
Download
@@ -370,7 +370,7 @@ export function EnquiryOutcomeCard({
isLoading={wonMutation.isPending}
onClick={() =>
wonMutation.mutate({
- id: enquiryId,
+ id: opportunityId,
values: {
poNumber,
poDate,
@@ -436,7 +436,7 @@ export function EnquiryOutcomeCard({
isLoading={lostMutation.isPending}
onClick={() =>
lostMutation.mutate({
- id: enquiryId,
+ id: opportunityId,
values: {
lostReason,
lostCompetitor,
@@ -472,7 +472,7 @@ export function EnquiryOutcomeCard({
reopenMutation.mutate({ id: enquiryId, reason: reopenReason })}
+ onClick={() => reopenMutation.mutate({ id: opportunityId, reason: reopenReason })}
>
Reopen
@@ -482,3 +482,4 @@ export function EnquiryOutcomeCard({
>
);
}
+
diff --git a/src/features/crm/enquiries/components/enquiry-status-badge.tsx b/src/features/crm/opportunities/components/opportunity-status-badge.tsx
similarity index 88%
rename from src/features/crm/enquiries/components/enquiry-status-badge.tsx
rename to src/features/crm/opportunities/components/opportunity-status-badge.tsx
index 1558f7b..a4d148c 100644
--- a/src/features/crm/enquiries/components/enquiry-status-badge.tsx
+++ b/src/features/crm/opportunities/components/opportunity-status-badge.tsx
@@ -3,7 +3,7 @@
import { Badge } from '@/components/ui/badge';
import { Icons } from '@/components/icons';
-export function EnquiryStatusBadge({ code, label }: { code?: string | null; label: string }) {
+export function OpportunityStatusBadge({ code, label }: { code?: string | null; label: string }) {
const normalized = code?.toLowerCase() ?? '';
const variant =
normalized === 'closed_lost' || normalized === 'cancelled'
@@ -29,3 +29,4 @@ export function EnquiryStatusBadge({ code, label }: { code?: string | null; labe
);
}
+
diff --git a/src/features/crm/enquiries/components/quotation-readiness-panel.tsx b/src/features/crm/opportunities/components/quotation-readiness-panel.tsx
similarity index 62%
rename from src/features/crm/enquiries/components/quotation-readiness-panel.tsx
rename to src/features/crm/opportunities/components/quotation-readiness-panel.tsx
index 8df7d90..a66fb3a 100644
--- a/src/features/crm/enquiries/components/quotation-readiness-panel.tsx
+++ b/src/features/crm/opportunities/components/quotation-readiness-panel.tsx
@@ -1,21 +1,21 @@
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
-import type { EnquiryRecord } from '../api/types';
+import type { OpportunityRecord } from '../api/types';
type ReadinessItem = {
label: string;
ready: boolean;
};
-export function QuotationReadinessPanel({ enquiry }: { enquiry: EnquiryRecord }) {
+export function QuotationReadinessPanel({ opportunity }: { opportunity: OpportunityRecord }) {
const checks: ReadinessItem[] = [
- { label: 'Customer selected', ready: Boolean(enquiry.customerId) },
- { label: 'Contact selected', ready: Boolean(enquiry.contactId) },
- { label: 'Project name present', ready: Boolean(enquiry.projectName?.trim()) },
- { label: 'Product type present', ready: Boolean(enquiry.productType) },
- { label: 'Requirement present', ready: Boolean(enquiry.requirement?.trim()) },
- { label: 'Estimated value present', ready: enquiry.estimatedValue !== null }
+ { label: 'Customer selected', ready: Boolean(opportunity.customerId) },
+ { label: 'Contact selected', ready: Boolean(opportunity.contactId) },
+ { label: 'Project name present', ready: Boolean(opportunity.projectName?.trim()) },
+ { label: 'Product type present', ready: Boolean(opportunity.productType) },
+ { label: 'Requirement present', ready: Boolean(opportunity.requirement?.trim()) },
+ { label: 'Estimated value present', ready: opportunity.estimatedValue !== null }
];
const readyCount = checks.filter((item) => item.ready).length;
@@ -44,3 +44,4 @@ export function QuotationReadinessPanel({ enquiry }: { enquiry: EnquiryRecord })
);
}
+
diff --git a/src/features/crm/enquiries/components/source-lead-card.tsx b/src/features/crm/opportunities/components/source-lead-card.tsx
similarity index 100%
rename from src/features/crm/enquiries/components/source-lead-card.tsx
rename to src/features/crm/opportunities/components/source-lead-card.tsx
diff --git a/src/features/crm/enquiries/schemas/enquiry.schema.ts b/src/features/crm/opportunities/schemas/opportunity.schema.ts
similarity index 81%
rename from src/features/crm/enquiries/schemas/enquiry.schema.ts
rename to src/features/crm/opportunities/schemas/opportunity.schema.ts
index 7ec5820..de25664 100644
--- a/src/features/crm/enquiries/schemas/enquiry.schema.ts
+++ b/src/features/crm/opportunities/schemas/opportunity.schema.ts
@@ -24,7 +24,7 @@ function coerceOptionalNumber(message: string) {
}, z.number().refine((value) => !Number.isNaN(value), message).optional());
}
-export const enquirySchema = z.object({
+export const opportunitySchema = z.object({
customerId: z.string().min(1, 'Please select a customer'),
contactId: z.string().optional().nullable(),
assignedToUserId: z.string().optional().nullable(),
@@ -63,7 +63,7 @@ export const enquirySchema = z.object({
.optional()
});
-export const enquiryFollowupSchema = z.object({
+export const opportunityFollowupSchema = z.object({
followupDate: z.string().min(1, 'Follow-up date is required'),
followupType: z.string().min(1, 'Please select a follow-up type'),
contactId: z.string().optional().nullable(),
@@ -73,7 +73,7 @@ export const enquiryFollowupSchema = z.object({
nextAction: z.string().optional()
});
-export const enquiryMarkWonSchema = z.object({
+export const opportunityMarkWonSchema = 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(
@@ -84,18 +84,19 @@ export const enquiryMarkWonSchema = z.object({
remark: z.string().optional().nullable()
});
-export const enquiryMarkLostSchema = z.object({
+export const opportunityMarkLostSchema = 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({
+export const opportunityReopenSchema = z.object({
reopenReason: z.string().min(3, 'Reopen reason is required')
});
-export type EnquiryFormValues = z.input
;
-export type EnquiryFollowupFormValues = z.input;
-export type EnquiryMarkWonFormValues = z.input;
-export type EnquiryMarkLostFormValues = z.input;
-export type EnquiryReopenFormValues = z.input;
+export type OpportunityFormValues = z.input;
+export type OpportunityFollowupFormValues = z.input;
+export type OpportunityMarkWonFormValues = z.input;
+export type OpportunityMarkLostFormValues = z.input;
+export type OpportunityReopenFormValues = z.input;
+
diff --git a/src/features/crm/enquiries/server/service.ts b/src/features/crm/opportunities/server/service.ts
similarity index 67%
rename from src/features/crm/enquiries/server/service.ts
rename to src/features/crm/opportunities/server/service.ts
index 8a3bf68..2497028 100644
--- a/src/features/crm/enquiries/server/service.ts
+++ b/src/features/crm/opportunities/server/service.ts
@@ -2,10 +2,10 @@ import { and, asc, count, desc, eq, ilike, inArray, isNull, or, type SQL } from
import {
crmCustomerContacts,
crmCustomers,
- crmEnquiries,
- crmEnquiryAttachments,
- crmEnquiryCustomers,
- crmEnquiryFollowups,
+ crmOpportunities,
+ crmOpportunityAttachments,
+ crmOpportunityCustomers,
+ crmOpportunityFollowups,
crmQuotations,
memberships,
users
@@ -19,27 +19,27 @@ import { generateNextDocumentCode } from '@/features/foundation/document-sequenc
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
import { buildOrganizationStorageKey, getStorageProvider } from '@/features/foundation/storage/service';
import type {
- EnquiryActivityRecord,
- EnquiryAssignableUserLookup,
- EnquiryAttachmentRecord,
- EnquiryBranchOption,
- EnquiryAssignmentMutationPayload,
- EnquiryCustomerRelationItem,
- EnquiryFilters,
- EnquiryFollowupMutationPayload,
- EnquiryFollowupRecord,
- EnquiryListItem,
- EnquiryMarkLostPayload,
- EnquiryMarkWonPayload,
- EnquiryMutationPayload,
- EnquiryOption,
- EnquiryProjectPartyListItem,
- EnquiryProjectPartyMutationPayload,
- EnquiryProjectPartyRecord,
- EnquiryPipelineStage,
- EnquiryReopenPayload,
- EnquiryRecord,
- EnquiryReferenceData
+ OpportunityActivityRecord,
+ OpportunityAssignableUserLookup,
+ OpportunityAttachmentRecord,
+ OpportunityBranchOption,
+ OpportunityAssignmentMutationPayload,
+ OpportunityCustomerRelationItem,
+ OpportunityFilters,
+ OpportunityFollowupMutationPayload,
+ OpportunityFollowupRecord,
+ OpportunityListItem,
+ OpportunityMarkLostPayload,
+ OpportunityMarkWonPayload,
+ OpportunityMutationPayload,
+ OpportunityOption,
+ OpportunityProjectPartyListItem,
+ OpportunityProjectPartyMutationPayload,
+ OpportunityProjectPartyRecord,
+ OpportunityPipelineStage,
+ OpportunityReopenPayload,
+ OpportunityRecord,
+ OpportunityReferenceData
} from '../api/types';
import {
buildProjectPartyKey,
@@ -49,7 +49,7 @@ import {
import { PERMISSIONS } from '@/lib/auth/rbac';
const ENQUIRY_OPTION_CATEGORIES = {
- status: 'crm_enquiry_status',
+ status: 'crm_opportunity_status',
productType: 'crm_product_type',
priority: 'crm_priority',
leadChannel: 'crm_lead_channel',
@@ -76,7 +76,7 @@ const ALLOWED_PO_CONTENT_TYPES = new Set([
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
]);
-export interface EnquiryAccessContext {
+export interface OpportunityAccessContext {
organizationId: string;
userId: string;
membershipRole: string;
@@ -91,7 +91,7 @@ export interface EnquiryAccessContext {
function mapOption(
option: Awaited>[number]
-): EnquiryOption {
+): OpportunityOption {
return {
id: option.id,
code: option.code,
@@ -102,7 +102,7 @@ function mapOption(
function mapBranch(
branch: Awaited>[number]
-): EnquiryBranchOption {
+): OpportunityBranchOption {
return {
id: branch.id,
code: branch.code,
@@ -111,8 +111,8 @@ function mapBranch(
};
}
-function mapEnquiryRecord(
- row: typeof crmEnquiries.$inferSelect,
+function mapOpportunityRecord(
+ row: typeof crmOpportunities.$inferSelect,
options?: {
assignedToName?: string | null;
assignedByName?: string | null;
@@ -120,7 +120,7 @@ function mapEnquiryRecord(
lostReason?: { code: string; label: string } | null;
canViewCommercialData?: boolean;
}
-): EnquiryRecord {
+): OpportunityRecord {
return {
id: row.id,
organizationId: row.organizationId,
@@ -146,7 +146,7 @@ function mapEnquiryRecord(
notes: row.notes,
isHotProject: row.isHotProject,
isActive: row.isActive,
- pipelineStage: row.pipelineStage as EnquiryPipelineStage,
+ pipelineStage: row.pipelineStage as OpportunityPipelineStage,
closedWonAt: row.closedWonAt?.toISOString() ?? null,
closedLostAt: row.closedLostAt?.toISOString() ?? null,
closedByUserId: row.closedByUserId,
@@ -174,11 +174,11 @@ function mapEnquiryRecord(
};
}
-function mapAttachmentRecord(row: typeof crmEnquiryAttachments.$inferSelect): EnquiryAttachmentRecord {
+function mapAttachmentRecord(row: typeof crmOpportunityAttachments.$inferSelect): OpportunityAttachmentRecord {
return {
id: row.id,
organizationId: row.organizationId,
- enquiryId: row.enquiryId,
+ opportunityId: row.opportunityId,
category: row.category,
fileName: row.fileName,
originalFileName: row.originalFileName,
@@ -195,7 +195,7 @@ function mapAttachmentRecord(row: typeof crmEnquiryAttachments.$inferSelect): En
};
}
-function canViewOutcomeCommercialData(context?: EnquiryAccessContext) {
+function canViewOutcomeCommercialData(context?: OpportunityAccessContext) {
if (!context) {
return true;
}
@@ -206,13 +206,13 @@ function canViewOutcomeCommercialData(context?: EnquiryAccessContext) {
);
}
-function hasEnquiryFullAccess(context: EnquiryAccessContext) {
+function hasOpportunityFullAccess(context: OpportunityAccessContext) {
return context.membershipRole === 'admin' || context.ownershipScope !== 'own';
}
-function canAccessEnquiryRow(
- row: typeof crmEnquiries.$inferSelect,
- context: EnquiryAccessContext
+function canAccessOpportunityRow(
+ row: typeof crmOpportunities.$inferSelect,
+ context: OpportunityAccessContext
) {
if (!hasBranchScopeAccess(context, row.branchId)) {
return false;
@@ -222,7 +222,7 @@ function canAccessEnquiryRow(
return false;
}
- if (hasEnquiryFullAccess(context)) {
+ if (hasOpportunityFullAccess(context)) {
return true;
}
@@ -233,31 +233,31 @@ function canAccessEnquiryRow(
return true;
}
-function buildAccessScopedFilters(context: EnquiryAccessContext): SQL[] {
+function buildAccessScopedFilters(context: OpportunityAccessContext): SQL[] {
const filters: SQL[] = [];
if (context.branchScopeMode === 'assigned' && context.branchScopeIds.length > 0) {
- filters.push(inArray(crmEnquiries.branchId, context.branchScopeIds));
+ filters.push(inArray(crmOpportunities.branchId, context.branchScopeIds));
}
if (context.productScopeMode === 'assigned' && context.productTypeScopeIds.length > 0) {
- filters.push(inArray(crmEnquiries.productType, context.productTypeScopeIds));
+ filters.push(inArray(crmOpportunities.productType, context.productTypeScopeIds));
}
- if (!hasEnquiryFullAccess(context) && context.ownershipScope === 'own') {
+ if (!hasOpportunityFullAccess(context) && context.ownershipScope === 'own') {
filters.push(
- or(eq(crmEnquiries.createdBy, context.userId), eq(crmEnquiries.assignedToUserId, context.userId))!
+ or(eq(crmOpportunities.createdBy, context.userId), eq(crmOpportunities.assignedToUserId, context.userId))!
);
}
return filters;
}
-function mapFollowupRecord(row: typeof crmEnquiryFollowups.$inferSelect): EnquiryFollowupRecord {
+function mapFollowupRecord(row: typeof crmOpportunityFollowups.$inferSelect): OpportunityFollowupRecord {
return {
id: row.id,
organizationId: row.organizationId,
- enquiryId: row.enquiryId,
+ opportunityId: row.opportunityId,
followupDate: row.followupDate.toISOString(),
followupType: row.followupType,
contactId: row.contactId,
@@ -274,13 +274,13 @@ function mapFollowupRecord(row: typeof crmEnquiryFollowups.$inferSelect): Enquir
}
function mapProjectPartyRecord(
- row: typeof crmEnquiryCustomers.$inferSelect,
+ row: typeof crmOpportunityCustomers.$inferSelect,
options: { role: { id: string; code: string; label: string } }
-): EnquiryProjectPartyRecord {
+): OpportunityProjectPartyRecord {
return {
id: row.id,
organizationId: row.organizationId,
- enquiryId: row.enquiryId,
+ opportunityId: row.opportunityId,
customerId: row.customerId,
role: options.role.id,
roleCode: options.role.code,
@@ -294,7 +294,7 @@ function mapProjectPartyRecord(
function parseSort(sort?: string) {
if (!sort) {
- return desc(crmEnquiries.updatedAt);
+ return desc(crmOpportunities.updatedAt);
}
try {
@@ -302,29 +302,29 @@ function parseSort(sort?: string) {
const [rule] = parsed;
if (!rule) {
- return desc(crmEnquiries.updatedAt);
+ return desc(crmOpportunities.updatedAt);
}
const sortMap = {
- code: crmEnquiries.code,
- title: crmEnquiries.title,
- status: crmEnquiries.status,
- priority: crmEnquiries.priority,
- productType: crmEnquiries.productType,
- branch: crmEnquiries.branchId,
- customer: crmEnquiries.customerId,
- updatedAt: crmEnquiries.updatedAt
+ code: crmOpportunities.code,
+ title: crmOpportunities.title,
+ status: crmOpportunities.status,
+ priority: crmOpportunities.priority,
+ productType: crmOpportunities.productType,
+ branch: crmOpportunities.branchId,
+ customer: crmOpportunities.customerId,
+ updatedAt: crmOpportunities.updatedAt
} as const;
const column = sortMap[rule.id as keyof typeof sortMap];
if (!column) {
- return desc(crmEnquiries.updatedAt);
+ return desc(crmOpportunities.updatedAt);
}
return rule.desc ? desc(column) : asc(column);
} catch {
- return desc(crmEnquiries.updatedAt);
+ return desc(crmOpportunities.updatedAt);
}
}
@@ -416,7 +416,7 @@ export async function assertContactBelongsToOrganization(
return contact;
}
-async function listAssignableUsers(organizationId: string): Promise {
+async function listAssignableUsers(organizationId: string): Promise {
const rows = await db
.select({
userId: memberships.userId,
@@ -435,7 +435,7 @@ async function listAssignableUsers(organizationId: string): Promise row.membershipRole === 'admin' || ASSIGNABLE_BUSINESS_ROLES.has(row.businessRole)
)
- .map((row) => ({
+ .map((row) => ({
id: row.userId,
name: row.name,
membershipRole: row.membershipRole === 'admin' ? 'admin' : 'user',
@@ -468,48 +468,48 @@ export async function assertAssignableUserBelongsToOrganization(
return membership;
}
-async function assertEnquiryBelongsToOrganization(
+async function assertOpportunityBelongsToOrganization(
id: string,
organizationId: string,
- accessContext?: EnquiryAccessContext
+ accessContext?: OpportunityAccessContext
) {
- const [enquiry] = await db
+ const [opportunity] = await db
.select()
- .from(crmEnquiries)
+ .from(crmOpportunities)
.where(
and(
- eq(crmEnquiries.id, id),
- eq(crmEnquiries.organizationId, organizationId),
- isNull(crmEnquiries.deletedAt)
+ eq(crmOpportunities.id, id),
+ eq(crmOpportunities.organizationId, organizationId),
+ isNull(crmOpportunities.deletedAt)
)
)
.limit(1);
- if (!enquiry) {
- throw new AuthError('Enquiry not found', 404);
+ if (!opportunity) {
+ throw new AuthError('Opportunity not found', 404);
}
- if (accessContext && !canAccessEnquiryRow(enquiry, accessContext)) {
+ if (accessContext && !canAccessOpportunityRow(opportunity, accessContext)) {
throw new AuthError('Forbidden', 403);
}
- return enquiry;
+ return opportunity;
}
-async function assertFollowupBelongsToEnquiry(
+async function assertFollowupBelongsToOpportunity(
followupId: string,
- enquiryId: string,
+ opportunityId: string,
organizationId: string
) {
const [followup] = await db
.select()
- .from(crmEnquiryFollowups)
+ .from(crmOpportunityFollowups)
.where(
and(
- eq(crmEnquiryFollowups.id, followupId),
- eq(crmEnquiryFollowups.enquiryId, enquiryId),
- eq(crmEnquiryFollowups.organizationId, organizationId),
- isNull(crmEnquiryFollowups.deletedAt)
+ eq(crmOpportunityFollowups.id, followupId),
+ eq(crmOpportunityFollowups.opportunityId, opportunityId),
+ eq(crmOpportunityFollowups.organizationId, organizationId),
+ isNull(crmOpportunityFollowups.deletedAt)
)
)
.limit(1);
@@ -521,10 +521,10 @@ async function assertFollowupBelongsToEnquiry(
return followup;
}
-async function validateEnquiryPayload(
+async function validateOpportunityPayload(
organizationId: string,
- payload: EnquiryMutationPayload,
- accessContext?: EnquiryAccessContext
+ payload: OpportunityMutationPayload,
+ accessContext?: OpportunityAccessContext
) {
await assertCustomerBelongsToOrganization(payload.customerId, organizationId);
@@ -577,12 +577,12 @@ async function validateEnquiryPayload(
async function validateFollowupPayload(
organizationId: string,
- enquiryId: string,
- payload: EnquiryFollowupMutationPayload,
- accessContext?: EnquiryAccessContext
+ opportunityId: string,
+ payload: OpportunityFollowupMutationPayload,
+ accessContext?: OpportunityAccessContext
) {
- const enquiry = await assertEnquiryBelongsToOrganization(
- enquiryId,
+ const opportunity = await assertOpportunityBelongsToOrganization(
+ opportunityId,
organizationId,
accessContext
);
@@ -594,11 +594,11 @@ async function validateFollowupPayload(
);
if (payload.contactId) {
- await assertContactBelongsToOrganization(payload.contactId, organizationId, enquiry.customerId);
+ await assertContactBelongsToOrganization(payload.contactId, organizationId, opportunity.customerId);
}
}
-function buildEnquiryFilters(context: EnquiryAccessContext, filters: EnquiryFilters): SQL[] {
+function buildOpportunityFilters(context: OpportunityAccessContext, filters: OpportunityFilters): SQL[] {
const pipelineStages = splitFilterValue(filters.pipelineStage);
const statuses = splitFilterValue(filters.status);
const productTypes = splitFilterValue(filters.productType);
@@ -607,55 +607,55 @@ function buildEnquiryFilters(context: EnquiryAccessContext, filters: EnquiryFilt
const customers = splitFilterValue(filters.customer);
return [
- eq(crmEnquiries.organizationId, context.organizationId),
- isNull(crmEnquiries.deletedAt),
+ eq(crmOpportunities.organizationId, context.organizationId),
+ isNull(crmOpportunities.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)] : []),
- ...(branches.length ? [inArray(crmEnquiries.branchId, branches)] : []),
- ...(customers.length ? [inArray(crmEnquiries.customerId, customers)] : []),
+ ...(pipelineStages.length ? [inArray(crmOpportunities.pipelineStage, pipelineStages)] : []),
+ ...(statuses.length ? [inArray(crmOpportunities.status, statuses)] : []),
+ ...(productTypes.length ? [inArray(crmOpportunities.productType, productTypes)] : []),
+ ...(priorities.length ? [inArray(crmOpportunities.priority, priorities)] : []),
+ ...(branches.length ? [inArray(crmOpportunities.branchId, branches)] : []),
+ ...(customers.length ? [inArray(crmOpportunities.customerId, customers)] : []),
...(filters.search
? [
or(
- ilike(crmEnquiries.code, `%${filters.search}%`),
- ilike(crmEnquiries.title, `%${filters.search}%`),
- ilike(crmEnquiries.projectName, `%${filters.search}%`),
- ilike(crmEnquiries.competitor, `%${filters.search}%`)
+ ilike(crmOpportunities.code, `%${filters.search}%`),
+ ilike(crmOpportunities.title, `%${filters.search}%`),
+ ilike(crmOpportunities.projectName, `%${filters.search}%`),
+ ilike(crmOpportunities.competitor, `%${filters.search}%`)
)!
]
: [])
];
}
-function derivePipelineStageFromRole(businessRole: string): EnquiryPipelineStage {
- return SALES_CREATED_ENQUIRY_ROLES.has(businessRole) ? 'enquiry' : 'lead';
+function derivePipelineStageFromRole(businessRole: string): OpportunityPipelineStage {
+ return SALES_CREATED_ENQUIRY_ROLES.has(businessRole) ? 'opportunity' : 'lead';
}
async function resolvePipelineStageForCreate(
businessRole: string,
-): Promise {
+): Promise {
return derivePipelineStageFromRole(businessRole);
}
async function resolvePipelineStageForUpdate(
- current: typeof crmEnquiries.$inferSelect,
-): Promise {
+ current: typeof crmOpportunities.$inferSelect,
+): Promise {
if (current.pipelineStage === 'closed_won' || current.pipelineStage === 'closed_lost') {
- return current.pipelineStage as EnquiryPipelineStage;
+ return current.pipelineStage as OpportunityPipelineStage;
}
- if (current.pipelineStage === 'enquiry' || current.assignedToUserId) {
- return 'enquiry';
+ if (current.pipelineStage === 'opportunity' || current.assignedToUserId) {
+ return 'opportunity';
}
return 'lead';
}
-export async function getEnquiryReferenceData(
+export async function getOpportunityReferenceData(
organizationId: string
-): Promise {
+): Promise {
const [
branches,
statuses,
@@ -738,20 +738,20 @@ export async function getEnquiryReferenceData(
};
}
-export async function listEnquiries(
- context: EnquiryAccessContext,
- filters: EnquiryFilters
-): Promise<{ items: EnquiryListItem[]; totalItems: number }> {
+export async function listOpportunities(
+ context: OpportunityAccessContext,
+ filters: OpportunityFilters
+): Promise<{ items: OpportunityListItem[]; totalItems: number }> {
const page = filters.page ?? 1;
const limit = filters.limit ?? 10;
- const whereFilters = buildEnquiryFilters(context, filters);
+ const whereFilters = buildOpportunityFilters(context, filters);
const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters);
const offset = (page - 1) * limit;
- const [totalResult] = await db.select({ value: count() }).from(crmEnquiries).where(where);
+ const [totalResult] = await db.select({ value: count() }).from(crmOpportunities).where(where);
const rows = await db
.select()
- .from(crmEnquiries)
+ .from(crmOpportunities)
.where(where)
.orderBy(parseSort(filters.sort))
.limit(limit)
@@ -763,7 +763,7 @@ export async function listEnquiries(
...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 opportunityIds = rows.map((row) => row.id);
const [customers, contacts, assignedUsers, createdByUsers, followupCounts, quotationCounts, nextFollowups] = await Promise.all([
customerIds.length
@@ -800,53 +800,53 @@ export async function listEnquiries(
.from(users)
.where(inArray(users.id, createdByUserIds))
: [],
- enquiryIds.length
+ opportunityIds.length
? db
.select({
- enquiryId: crmEnquiryFollowups.enquiryId,
+ opportunityId: crmOpportunityFollowups.opportunityId,
value: count()
})
- .from(crmEnquiryFollowups)
+ .from(crmOpportunityFollowups)
.where(
and(
- eq(crmEnquiryFollowups.organizationId, context.organizationId),
- inArray(crmEnquiryFollowups.enquiryId, enquiryIds),
- isNull(crmEnquiryFollowups.deletedAt)
+ eq(crmOpportunityFollowups.organizationId, context.organizationId),
+ inArray(crmOpportunityFollowups.opportunityId, opportunityIds),
+ isNull(crmOpportunityFollowups.deletedAt)
)
)
- .groupBy(crmEnquiryFollowups.enquiryId)
+ .groupBy(crmOpportunityFollowups.opportunityId)
: [],
- enquiryIds.length
+ opportunityIds.length
? db
.select({
- enquiryId: crmQuotations.enquiryId,
+ opportunityId: crmQuotations.opportunityId,
value: count()
})
.from(crmQuotations)
.where(
and(
eq(crmQuotations.organizationId, context.organizationId),
- inArray(crmQuotations.enquiryId, enquiryIds),
+ inArray(crmQuotations.opportunityId, opportunityIds),
isNull(crmQuotations.deletedAt)
)
)
- .groupBy(crmQuotations.enquiryId)
+ .groupBy(crmQuotations.opportunityId)
: [],
- enquiryIds.length
+ opportunityIds.length
? db
.select({
- enquiryId: crmEnquiryFollowups.enquiryId,
- nextFollowupDate: crmEnquiryFollowups.nextFollowupDate
+ opportunityId: crmOpportunityFollowups.opportunityId,
+ nextFollowupDate: crmOpportunityFollowups.nextFollowupDate
})
- .from(crmEnquiryFollowups)
+ .from(crmOpportunityFollowups)
.where(
and(
- eq(crmEnquiryFollowups.organizationId, context.organizationId),
- inArray(crmEnquiryFollowups.enquiryId, enquiryIds),
- isNull(crmEnquiryFollowups.deletedAt)
+ eq(crmOpportunityFollowups.organizationId, context.organizationId),
+ inArray(crmOpportunityFollowups.opportunityId, opportunityIds),
+ isNull(crmOpportunityFollowups.deletedAt)
)
)
- .orderBy(asc(crmEnquiryFollowups.nextFollowupDate))
+ .orderBy(asc(crmOpportunityFollowups.nextFollowupDate))
: []
]);
@@ -854,11 +854,11 @@ export async function listEnquiries(
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 followupCountMap = new Map(followupCounts.map((item) => [item.opportunityId, item.value]));
const quotationCountMap = new Map(
quotationCounts
- .filter((item) => item.enquiryId !== null)
- .map((item) => [item.enquiryId as string, item.value])
+ .filter((item) => item.opportunityId !== null)
+ .map((item) => [item.opportunityId as string, item.value])
);
const nextFollowupMap = new Map();
@@ -867,14 +867,14 @@ export async function listEnquiries(
continue;
}
- if (!nextFollowupMap.has(item.enquiryId)) {
- nextFollowupMap.set(item.enquiryId, item.nextFollowupDate.toISOString());
+ if (!nextFollowupMap.has(item.opportunityId)) {
+ nextFollowupMap.set(item.opportunityId, item.nextFollowupDate.toISOString());
}
}
return {
items: rows.map((row) => ({
- ...mapEnquiryRecord(row, {
+ ...mapOpportunityRecord(row, {
assignedToName: row.assignedToUserId
? (assignedUserMap.get(row.assignedToUserId) ?? null)
: null
@@ -890,16 +890,16 @@ export async function listEnquiries(
};
}
-export async function getEnquiryDetail(
+export async function getOpportunityDetail(
id: string,
organizationId: string,
- accessContext?: EnquiryAccessContext
-): Promise {
- const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
+ accessContext?: OpportunityAccessContext
+): Promise {
+ const opportunity = await assertOpportunityBelongsToOrganization(id, organizationId, accessContext);
const relatedUserIds = [
- enquiry.assignedToUserId,
- enquiry.assignedBy,
- enquiry.closedByUserId
+ opportunity.assignedToUserId,
+ opportunity.assignedBy,
+ opportunity.closedByUserId
].filter(Boolean) as string[];
const [userRows, lostReasonOptions] = await Promise.all([
relatedUserIds.length
@@ -909,15 +909,15 @@ export async function getEnquiryDetail(
]);
const userMap = new Map(userRows.map((row) => [row.id, row.name]));
const lostReasonOption =
- lostReasonOptions.find((option) => option.id === enquiry.lostReason || option.code === enquiry.lostReason) ??
+ lostReasonOptions.find((option) => option.id === opportunity.lostReason || option.code === opportunity.lostReason) ??
null;
- return mapEnquiryRecord(enquiry, {
- assignedToName: enquiry.assignedToUserId
- ? (userMap.get(enquiry.assignedToUserId) ?? null)
+ return mapOpportunityRecord(opportunity, {
+ assignedToName: opportunity.assignedToUserId
+ ? (userMap.get(opportunity.assignedToUserId) ?? null)
: null,
- assignedByName: enquiry.assignedBy ? (userMap.get(enquiry.assignedBy) ?? null) : null,
- closedByName: enquiry.closedByUserId ? (userMap.get(enquiry.closedByUserId) ?? null) : null,
+ assignedByName: opportunity.assignedBy ? (userMap.get(opportunity.assignedBy) ?? null) : null,
+ closedByName: opportunity.closedByUserId ? (userMap.get(opportunity.closedByUserId) ?? null) : null,
lostReason: lostReasonOption
? { code: lostReasonOption.code, label: lostReasonOption.label }
: null,
@@ -928,7 +928,7 @@ export async function getEnquiryDetail(
async function buildPreparedProjectParties(
organizationId: string,
billingCustomerId: string,
- projectParties: EnquiryProjectPartyMutationPayload[] = []
+ projectParties: OpportunityProjectPartyMutationPayload[] = []
) {
const roleOptions = await getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.projectPartyRole, {
organizationId
@@ -977,12 +977,12 @@ async function buildPreparedProjectParties(
return [...prepared.values()];
}
-async function syncEnquiryProjectParties(
+async function syncOpportunityProjectParties(
tx: Pick,
organizationId: string,
- enquiryId: string,
+ opportunityId: string,
billingCustomerId: string,
- payload: EnquiryProjectPartyMutationPayload[]
+ payload: OpportunityProjectPartyMutationPayload[]
) {
const now = new Date();
const preparedParties = await buildPreparedProjectParties(
@@ -992,13 +992,13 @@ async function syncEnquiryProjectParties(
);
await tx
- .update(crmEnquiryCustomers)
+ .update(crmOpportunityCustomers)
.set({ deletedAt: now, updatedAt: now })
.where(
and(
- eq(crmEnquiryCustomers.organizationId, organizationId),
- eq(crmEnquiryCustomers.enquiryId, enquiryId),
- isNull(crmEnquiryCustomers.deletedAt)
+ eq(crmOpportunityCustomers.organizationId, organizationId),
+ eq(crmOpportunityCustomers.opportunityId, opportunityId),
+ isNull(crmOpportunityCustomers.deletedAt)
)
);
@@ -1006,11 +1006,11 @@ async function syncEnquiryProjectParties(
return;
}
- await tx.insert(crmEnquiryCustomers).values(
+ await tx.insert(crmOpportunityCustomers).values(
preparedParties.map((item) => ({
id: crypto.randomUUID(),
organizationId,
- enquiryId,
+ opportunityId,
customerId: item.customerId,
role: item.roleId,
remark: item.remark
@@ -1018,24 +1018,24 @@ async function syncEnquiryProjectParties(
);
}
-export async function listEnquiryProjectParties(
+export async function listOpportunityProjectParties(
id: string,
organizationId: string,
- accessContext?: EnquiryAccessContext
-): Promise {
- await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
+ accessContext?: OpportunityAccessContext
+): Promise {
+ await assertOpportunityBelongsToOrganization(id, organizationId, accessContext);
const [relations, customers, roleOptions] = await Promise.all([
db
.select()
- .from(crmEnquiryCustomers)
+ .from(crmOpportunityCustomers)
.where(
and(
- eq(crmEnquiryCustomers.enquiryId, id),
- eq(crmEnquiryCustomers.organizationId, organizationId),
- isNull(crmEnquiryCustomers.deletedAt)
+ eq(crmOpportunityCustomers.opportunityId, id),
+ eq(crmOpportunityCustomers.organizationId, organizationId),
+ isNull(crmOpportunityCustomers.deletedAt)
)
)
- .orderBy(asc(crmEnquiryCustomers.createdAt)),
+ .orderBy(asc(crmOpportunityCustomers.createdAt)),
db
.select()
.from(crmCustomers)
@@ -1057,17 +1057,17 @@ export async function listEnquiryProjectParties(
...mapProjectPartyRecord(row, { role: resolvedRole }),
customerName: customerMap.get(row.customerId)?.name ?? 'Unknown customer',
customerCode: customerMap.get(row.customerId)?.code ?? '-'
- } satisfies EnquiryProjectPartyListItem;
+ } satisfies OpportunityProjectPartyListItem;
})
- .filter((item): item is EnquiryProjectPartyListItem => item !== null);
+ .filter((item): item is OpportunityProjectPartyListItem => item !== null);
}
-export async function getEnquiryActivity(
+export async function getOpportunityActivity(
id: string,
organizationId: string,
- accessContext?: EnquiryAccessContext
-): Promise {
- await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
+ accessContext?: OpportunityAccessContext
+): Promise {
+ await assertOpportunityBelongsToOrganization(id, organizationId, accessContext);
const auditLogs = await listAuditLogs({
organizationId,
entityId: id,
@@ -1075,10 +1075,10 @@ export async function getEnquiryActivity(
});
const filteredLogs = auditLogs.filter(
(log) =>
- (log.entityType === 'crm_enquiry' && log.entityId === id) ||
- (log.entityType === 'crm_enquiry_followup' &&
- ((log.afterData as { enquiryId?: string } | null)?.enquiryId === id ||
- (log.beforeData as { enquiryId?: string } | null)?.enquiryId === id))
+ (log.entityType === 'crm_opportunity' && log.entityId === id) ||
+ (log.entityType === 'crm_opportunity_followup' &&
+ ((log.afterData as { opportunityId?: string } | null)?.opportunityId === id ||
+ (log.beforeData as { opportunityId?: string } | null)?.opportunityId === id))
);
const userIds = [...new Set(filteredLogs.map((log) => log.userId))];
const actorRows = userIds.length
@@ -1100,24 +1100,24 @@ export async function getEnquiryActivity(
}));
}
-export async function createEnquiry(
+export async function createOpportunity(
organizationId: string,
userId: string,
- accessContext: EnquiryAccessContext,
- payload: EnquiryMutationPayload
+ accessContext: OpportunityAccessContext,
+ payload: OpportunityMutationPayload
) {
- await validateEnquiryPayload(organizationId, payload, accessContext);
+ await validateOpportunityPayload(organizationId, payload, accessContext);
const pipelineStage = await resolvePipelineStageForCreate(accessContext.businessRole);
const documentCode = await generateNextDocumentCode({
organizationId,
- documentType: 'enquiry',
+ documentType: 'opportunity',
branchId: payload.branchId ?? ''
});
return db.transaction(async (tx) => {
const [created] = await tx
- .insert(crmEnquiries)
+ .insert(crmOpportunities)
.values({
id: crypto.randomUUID(),
organizationId,
@@ -1152,7 +1152,7 @@ export async function createEnquiry(
})
.returning();
- await syncEnquiryProjectParties(
+ await syncOpportunityProjectParties(
tx,
organizationId,
created.id,
@@ -1164,20 +1164,20 @@ export async function createEnquiry(
});
}
-export async function updateEnquiry(
+export async function updateOpportunity(
id: string,
organizationId: string,
userId: string,
- payload: EnquiryMutationPayload,
- accessContext?: EnquiryAccessContext
+ payload: OpportunityMutationPayload,
+ accessContext?: OpportunityAccessContext
) {
- await validateEnquiryPayload(organizationId, payload, accessContext);
- const current = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
+ await validateOpportunityPayload(organizationId, payload, accessContext);
+ const current = await assertOpportunityBelongsToOrganization(id, organizationId, accessContext);
const pipelineStage = await resolvePipelineStageForUpdate(current);
return db.transaction(async (tx) => {
const [updated] = await tx
- .update(crmEnquiries)
+ .update(crmOpportunities)
.set({
branchId: payload.branchId ?? null,
customerId: payload.customerId,
@@ -1203,10 +1203,10 @@ export async function updateEnquiry(
updatedBy: userId,
updatedAt: new Date()
})
- .where(eq(crmEnquiries.id, id))
+ .where(eq(crmOpportunities.id, id))
.returning();
- await syncEnquiryProjectParties(
+ await syncOpportunityProjectParties(
tx,
organizationId,
id,
@@ -1218,28 +1218,28 @@ export async function updateEnquiry(
});
}
-export async function softDeleteEnquiry(
+export async function softDeleteOpportunity(
id: string,
organizationId: string,
userId: string,
- accessContext?: EnquiryAccessContext
+ accessContext?: OpportunityAccessContext
) {
- await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
+ await assertOpportunityBelongsToOrganization(id, organizationId, accessContext);
const now = new Date();
const [updated] = await db
- .update(crmEnquiries)
+ .update(crmOpportunities)
.set({
deletedAt: now,
updatedAt: now,
updatedBy: userId,
isActive: false
})
- .where(eq(crmEnquiries.id, id))
+ .where(eq(crmOpportunities.id, id))
.returning();
await db
- .update(crmEnquiryFollowups)
+ .update(crmOpportunityFollowups)
.set({
deletedAt: now,
updatedAt: now,
@@ -1247,52 +1247,52 @@ export async function softDeleteEnquiry(
})
.where(
and(
- eq(crmEnquiryFollowups.enquiryId, id),
- eq(crmEnquiryFollowups.organizationId, organizationId)
+ eq(crmOpportunityFollowups.opportunityId, id),
+ eq(crmOpportunityFollowups.organizationId, organizationId)
)
);
await db
- .update(crmEnquiryCustomers)
+ .update(crmOpportunityCustomers)
.set({
deletedAt: now,
updatedAt: now
})
.where(
and(
- eq(crmEnquiryCustomers.enquiryId, id),
- eq(crmEnquiryCustomers.organizationId, organizationId),
- isNull(crmEnquiryCustomers.deletedAt)
+ eq(crmOpportunityCustomers.opportunityId, id),
+ eq(crmOpportunityCustomers.organizationId, organizationId),
+ isNull(crmOpportunityCustomers.deletedAt)
)
);
return updated;
}
-async function updateEnquiryAssignment(
+async function updateOpportunityAssignment(
id: string,
organizationId: string,
userId: string,
- payload: EnquiryAssignmentMutationPayload,
+ payload: OpportunityAssignmentMutationPayload,
mode: 'assign' | 'reassign',
- accessContext?: EnquiryAccessContext
+ accessContext?: OpportunityAccessContext
) {
- const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
+ const opportunity = await assertOpportunityBelongsToOrganization(id, organizationId, accessContext);
- if (mode === 'assign' && enquiry.assignedToUserId) {
- throw new AuthError('Enquiry is already assigned. Use reassign instead.', 400);
+ if (mode === 'assign' && opportunity.assignedToUserId) {
+ throw new AuthError('Opportunity is already assigned. Use reassign instead.', 400);
}
- if (mode === 'reassign' && !enquiry.assignedToUserId) {
- throw new AuthError('Enquiry is not assigned yet. Use assign instead.', 400);
+ if (mode === 'reassign' && !opportunity.assignedToUserId) {
+ throw new AuthError('Opportunity is not assigned yet. Use assign instead.', 400);
}
await assertAssignableUserBelongsToOrganization(payload.assignedToUserId, organizationId);
const [updated] = await db
- .update(crmEnquiries)
+ .update(crmOpportunities)
.set({
- pipelineStage: 'enquiry',
+ pipelineStage: 'opportunity',
assignedToUserId: payload.assignedToUserId,
assignedAt: new Date(),
assignedBy: userId,
@@ -1300,68 +1300,68 @@ async function updateEnquiryAssignment(
updatedAt: new Date(),
updatedBy: userId
})
- .where(eq(crmEnquiries.id, id))
+ .where(eq(crmOpportunities.id, id))
.returning();
return updated;
}
-export async function assignEnquiryToUser(
+export async function assignOpportunityToUser(
id: string,
organizationId: string,
userId: string,
- payload: EnquiryAssignmentMutationPayload,
- accessContext?: EnquiryAccessContext
+ payload: OpportunityAssignmentMutationPayload,
+ accessContext?: OpportunityAccessContext
) {
- return updateEnquiryAssignment(id, organizationId, userId, payload, 'assign', accessContext);
+ return updateOpportunityAssignment(id, organizationId, userId, payload, 'assign', accessContext);
}
-export async function reassignEnquiryToUser(
+export async function reassignOpportunityToUser(
id: string,
organizationId: string,
userId: string,
- payload: EnquiryAssignmentMutationPayload,
- accessContext?: EnquiryAccessContext
+ payload: OpportunityAssignmentMutationPayload,
+ accessContext?: OpportunityAccessContext
) {
- return updateEnquiryAssignment(id, organizationId, userId, payload, 'reassign', accessContext);
+ return updateOpportunityAssignment(id, organizationId, userId, payload, 'reassign', accessContext);
}
-export async function listEnquiryFollowups(
+export async function listOpportunityFollowups(
id: string,
organizationId: string,
- accessContext?: EnquiryAccessContext
+ accessContext?: OpportunityAccessContext
) {
- await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
+ await assertOpportunityBelongsToOrganization(id, organizationId, accessContext);
const rows = await db
.select()
- .from(crmEnquiryFollowups)
+ .from(crmOpportunityFollowups)
.where(
and(
- eq(crmEnquiryFollowups.enquiryId, id),
- eq(crmEnquiryFollowups.organizationId, organizationId),
- isNull(crmEnquiryFollowups.deletedAt)
+ eq(crmOpportunityFollowups.opportunityId, id),
+ eq(crmOpportunityFollowups.organizationId, organizationId),
+ isNull(crmOpportunityFollowups.deletedAt)
)
)
- .orderBy(desc(crmEnquiryFollowups.followupDate));
+ .orderBy(desc(crmOpportunityFollowups.followupDate));
return rows.map(mapFollowupRecord);
}
-export async function createEnquiryFollowup(
- enquiryId: string,
+export async function createOpportunityFollowup(
+ opportunityId: string,
organizationId: string,
userId: string,
- payload: EnquiryFollowupMutationPayload,
- accessContext?: EnquiryAccessContext
+ payload: OpportunityFollowupMutationPayload,
+ accessContext?: OpportunityAccessContext
) {
- await validateFollowupPayload(organizationId, enquiryId, payload, accessContext);
+ await validateFollowupPayload(organizationId, opportunityId, payload, accessContext);
const [created] = await db
- .insert(crmEnquiryFollowups)
+ .insert(crmOpportunityFollowups)
.values({
id: crypto.randomUUID(),
organizationId,
- enquiryId,
+ opportunityId,
followupDate: new Date(payload.followupDate),
followupType: payload.followupType,
contactId: payload.contactId ?? null,
@@ -1377,19 +1377,19 @@ export async function createEnquiryFollowup(
return created;
}
-export async function updateEnquiryFollowup(
- enquiryId: string,
+export async function updateOpportunityFollowup(
+ opportunityId: string,
followupId: string,
organizationId: string,
userId: string,
- payload: EnquiryFollowupMutationPayload,
- accessContext?: EnquiryAccessContext
+ payload: OpportunityFollowupMutationPayload,
+ accessContext?: OpportunityAccessContext
) {
- await validateFollowupPayload(organizationId, enquiryId, payload, accessContext);
- await assertFollowupBelongsToEnquiry(followupId, enquiryId, organizationId);
+ await validateFollowupPayload(organizationId, opportunityId, payload, accessContext);
+ await assertFollowupBelongsToOpportunity(followupId, opportunityId, organizationId);
const [updated] = await db
- .update(crmEnquiryFollowups)
+ .update(crmOpportunityFollowups)
.set({
followupDate: new Date(payload.followupDate),
followupType: payload.followupType,
@@ -1401,53 +1401,53 @@ export async function updateEnquiryFollowup(
updatedAt: new Date(),
updatedBy: userId
})
- .where(eq(crmEnquiryFollowups.id, followupId))
+ .where(eq(crmOpportunityFollowups.id, followupId))
.returning();
return updated;
}
-export async function softDeleteEnquiryFollowup(
- enquiryId: string,
+export async function softDeleteOpportunityFollowup(
+ opportunityId: string,
followupId: string,
organizationId: string,
userId: string,
- accessContext?: EnquiryAccessContext
+ accessContext?: OpportunityAccessContext
) {
- await assertEnquiryBelongsToOrganization(enquiryId, organizationId, accessContext);
- await assertFollowupBelongsToEnquiry(followupId, enquiryId, organizationId);
+ await assertOpportunityBelongsToOrganization(opportunityId, organizationId, accessContext);
+ await assertFollowupBelongsToOpportunity(followupId, opportunityId, organizationId);
const [updated] = await db
- .update(crmEnquiryFollowups)
+ .update(crmOpportunityFollowups)
.set({
deletedAt: new Date(),
updatedAt: new Date(),
updatedBy: userId
})
- .where(eq(crmEnquiryFollowups.id, followupId))
+ .where(eq(crmOpportunityFollowups.id, followupId))
.returning();
return updated;
}
-function ensureEnquiryStageForOutcome(
- enquiry: typeof crmEnquiries.$inferSelect,
- expectedStage: EnquiryPipelineStage
+function ensureOpportunityStageForOutcome(
+ opportunity: typeof crmOpportunities.$inferSelect,
+ expectedStage: OpportunityPipelineStage
) {
- if (enquiry.pipelineStage !== expectedStage) {
- throw new AuthError(`Enquiry must be in ${expectedStage} stage`, 400);
+ if (opportunity.pipelineStage !== expectedStage) {
+ throw new AuthError(`Opportunity must be in ${expectedStage} stage`, 400);
}
}
-export async function markEnquiryAsWon(
+export async function markOpportunityAsWon(
id: string,
organizationId: string,
userId: string,
- payload: EnquiryMarkWonPayload,
- accessContext?: EnquiryAccessContext
+ payload: OpportunityMarkWonPayload,
+ accessContext?: OpportunityAccessContext
) {
- const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
- ensureEnquiryStageForOutcome(enquiry, 'enquiry');
+ const opportunity = await assertOpportunityBelongsToOrganization(id, organizationId, accessContext);
+ ensureOpportunityStageForOutcome(opportunity, 'opportunity');
if (!payload.poNumber.trim()) {
throw new AuthError('PO number is required', 400);
@@ -1455,7 +1455,7 @@ export async function markEnquiryAsWon(
const now = new Date();
const [updated] = await db
- .update(crmEnquiries)
+ .update(crmOpportunities)
.set({
pipelineStage: 'closed_won',
closedWonAt: now,
@@ -1471,14 +1471,14 @@ export async function markEnquiryAsWon(
updatedAt: now,
updatedBy: userId
})
- .where(eq(crmEnquiries.id, id))
+ .where(eq(crmOpportunities.id, id))
.returning();
await auditAction({
organizationId,
branchId: updated.branchId,
userId,
- entityType: 'crm_enquiry',
+ entityType: 'crm_opportunity',
entityId: id,
action: 'mark_won',
afterData: {
@@ -1494,15 +1494,15 @@ export async function markEnquiryAsWon(
return updated;
}
-export async function markEnquiryAsLost(
+export async function markOpportunityAsLost(
id: string,
organizationId: string,
userId: string,
- payload: EnquiryMarkLostPayload,
- accessContext?: EnquiryAccessContext
+ payload: OpportunityMarkLostPayload,
+ accessContext?: OpportunityAccessContext
) {
- const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
- ensureEnquiryStageForOutcome(enquiry, 'enquiry');
+ const opportunity = await assertOpportunityBelongsToOrganization(id, organizationId, accessContext);
+ ensureOpportunityStageForOutcome(opportunity, 'opportunity');
await assertMasterOptionValue(organizationId, ENQUIRY_OPTION_CATEGORIES.lostReason, payload.lostReason);
const lostReasonCode =
@@ -1510,7 +1510,7 @@ export async function markEnquiryAsLost(
payload.lostReason;
const now = new Date();
const [updated] = await db
- .update(crmEnquiries)
+ .update(crmOpportunities)
.set({
pipelineStage: 'closed_lost',
closedWonAt: null,
@@ -1526,14 +1526,14 @@ export async function markEnquiryAsLost(
updatedAt: now,
updatedBy: userId
})
- .where(eq(crmEnquiries.id, id))
+ .where(eq(crmOpportunities.id, id))
.returning();
await auditAction({
organizationId,
branchId: updated.branchId,
userId,
- entityType: 'crm_enquiry',
+ entityType: 'crm_opportunity',
entityId: id,
action: 'mark_lost',
afterData: {
@@ -1547,25 +1547,25 @@ export async function markEnquiryAsLost(
return updated;
}
-export async function reopenLostEnquiry(
+export async function reopenLostOpportunity(
id: string,
organizationId: string,
userId: string,
- payload: EnquiryReopenPayload,
- accessContext?: EnquiryAccessContext
+ payload: OpportunityReopenPayload,
+ accessContext?: OpportunityAccessContext
) {
- const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
+ const opportunity = await assertOpportunityBelongsToOrganization(id, organizationId, accessContext);
- if (enquiry.pipelineStage === 'closed_won') {
- throw new AuthError('Won enquiries cannot be reopened', 400);
+ if (opportunity.pipelineStage === 'closed_won') {
+ throw new AuthError('Won opportunities cannot be reopened', 400);
}
- ensureEnquiryStageForOutcome(enquiry, 'closed_lost');
+ ensureOpportunityStageForOutcome(opportunity, 'closed_lost');
const now = new Date();
const [updated] = await db
- .update(crmEnquiries)
+ .update(crmOpportunities)
.set({
- pipelineStage: 'enquiry',
+ pipelineStage: 'opportunity',
closedLostAt: null,
closedByUserId: null,
lostReason: null,
@@ -1574,19 +1574,19 @@ export async function reopenLostEnquiry(
updatedAt: now,
updatedBy: userId
})
- .where(eq(crmEnquiries.id, id))
+ .where(eq(crmOpportunities.id, id))
.returning();
await auditAction({
organizationId,
branchId: updated.branchId,
userId,
- entityType: 'crm_enquiry',
+ entityType: 'crm_opportunity',
entityId: id,
action: 'reopen',
afterData: {
reopenReason: payload.reopenReason.trim(),
- previousStage: enquiry.pipelineStage,
+ previousStage: opportunity.pipelineStage,
nextStage: updated.pipelineStage
}
});
@@ -1594,44 +1594,44 @@ export async function reopenLostEnquiry(
return updated;
}
-export async function listEnquiryAttachments(
- enquiryId: string,
+export async function listOpportunityAttachments(
+ opportunityId: string,
organizationId: string,
- accessContext?: EnquiryAccessContext
+ accessContext?: OpportunityAccessContext
) {
- await assertEnquiryBelongsToOrganization(enquiryId, organizationId, accessContext);
+ await assertOpportunityBelongsToOrganization(opportunityId, organizationId, accessContext);
const rows = await db
.select()
- .from(crmEnquiryAttachments)
+ .from(crmOpportunityAttachments)
.where(
and(
- eq(crmEnquiryAttachments.organizationId, organizationId),
- eq(crmEnquiryAttachments.enquiryId, enquiryId),
- eq(crmEnquiryAttachments.category, PURCHASE_ORDER_ATTACHMENT_CATEGORY),
- isNull(crmEnquiryAttachments.deletedAt)
+ eq(crmOpportunityAttachments.organizationId, organizationId),
+ eq(crmOpportunityAttachments.opportunityId, opportunityId),
+ eq(crmOpportunityAttachments.category, PURCHASE_ORDER_ATTACHMENT_CATEGORY),
+ isNull(crmOpportunityAttachments.deletedAt)
)
)
- .orderBy(desc(crmEnquiryAttachments.uploadedAt));
+ .orderBy(desc(crmOpportunityAttachments.uploadedAt));
return rows.map(mapAttachmentRecord);
}
-export async function getEnquiryAttachment(
- enquiryId: string,
+export async function getOpportunityAttachment(
+ opportunityId: string,
attachmentId: string,
organizationId: string,
- accessContext?: EnquiryAccessContext
+ accessContext?: OpportunityAccessContext
) {
- await assertEnquiryBelongsToOrganization(enquiryId, organizationId, accessContext);
+ await assertOpportunityBelongsToOrganization(opportunityId, organizationId, accessContext);
const [attachment] = await db
.select()
- .from(crmEnquiryAttachments)
+ .from(crmOpportunityAttachments)
.where(
and(
- eq(crmEnquiryAttachments.id, attachmentId),
- eq(crmEnquiryAttachments.organizationId, organizationId),
- eq(crmEnquiryAttachments.enquiryId, enquiryId),
- isNull(crmEnquiryAttachments.deletedAt)
+ eq(crmOpportunityAttachments.id, attachmentId),
+ eq(crmOpportunityAttachments.organizationId, organizationId),
+ eq(crmOpportunityAttachments.opportunityId, opportunityId),
+ isNull(crmOpportunityAttachments.deletedAt)
)
)
.limit(1);
@@ -1644,7 +1644,7 @@ export async function getEnquiryAttachment(
}
export async function uploadPurchaseOrderAttachment(input: {
- enquiryId: string;
+ opportunityId: string;
organizationId: string;
userId: string;
file: {
@@ -1654,16 +1654,16 @@ export async function uploadPurchaseOrderAttachment(input: {
buffer: Buffer;
};
description?: string | null;
- accessContext?: EnquiryAccessContext;
+ accessContext?: OpportunityAccessContext;
}) {
- const enquiry = await assertEnquiryBelongsToOrganization(
- input.enquiryId,
+ const opportunity = await assertOpportunityBelongsToOrganization(
+ input.opportunityId,
input.organizationId,
input.accessContext
);
- if (enquiry.pipelineStage !== 'closed_won') {
- throw new AuthError('PO attachment is available only for won enquiries', 400);
+ if (opportunity.pipelineStage !== 'closed_won') {
+ throw new AuthError('PO attachment is available only for won opportunities', 400);
}
if (!ALLOWED_PO_CONTENT_TYPES.has(input.file.type)) {
@@ -1673,8 +1673,8 @@ export async function uploadPurchaseOrderAttachment(input: {
const storageProvider = getStorageProvider();
const safeFileName = input.file.name.replace(/[^a-zA-Z0-9._-]/g, '-');
const storageKey = buildOrganizationStorageKey(input.organizationId, [
- 'enquiries',
- input.enquiryId,
+ 'opportunities',
+ input.opportunityId,
'purchase-orders',
`${Date.now()}-${safeFileName}`
]);
@@ -1685,11 +1685,11 @@ export async function uploadPurchaseOrderAttachment(input: {
fileName: safeFileName
});
const [created] = await db
- .insert(crmEnquiryAttachments)
+ .insert(crmOpportunityAttachments)
.values({
id: crypto.randomUUID(),
organizationId: input.organizationId,
- enquiryId: input.enquiryId,
+ opportunityId: input.opportunityId,
category: PURCHASE_ORDER_ATTACHMENT_CATEGORY,
fileName: safeFileName,
originalFileName: input.file.name,
@@ -1704,14 +1704,14 @@ export async function uploadPurchaseOrderAttachment(input: {
await auditAction({
organizationId: input.organizationId,
- branchId: enquiry.branchId,
+ branchId: opportunity.branchId,
userId: input.userId,
- entityType: 'crm_enquiry',
- entityId: input.enquiryId,
+ entityType: 'crm_opportunity',
+ entityId: input.opportunityId,
action: 'upload_po',
afterData: {
attachmentId: created.id,
- poNumber: enquiry.poNumber,
+ poNumber: opportunity.poNumber,
fileName: created.fileName,
fileType: created.fileType
}
@@ -1720,26 +1720,26 @@ export async function uploadPurchaseOrderAttachment(input: {
return created;
}
-export async function listCustomerEnquiryRelations(
+export async function listCustomerOpportunityRelations(
customerId: string,
organizationId: string,
- accessContext?: EnquiryAccessContext
-): Promise {
+ accessContext?: OpportunityAccessContext
+): Promise {
const rows = await db
.select()
- .from(crmEnquiries)
+ .from(crmOpportunities)
.where(
and(
- eq(crmEnquiries.customerId, customerId),
- eq(crmEnquiries.organizationId, organizationId),
- isNull(crmEnquiries.deletedAt)
+ eq(crmOpportunities.customerId, customerId),
+ eq(crmOpportunities.organizationId, organizationId),
+ isNull(crmOpportunities.deletedAt)
)
)
- .orderBy(desc(crmEnquiries.updatedAt))
+ .orderBy(desc(crmOpportunities.updatedAt))
.limit(10);
return rows
- .filter((row) => !accessContext || canAccessEnquiryRow(row, accessContext))
+ .filter((row) => !accessContext || canAccessOpportunityRow(row, accessContext))
.map((row) => ({
id: row.id,
code: row.code,
@@ -1750,12 +1750,13 @@ export async function listCustomerEnquiryRelations(
}));
}
-export async function syncEnquiryPipelineStageFromQuotationStatus(
- enquiryId: string,
+export async function syncOpportunityPipelineStageFromQuotationStatus(
+ opportunityId: string,
organizationId: string,
quotationStatusCode: string | null
) {
- void enquiryId;
+ void opportunityId;
void organizationId;
void quotationStatusCode;
}
+
diff --git a/src/features/crm/quotations/api/service.ts b/src/features/crm/quotations/api/service.ts
index caff593..9e48d61 100644
--- a/src/features/crm/quotations/api/service.ts
+++ b/src/features/crm/quotations/api/service.ts
@@ -28,7 +28,7 @@ export async function getQuotations(filters: QuotationFilters): Promise row.enquiryId ?? '',
+ id: 'opportunity',
+ accessorFn: (row) => row.opportunityId ?? '',
header: ({ column }: { column: Column }) => (
),
- cell: ({ row }) => row.original.enquiryCode ?? '-',
+ cell: ({ row }) => row.original.opportunityCode ?? '-',
meta: {
label: 'โอกาสขาย',
variant: 'multiSelect' as const,
- options: referenceData.enquiries.map((item) => ({ value: item.id, label: `${item.code} - ${item.title}` }))
+ options: referenceData.opportunities.map((item) => ({ value: item.id, label: `${item.code} - ${item.title}` }))
},
enableColumnFilter: true
},
@@ -165,3 +165,4 @@ export function getQuotationColumns({
}
];
}
+
diff --git a/src/features/crm/quotations/components/quotation-form-sheet.tsx b/src/features/crm/quotations/components/quotation-form-sheet.tsx
index 45bfc69..09445f6 100644
--- a/src/features/crm/quotations/components/quotation-form-sheet.tsx
+++ b/src/features/crm/quotations/components/quotation-form-sheet.tsx
@@ -39,7 +39,7 @@ import { createQuotationMutation, updateQuotationMutation } from '../api/mutatio
import type { QuotationMutationPayload, QuotationRecord, QuotationReferenceData } from '../api/types';
type FormState = {
- enquiryId: string;
+ opportunityId: string;
customerId: string;
contactId: string;
quotationDate: string;
@@ -71,7 +71,7 @@ function toFormState(
referenceData: QuotationReferenceData
): FormState {
return {
- enquiryId: quotation?.enquiryId ?? '',
+ opportunityId: quotation?.opportunityId ?? '',
customerId: quotation?.customerId ?? referenceData.customers[0]?.id ?? '',
contactId: quotation?.contactId ?? '',
quotationDate:
@@ -190,16 +190,16 @@ export function QuotationFormSheet({
setState((current) => ({ ...current, [key]: value }));
}
- function handleEnquiryChange(value: string) {
- const enquiry = referenceData.enquiries.find((item) => item.id === value);
+ function handleOpportunityChange(value: string) {
+ const opportunity = referenceData.opportunities.find((item) => item.id === value);
setState((current) => ({
...current,
- enquiryId: value === '__none__' ? '' : value,
- customerId: enquiry?.customerId ?? current.customerId,
- contactId: enquiry?.contactId ?? '',
- projectName: enquiry?.projectName ?? current.projectName,
- projectLocation: enquiry?.projectLocation ?? current.projectLocation,
- branchId: enquiry?.branchId ?? current.branchId
+ opportunityId: value === '__none__' ? '' : value,
+ customerId: opportunity?.customerId ?? current.customerId,
+ contactId: opportunity?.contactId ?? '',
+ projectName: opportunity?.projectName ?? current.projectName,
+ projectLocation: opportunity?.projectLocation ?? current.projectLocation,
+ branchId: opportunity?.branchId ?? current.branchId
}));
}
@@ -207,7 +207,7 @@ export function QuotationFormSheet({
event.preventDefault();
const payload: QuotationMutationPayload = {
- enquiryId: state.enquiryId || null,
+ opportunityId: state.opportunityId || null,
customerId: state.customerId,
contactId: state.contactId || null,
quotationDate: state.quotationDate,
@@ -263,14 +263,14 @@ export function QuotationFormSheet({