task-d5.5.1
This commit is contained in:
@@ -9,7 +9,7 @@ export type {
|
||||
LeadDetail,
|
||||
LeadDetailResponse,
|
||||
LeadFollowupSummary,
|
||||
LeadLinkedEnquirySummary,
|
||||
LeadLinkedOpportunitySummary,
|
||||
LeadListFilters,
|
||||
LeadListResponse,
|
||||
LeadMutationSuccessResponse,
|
||||
@@ -18,3 +18,4 @@ export type {
|
||||
LeadSummary,
|
||||
UpdateLeadInput
|
||||
} from '../types';
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user