task-d5.5.1

This commit is contained in:
phaichayon
2026-06-25 12:16:41 +07:00
parent f2c7156851
commit c2a74b6764
121 changed files with 3407 additions and 2679 deletions

View File

@@ -9,7 +9,7 @@ export type {
LeadDetail,
LeadDetailResponse,
LeadFollowupSummary,
LeadLinkedEnquirySummary,
LeadLinkedOpportunitySummary,
LeadListFilters,
LeadListResponse,
LeadMutationSuccessResponse,
@@ -18,3 +18,4 @@ export type {
LeadSummary,
UpdateLeadInput
} from '../types';

View File

@@ -54,9 +54,9 @@ export function LeadAssignmentDialog({
...assignLeadMutation,
onSuccess: (result) => {
toast.success(
result.reusedExistingEnquiry
? `มอบหมายสำเร็จและใช้ enquiry เดิม ${result.enquiryCode}`
: `มอบหมายสำเร็จและสร้าง enquiry ${result.enquiryCode}`
result.reusedExistingOpportunity
? `มอบหมายสำเร็จและใช้ opportunity เดิม ${result.opportunityCode}`
: `มอบหมายสำเร็จและสร้าง opportunity ${result.opportunityCode}`
);
onOpenChange(false);
},
@@ -90,7 +90,7 @@ export function LeadAssignmentDialog({
<DialogHeader>
<DialogTitle>Assign Lead to Sales</DialogTitle>
<DialogDescription>
enquiry workspace lead workspace
opportunity workspace lead workspace
</DialogDescription>
</DialogHeader>
@@ -127,17 +127,17 @@ export function LeadAssignmentDialog({
</Button>
</DialogFooter>
{'linkedEnquiries' in lead && lead.linkedEnquiries.length > 0 ? (
{'linkedOpportunities' in lead && lead.linkedOpportunities.length > 0 ? (
<div className='border-border rounded-md border p-3 text-sm'>
<div className='mb-2 font-medium'>Linked enquiries</div>
<div className='mb-2 font-medium'>Linked opportunities</div>
<div className='space-y-1'>
{lead.linkedEnquiries.map((enquiry) => (
{lead.linkedOpportunities.map((opportunity) => (
<Link
key={enquiry.id}
href={`/dashboard/crm/enquiries/${enquiry.id}`}
key={opportunity.id}
href={`/dashboard/crm/opportunities/${opportunity.id}`}
className='block hover:underline'
>
{enquiry.code} - {enquiry.title}
{opportunity.code} - {opportunity.title}
</Link>
))}
</div>
@@ -147,3 +147,4 @@ export function LeadAssignmentDialog({
</Dialog>
);
}

View File

@@ -57,7 +57,7 @@ export function LeadDetail({
<Badge variant='outline'>{lead.statusLabel ?? lead.status}</Badge>
</div>
<p className='text-muted-foreground mt-2 text-sm'>
Lead workspace for marketing qualification before sales enquiry handoff
Lead workspace for marketing qualification before sales opportunity handoff
</p>
</div>
@@ -103,7 +103,7 @@ export function LeadDetail({
<Tabs defaultValue='followups' className='space-y-4'>
<TabsList>
<TabsTrigger value='followups'>Follow-ups</TabsTrigger>
<TabsTrigger value='linked-enquiries'>Linked Enquiries</TabsTrigger>
<TabsTrigger value='linked-opportunities'>Linked Opportunities</TabsTrigger>
<TabsTrigger value='activity'>Activity</TabsTrigger>
</TabsList>
@@ -111,29 +111,29 @@ export function LeadDetail({
<LeadFollowupPanel leadId={leadId} detail={data} />
</TabsContent>
<TabsContent value='linked-enquiries'>
<TabsContent value='linked-opportunities'>
<Card>
<CardHeader>
<CardTitle>Linked Enquiries</CardTitle>
<CardTitle>Linked Opportunities</CardTitle>
</CardHeader>
<CardContent className='space-y-3'>
{lead.linkedEnquiries.length === 0 ? (
<div className='text-muted-foreground text-sm'>No linked enquiries yet.</div>
{lead.linkedOpportunities.length === 0 ? (
<div className='text-muted-foreground text-sm'>No linked opportunities yet.</div>
) : (
lead.linkedEnquiries.map((enquiry) => (
<div key={enquiry.id} className='border-border rounded-md border p-3'>
lead.linkedOpportunities.map((opportunity) => (
<div key={opportunity.id} className='border-border rounded-md border p-3'>
<div className='flex items-center justify-between gap-3'>
<Link
href={`/dashboard/crm/enquiries/${enquiry.id}`}
href={`/dashboard/crm/opportunities/${opportunity.id}`}
className='font-medium hover:underline'
>
{enquiry.code}
{opportunity.code}
</Link>
<Badge variant='outline'>{enquiry.status}</Badge>
<Badge variant='outline'>{opportunity.status}</Badge>
</div>
<div className='mt-1 text-sm'>{enquiry.title}</div>
<div className='mt-1 text-sm'>{opportunity.title}</div>
<div className='text-muted-foreground mt-2 text-xs'>
Owner: {enquiry.assignedToName ?? '-'}
Owner: {opportunity.assignedToName ?? '-'}
</div>
</div>
))
@@ -174,7 +174,7 @@ export function LeadDetail({
</CardHeader>
<CardContent className='space-y-3'>
<FieldItem label='Current Workspace' value='Lead' />
<FieldItem label='Related Enquiries' value={String(lead.relatedEnquiryCount)} />
<FieldItem label='Related Opportunities' value={String(lead.relatedOpportunityCount)} />
<FieldItem
label='Assigned At'
value={lead.assignedAt ? new Date(lead.assignedAt).toLocaleString() : null}
@@ -188,3 +188,4 @@ export function LeadDetail({
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { and, asc, eq, inArray, isNull } from 'drizzle-orm';
import { crmEnquiries, crmLeads } from '@/db/schema';
import { crmOpportunities, crmLeads } from '@/db/schema';
import { db } from '@/lib/db';
import { AuthError } from '@/lib/auth/session';
import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service';
@@ -13,13 +13,13 @@ import {
assertAssignableUserBelongsToOrganization,
assertContactBelongsToOrganization,
assertCustomerBelongsToOrganization
} from '@/features/crm/enquiries/server/service';
} from '@/features/crm/opportunities/server/service';
import type { AssignLeadInput, AssignLeadResult } from '../types';
export type LeadAssignmentAccessContext = CrmSecurityContext;
type LeadRow = typeof crmLeads.$inferSelect;
type EnquiryRow = typeof crmEnquiries.$inferSelect;
type OpportunityRow = typeof crmOpportunities.$inferSelect;
function canAccessLeadRow(row: LeadRow, accessContext: LeadAssignmentAccessContext) {
return canAccessScopedCrmRecord(accessContext, {
@@ -57,34 +57,34 @@ async function assertLeadBelongsToOrganization(
return lead;
}
async function resolveDefaultEnquiryStatusId(organizationId: string) {
const options = await getActiveOptionsByCategory('crm_enquiry_status', { organizationId });
async function resolveDefaultOpportunityStatusId(organizationId: string) {
const options = await getActiveOptionsByCategory('crm_opportunity_status', { organizationId });
const preferred = options.find((option) => option.code === 'new');
const resolved = preferred ?? options[0];
if (!resolved) {
throw new AuthError('Enquiry status options are not configured', 400);
throw new AuthError('Opportunity status options are not configured', 400);
}
return resolved.id;
}
async function findActiveEnquiryByLead(leadId: string, organizationId: string) {
const [enquiry] = await db
async function findActiveOpportunityByLead(leadId: string, organizationId: string) {
const [opportunity] = await db
.select()
.from(crmEnquiries)
.from(crmOpportunities)
.where(
and(
eq(crmEnquiries.leadId, leadId),
eq(crmEnquiries.organizationId, organizationId),
inArray(crmEnquiries.pipelineStage, ['lead', 'enquiry']),
isNull(crmEnquiries.deletedAt)
eq(crmOpportunities.leadId, leadId),
eq(crmOpportunities.organizationId, organizationId),
inArray(crmOpportunities.pipelineStage, ['lead', 'opportunity']),
isNull(crmOpportunities.deletedAt)
)
)
.orderBy(asc(crmEnquiries.createdAt))
.orderBy(asc(crmOpportunities.createdAt))
.limit(1);
return enquiry ?? null;
return opportunity ?? null;
}
async function syncLeadAssignment(input: {
@@ -109,7 +109,7 @@ async function syncLeadAssignment(input: {
.where(eq(crmLeads.id, input.leadId));
}
async function createEnquiryFromLead(input: {
async function createOpportunityFromLead(input: {
lead: LeadRow;
organizationId: string;
actorUserId: string;
@@ -139,17 +139,17 @@ async function createEnquiryFromLead(input: {
}
const [statusId, documentCode] = await Promise.all([
resolveDefaultEnquiryStatusId(input.organizationId),
resolveDefaultOpportunityStatusId(input.organizationId),
generateNextDocumentCode({
organizationId: input.organizationId,
documentType: 'crm_enquiry',
documentType: 'crm_opportunity',
branchId: input.lead.branchId ?? null
})
]);
const now = new Date();
const [created] = await db
.insert(crmEnquiries)
.insert(crmOpportunities)
.values({
id: crypto.randomUUID(),
organizationId: input.organizationId,
@@ -175,7 +175,7 @@ async function createEnquiryFromLead(input: {
notes: null,
isHotProject: false,
isActive: true,
pipelineStage: 'enquiry',
pipelineStage: 'opportunity',
assignedToUserId: input.salesOwnerId,
assignedAt: now,
assignedBy: input.actorUserId,
@@ -197,16 +197,16 @@ export async function assignLead(
const lead = await assertLeadBelongsToOrganization(input.leadId, organizationId, accessContext);
await assertAssignableUserBelongsToOrganization(input.salesOwnerId, organizationId);
const existingEnquiry = await findActiveEnquiryByLead(input.leadId, organizationId);
const existingOpportunity = await findActiveOpportunityByLead(input.leadId, organizationId);
if (existingEnquiry) {
const resolvedSalesOwnerId = existingEnquiry.assignedToUserId ?? input.salesOwnerId;
if (existingOpportunity) {
const resolvedSalesOwnerId = existingOpportunity.assignedToUserId ?? input.salesOwnerId;
if (!existingEnquiry.assignedToUserId) {
if (!existingOpportunity.assignedToUserId) {
await db
.update(crmEnquiries)
.update(crmOpportunities)
.set({
pipelineStage: 'enquiry',
pipelineStage: 'opportunity',
assignedToUserId: input.salesOwnerId,
assignedAt: new Date(),
assignedBy: actorUserId,
@@ -214,7 +214,7 @@ export async function assignLead(
updatedAt: new Date(),
updatedBy: actorUserId
})
.where(eq(crmEnquiries.id, existingEnquiry.id));
.where(eq(crmOpportunities.id, existingOpportunity.id));
}
await syncLeadAssignment({
@@ -234,21 +234,21 @@ export async function assignLead(
action: 'assign_lead',
afterData: {
leadId: input.leadId,
enquiryId: existingEnquiry.id,
opportunityId: existingOpportunity.id,
salesOwnerId: resolvedSalesOwnerId,
reusedExistingEnquiry: true
reusedExistingOpportunity: true
}
});
return {
leadId: input.leadId,
enquiryId: existingEnquiry.id,
enquiryCode: existingEnquiry.code,
reusedExistingEnquiry: true
opportunityId: existingOpportunity.id,
opportunityCode: existingOpportunity.code,
reusedExistingOpportunity: true
};
}
const createdEnquiry = await createEnquiryFromLead({
const createdOpportunity = await createOpportunityFromLead({
lead,
organizationId,
actorUserId,
@@ -268,12 +268,12 @@ export async function assignLead(
organizationId,
branchId: lead.branchId,
userId: actorUserId,
entityType: 'crm_enquiry',
entityId: createdEnquiry.id,
action: 'create_enquiry_from_lead',
entityType: 'crm_opportunity',
entityId: createdOpportunity.id,
action: 'create_opportunity_from_lead',
afterData: {
leadId: input.leadId,
enquiryId: createdEnquiry.id,
opportunityId: createdOpportunity.id,
salesOwnerId: input.salesOwnerId
}
});
@@ -287,16 +287,17 @@ export async function assignLead(
action: 'assign_lead',
afterData: {
leadId: input.leadId,
enquiryId: createdEnquiry.id,
opportunityId: createdOpportunity.id,
salesOwnerId: input.salesOwnerId,
reusedExistingEnquiry: false
reusedExistingOpportunity: false
}
});
return {
leadId: input.leadId,
enquiryId: createdEnquiry.id,
enquiryCode: createdEnquiry.code,
reusedExistingEnquiry: false
opportunityId: createdOpportunity.id,
opportunityCode: createdOpportunity.code,
reusedExistingOpportunity: false
};
}

View File

@@ -13,7 +13,7 @@ import {
import {
crmCustomerContacts,
crmCustomers,
crmEnquiries,
crmOpportunities,
crmLeads,
memberships,
trAuditLogs,
@@ -41,7 +41,7 @@ import type {
LeadCustomerLookup,
LeadDetail,
LeadFollowupSummary,
LeadLinkedEnquirySummary,
LeadLinkedOpportunitySummary,
LeadListFilters,
LeadListResponse,
LeadOption,
@@ -187,28 +187,28 @@ async function listLeadContacts(organizationId: string): Promise<LeadContactLook
}));
}
async function listLinkedEnquiries(
async function listLinkedOpportunities(
leadId: string,
organizationId: string
): Promise<LeadLinkedEnquirySummary[]> {
): Promise<LeadLinkedOpportunitySummary[]> {
const rows = await db
.select({
id: crmEnquiries.id,
code: crmEnquiries.code,
title: crmEnquiries.title,
status: crmEnquiries.status,
assignedToUserId: crmEnquiries.assignedToUserId,
updatedAt: crmEnquiries.updatedAt
id: crmOpportunities.id,
code: crmOpportunities.code,
title: crmOpportunities.title,
status: crmOpportunities.status,
assignedToUserId: crmOpportunities.assignedToUserId,
updatedAt: crmOpportunities.updatedAt
})
.from(crmEnquiries)
.from(crmOpportunities)
.where(
and(
eq(crmEnquiries.organizationId, organizationId),
eq(crmEnquiries.leadId, leadId),
isNull(crmEnquiries.deletedAt)
eq(crmOpportunities.organizationId, organizationId),
eq(crmOpportunities.leadId, leadId),
isNull(crmOpportunities.deletedAt)
)
)
.orderBy(desc(crmEnquiries.updatedAt));
.orderBy(desc(crmOpportunities.updatedAt));
const assigneeIds = [
...new Set(rows.map((row) => row.assignedToUserId).filter((value): value is string => Boolean(value)))
@@ -340,13 +340,13 @@ function mapLeadDetail(
row: LeadRow,
lookups: LeadLookupMaps,
suggestedSalesOwnerId: string | null,
relatedEnquiryCount: number
relatedOpportunityCount: number
): LeadDetail {
return {
...mapLeadSummary(row, lookups),
suggestedSalesOwnerId,
relatedEnquiryCount,
linkedEnquiries: [],
relatedOpportunityCount,
linkedOpportunities: [],
activity: []
};
}
@@ -766,32 +766,32 @@ export async function getLeadById(
accessContext?: LeadAccessContext
): Promise<LeadDetail> {
const lead = await assertLeadBelongsToOrganization(id, organizationId, accessContext);
const [lookups, relatedEnquiryResult, customer, linkedEnquiries, activity] = await Promise.all([
const [lookups, relatedOpportunityResult, customer, linkedOpportunities, activity] = await Promise.all([
buildLookupMaps(organizationId, [lead]),
db
.select({ value: count() })
.from(crmEnquiries)
.from(crmOpportunities)
.where(
and(
eq(crmEnquiries.organizationId, organizationId),
eq(crmEnquiries.leadId, id),
isNull(crmEnquiries.deletedAt)
eq(crmOpportunities.organizationId, organizationId),
eq(crmOpportunities.leadId, id),
isNull(crmOpportunities.deletedAt)
)
),
lead.customerId ? assertCustomerBelongsToOrganization(lead.customerId, organizationId) : Promise.resolve(null),
listLinkedEnquiries(id, organizationId),
listLinkedOpportunities(id, organizationId),
listLeadActivity(id, organizationId)
]);
const detail = mapLeadDetail(
lead,
lookups,
customer?.ownerUserId ?? null,
relatedEnquiryResult[0]?.value ?? 0
relatedOpportunityResult[0]?.value ?? 0
);
return {
...detail,
linkedEnquiries,
linkedOpportunities,
activity
};
}
@@ -926,7 +926,7 @@ export async function deleteLead(
return {
...mapLeadDetail(deleted!, lookups, null, 0),
linkedEnquiries: [],
linkedOpportunities: [],
activity: []
};
});
@@ -1077,3 +1077,4 @@ export function buildLeadAccessContext(input: {
}) {
return buildCrmSecurityContext(input);
}

View File

@@ -37,7 +37,7 @@ export interface LeadContactLookup {
isPrimary: boolean;
}
export interface LeadLinkedEnquirySummary {
export interface LeadLinkedOpportunitySummary {
id: string;
code: string;
title: string;
@@ -174,8 +174,8 @@ export interface LeadSummary {
}
export interface LeadDetail extends LeadSummary {
relatedEnquiryCount: number;
linkedEnquiries: LeadLinkedEnquirySummary[];
relatedOpportunityCount: number;
linkedOpportunities: LeadLinkedOpportunitySummary[];
activity: LeadActivityRecord[];
}
@@ -194,9 +194,9 @@ export interface LeadFollowupSummary {
export interface AssignLeadResult {
leadId: string;
enquiryId: string;
enquiryCode: string;
reusedExistingEnquiry: boolean;
opportunityId: string;
opportunityCode: string;
reusedExistingOpportunity: boolean;
}
export interface LeadListResponse {
@@ -225,3 +225,4 @@ export interface LeadMutationSuccessResponse {
lead?: LeadDetail;
followup?: LeadFollowupSummary;
}