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

@@ -107,7 +107,7 @@ export async function GET(request: NextRequest) {
...data.salesRanking.map((row) => [
row.salesPersonName,
String(row.leadCount),
String(row.enquiryCount),
String(row.opportunityCount),
String(row.quotationCount),
String(row.approvedQuotations),
String(row.wonRevenue),
@@ -148,3 +148,4 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ message: 'Unable to export CRM dashboard data' }, { status: 500 });
}
}

View File

@@ -38,9 +38,9 @@ export async function POST(request: NextRequest, { params }: Params) {
success: true,
message: 'Lead assigned successfully',
leadId: result.leadId,
enquiryId: result.enquiryId,
enquiryCode: result.enquiryCode,
reusedExistingEnquiry: result.reusedExistingEnquiry
opportunityId: result.opportunityId,
opportunityCode: result.opportunityCode,
reusedExistingOpportunity: result.reusedExistingOpportunity
});
} catch (error) {
if (error instanceof AuthError) {
@@ -61,3 +61,4 @@ export async function POST(request: NextRequest, { params }: Params) {
return NextResponse.json({ message: 'Unable to assign lead' }, { status: 500 });
}
}

View File

@@ -1,6 +1,7 @@
import { z } from 'zod';
export const enquiryAssignmentRequestSchema = z.object({
export const opportunityAssignmentRequestSchema = z.object({
assignedToUserId: z.string().min(1, 'Sales user is required'),
remark: z.string().trim().max(1000).optional()
});

View File

@@ -1,10 +1,10 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditAction } from '@/features/foundation/audit-log/service';
import { assignEnquiryToUser, getEnquiryDetail } from '@/features/crm/enquiries/server/service';
import { assignOpportunityToUser, getOpportunityDetail } from '@/features/crm/opportunities/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
import { enquiryAssignmentRequestSchema } from '../_schemas';
import { opportunityAssignmentRequestSchema } from '../_schemas';
type Params = {
params: Promise<{ id: string }>;
@@ -14,9 +14,9 @@ export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryAssign
permission: PERMISSIONS.crmOpportunityAssign
});
const payload = enquiryAssignmentRequestSchema.parse(await request.json());
const payload = opportunityAssignmentRequestSchema.parse(await request.json());
const accessContext = {
organizationId: organization.id,
userId: session.user.id,
@@ -29,8 +29,8 @@ export async function POST(request: NextRequest, { params }: Params) {
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const before = await getEnquiryDetail(id, organization.id, accessContext);
const updated = await assignEnquiryToUser(
const before = await getOpportunityDetail(id, organization.id, accessContext);
const updated = await assignOpportunityToUser(
id,
organization.id,
session.user.id,
@@ -42,9 +42,9 @@ export async function POST(request: NextRequest, { params }: Params) {
organizationId: organization.id,
branchId: updated.branchId,
userId: session.user.id,
entityType: 'crm_enquiry',
entityType: 'crm_opportunity',
entityId: id,
action: 'lead_to_enquiry',
action: 'lead_to_opportunity',
beforeData: {
oldAssignedToUserId: before.assignedToUserId,
oldPipelineStage: before.pipelineStage
@@ -58,8 +58,8 @@ export async function POST(request: NextRequest, { params }: Params) {
return NextResponse.json({
success: true,
message: 'Lead converted to enquiry successfully',
enquiry: updated
message: 'Lead converted to opportunity successfully',
opportunity: updated
});
} catch (error) {
if (error instanceof AuthError) {
@@ -77,6 +77,7 @@ export async function POST(request: NextRequest, { params }: Params) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to assign enquiry' }, { status: 500 });
return NextResponse.json({ message: 'Unable to assign opportunity' }, { status: 500 });
}
}

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { listEnquiryProjectParties } from '@/features/crm/enquiries/server/service';
import { listOpportunityProjectParties } from '@/features/crm/opportunities/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
@@ -11,7 +11,7 @@ export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryRead
permission: PERMISSIONS.crmOpportunityRead
});
const accessContext = {
organizationId: organization.id,
@@ -24,12 +24,12 @@ export async function GET(_request: NextRequest, { params }: Params) {
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const items = await listEnquiryProjectParties(id, organization.id, accessContext);
const items = await listOpportunityProjectParties(id, organization.id, accessContext);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Enquiry project parties loaded successfully',
message: 'Opportunity project parties loaded successfully',
items
});
} catch (error) {
@@ -42,8 +42,9 @@ export async function GET(_request: NextRequest, { params }: Params) {
}
return NextResponse.json(
{ message: 'Unable to load enquiry project parties' },
{ message: 'Unable to load opportunity project parties' },
{ status: 500 }
);
}
}

View File

@@ -2,11 +2,11 @@ import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service';
import {
listEnquiryFollowups,
softDeleteEnquiryFollowup,
updateEnquiryFollowup
} from '@/features/crm/enquiries/server/service';
import { enquiryFollowupSchema } from '@/features/crm/enquiries/schemas/enquiry.schema';
listOpportunityFollowups,
softDeleteOpportunityFollowup,
updateOpportunityFollowup
} from '@/features/crm/opportunities/server/service';
import { opportunityFollowupSchema } from '@/features/crm/opportunities/schemas/opportunity.schema';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
@@ -14,7 +14,7 @@ type Params = {
params: Promise<{ id: string; followupId: string }>;
};
const followupRequestSchema = enquiryFollowupSchema.extend({
const followupRequestSchema = opportunityFollowupSchema.extend({
contactId: z.string().nullable().optional(),
nextFollowupDate: z.string().nullable().optional()
});
@@ -23,7 +23,7 @@ export async function PATCH(request: NextRequest, { params }: Params) {
try {
const { id, followupId } = await params;
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryFollowupUpdate
permission: PERMISSIONS.crmOpportunityFollowupUpdate
});
const payload = followupRequestSchema.parse(await request.json());
const accessContext = {
@@ -38,7 +38,7 @@ export async function PATCH(request: NextRequest, { params }: Params) {
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const before = (
await listEnquiryFollowups(id, organization.id, accessContext)
await listOpportunityFollowups(id, organization.id, accessContext)
).find(
(item) => item.id === followupId
);
@@ -47,7 +47,7 @@ export async function PATCH(request: NextRequest, { params }: Params) {
return NextResponse.json({ message: 'Follow-up not found' }, { status: 404 });
}
const updated = await updateEnquiryFollowup(
const updated = await updateOpportunityFollowup(
id,
followupId,
organization.id,
@@ -59,7 +59,7 @@ export async function PATCH(request: NextRequest, { params }: Params) {
await auditUpdate({
organizationId: organization.id,
userId: session.user.id,
entityType: 'crm_enquiry_followup',
entityType: 'crm_opportunity_followup',
entityId: followupId,
beforeData: before,
afterData: updated
@@ -67,7 +67,7 @@ export async function PATCH(request: NextRequest, { params }: Params) {
return NextResponse.json({
success: true,
message: 'Enquiry follow-up updated successfully',
message: 'Opportunity follow-up updated successfully',
followup: updated
});
} catch (error) {
@@ -86,7 +86,7 @@ export async function PATCH(request: NextRequest, { params }: Params) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to update enquiry follow-up' }, { status: 500 });
return NextResponse.json({ message: 'Unable to update opportunity follow-up' }, { status: 500 });
}
}
@@ -94,7 +94,7 @@ export async function DELETE(_request: NextRequest, { params }: Params) {
try {
const { id, followupId } = await params;
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryFollowupDelete
permission: PERMISSIONS.crmOpportunityFollowupDelete
});
const accessContext = {
organizationId: organization.id,
@@ -108,7 +108,7 @@ export async function DELETE(_request: NextRequest, { params }: Params) {
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const before = (
await listEnquiryFollowups(id, organization.id, accessContext)
await listOpportunityFollowups(id, organization.id, accessContext)
).find(
(item) => item.id === followupId
);
@@ -117,7 +117,7 @@ export async function DELETE(_request: NextRequest, { params }: Params) {
return NextResponse.json({ message: 'Follow-up not found' }, { status: 404 });
}
const updated = await softDeleteEnquiryFollowup(
const updated = await softDeleteOpportunityFollowup(
id,
followupId,
organization.id,
@@ -128,7 +128,7 @@ export async function DELETE(_request: NextRequest, { params }: Params) {
await auditDelete({
organizationId: organization.id,
userId: session.user.id,
entityType: 'crm_enquiry_followup',
entityType: 'crm_opportunity_followup',
entityId: followupId,
beforeData: before,
afterData: updated
@@ -136,7 +136,7 @@ export async function DELETE(_request: NextRequest, { params }: Params) {
return NextResponse.json({
success: true,
message: 'Enquiry follow-up deleted successfully'
message: 'Opportunity follow-up deleted successfully'
});
} catch (error) {
if (error instanceof AuthError) {
@@ -147,6 +147,7 @@ export async function DELETE(_request: NextRequest, { params }: Params) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to delete enquiry follow-up' }, { status: 500 });
return NextResponse.json({ message: 'Unable to delete opportunity follow-up' }, { status: 500 });
}
}

View File

@@ -2,10 +2,10 @@ import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditCreate } from '@/features/foundation/audit-log/service';
import {
createEnquiryFollowup,
listEnquiryFollowups
} from '@/features/crm/enquiries/server/service';
import { enquiryFollowupSchema } from '@/features/crm/enquiries/schemas/enquiry.schema';
createOpportunityFollowup,
listOpportunityFollowups
} from '@/features/crm/opportunities/server/service';
import { opportunityFollowupSchema } from '@/features/crm/opportunities/schemas/opportunity.schema';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
@@ -13,7 +13,7 @@ type Params = {
params: Promise<{ id: string }>;
};
const followupRequestSchema = enquiryFollowupSchema.extend({
const followupRequestSchema = opportunityFollowupSchema.extend({
contactId: z.string().nullable().optional(),
nextFollowupDate: z.string().nullable().optional()
});
@@ -22,7 +22,7 @@ export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryFollowupRead
permission: PERMISSIONS.crmOpportunityFollowupRead
});
const accessContext = {
organizationId: organization.id,
@@ -35,12 +35,12 @@ export async function GET(_request: NextRequest, { params }: Params) {
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const items = await listEnquiryFollowups(id, organization.id, accessContext);
const items = await listOpportunityFollowups(id, organization.id, accessContext);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Enquiry follow-ups loaded successfully',
message: 'Opportunity follow-ups loaded successfully',
items
});
} catch (error) {
@@ -52,7 +52,7 @@ export async function GET(_request: NextRequest, { params }: Params) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to load enquiry follow-ups' }, { status: 500 });
return NextResponse.json({ message: 'Unable to load opportunity follow-ups' }, { status: 500 });
}
}
@@ -60,7 +60,7 @@ export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryFollowupCreate
permission: PERMISSIONS.crmOpportunityFollowupCreate
});
const payload = followupRequestSchema.parse(await request.json());
const accessContext = {
@@ -74,7 +74,7 @@ export async function POST(request: NextRequest, { params }: Params) {
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const created = await createEnquiryFollowup(
const created = await createOpportunityFollowup(
id,
organization.id,
session.user.id,
@@ -85,7 +85,7 @@ export async function POST(request: NextRequest, { params }: Params) {
await auditCreate({
organizationId: organization.id,
userId: session.user.id,
entityType: 'crm_enquiry_followup',
entityType: 'crm_opportunity_followup',
entityId: created.id,
afterData: created
});
@@ -93,7 +93,7 @@ export async function POST(request: NextRequest, { params }: Params) {
return NextResponse.json(
{
success: true,
message: 'Enquiry follow-up created successfully',
message: 'Opportunity follow-up created successfully',
followup: created
},
{ status: 201 }
@@ -114,6 +114,7 @@ export async function POST(request: NextRequest, { params }: Params) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to create enquiry follow-up' }, { status: 500 });
return NextResponse.json({ message: 'Unable to create opportunity follow-up' }, { status: 500 });
}
}

View File

@@ -1,9 +1,9 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import {
enquiryMarkLostSchema
} from '@/features/crm/enquiries/schemas/enquiry.schema';
import { getEnquiryDetail, markEnquiryAsLost } from '@/features/crm/enquiries/server/service';
opportunityMarkLostSchema
} from '@/features/crm/opportunities/schemas/opportunity.schema';
import { getOpportunityDetail, markOpportunityAsLost } from '@/features/crm/opportunities/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
@@ -15,9 +15,9 @@ export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryMarkLost
permission: PERMISSIONS.crmOpportunityMarkLost
});
const payload = enquiryMarkLostSchema.parse(await request.json());
const payload = opportunityMarkLostSchema.parse(await request.json());
const accessContext = {
organizationId: organization.id,
userId: session.user.id,
@@ -30,15 +30,15 @@ export async function POST(request: NextRequest, { params }: Params) {
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const before = await getEnquiryDetail(id, organization.id, accessContext);
const updated = await markEnquiryAsLost(id, organization.id, session.user.id, payload, accessContext);
const after = await getEnquiryDetail(id, organization.id, accessContext);
const before = await getOpportunityDetail(id, organization.id, accessContext);
const updated = await markOpportunityAsLost(id, organization.id, session.user.id, payload, accessContext);
const after = await getOpportunityDetail(id, organization.id, accessContext);
return NextResponse.json({
success: true,
message: 'Enquiry marked as lost successfully',
message: 'Opportunity marked as lost successfully',
before,
enquiry: updated,
opportunity: updated,
after
});
} catch (error) {
@@ -57,6 +57,7 @@ export async function POST(request: NextRequest, { params }: Params) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to mark enquiry as lost' }, { status: 500 });
return NextResponse.json({ message: 'Unable to mark opportunity as lost' }, { status: 500 });
}
}

View File

@@ -1,9 +1,9 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import {
enquiryMarkWonSchema
} from '@/features/crm/enquiries/schemas/enquiry.schema';
import { getEnquiryDetail, markEnquiryAsWon } from '@/features/crm/enquiries/server/service';
opportunityMarkWonSchema
} from '@/features/crm/opportunities/schemas/opportunity.schema';
import { getOpportunityDetail, markOpportunityAsWon } from '@/features/crm/opportunities/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
@@ -15,9 +15,9 @@ export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryMarkWon
permission: PERMISSIONS.crmOpportunityMarkWon
});
const payload = enquiryMarkWonSchema.parse(await request.json());
const payload = opportunityMarkWonSchema.parse(await request.json());
const accessContext = {
organizationId: organization.id,
userId: session.user.id,
@@ -30,15 +30,15 @@ export async function POST(request: NextRequest, { params }: Params) {
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const before = await getEnquiryDetail(id, organization.id, accessContext);
const updated = await markEnquiryAsWon(id, organization.id, session.user.id, payload, accessContext);
const after = await getEnquiryDetail(id, organization.id, accessContext);
const before = await getOpportunityDetail(id, organization.id, accessContext);
const updated = await markOpportunityAsWon(id, organization.id, session.user.id, payload, accessContext);
const after = await getOpportunityDetail(id, organization.id, accessContext);
return NextResponse.json({
success: true,
message: 'Enquiry marked as won successfully',
message: 'Opportunity marked as won successfully',
before,
enquiry: updated,
opportunity: updated,
after
});
} catch (error) {
@@ -57,6 +57,7 @@ export async function POST(request: NextRequest, { params }: Params) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to mark enquiry as won' }, { status: 500 });
return NextResponse.json({ message: 'Unable to mark opportunity as won' }, { status: 500 });
}
}

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { getEnquiryAttachment } from '@/features/crm/enquiries/server/service';
import { getOpportunityAttachment } from '@/features/crm/opportunities/server/service';
import { getStorageProvider } from '@/features/foundation/storage/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
@@ -40,7 +40,7 @@ export async function GET(request: NextRequest, { params }: Params) {
try {
const { id, attachmentId } = await params;
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryRead
permission: PERMISSIONS.crmOpportunityRead
});
if (
@@ -50,7 +50,7 @@ export async function GET(request: NextRequest, { params }: Params) {
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
}
const attachment = await getEnquiryAttachment(
const attachment = await getOpportunityAttachment(
id,
attachmentId,
organization.id,
@@ -81,3 +81,4 @@ export async function GET(request: NextRequest, { params }: Params) {
return NextResponse.json({ message: 'Unable to load PO attachment' }, { status: 500 });
}
}

View File

@@ -1,8 +1,8 @@
import { NextRequest, NextResponse } from 'next/server';
import {
listEnquiryAttachments,
listOpportunityAttachments,
uploadPurchaseOrderAttachment
} from '@/features/crm/enquiries/server/service';
} from '@/features/crm/opportunities/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
@@ -49,14 +49,14 @@ export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryRead
permission: PERMISSIONS.crmOpportunityRead
});
if (!canViewCommercialData(membership)) {
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
}
const items = await listEnquiryAttachments(
const items = await listOpportunityAttachments(
id,
organization.id,
buildAccessContext({
@@ -69,7 +69,7 @@ export async function GET(_request: NextRequest, { params }: Params) {
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Enquiry purchase order attachments loaded successfully',
message: 'Opportunity purchase order attachments loaded successfully',
items
});
} catch (error) {
@@ -89,7 +89,7 @@ export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryMarkWon
permission: PERMISSIONS.crmOpportunityMarkWon
});
if (!canViewCommercialData(membership)) {
@@ -104,7 +104,7 @@ export async function POST(request: NextRequest, { params }: Params) {
}
const created = await uploadPurchaseOrderAttachment({
enquiryId: id,
opportunityId: id,
organizationId: organization.id,
userId: session.user.id,
description:
@@ -139,3 +139,4 @@ export async function POST(request: NextRequest, { params }: Params) {
return NextResponse.json({ message: 'Unable to upload PO attachment' }, { status: 500 });
}
}

View File

@@ -1,10 +1,10 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditAction } from '@/features/foundation/audit-log/service';
import { getEnquiryDetail, reassignEnquiryToUser } from '@/features/crm/enquiries/server/service';
import { getOpportunityDetail, reassignOpportunityToUser } from '@/features/crm/opportunities/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
import { enquiryAssignmentRequestSchema } from '../_schemas';
import { opportunityAssignmentRequestSchema } from '../_schemas';
type Params = {
params: Promise<{ id: string }>;
@@ -14,9 +14,9 @@ export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryReassign
permission: PERMISSIONS.crmOpportunityReassign
});
const payload = enquiryAssignmentRequestSchema.parse(await request.json());
const payload = opportunityAssignmentRequestSchema.parse(await request.json());
const accessContext = {
organizationId: organization.id,
userId: session.user.id,
@@ -29,8 +29,8 @@ export async function POST(request: NextRequest, { params }: Params) {
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const before = await getEnquiryDetail(id, organization.id, accessContext);
const updated = await reassignEnquiryToUser(
const before = await getOpportunityDetail(id, organization.id, accessContext);
const updated = await reassignOpportunityToUser(
id,
organization.id,
session.user.id,
@@ -42,7 +42,7 @@ export async function POST(request: NextRequest, { params }: Params) {
organizationId: organization.id,
branchId: updated.branchId,
userId: session.user.id,
entityType: 'crm_enquiry',
entityType: 'crm_opportunity',
entityId: id,
action: 'reassign',
beforeData: {
@@ -58,8 +58,8 @@ export async function POST(request: NextRequest, { params }: Params) {
return NextResponse.json({
success: true,
message: 'Enquiry reassigned successfully',
enquiry: updated
message: 'Opportunity reassigned successfully',
opportunity: updated
});
} catch (error) {
if (error instanceof AuthError) {
@@ -77,6 +77,7 @@ export async function POST(request: NextRequest, { params }: Params) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to reassign enquiry' }, { status: 500 });
return NextResponse.json({ message: 'Unable to reassign opportunity' }, { status: 500 });
}
}

View File

@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { enquiryReopenSchema } from '@/features/crm/enquiries/schemas/enquiry.schema';
import { getEnquiryDetail, reopenLostEnquiry } from '@/features/crm/enquiries/server/service';
import { opportunityReopenSchema } from '@/features/crm/opportunities/schemas/opportunity.schema';
import { getOpportunityDetail, reopenLostOpportunity } from '@/features/crm/opportunities/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
@@ -13,9 +13,9 @@ export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryReopen
permission: PERMISSIONS.crmOpportunityReopen
});
const payload = enquiryReopenSchema.parse(await request.json());
const payload = opportunityReopenSchema.parse(await request.json());
const accessContext = {
organizationId: organization.id,
userId: session.user.id,
@@ -28,15 +28,15 @@ export async function POST(request: NextRequest, { params }: Params) {
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const before = await getEnquiryDetail(id, organization.id, accessContext);
const updated = await reopenLostEnquiry(id, organization.id, session.user.id, payload, accessContext);
const after = await getEnquiryDetail(id, organization.id, accessContext);
const before = await getOpportunityDetail(id, organization.id, accessContext);
const updated = await reopenLostOpportunity(id, organization.id, session.user.id, payload, accessContext);
const after = await getOpportunityDetail(id, organization.id, accessContext);
return NextResponse.json({
success: true,
message: 'Enquiry reopened successfully',
message: 'Opportunity reopened successfully',
before,
enquiry: updated,
opportunity: updated,
after
});
} catch (error) {
@@ -55,6 +55,7 @@ export async function POST(request: NextRequest, { params }: Params) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to reopen enquiry' }, { status: 500 });
return NextResponse.json({ message: 'Unable to reopen opportunity' }, { status: 500 });
}
}

View File

@@ -2,12 +2,12 @@ import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service';
import {
getEnquiryActivity,
getEnquiryDetail,
softDeleteEnquiry,
updateEnquiry
} from '@/features/crm/enquiries/server/service';
import { enquirySchema } from '@/features/crm/enquiries/schemas/enquiry.schema';
getOpportunityActivity,
getOpportunityDetail,
softDeleteOpportunity,
updateOpportunity
} from '@/features/crm/opportunities/server/service';
import { opportunitySchema } from '@/features/crm/opportunities/schemas/opportunity.schema';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
@@ -15,7 +15,7 @@ type Params = {
params: Promise<{ id: string }>;
};
const enquiryRequestSchema = enquirySchema.extend({
const opportunityRequestSchema = opportunitySchema.extend({
contactId: z.string().nullable().optional(),
branchId: z.string().nullable().optional(),
leadChannel: z.string().nullable().optional(),
@@ -26,7 +26,7 @@ export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryRead
permission: PERMISSIONS.crmOpportunityRead
});
const accessContext = {
organizationId: organization.id,
@@ -40,16 +40,16 @@ export async function GET(_request: NextRequest, { params }: Params) {
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const [enquiry, activity] = await Promise.all([
getEnquiryDetail(id, organization.id, accessContext),
getEnquiryActivity(id, organization.id, accessContext)
const [opportunity, activity] = await Promise.all([
getOpportunityDetail(id, organization.id, accessContext),
getOpportunityActivity(id, organization.id, accessContext)
]);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Enquiry loaded successfully',
enquiry,
message: 'Opportunity loaded successfully',
opportunity,
activity
});
} catch (error) {
@@ -61,7 +61,7 @@ export async function GET(_request: NextRequest, { params }: Params) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to load enquiry' }, { status: 500 });
return NextResponse.json({ message: 'Unable to load opportunity' }, { status: 500 });
}
}
@@ -69,9 +69,9 @@ export async function PATCH(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryUpdate
permission: PERMISSIONS.crmOpportunityUpdate
});
const payload = enquiryRequestSchema.parse(await request.json());
const payload = opportunityRequestSchema.parse(await request.json());
const accessContext = {
organizationId: organization.id,
userId: session.user.id,
@@ -84,14 +84,14 @@ export async function PATCH(request: NextRequest, { params }: Params) {
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const before = await getEnquiryDetail(id, organization.id, accessContext);
const updated = await updateEnquiry(id, organization.id, session.user.id, payload, accessContext);
const before = await getOpportunityDetail(id, organization.id, accessContext);
const updated = await updateOpportunity(id, organization.id, session.user.id, payload, accessContext);
await auditUpdate({
organizationId: organization.id,
branchId: updated.branchId,
userId: session.user.id,
entityType: 'crm_enquiry',
entityType: 'crm_opportunity',
entityId: id,
beforeData: before,
afterData: updated
@@ -99,8 +99,8 @@ export async function PATCH(request: NextRequest, { params }: Params) {
return NextResponse.json({
success: true,
message: 'Enquiry updated successfully',
enquiry: updated
message: 'Opportunity updated successfully',
opportunity: updated
});
} catch (error) {
if (error instanceof AuthError) {
@@ -118,7 +118,7 @@ export async function PATCH(request: NextRequest, { params }: Params) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to update enquiry' }, { status: 500 });
return NextResponse.json({ message: 'Unable to update opportunity' }, { status: 500 });
}
}
@@ -126,7 +126,7 @@ export async function DELETE(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryDelete
permission: PERMISSIONS.crmOpportunityDelete
});
const accessContext = {
organizationId: organization.id,
@@ -140,14 +140,14 @@ export async function DELETE(_request: NextRequest, { params }: Params) {
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const before = await getEnquiryDetail(id, organization.id, accessContext);
const updated = await softDeleteEnquiry(id, organization.id, session.user.id, accessContext);
const before = await getOpportunityDetail(id, organization.id, accessContext);
const updated = await softDeleteOpportunity(id, organization.id, session.user.id, accessContext);
await auditDelete({
organizationId: organization.id,
branchId: updated.branchId,
userId: session.user.id,
entityType: 'crm_enquiry',
entityType: 'crm_opportunity',
entityId: id,
beforeData: before,
afterData: updated
@@ -155,7 +155,7 @@ export async function DELETE(_request: NextRequest, { params }: Params) {
return NextResponse.json({
success: true,
message: 'Enquiry deleted successfully'
message: 'Opportunity deleted successfully'
});
} catch (error) {
if (error instanceof AuthError) {
@@ -166,6 +166,7 @@ export async function DELETE(_request: NextRequest, { params }: Params) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to delete enquiry' }, { status: 500 });
return NextResponse.json({ message: 'Unable to delete opportunity' }, { status: 500 });
}
}

View File

@@ -1,13 +1,13 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditAction, auditCreate } from '@/features/foundation/audit-log/service';
import { createEnquiry, listEnquiries } from '@/features/crm/enquiries/server/service';
import { createOpportunity, listOpportunities } from '@/features/crm/opportunities/server/service';
import { getCustomerDetail } from '@/features/crm/customers/server/service';
import { enquirySchema } from '@/features/crm/enquiries/schemas/enquiry.schema';
import { opportunitySchema } from '@/features/crm/opportunities/schemas/opportunity.schema';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
const enquiryRequestSchema = enquirySchema.extend({
const opportunityRequestSchema = opportunitySchema.extend({
contactId: z.string().nullable().optional(),
assignedToUserId: z.string().nullable().optional(),
branchId: z.string().nullable().optional(),
@@ -18,7 +18,7 @@ const enquiryRequestSchema = enquirySchema.extend({
export async function GET(request: NextRequest) {
try {
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryRead
permission: PERMISSIONS.crmOpportunityRead
});
const { searchParams } = request.nextUrl;
const page = Number(searchParams.get('page') ?? 1);
@@ -42,13 +42,13 @@ export async function GET(request: NextRequest) {
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const result = await listEnquiries(
const result = await listOpportunities(
accessContext,
{
page,
limit,
search,
pipelineStage: pipelineStage as 'lead' | 'enquiry' | 'closed_won' | 'closed_lost' | undefined,
pipelineStage: pipelineStage as 'lead' | 'opportunity' | 'closed_won' | 'closed_lost' | undefined,
status,
productType,
priority,
@@ -62,7 +62,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Enquiries loaded successfully',
message: 'Opportunities loaded successfully',
totalItems: result.totalItems,
offset,
limit,
@@ -77,16 +77,16 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to load enquiries' }, { status: 500 });
return NextResponse.json({ message: 'Unable to load opportunities' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryCreate
permission: PERMISSIONS.crmOpportunityCreate
});
const payload = enquiryRequestSchema.parse(await request.json());
const payload = opportunityRequestSchema.parse(await request.json());
const accessContext = {
organizationId: organization.id,
userId: session.user.id,
@@ -98,7 +98,7 @@ export async function POST(request: NextRequest) {
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
const created = await createEnquiry(
const created = await createOpportunity(
organization.id,
session.user.id,
accessContext,
@@ -110,7 +110,7 @@ export async function POST(request: NextRequest) {
organizationId: organization.id,
branchId: created.branchId,
userId: session.user.id,
entityType: 'crm_enquiry',
entityType: 'crm_opportunity',
entityId: created.id,
afterData: created
});
@@ -120,7 +120,7 @@ export async function POST(request: NextRequest) {
organizationId: organization.id,
branchId: created.branchId,
userId: session.user.id,
entityType: 'crm_enquiry',
entityType: 'crm_opportunity',
entityId: created.id,
action:
payload.assignedToUserId && payload.assignedToUserId !== customer.ownerUserId
@@ -137,8 +137,8 @@ export async function POST(request: NextRequest) {
return NextResponse.json(
{
success: true,
message: 'Enquiry created successfully',
enquiry: created
message: 'Opportunity created successfully',
opportunity: created
},
{ status: 201 }
);
@@ -158,6 +158,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to create enquiry' }, { status: 500 });
return NextResponse.json({ message: 'Unable to create opportunity' }, { status: 500 });
}
}

View File

@@ -20,7 +20,7 @@ export async function GET(request: NextRequest) {
const quotationType = searchParams.get('quotationType') ?? undefined;
const branch = searchParams.get('branch') ?? undefined;
const customer = searchParams.get('customer') ?? undefined;
const enquiry = searchParams.get('enquiry') ?? undefined;
const opportunity = searchParams.get('opportunity') ?? undefined;
const hot = searchParams.get('hot') ?? undefined;
const sort = searchParams.get('sort') ?? undefined;
const accessContext = buildCrmSecurityContext({
@@ -36,7 +36,7 @@ export async function GET(request: NextRequest) {
quotationType,
branch,
customer,
enquiry,
opportunity,
hot,
sort
}, accessContext);
@@ -113,3 +113,4 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ message: 'Unable to create quotation' }, { status: 500 });
}
}

View File

@@ -7,7 +7,7 @@ import {
} from '@/features/crm/reports/server/exports/service';
import { parseSharedReportFilters } from '@/features/crm/reports/server/filters/shared';
import {
getCrmEnquiryAgingReportResponse,
getCrmOpportunityAgingReportResponse,
getCrmLeadAgingReportResponse,
getCrmPipelineReportResponse
} from '@/features/crm/reports/server/service';
@@ -18,7 +18,7 @@ function buildExportRows(input: {
reportCode: string;
pipeline?: Awaited<ReturnType<typeof getCrmPipelineReportResponse>>;
leadAging?: Awaited<ReturnType<typeof getCrmLeadAgingReportResponse>>;
enquiryAging?: Awaited<ReturnType<typeof getCrmEnquiryAgingReportResponse>>;
opportunityAging?: Awaited<ReturnType<typeof getCrmOpportunityAgingReportResponse>>;
}) {
switch (input.reportCode) {
case 'lead_pipeline':
@@ -37,15 +37,15 @@ function buildExportRows(input: {
String(row.convertedLeads)
])
];
case 'enquiry_pipeline':
case 'opportunity_pipeline':
return [
['Salesman', 'Branch', 'Product Type', 'Customer Owner', 'Open Enquiries', 'Converted To Quotation', 'Won', 'Lost'],
...(input.pipeline?.enquiryPipeline ?? []).map((row) => [
['Salesman', 'Branch', 'Product Type', 'Customer Owner', 'Open Opportunities', 'Converted To Quotation', 'Won', 'Lost'],
...(input.pipeline?.opportunityPipeline ?? []).map((row) => [
row.salesmanName,
row.branchLabel,
row.productTypeLabel,
row.customerOwnerName,
String(row.openEnquiries),
String(row.openOpportunities),
String(row.convertedToQuotation),
String(row.won),
String(row.lost)
@@ -53,26 +53,26 @@ function buildExportRows(input: {
];
case 'lead_conversion':
return [
['Lead Source', 'Branch', 'Product Type', 'Marketing User', 'Total Leads', 'Converted Enquiries', 'Conversion Rate %'],
['Lead Source', 'Branch', 'Product Type', 'Marketing User', 'Total Leads', 'Converted Opportunities', 'Conversion Rate %'],
...(input.pipeline?.leadConversion ?? []).map((row) => [
row.leadSourceLabel,
row.branchLabel,
row.productTypeLabel,
row.marketingUserName,
String(row.totalLeads),
String(row.convertedEnquiries),
String(row.convertedOpportunities),
String(row.conversionRate)
])
];
case 'enquiry_conversion':
case 'opportunity_conversion':
return [
['Salesman', 'Branch', 'Product Type', 'Total Enquiries', 'Enquiries With Quotation', 'Conversion Rate %'],
...(input.pipeline?.enquiryConversion ?? []).map((row) => [
['Salesman', 'Branch', 'Product Type', 'Total Opportunities', 'Opportunities With Quotation', 'Conversion Rate %'],
...(input.pipeline?.opportunityConversion ?? []).map((row) => [
row.salesmanName,
row.branchLabel,
row.productTypeLabel,
String(row.totalEnquiries),
String(row.enquiriesWithQuotation),
String(row.totalOpportunities),
String(row.opportunitiesWithQuotation),
String(row.conversionRate)
])
];
@@ -97,11 +97,11 @@ function buildExportRows(input: {
String(row.agingDays)
])
];
case 'enquiry_aging':
case 'opportunity_aging':
return [
['Enquiry No', 'Customer', 'Salesman', 'Last Follow-Up', 'Aging Days'],
...(input.enquiryAging?.rows ?? []).map((row) => [
row.enquiryCode,
['Opportunity No', 'Customer', 'Salesman', 'Last Follow-Up', 'Aging Days'],
...(input.opportunityAging?.rows ?? []).map((row) => [
row.opportunityCode,
row.customerName,
row.salesmanName ?? '',
row.lastFollowupDate ?? '',
@@ -117,23 +117,23 @@ function getRecordCount(input: {
reportCode: string;
pipeline?: Awaited<ReturnType<typeof getCrmPipelineReportResponse>>;
leadAging?: Awaited<ReturnType<typeof getCrmLeadAgingReportResponse>>;
enquiryAging?: Awaited<ReturnType<typeof getCrmEnquiryAgingReportResponse>>;
opportunityAging?: Awaited<ReturnType<typeof getCrmOpportunityAgingReportResponse>>;
}) {
switch (input.reportCode) {
case 'lead_pipeline':
return input.pipeline?.leadPipeline.length ?? 0;
case 'enquiry_pipeline':
return input.pipeline?.enquiryPipeline.length ?? 0;
case 'opportunity_pipeline':
return input.pipeline?.opportunityPipeline.length ?? 0;
case 'lead_conversion':
return input.pipeline?.leadConversion.length ?? 0;
case 'enquiry_conversion':
return input.pipeline?.enquiryConversion.length ?? 0;
case 'opportunity_conversion':
return input.pipeline?.opportunityConversion.length ?? 0;
case 'pipeline_funnel':
return input.pipeline?.funnel.length ?? 0;
case 'lead_aging':
return input.leadAging?.rows.length ?? 0;
case 'enquiry_aging':
return input.enquiryAging?.rows.length ?? 0;
case 'opportunity_aging':
return input.opportunityAging?.rows.length ?? 0;
default:
return 0;
}
@@ -155,9 +155,9 @@ export async function GET(request: NextRequest) {
const pipelineCodes = new Set([
'lead_pipeline',
'enquiry_pipeline',
'opportunity_pipeline',
'lead_conversion',
'enquiry_conversion',
'opportunity_conversion',
'pipeline_funnel'
]);
const pipeline = pipelineCodes.has(reportCode)
@@ -165,11 +165,11 @@ export async function GET(request: NextRequest) {
: undefined;
const leadAging =
reportCode === 'lead_aging' ? await getCrmLeadAgingReportResponse(context, filters) : undefined;
const enquiryAging =
reportCode === 'enquiry_aging'
? await getCrmEnquiryAgingReportResponse(context, filters)
const opportunityAging =
reportCode === 'opportunity_aging'
? await getCrmOpportunityAgingReportResponse(context, filters)
: undefined;
const rows = buildExportRows({ reportCode, pipeline, leadAging, enquiryAging });
const rows = buildExportRows({ reportCode, pipeline, leadAging, opportunityAging });
if (!rows) {
return NextResponse.json({ message: 'Unsupported report code' }, { status: 400 });
@@ -186,7 +186,7 @@ export async function GET(request: NextRequest) {
reportCode,
format,
filters: { ...filters },
recordCount: getRecordCount({ reportCode, pipeline, leadAging, enquiryAging })
recordCount: getRecordCount({ reportCode, pipeline, leadAging, opportunityAging })
});
return new NextResponse(body, {
@@ -203,3 +203,4 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ message: 'Unable to export CRM report' }, { status: 500 });
}
}

View File

@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { resolveCrmAccess } from '@/features/crm/reports/server/context';
import { parseSharedReportFilters } from '@/features/crm/reports/server/filters/shared';
import { getCrmEnquiryAgingReportResponse } from '@/features/crm/reports/server/service';
import { getCrmOpportunityAgingReportResponse } from '@/features/crm/reports/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
@@ -12,7 +12,7 @@ export async function GET(request: NextRequest) {
});
return NextResponse.json(
await getCrmEnquiryAgingReportResponse(
await getCrmOpportunityAgingReportResponse(
resolveCrmAccess({
organizationId: organization.id,
userId: session.user.id,
@@ -27,8 +27,9 @@ export async function GET(request: NextRequest) {
}
return NextResponse.json(
{ message: 'Unable to load CRM enquiry aging report' },
{ message: 'Unable to load CRM opportunity aging report' },
{ status: 500 }
);
}
}

View File

@@ -14,7 +14,7 @@ export default async function CustomerDetailRoute({ params }: PageProps) {
return (
<PageContainer
pageTitle='Customer Detail'
pageDescription='Overview, contacts, shared contacts, enquiries, quotations, and activity log'
pageDescription='Overview, contacts, shared contacts, opportunities, quotations, and activity log'
>
<HydrationBoundary state={dehydrate(queryClient)}>
<CustomerDetailPage id={id} />
@@ -22,3 +22,4 @@ export default async function CustomerDetailRoute({ params }: PageProps) {
</PageContainer>
);
}

View File

@@ -1,24 +1,25 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import PageContainer from '@/components/layout/page-container';
import { enquiryByIdOptions } from '@/features/crm-demo/api/queries';
import { EnquiryDetailPage } from '@/features/crm-demo/components/enquiry-detail';
import { opportunityByIdOptions } from '@/features/crm-demo/api/queries';
import { OpportunityDetailPage } from '@/features/crm-demo/components/opportunity-detail';
import { getQueryClient } from '@/lib/query-client';
type PageProps = { params: Promise<{ id: string }> };
export default async function EnquiryDetailRoute({ params }: PageProps) {
export default async function OpportunityDetailRoute({ params }: PageProps) {
const { id } = await params;
const queryClient = getQueryClient();
void queryClient.prefetchQuery(enquiryByIdOptions(id));
void queryClient.prefetchQuery(opportunityByIdOptions(id));
return (
<PageContainer
pageTitle='Enquiry Detail'
pageTitle='Opportunity Detail'
pageDescription='Header summary, customer/contact, requirement summary, timeline, and related quotations'
>
<HydrationBoundary state={dehydrate(queryClient)}>
<EnquiryDetailPage id={id} />
<OpportunityDetailPage id={id} />
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -1,16 +1,16 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import type { SearchParams } from 'nuqs/server';
import PageContainer from '@/components/layout/page-container';
import { crmReferenceQueryOptions, enquiriesQueryOptions } from '@/features/crm-demo/api/queries';
import { EnquiriesTablePage } from '@/features/crm-demo/components/enquiries-table';
import { crmReferenceQueryOptions, opportunitiesQueryOptions } from '@/features/crm-demo/api/queries';
import { OpportunitiesTablePage } from '@/features/crm-demo/components/opportunities-table';
import { getQueryClient } from '@/lib/query-client';
import { searchParamsCache } from '@/lib/searchparams';
export const metadata = {
title: 'Dashboard: CRM Enquiries'
title: 'Dashboard: CRM Opportunities'
};
export default async function EnquiriesRoute({
export default async function OpportunitiesRoute({
searchParams
}: {
searchParams: Promise<SearchParams>;
@@ -29,7 +29,7 @@ export default async function EnquiriesRoute({
void queryClient.prefetchQuery(crmReferenceQueryOptions());
void queryClient.prefetchQuery(
enquiriesQueryOptions({
opportunitiesQueryOptions({
page,
limit: perPage,
...(search && { search }),
@@ -44,12 +44,13 @@ export default async function EnquiriesRoute({
return (
<PageContainer
pageTitle='Enquiries'
pageTitle='Opportunities'
pageDescription='List ของ sales opportunity พร้อม filter, date range และ convert to quotation'
>
<HydrationBoundary state={dehydrate(queryClient)}>
<EnquiriesTablePage />
<OpportunitiesTablePage />
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -15,7 +15,7 @@ export default function CrmDashboardRoute() {
return (
<PageContainer
pageTitle='CRM Dashboard'
pageDescription='ภาพรวม workflow ของ Customer → Enquiry → Quotation → Approval → Won/Lost'
pageDescription='ภาพรวม workflow ของ Customer → Opportunity → Quotation → Approval → Won/Lost'
>
<HydrationBoundary state={dehydrate(queryClient)}>
<CrmDashboardPage />
@@ -23,3 +23,4 @@ export default function CrmDashboardRoute() {
</PageContainer>
);
}

View File

@@ -7,7 +7,7 @@ import {
getCustomerDetail,
getCustomerReferenceData
} from '@/features/crm/customers/server/service';
import { listCustomerEnquiryRelations } from '@/features/crm/enquiries/server/service';
import { listCustomerOpportunityRelations } from '@/features/crm/opportunities/server/service';
import { listCustomerQuotationRelations } from '@/features/crm/quotations/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { getQueryClient } from '@/lib/query-client';
@@ -100,9 +100,9 @@ export default async function CustomerDetailRoute({ params }: PageProps) {
resolvedCanRead && session?.user?.activeOrganizationId
? await getCustomerReferenceData(session.user.activeOrganizationId)
: null;
const relatedEnquiries =
const relatedOpportunities =
resolvedCanRead && session?.user?.activeOrganizationId && accessContext
? await listCustomerEnquiryRelations(id, session.user.activeOrganizationId, accessContext)
? await listCustomerOpportunityRelations(id, session.user.activeOrganizationId, accessContext)
: [];
const relatedQuotations =
resolvedCanRead && session?.user?.activeOrganizationId && accessContext
@@ -135,7 +135,7 @@ export default async function CustomerDetailRoute({ params }: PageProps) {
canReadContactShares={canContactShareRead}
canManageContactShares={canContactShareManage}
canManageOwner={canOwnerManage}
relatedEnquiries={relatedEnquiries}
relatedOpportunities={relatedOpportunities}
relatedQuotations={relatedQuotations}
/>
</HydrationBoundary>
@@ -143,3 +143,4 @@ export default async function CustomerDetailRoute({ params }: PageProps) {
</PageContainer>
);
}

View File

@@ -1,83 +0,0 @@
import { auth } from '@/auth';
import PageContainer from '@/components/layout/page-container';
import EnquiryListing from '@/features/crm/enquiries/components/enquiry-listing';
import { EnquiryFormSheetTrigger } from '@/features/crm/enquiries/components/enquiry-form-sheet';
import { getEnquiryReferenceData } from '@/features/crm/enquiries/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import type { SearchParams } from 'nuqs/server';
export const metadata = {
title: 'Dashboard: CRM Opportunities'
};
type PageProps = {
searchParams: Promise<SearchParams>;
};
export default async function EnquiriesRoute(props: PageProps) {
await props.searchParams;
const session = await auth();
const canRead =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryRead)));
const canCreate =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryCreate)));
const canUpdate =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryUpdate)));
const canDelete =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryDelete)));
const canAssign =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryAssign)));
const canReassign =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryReassign)));
if (!canRead || !session?.user?.activeOrganizationId) {
return (
<PageContainer access={false} pageTitle='CRM Opportunities'>
{null}
</PageContainer>
);
}
const referenceData = await getEnquiryReferenceData(session.user.activeOrganizationId);
return (
<PageContainer
pageTitle='CRM Opportunities'
pageDescription='Sales execution workspace for opportunities created directly or handed off from marketing leads.'
pageHeaderAction={canCreate ? <EnquiryFormSheetTrigger referenceData={referenceData} /> : null}
>
<EnquiryListing
referenceData={referenceData}
workspace='enquiry'
canUpdate={canUpdate}
canDelete={canDelete}
canAssign={canAssign}
canReassign={canReassign}
/>
</PageContainer>
);
}

View File

@@ -64,7 +64,7 @@ export default async function LeadsRoute(props: PageProps) {
return (
<PageContainer
pageTitle="CRM Leads"
pageDescription="Marketing-qualified lead workspace before handoff into sales enquiries."
pageDescription="Marketing-qualified lead workspace before handoff into sales opportunities."
pageHeaderAction={
canCreate ? <LeadFormTrigger referenceData={referenceData} /> : null
}
@@ -80,3 +80,4 @@ export default async function LeadsRoute(props: PageProps) {
</PageContainer>
);
}

View File

@@ -3,14 +3,14 @@ import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { notFound } from 'next/navigation';
import PageContainer from '@/components/layout/page-container';
import {
enquiryByIdOptions,
enquiryFollowupsOptions,
enquiryProjectPartiesOptions
} from '@/features/crm/enquiries/api/queries';
import { EnquiryDetail } from '@/features/crm/enquiries/components/enquiry-detail';
import { getEnquiryDetail, getEnquiryReferenceData } from '@/features/crm/enquiries/server/service';
opportunityByIdOptions,
opportunityFollowupsOptions,
opportunityProjectPartiesOptions
} from '@/features/crm/opportunities/api/queries';
import { OpportunityDetail } from '@/features/crm/opportunities/components/opportunity-detail';
import { getOpportunityDetail, getOpportunityReferenceData } from '@/features/crm/opportunities/server/service';
import { getLeadById } from '@/features/crm/leads/server/service';
import { listEnquiryQuotationRelations } from '@/features/crm/quotations/server/service';
import { listOpportunityQuotationRelations } from '@/features/crm/quotations/server/service';
import { getQuotationReferenceData } from '@/features/crm/quotations/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { getQueryClient } from '@/lib/query-client';
@@ -19,7 +19,7 @@ type PageProps = {
params: Promise<{ id: string }>;
};
export default async function EnquiryDetailRoute({ params }: PageProps) {
export default async function OpportunityDetailRoute({ params }: PageProps) {
const { id } = await params;
const session = await auth();
@@ -27,61 +27,61 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryRead)));
session.user.activePermissions.includes(PERMISSIONS.crmOpportunityRead)));
const canUpdate =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryUpdate)));
session.user.activePermissions.includes(PERMISSIONS.crmOpportunityUpdate)));
const canAssign =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryAssign)));
session.user.activePermissions.includes(PERMISSIONS.crmOpportunityAssign)));
const canReassign =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryReassign)));
session.user.activePermissions.includes(PERMISSIONS.crmOpportunityReassign)));
const canManageFollowups = {
create:
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryFollowupCreate))),
session.user.activePermissions.includes(PERMISSIONS.crmOpportunityFollowupCreate))),
update:
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryFollowupUpdate))),
session.user.activePermissions.includes(PERMISSIONS.crmOpportunityFollowupUpdate))),
delete:
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryFollowupDelete)))
session.user.activePermissions.includes(PERMISSIONS.crmOpportunityFollowupDelete)))
};
const canMarkWon =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryMarkWon)));
session.user.activePermissions.includes(PERMISSIONS.crmOpportunityMarkWon)));
const canMarkLost =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryMarkLost)));
session.user.activePermissions.includes(PERMISSIONS.crmOpportunityMarkLost)));
const canReopen =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryReopen)));
session.user.activePermissions.includes(PERMISSIONS.crmOpportunityReopen)));
const canViewRelatedQuotations =
session?.user?.systemRole === 'super_admin' ||
@@ -118,17 +118,17 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
const queryClient = getQueryClient();
await Promise.all([
queryClient.prefetchQuery(enquiryByIdOptions(id)),
queryClient.prefetchQuery(enquiryFollowupsOptions(id)),
queryClient.prefetchQuery(enquiryProjectPartiesOptions(id))
queryClient.prefetchQuery(opportunityByIdOptions(id)),
queryClient.prefetchQuery(opportunityFollowupsOptions(id)),
queryClient.prefetchQuery(opportunityProjectPartiesOptions(id))
]);
const enquiryDetail = await getEnquiryDetail(id, session.user.activeOrganizationId, accessContext);
const opportunityDetail = await getOpportunityDetail(id, session.user.activeOrganizationId, accessContext);
const [referenceData, relatedQuotations, sourceLead, quotationReferenceData] = await Promise.all([
getEnquiryReferenceData(session.user.activeOrganizationId),
listEnquiryQuotationRelations(id, session.user.activeOrganizationId),
enquiryDetail.leadId
? getLeadById(enquiryDetail.leadId, session.user.activeOrganizationId).catch(() => null)
getOpportunityReferenceData(session.user.activeOrganizationId),
listOpportunityQuotationRelations(id, session.user.activeOrganizationId),
opportunityDetail.leadId
? getLeadById(opportunityDetail.leadId, session.user.activeOrganizationId).catch(() => null)
: Promise.resolve(null),
canCreateQuotation ? getQuotationReferenceData(session.user.activeOrganizationId) : Promise.resolve(null)
]);
@@ -139,11 +139,11 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
pageDescription='Sales execution workspace for follow-up continuity, quotation readiness, and outcome control.'
>
<HydrationBoundary state={dehydrate(queryClient)}>
<EnquiryDetail
enquiryId={id}
<OpportunityDetail
opportunityId={id}
referenceData={referenceData}
relatedQuotations={relatedQuotations}
workspace='enquiry'
workspace='opportunity'
canUpdate={canUpdate}
canAssign={canAssign}
canReassign={canReassign}
@@ -171,3 +171,4 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
</PageContainer>
);
}

View File

@@ -0,0 +1,93 @@
import { auth } from "@/auth";
import PageContainer from "@/components/layout/page-container";
import OpportunityListing from "@/features/crm/opportunities/components/opportunity-listing";
import { OpportunityFormSheetTrigger } from "@/features/crm/opportunities/components/opportunity-form-sheet";
import { getOpportunityReferenceData } from "@/features/crm/opportunities/server/service";
import { PERMISSIONS } from "@/lib/auth/rbac";
import type { SearchParams } from "nuqs/server";
import { searchParamsCache } from "@/lib/searchparams";
export const metadata = {
title: "Dashboard: CRM Opportunities",
};
type PageProps = {
searchParams: Promise<SearchParams>;
};
export default async function OpportunitiesRoute(props: PageProps) {
const searchParams = await props.searchParams;
const session = await auth();
const canRead =
session?.user?.systemRole === "super_admin" ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === "admin" ||
session.user.activePermissions.includes(PERMISSIONS.crmOpportunityRead)));
const canCreate =
session?.user?.systemRole === "super_admin" ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === "admin" ||
session.user.activePermissions.includes(PERMISSIONS.crmOpportunityCreate)));
const canUpdate =
session?.user?.systemRole === "super_admin" ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === "admin" ||
session.user.activePermissions.includes(PERMISSIONS.crmOpportunityUpdate)));
const canDelete =
session?.user?.systemRole === "super_admin" ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === "admin" ||
session.user.activePermissions.includes(PERMISSIONS.crmOpportunityDelete)));
const canAssign =
session?.user?.systemRole === "super_admin" ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === "admin" ||
session.user.activePermissions.includes(PERMISSIONS.crmOpportunityAssign)));
const canReassign =
session?.user?.systemRole === "super_admin" ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === "admin" ||
session.user.activePermissions.includes(
PERMISSIONS.crmOpportunityReassign,
)));
if (!canRead || !session?.user?.activeOrganizationId) {
return (
<PageContainer access={false} pageTitle="CRM Opportunities">
{null}
</PageContainer>
);
}
searchParamsCache.parse(searchParams);
const referenceData = await getOpportunityReferenceData(
session.user.activeOrganizationId,
);
return (
<PageContainer
pageTitle="CRM Opportunities"
pageDescription="Sales execution workspace for opportunities created directly or handed off from marketing leads."
pageHeaderAction={
canCreate ? (
<OpportunityFormSheetTrigger referenceData={referenceData} />
) : null
}
>
<OpportunityListing
referenceData={referenceData}
workspace="opportunity"
canUpdate={canUpdate}
canDelete={canDelete}
canAssign={canAssign}
canReassign={canReassign}
/>
</PageContainer>
);
}

View File

@@ -2,7 +2,7 @@ import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { auth } from '@/auth';
import PageContainer from '@/components/layout/page-container';
import {
crmEnquiryAgingReportQueryOptions,
crmOpportunityAgingReportQueryOptions,
crmReportFiltersQueryOptions
} from '@/features/crm/reports/api/queries';
import { AgingReportView } from '@/features/crm/reports/components/aging-report-view';
@@ -11,7 +11,7 @@ import { getQueryClient } from '@/lib/query-client';
import type { SearchParams } from 'nuqs/server';
export const metadata = {
title: 'CRM Enquiry Aging Report'
title: 'CRM Opportunity Aging Report'
};
type PageProps = {
@@ -22,7 +22,7 @@ function getFilterValue(value: string | string[] | undefined): string | null {
return typeof value === 'string' ? value : null;
}
export default async function CrmEnquiryAgingRoute(props: PageProps) {
export default async function CrmOpportunityAgingRoute(props: PageProps) {
const searchParams = await props.searchParams;
const session = await auth();
const canRead =
@@ -48,25 +48,26 @@ export default async function CrmEnquiryAgingRoute(props: PageProps) {
if (canRead) {
void queryClient.prefetchQuery(crmReportFiltersQueryOptions());
void queryClient.prefetchQuery(crmEnquiryAgingReportQueryOptions(filters));
void queryClient.prefetchQuery(crmOpportunityAgingReportQueryOptions(filters));
}
return (
<PageContainer
pageTitle='Enquiry Aging Report'
pageDescription='Identify stalled enquiries that need follow-up or progression.'
pageTitle='Opportunity Aging Report'
pageDescription='Identify stalled opportunities that need follow-up or progression.'
access={canRead}
accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
You do not have access to CRM enquiry aging reports.
You do not have access to CRM opportunity aging reports.
</div>
}
>
{canRead ? (
<HydrationBoundary state={dehydrate(queryClient)}>
<AgingReportView variant='enquiry' canExport={canExport} />
<AgingReportView variant='opportunity' canExport={canExport} />
</HydrationBoundary>
) : null}
</PageContainer>
);
}

View File

@@ -56,7 +56,7 @@ export default async function CrmPipelineReportsRoute(props: PageProps) {
return (
<PageContainer
pageTitle='Pipeline Reports'
pageDescription='Lead, enquiry, conversion, and funnel analytics for the CRM pipeline lifecycle.'
pageDescription='Lead, opportunity, conversion, and funnel analytics for the CRM pipeline lifecycle.'
access={canRead}
accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
@@ -72,3 +72,4 @@ export default async function CrmPipelineReportsRoute(props: PageProps) {
</PageContainer>
);
}

View File

@@ -1,211 +1,184 @@
import { NavGroup } from "@/types";
/**
* Navigation configuration with RBAC support
*
* This configuration is used for both the sidebar navigation and Cmd+K bar.
* Items are organized into groups, each rendered with a SidebarGroupLabel.
*
* RBAC Access Control:
* Each navigation item can have an `access` property that controls visibility
* based on permissions, plans, features, roles, and organization context.
*
* Examples:
*
* 1. Require organization:
* access: { requireOrg: true }
*
* 2. Require specific permission:
* access: { requireOrg: true, permission: 'org:teams:manage' }
*
* 3. Require specific plan:
* access: { plan: 'pro' }
*
* 4. Require specific feature:
* access: { feature: 'premium_access' }
*
* 5. Require specific role:
* access: { role: 'admin' }
*
* 6. Multiple conditions (all must be true):
* access: { requireOrg: true, permission: 'org:teams:manage', plan: 'pro' }
*
* Note: The `visible` function is deprecated but still supported for backward compatibility.
* Use the `access` property for new items.
*/
export const navGroups: NavGroup[] = [
{
label: "Overview",
items: [
{
title: "Dashboard Overview",
url: "/dashboard",
icon: "dashboard",
isActive: false,
shortcut: ["d", "d"],
items: [],
},
{
title: "Workspaces",
url: "/dashboard/workspaces",
icon: "workspace",
isActive: false,
items: [],
access: { systemRole: "super_admin" },
},
{
title: "Teams",
url: "/dashboard/workspaces/team",
icon: "teams",
isActive: false,
items: [],
access: { requireOrg: true, permission: "crm.quotation.read" },
},
{
title: "Users",
url: "/dashboard/users",
icon: "teams",
shortcut: ["u", "u"],
isActive: false,
items: [],
access: { requireOrg: true, permission: "users:manage" },
},
{
title: "Kanban",
url: "/dashboard/kanban",
icon: "kanban",
shortcut: ["k", "k"],
isActive: false,
items: [],
},
],
},
{
label: "CRM",
items: [
{
title: "Dashboard",
url: "/dashboard/crm",
icon: "dashboard",
isActive: false,
items: [],
access: { requireOrg: true, permission: "crm.dashboard.read" },
},
{
title: "ลูกค้า",
url: "/dashboard/crm/customers",
icon: "teams",
isActive: false,
items: [],
access: { requireOrg: true, permission: "crm.customer.read" },
},
{
title: "ลีด",
url: "/dashboard/crm/leads",
icon: "forms",
isActive: false,
items: [],
access: { requireOrg: true, permission: "crm.lead.read" },
},
{
title: "โอกาสขาย",
url: "/dashboard/crm/enquiries",
icon: "forms",
isActive: false,
items: [],
access: { requireOrg: true, permission: "crm.enquiry.read" },
},
{
title: "ใบเสนอราคา",
url: "/dashboard/crm/quotations",
icon: "post",
isActive: false,
items: [],
access: { requireOrg: true, role: "admin" },
},
{
title: "อนุมัติเอกสาร",
url: "/dashboard/crm/approvals",
icon: "checks",
isActive: false,
items: [],
access: { requireOrg: true, permission: "crm.approval.read" },
},
{
title: "Reports",
url: "/dashboard/crm/reports",
icon: "dashboard",
isActive: false,
items: [
{
title: "Pipeline Suite",
url: "/dashboard/crm/reports/pipeline",
access: { requireOrg: true, permission: "crm.report.read" },
},
{
title: "Lead Aging",
url: "/dashboard/crm/reports/lead-aging",
access: { requireOrg: true, permission: "crm.report.read" },
},
{
title: "Enquiry Aging",
url: "/dashboard/crm/reports/enquiry-aging",
access: { requireOrg: true, permission: "crm.report.read" },
},
],
access: { requireOrg: true, permission: "crm.report.read" },
},
{
title: "ตั้งค่า CRM",
url: "/dashboard/crm/settings/master-options",
icon: "settings",
isActive: false,
access: { requireOrg: true, permission: "crm.user_role_assignment.read" },
items: [
{
title: "Roles & Permissions",
url: "/dashboard/crm/settings/roles",
access: { requireOrg: true, permission: "crm.role.read" },
},
{
title: "User Role Assignments",
url: "/dashboard/crm/settings/user-role-assignments",
access: {
requireOrg: true,
permission: "crm.user_role_assignment.read",
/** * Navigation configuration with RBAC support * * This configuration is used for both the sidebar navigation and Cmd+K bar. * Items are organized into groups, each rendered with a SidebarGroupLabel. * * RBAC Access Control: * Each navigation item can have an `access` property that controls visibility * based on permissions, plans, features, roles, and organization context. * * Examples: * * 1. Require organization: * access: { requireOrg: true } * * 2. Require specific permission: * access: { requireOrg: true, permission: 'org:teams:manage' } * * 3. Require specific plan: * access: { plan: 'pro' } * * 4. Require specific feature: * access: { feature: 'premium_access' } * * 5. Require specific role: * access: { role: 'admin' } * * 6. Multiple conditions (all must be true): * access: { requireOrg: true, permission: 'org:teams:manage', plan: 'pro' } * * Note: The `visible` function is deprecated but still supported for backward compatibility. * Use the `access` property for new items. */ export const navGroups: NavGroup[] =
[
{
label: "Overview",
items: [
{
title: "Dashboard Overview",
url: "/dashboard",
icon: "dashboard",
isActive: false,
shortcut: ["d", "d"],
items: [],
},
{
title: "Workspaces",
url: "/dashboard/workspaces",
icon: "workspace",
isActive: false,
items: [],
access: { systemRole: "super_admin" },
},
{
title: "Teams",
url: "/dashboard/workspaces/team",
icon: "teams",
isActive: false,
items: [],
access: { requireOrg: true, permission: "crm.quotation.read" },
},
{
title: "Users",
url: "/dashboard/users",
icon: "teams",
shortcut: ["u", "u"],
isActive: false,
items: [],
access: { requireOrg: true, permission: "users:manage" },
},
{
title: "Kanban",
url: "/dashboard/kanban",
icon: "kanban",
shortcut: ["k", "k"],
isActive: false,
items: [],
},
],
},
{
label: "CRM",
items: [
{
title: "Dashboard",
url: "/dashboard/crm",
icon: "dashboard",
isActive: false,
items: [],
access: { requireOrg: true, permission: "crm.dashboard.read" },
},
{
title: "ลูกค้า",
url: "/dashboard/crm/customers",
icon: "teams",
isActive: false,
items: [],
access: { requireOrg: true, permission: "crm.customer.read" },
},
{
title: "ลีด",
url: "/dashboard/crm/leads",
icon: "forms",
isActive: false,
items: [],
access: { requireOrg: true, permission: "crm.lead.read" },
},
{
title: "โอกาสขาย",
url: "/dashboard/crm/opportunities",
icon: "forms",
isActive: false,
items: [],
access: { requireOrg: true, permission: "crm.opportunity.read" },
},
{
title: "ใบเสนอราคา",
url: "/dashboard/crm/quotations",
icon: "post",
isActive: false,
items: [],
access: { requireOrg: true, role: "admin" },
},
{
title: "อนุมัติเอกสาร",
url: "/dashboard/crm/approvals",
icon: "checks",
isActive: false,
items: [],
access: { requireOrg: true, permission: "crm.approval.read" },
},
{
title: "Reports",
url: "/dashboard/crm/reports",
icon: "dashboard",
isActive: false,
items: [
{
title: "Pipeline Suite",
url: "/dashboard/crm/reports/pipeline",
access: { requireOrg: true, permission: "crm.report.read" },
},
},
{
title: "Master Options",
url: "/dashboard/crm/settings/master-options",
access: { requireOrg: true, permission: "crm.master_option.read" },
},
{
title: "Document Sequences",
url: "/dashboard/crm/settings/document-sequences",
access: {
requireOrg: true,
permission: "crm.document_sequence.read",
{
title: "Lead Aging",
url: "/dashboard/crm/reports/lead-aging",
access: { requireOrg: true, permission: "crm.report.read" },
},
},
{
title: "Approval Workflows",
url: "/dashboard/crm/settings/approval-workflows",
access: {
requireOrg: true,
permission: "crm.approval.workflow.read",
{
title: "Opportunity Aging",
url: "/dashboard/crm/reports/opportunity-aging",
access: { requireOrg: true, permission: "crm.report.read" },
},
],
access: { requireOrg: true, permission: "crm.report.read" },
},
{
title: "ตั้งค่า CRM",
url: "/dashboard/crm/settings/master-options",
icon: "settings",
isActive: false,
access: {
requireOrg: true,
permission: "crm.user_role_assignment.read",
},
{
title: "Templates",
url: "/dashboard/crm/settings/templates",
access: {
requireOrg: true,
permission: "crm.document_template.read",
items: [
{
title: "Roles & Permissions",
url: "/dashboard/crm/settings/roles",
access: { requireOrg: true, permission: "crm.role.read" },
},
},
],
},
],
},
];
{
title: "User Role Assignments",
url: "/dashboard/crm/settings/user-role-assignments",
access: {
requireOrg: true,
permission: "crm.user_role_assignment.read",
},
},
{
title: "Master Options",
url: "/dashboard/crm/settings/master-options",
access: {
requireOrg: true,
permission: "crm.master_option.read",
},
},
{
title: "Document Sequences",
url: "/dashboard/crm/settings/document-sequences",
access: {
requireOrg: true,
permission: "crm.document_sequence.read",
},
},
{
title: "Approval Workflows",
url: "/dashboard/crm/settings/approval-workflows",
access: {
requireOrg: true,
permission: "crm.approval.workflow.read",
},
},
{
title: "Templates",
url: "/dashboard/crm/settings/templates",
access: {
requireOrg: true,
permission: "crm.document_template.read",
},
},
],
},
],
},
];

View File

@@ -325,8 +325,8 @@ export const crmContactShares = pgTable(
})
);
export const crmEnquiries = pgTable(
'crm_enquiries',
export const crmOpportunities = pgTable(
'crm_opportunities',
{
id: text('id').primaryKey(),
organizationId: text('organization_id').notNull(),
@@ -374,17 +374,17 @@ export const crmEnquiries = pgTable(
updatedBy: text('updated_by').notNull()
},
(table) => ({
organizationCodeIdx: uniqueIndex('crm_enquiries_org_code_idx').on(
organizationCodeIdx: uniqueIndex('crm_opportunities_org_code_idx').on(
table.organizationId,
table.code
)
})
);
export const crmEnquiryFollowups = pgTable('crm_enquiry_followups', {
export const crmOpportunityFollowups = pgTable('crm_opportunity_followups', {
id: text('id').primaryKey(),
organizationId: text('organization_id').notNull(),
enquiryId: text('enquiry_id').notNull(),
opportunityId: text('opportunity_id').notNull(),
followupDate: timestamp('followup_date', { withTimezone: true }).notNull(),
followupType: text('followup_type').notNull(),
contactId: text('contact_id'),
@@ -399,10 +399,10 @@ export const crmEnquiryFollowups = pgTable('crm_enquiry_followups', {
updatedBy: text('updated_by').notNull()
});
export const crmEnquiryCustomers = pgTable('crm_enquiry_customers', {
export const crmOpportunityCustomers = pgTable('crm_opportunity_customers', {
id: text('id').primaryKey(),
organizationId: text('organization_id').notNull(),
enquiryId: text('enquiry_id').notNull(),
opportunityId: text('opportunity_id').notNull(),
customerId: text('customer_id').notNull(),
role: text('role').notNull(),
remark: text('remark'),
@@ -411,10 +411,10 @@ export const crmEnquiryCustomers = pgTable('crm_enquiry_customers', {
deletedAt: timestamp('deleted_at', { withTimezone: true })
});
export const crmEnquiryAttachments = pgTable('crm_enquiry_attachments', {
export const crmOpportunityAttachments = pgTable('crm_opportunity_attachments', {
id: text('id').primaryKey(),
organizationId: text('organization_id').notNull(),
enquiryId: text('enquiry_id').notNull(),
opportunityId: text('opportunity_id').notNull(),
category: text('category').notNull(),
fileName: text('file_name').notNull(),
originalFileName: text('original_file_name').notNull(),
@@ -459,7 +459,7 @@ export const crmQuotations = pgTable(
organizationId: text('organization_id').notNull(),
branchId: text('branch_id'),
code: text('code').notNull(),
enquiryId: text('enquiry_id'),
opportunityId: text('opportunity_id'),
customerId: text('customer_id').notNull(),
contactId: text('contact_id'),
quotationDate: timestamp('quotation_date', { withTimezone: true }).notNull(),
@@ -804,3 +804,4 @@ export const crmDocumentTemplateTableColumns = pgTable(
)
})
);

View File

@@ -199,7 +199,7 @@ const FOUNDATION_OPTIONS = {
parentCategory: 'crm_customer_group'
}
],
crm_enquiry_status: [
crm_opportunity_status: [
{ code: 'new', label: 'New', value: 'new', sortOrder: 1 },
{ code: 'qualifying', label: 'Qualifying', value: 'qualifying', sortOrder: 2 },
{ code: 'requirement', label: 'Requirement', value: 'requirement', sortOrder: 3 },
@@ -468,15 +468,15 @@ const REPORT_DEFINITIONS: ReportDefinitionSeed[] = [
category: 'pipeline'
},
{
code: 'enquiry_pipeline',
name: 'Enquiry Pipeline',
description: 'Operational report for open enquiries, quotation progress, and sales pipeline health.',
code: 'opportunity_pipeline',
name: 'Opportunity Pipeline',
description: 'Operational report for open opportunities, quotation progress, and sales pipeline health.',
category: 'pipeline'
},
{
code: 'enquiry_aging',
name: 'Enquiry Aging',
description: 'Operational report for stalled enquiries and aging opportunity follow-ups.',
code: 'opportunity_aging',
name: 'Opportunity Aging',
description: 'Operational report for stalled opportunities and aging opportunity follow-ups.',
category: 'pipeline'
},
{
@@ -488,13 +488,13 @@ const REPORT_DEFINITIONS: ReportDefinitionSeed[] = [
{
code: 'lead_conversion',
name: 'Lead Conversion',
description: 'Marketing conversion report from lead creation into active enquiries.',
description: 'Marketing conversion report from lead creation into active opportunities.',
category: 'pipeline'
},
{
code: 'enquiry_conversion',
name: 'Enquiry Conversion',
description: 'Sales conversion report from enquiry creation into quotation generation.',
code: 'opportunity_conversion',
name: 'Opportunity Conversion',
description: 'Sales conversion report from opportunity creation into quotation generation.',
category: 'pipeline'
},
{
@@ -520,9 +520,9 @@ const REPORT_DEFINITIONS: ReportDefinitionSeed[] = [
const DOCUMENT_SEQUENCES = [
{ documentType: 'customer', prefix: 'CUS' },
{ documentType: 'contact', prefix: 'CON' },
{ documentType: 'enquiry', prefix: 'ENQ' },
{ documentType: 'opportunity', prefix: 'ENQ' },
{ documentType: 'crm_lead', prefix: 'LD' },
{ documentType: 'crm_enquiry', prefix: 'EN' },
{ documentType: 'crm_opportunity', prefix: 'EN' },
{ documentType: 'quotation', prefix: 'QT' },
{ documentType: 'approval', prefix: 'APV' }
];
@@ -1229,3 +1229,4 @@ if (require.main === module) {
process.exitCode = 1;
});
}

View File

@@ -2,7 +2,7 @@ import { mutationOptions } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/query-client';
import {
approveQuotation,
convertEnquiryToQuotation,
convertOpportunityToQuotation,
createCustomer,
createQuotationRevision,
markQuotationStatus,
@@ -35,8 +35,8 @@ export const updateCustomerMutation = mutationOptions({
onSuccess: invalidateCrm
});
export const convertEnquiryMutation = mutationOptions({
mutationFn: (enquiryId: string) => convertEnquiryToQuotation(enquiryId),
export const convertOpportunityMutation = mutationOptions({
mutationFn: (opportunityId: string) => convertOpportunityToQuotation(opportunityId),
onSuccess: invalidateCrm
});
@@ -76,3 +76,4 @@ export const revokeContactShareMutation = mutationOptions({
revokeContactShare(contactId, shareId),
onSuccess: invalidateCrm
});

View File

@@ -5,12 +5,12 @@ import {
getCrmReferenceData,
getCustomerById,
getCustomers,
getEnquiries,
getEnquiryById,
getOpportunities,
getOpportunityById,
getQuotationById,
getQuotations
} from './service';
import type { ApprovalFilters, CustomerFilters, EnquiryFilters, QuotationFilters } from './types';
import type { ApprovalFilters, CustomerFilters, OpportunityFilters, QuotationFilters } from './types';
export const crmKeys = {
all: ['crm'] as const,
@@ -18,8 +18,8 @@ export const crmKeys = {
dashboard: () => [...crmKeys.all, 'dashboard'] as const,
customers: (filters: CustomerFilters) => [...crmKeys.all, 'customers', filters] as const,
customer: (id: string) => [...crmKeys.all, 'customer', id] as const,
enquiries: (filters: EnquiryFilters) => [...crmKeys.all, 'enquiries', filters] as const,
enquiry: (id: string) => [...crmKeys.all, 'enquiry', id] as const,
opportunities: (filters: OpportunityFilters) => [...crmKeys.all, 'opportunities', filters] as const,
opportunity: (id: string) => [...crmKeys.all, 'opportunity', id] as const,
quotations: (filters: QuotationFilters) => [...crmKeys.all, 'quotations', filters] as const,
quotation: (id: string) => [...crmKeys.all, 'quotation', id] as const,
approvals: (filters: ApprovalFilters) => [...crmKeys.all, 'approvals', filters] as const
@@ -49,16 +49,16 @@ export const customerByIdOptions = (id: string) =>
queryFn: () => getCustomerById(id)
});
export const enquiriesQueryOptions = (filters: EnquiryFilters) =>
export const opportunitiesQueryOptions = (filters: OpportunityFilters) =>
queryOptions({
queryKey: crmKeys.enquiries(filters),
queryFn: () => getEnquiries(filters)
queryKey: crmKeys.opportunities(filters),
queryFn: () => getOpportunities(filters)
});
export const enquiryByIdOptions = (id: string) =>
export const opportunityByIdOptions = (id: string) =>
queryOptions({
queryKey: crmKeys.enquiry(id),
queryFn: () => getEnquiryById(id)
queryKey: crmKeys.opportunity(id),
queryFn: () => getOpportunityById(id)
});
export const quotationsQueryOptions = (filters: QuotationFilters) =>
@@ -78,3 +78,4 @@ export const approvalsQueryOptions = (filters: ApprovalFilters) =>
queryKey: crmKeys.approvals(filters),
queryFn: () => getApprovals(filters)
});

View File

@@ -8,9 +8,9 @@ import type {
CustomerDetailResponse,
CustomerFilters,
CustomerMutationPayload,
Enquiry,
EnquiryDetailResponse,
EnquiryFilters,
Opportunity,
OpportunityDetailResponse,
OpportunityFilters,
PaginatedResponse,
Quotation,
QuotationDetailResponse,
@@ -70,8 +70,8 @@ export async function getCrmReferenceData(): Promise<CrmReferenceData> {
}
export async function getCrmDashboardData(): Promise<CrmDashboardData> {
const openEnquiries = crmState.enquiries.filter((enquiry) =>
['new', 'qualifying', 'requirement', 'follow_up'].includes(enquiry.status)
const openOpportunities = crmState.opportunities.filter((opportunity) =>
['new', 'qualifying', 'requirement', 'follow_up'].includes(opportunity.status)
);
const pendingQuotations = crmState.quotations.filter(
(quotation) => quotation.status === 'pending_approval'
@@ -81,22 +81,22 @@ export async function getCrmDashboardData(): Promise<CrmDashboardData> {
const hotQuotations = crmState.quotations.filter((quotation) => quotation.isHotProject);
const dueFollowUps = crmState.quotations.flatMap((quotation) => quotation.followUps).slice(0, 5);
const conversionRate = Math.round(
(wonQuotations.length / Math.max(1, crmState.enquiries.length)) * 100
(wonQuotations.length / Math.max(1, crmState.opportunities.length)) * 100
);
return clone({
metrics: [
{
id: 'm-1',
label: 'Total Enquiries',
value: String(crmState.enquiries.length),
label: 'Total Opportunities',
value: String(crmState.opportunities.length),
change: '+12%',
trend: 'up'
},
{
id: 'm-2',
label: 'Open Enquiries',
value: String(openEnquiries.length),
label: 'Open Opportunities',
value: String(openOpportunities.length),
change: '+3',
trend: 'up'
},
@@ -150,23 +150,23 @@ export async function getCrmDashboardData(): Promise<CrmDashboardData> {
trend: 'flat'
}
],
enquiryPipeline: [
{ label: 'New', value: crmState.enquiries.filter((item) => item.status === 'new').length },
opportunityPipeline: [
{ label: 'New', value: crmState.opportunities.filter((item) => item.status === 'new').length },
{
label: 'Qualifying',
value: crmState.enquiries.filter((item) => item.status === 'qualifying').length
value: crmState.opportunities.filter((item) => item.status === 'qualifying').length
},
{
label: 'Requirement',
value: crmState.enquiries.filter((item) => item.status === 'requirement').length
value: crmState.opportunities.filter((item) => item.status === 'requirement').length
},
{
label: 'Follow Up',
value: crmState.enquiries.filter((item) => item.status === 'follow_up').length
value: crmState.opportunities.filter((item) => item.status === 'follow_up').length
},
{
label: 'Converted',
value: crmState.enquiries.filter((item) => item.status === 'converted').length
value: crmState.opportunities.filter((item) => item.status === 'converted').length
}
],
quotationPipeline: [
@@ -253,7 +253,7 @@ export async function getCustomerById(id: string): Promise<CustomerDetailRespons
shareLogs: crmState.contactShareLogs.filter((log) =>
crmState.contacts.some((contact) => contact.id === log.contactId && contact.customerId === id)
),
enquiries: crmState.enquiries.filter((enquiry) => enquiry.customerId === id),
opportunities: crmState.opportunities.filter((opportunity) => opportunity.customerId === id),
quotations: crmState.quotations.filter((quotation) =>
quotation.customerRoles.some((role) => role.customerId === id)
),
@@ -262,8 +262,8 @@ export async function getCustomerById(id: string): Promise<CustomerDetailRespons
});
}
export async function getEnquiries(filters: EnquiryFilters): Promise<PaginatedResponse<Enquiry>> {
let items = [...crmState.enquiries];
export async function getOpportunities(filters: OpportunityFilters): Promise<PaginatedResponse<Opportunity>> {
let items = [...crmState.opportunities];
if (filters.search) {
const search = filters.search.toLowerCase();
@@ -283,21 +283,21 @@ export async function getEnquiries(filters: EnquiryFilters): Promise<PaginatedRe
return clone(paginate(items, filters.page, filters.limit));
}
export async function getEnquiryById(id: string): Promise<EnquiryDetailResponse> {
const enquiry = crmState.enquiries.find((item) => item.id === id);
if (!enquiry) {
throw new Error('Enquiry not found');
export async function getOpportunityById(id: string): Promise<OpportunityDetailResponse> {
const opportunity = crmState.opportunities.find((item) => item.id === id);
if (!opportunity) {
throw new Error('Opportunity not found');
}
const customer = crmState.customers.find((item) => item.id === enquiry.customerId)!;
const contact = crmState.contacts.find((item) => item.id === enquiry.contactId)!;
const customer = crmState.customers.find((item) => item.id === opportunity.customerId)!;
const contact = crmState.contacts.find((item) => item.id === opportunity.contactId)!;
return clone({
enquiry,
opportunity,
customer,
contact,
quotations: crmState.quotations.filter((quotation) => quotation.enquiryId === id),
branch: findBranch(enquiry.branchId)!,
salesman: findSalesperson(enquiry.salesmanId)!
quotations: crmState.quotations.filter((quotation) => quotation.opportunityId === id),
branch: findBranch(opportunity.branchId)!,
salesman: findSalesperson(opportunity.salesmanId)!
});
}
@@ -338,7 +338,7 @@ export async function getQuotationById(id: string): Promise<QuotationDetailRespo
throw new Error('Quotation not found');
}
const enquiry = crmState.enquiries.find((item) => item.id === quotation.enquiryId)!;
const opportunity = crmState.opportunities.find((item) => item.id === quotation.opportunityId)!;
const customerIds = [...new Set(quotation.customerRoles.map((role) => role.customerId))];
const contactIds = [
...new Set(quotation.customerRoles.map((role) => role.contactId).filter(Boolean))
@@ -346,7 +346,7 @@ export async function getQuotationById(id: string): Promise<QuotationDetailRespo
return clone({
quotation,
enquiry,
opportunity,
customers: crmState.customers.filter((item) => customerIds.includes(item.id)),
contacts: crmState.contacts.filter((item) => contactIds.includes(item.id)),
branch: findBranch(quotation.branchId)!,
@@ -376,40 +376,40 @@ export async function updateCustomer(id: string, values: CustomerMutationPayload
return getCustomerById(id);
}
export async function convertEnquiryToQuotation(enquiryId: string) {
const enquiry = crmState.enquiries.find((item) => item.id === enquiryId);
if (!enquiry) throw new Error('Enquiry not found');
export async function convertOpportunityToQuotation(opportunityId: string) {
const opportunity = crmState.opportunities.find((item) => item.id === opportunityId);
if (!opportunity) throw new Error('Opportunity not found');
const nextId = `qt-${Date.now()}`;
const newQuotation: Quotation = {
id: nextId,
code: `QT2606-${String(crmState.quotations.length + 1).padStart(3, '0')}`,
enquiryId,
opportunityId,
quotationDate: '2026-06-11',
validUntil: '2026-07-11',
quotationType: 'official',
project: enquiry.title,
siteLocation: enquiry.projectLocation,
project: opportunity.title,
siteLocation: opportunity.projectLocation,
customerRoles: [
{ role: 'owner', customerId: enquiry.customerId, contactId: enquiry.contactId }
{ role: 'owner', customerId: opportunity.customerId, contactId: opportunity.contactId }
],
salesmanId: enquiry.salesmanId,
salesmanId: opportunity.salesmanId,
saleAdminId: 'sp-2',
branchId: enquiry.branchId,
branchId: opportunity.branchId,
status: 'draft',
revision: 0,
totalAmount: enquiry.expectedValue,
chancePercent: enquiry.chancePercent,
isHotProject: enquiry.chancePercent >= 70,
totalAmount: opportunity.expectedValue,
chancePercent: opportunity.chancePercent,
isHotProject: opportunity.chancePercent >= 70,
items: [
{
id: `${nextId}-item-1`,
topic: 'Scope',
description: enquiry.requirementSummary,
description: opportunity.requirementSummary,
quantity: 1,
unit: 'lot',
unitPrice: enquiry.expectedValue,
amount: enquiry.expectedValue
unitPrice: opportunity.expectedValue,
amount: opportunity.expectedValue
}
],
topics: [
@@ -417,7 +417,7 @@ export async function convertEnquiryToQuotation(enquiryId: string) {
id: `${nextId}-topic-1`,
type: 'scope',
label: 'Scope',
items: [enquiry.requirementSummary]
items: [opportunity.requirementSummary]
},
{
id: `${nextId}-topic-2`,
@@ -434,8 +434,8 @@ export async function convertEnquiryToQuotation(enquiryId: string) {
};
crmState.quotations.unshift(newQuotation);
crmState.enquiries = crmState.enquiries.map((item) =>
item.id === enquiryId
crmState.opportunities = crmState.opportunities.map((item) =>
item.id === opportunityId
? {
...item,
status: 'converted',
@@ -443,7 +443,7 @@ export async function convertEnquiryToQuotation(enquiryId: string) {
activities: [
...item.activities,
{
id: `${enquiryId}-activity-${Date.now()}`,
id: `${opportunityId}-activity-${Date.now()}`,
type: 'CONVERT',
title: 'Convert to quotation',
detail: `สร้าง ${newQuotation.code}`,
@@ -591,3 +591,4 @@ export async function revokeContactShare(contactId: string, shareId: string) {
});
}
}

View File

@@ -63,7 +63,7 @@ export interface Customer {
contactIds: string[];
}
export type EnquiryStatus =
export type OpportunityStatus =
| 'new'
| 'qualifying'
| 'requirement'
@@ -72,7 +72,7 @@ export type EnquiryStatus =
| 'closed_lost'
| 'cancelled';
export interface EnquiryActivity {
export interface OpportunityActivity {
id: string;
type: 'CALL' | 'MEETING' | 'VISIT' | 'NOTE' | 'UPDATE' | 'CONVERT';
title: string;
@@ -81,7 +81,7 @@ export interface EnquiryActivity {
createdAt: string;
}
export interface Enquiry {
export interface Opportunity {
id: string;
code: string;
title: string;
@@ -90,7 +90,7 @@ export interface Enquiry {
branchId: string;
salesmanId: string;
productType: 'crane' | 'dockdoor' | 'solarcell';
status: EnquiryStatus;
status: OpportunityStatus;
requirementSummary: string;
projectLocation: string;
chancePercent: number;
@@ -100,7 +100,7 @@ export interface Enquiry {
createdAt: string;
updatedAt: string;
quotationIds: string[];
activities: EnquiryActivity[];
activities: OpportunityActivity[];
}
export type QuotationStatus =
@@ -165,7 +165,7 @@ export interface ApprovalStep {
export interface Quotation {
id: string;
code: string;
enquiryId: string;
opportunityId: string;
quotationDate: string;
validUntil: string;
quotationType: 'budgetary' | 'official' | 'service';
@@ -204,7 +204,7 @@ export interface ApprovalItem {
export interface AuditEvent {
id: string;
entityType: 'customer' | 'enquiry' | 'quotation' | 'approval';
entityType: 'customer' | 'opportunity' | 'quotation' | 'approval';
entityId: string;
action: 'CREATE' | 'UPDATE' | 'SEND' | 'APPROVE' | 'REJECT' | 'CONVERT' | 'REVISE';
actorName: string;
@@ -214,7 +214,7 @@ export interface AuditEvent {
export interface DocumentSequence {
id: string;
documentType: 'enquiry' | 'quotation' | 'quotation_revision';
documentType: 'opportunity' | 'quotation' | 'quotation_revision';
prefix: string;
period: string;
branchId: string;
@@ -264,7 +264,7 @@ export interface CrmChartDatum {
export interface CrmDashboardData {
metrics: CrmDashboardMetric[];
enquiryPipeline: CrmChartDatum[];
opportunityPipeline: CrmChartDatum[];
quotationPipeline: CrmChartDatum[];
salesPerformance: CrmChartDatum[];
competitorAnalysis: CrmChartDatum[];
@@ -283,7 +283,7 @@ export interface CustomerFilters {
sort?: string;
}
export interface EnquiryFilters {
export interface OpportunityFilters {
page?: number;
limit?: number;
search?: string;
@@ -328,14 +328,14 @@ export interface CustomerDetailResponse {
contacts: CustomerContact[];
shares: ContactShare[];
shareLogs: ContactShareLog[];
enquiries: Enquiry[];
opportunities: Opportunity[];
quotations: Quotation[];
auditEvents: AuditEvent[];
branch: CrmBranch;
}
export interface EnquiryDetailResponse {
enquiry: Enquiry;
export interface OpportunityDetailResponse {
opportunity: Opportunity;
customer: Customer;
contact: CustomerContact;
quotations: Quotation[];
@@ -345,7 +345,7 @@ export interface EnquiryDetailResponse {
export interface QuotationDetailResponse {
quotation: Quotation;
enquiry: Enquiry;
opportunity: Opportunity;
customers: Customer[];
contacts: CustomerContact[];
branch: CrmBranch;
@@ -390,3 +390,4 @@ export interface ContactSharePayload {
sharedWithUserName: string;
permission: 'view' | 'edit';
}

View File

@@ -45,12 +45,12 @@ export function CrmDashboardPage() {
<div className='grid gap-4 xl:grid-cols-12'>
<Card className='xl:col-span-5'>
<CardHeader>
<CardTitle>Enquiry Pipeline</CardTitle>
<CardTitle>Opportunity Pipeline</CardTitle>
<CardDescription> opportunity </CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig}>
<BarChart data={data.enquiryPipeline}>
<BarChart data={data.opportunityPipeline}>
<CartesianGrid vertical={false} />
<XAxis dataKey='label' tickLine={false} axisLine={false} />
<YAxis allowDecimals={false} />
@@ -217,3 +217,4 @@ export function CrmDashboardPage() {
</div>
);
}

View File

@@ -63,7 +63,7 @@ export function CustomerDetailPage({ id }: { id: string }) {
<TabsTrigger value='overview'>Overview</TabsTrigger>
<TabsTrigger value='contacts'>Contacts</TabsTrigger>
<TabsTrigger value='shared'>Shared Contacts</TabsTrigger>
<TabsTrigger value='enquiries'>Enquiries</TabsTrigger>
<TabsTrigger value='opportunities'>Opportunities</TabsTrigger>
<TabsTrigger value='quotations'>Quotations</TabsTrigger>
<TabsTrigger value='activity'>Activity Log</TabsTrigger>
</TabsList>
@@ -177,13 +177,13 @@ export function CustomerDetailPage({ id }: { id: string }) {
</SectionCard>
</TabsContent>
<TabsContent value='enquiries'>
<SectionCard title='Customer Enquiries'>
<TabsContent value='opportunities'>
<SectionCard title='Customer Opportunities'>
<div className='space-y-3'>
{data.enquiries.map((enquiry) => (
<div key={enquiry.id} className='rounded-lg border p-3'>
<p className='font-medium'>{enquiry.code}</p>
<p className='text-muted-foreground text-sm'>{enquiry.title}</p>
{data.opportunities.map((opportunity) => (
<div key={opportunity.id} className='rounded-lg border p-3'>
<p className='font-medium'>{opportunity.code}</p>
<p className='text-muted-foreground text-sm'>{opportunity.title}</p>
</div>
))}
</div>
@@ -203,3 +203,4 @@ export function CustomerDetailPage({ id }: { id: string }) {
</div>
);
}

View File

@@ -10,19 +10,19 @@ import { DataTable } from '@/components/ui/table/data-table';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import { crmReferenceQueryOptions, enquiriesQueryOptions } from '../api/queries';
import { convertEnquiryMutation } from '../api/mutations';
import type { Enquiry } from '../api/types';
import { ChancePill, EnquiryStatusBadge } from './shared';
import { crmReferenceQueryOptions, opportunitiesQueryOptions } from '../api/queries';
import { convertOpportunityMutation } from '../api/mutations';
import type { Opportunity } from '../api/types';
import { ChancePill, OpportunityStatusBadge } from './shared';
const columns: ColumnDef<Enquiry>[] = [
const columns: ColumnDef<Opportunity>[] = [
{ accessorKey: 'code', header: 'Code' },
{ accessorKey: 'title', header: 'Enquiry' },
{ accessorKey: 'title', header: 'Opportunity' },
{ accessorKey: 'productType', header: 'Product' },
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => <EnquiryStatusBadge status={row.original.status} />
cell: ({ row }) => <OpportunityStatusBadge status={row.original.status} />
},
{
accessorKey: 'chancePercent',
@@ -32,31 +32,31 @@ const columns: ColumnDef<Enquiry>[] = [
{
id: 'actions',
header: '',
cell: ({ row }) => <RowActions enquiry={row.original} />
cell: ({ row }) => <RowActions opportunity={row.original} />
}
];
const columnIds = ['code', 'title', 'productType', 'status', 'chancePercent', 'actions'];
function RowActions({ enquiry }: { enquiry: Enquiry }) {
function RowActions({ opportunity }: { opportunity: Opportunity }) {
const mutation = useMutation({
...convertEnquiryMutation,
onSuccess: () => toast.success('สร้าง quotation mock จาก enquiry แล้ว')
...convertOpportunityMutation,
onSuccess: () => toast.success('สร้าง quotation mock จาก opportunity แล้ว')
});
return (
<div className='flex justify-end gap-2'>
<Button variant='outline' size='sm' asChild>
<Link href={`/dashboard/crm-demo/enquiries/${enquiry.id}`}>View</Link>
<Link href={`/dashboard/crm-demo/opportunities/${opportunity.id}`}>View</Link>
</Button>
<Button size='sm' isLoading={mutation.isPending} onClick={() => mutation.mutate(enquiry.id)}>
<Button size='sm' isLoading={mutation.isPending} onClick={() => mutation.mutate(opportunity.id)}>
Convert
</Button>
</div>
);
}
export function EnquiriesTablePage() {
export function OpportunitiesTablePage() {
const [params, setParams] = useQueryStates({
page: parseAsInteger.withDefault(1),
perPage: parseAsInteger.withDefault(10),
@@ -82,7 +82,7 @@ export function EnquiriesTablePage() {
...(params.sort.length ? { sort: JSON.stringify(params.sort) } : {})
};
const { data } = useSuspenseQuery(enquiriesQueryOptions(filters));
const { data } = useSuspenseQuery(opportunitiesQueryOptions(filters));
const pageCount = Math.ceil(data.totalItems / params.perPage);
const { table } = useDataTable({
data: data.items,
@@ -97,7 +97,7 @@ export function EnquiriesTablePage() {
<div className='flex flex-1 flex-col space-y-4'>
<div className='grid gap-3 md:grid-cols-2 xl:grid-cols-6'>
<Input
placeholder='ค้นหา enquiry หรือ code'
placeholder='ค้นหา opportunity หรือ code'
value={params.name ?? ''}
onChange={(event) => void setParams({ name: event.target.value || null, page: 1 })}
/>
@@ -148,10 +148,11 @@ export function EnquiriesTablePage() {
</div>
<div className='flex justify-end'>
<Button variant='outline' asChild>
<Link href='/dashboard/crm-demo/customers'>Create Enquiry</Link>
<Link href='/dashboard/crm-demo/customers'>Create Opportunity</Link>
</Button>
</div>
<DataTable table={table} />
</div>
);
}

View File

@@ -3,14 +3,14 @@
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { enquiryByIdOptions } from '../api/queries';
import { convertEnquiryMutation } from '../api/mutations';
import { opportunityByIdOptions } from '../api/queries';
import { convertOpportunityMutation } from '../api/mutations';
import { InfoGrid, RelatedQuotationList, SectionCard, TimelineList } from './shared';
import { formatCurrency } from '../utils/format';
export function EnquiryDetailPage({ id }: { id: string }) {
const { data } = useSuspenseQuery(enquiryByIdOptions(id));
const baseMutation = convertEnquiryMutation;
export function OpportunityDetailPage({ id }: { id: string }) {
const { data } = useSuspenseQuery(opportunityByIdOptions(id));
const baseMutation = convertOpportunityMutation;
const mutation = useMutation({
...baseMutation,
onSuccess: async (...args) => {
@@ -22,10 +22,10 @@ export function EnquiryDetailPage({ id }: { id: string }) {
return (
<div className='space-y-4'>
<SectionCard
title={data.enquiry.title}
description={`${data.enquiry.code} / ${data.branch.name}`}
title={data.opportunity.title}
description={`${data.opportunity.code} / ${data.branch.name}`}
action={
<Button isLoading={mutation.isPending} onClick={() => mutation.mutate(data.enquiry.id)}>
<Button isLoading={mutation.isPending} onClick={() => mutation.mutate(data.opportunity.id)}>
Convert to Quotation
</Button>
}
@@ -35,19 +35,19 @@ export function EnquiryDetailPage({ id }: { id: string }) {
items={[
{ label: 'Customer', value: data.customer.name },
{ label: 'Contact', value: data.contact.name },
{ label: 'Chance', value: `${data.enquiry.chancePercent}%` },
{ label: 'Potential', value: formatCurrency(data.enquiry.expectedValue) },
{ label: 'Project Location', value: data.enquiry.projectLocation },
{ label: 'Competitor', value: data.enquiry.competitor },
{ label: 'Chance', value: `${data.opportunity.chancePercent}%` },
{ label: 'Potential', value: formatCurrency(data.opportunity.expectedValue) },
{ label: 'Project Location', value: data.opportunity.projectLocation },
{ label: 'Competitor', value: data.opportunity.competitor },
{ label: 'Salesman', value: data.salesman.name },
{ label: 'Requirement', value: data.enquiry.requirementSummary }
{ label: 'Requirement', value: data.opportunity.requirementSummary }
]}
/>
</SectionCard>
<SectionCard title='Timeline / Activity'>
<TimelineList
items={data.enquiry.activities.map((activity) => ({
items={data.opportunity.activities.map((activity) => ({
id: activity.id,
title: activity.title,
detail: activity.detail,
@@ -63,3 +63,4 @@ export function EnquiryDetailPage({ id }: { id: string }) {
</div>
);
}

View File

@@ -202,7 +202,7 @@ export function QuotationsTablePage() {
<div className='flex items-center gap-2'>
<Button variant='outline' asChild>
<Link href='/dashboard/crm-demo/enquiries'>Create quotation</Link>
<Link href='/dashboard/crm-demo/opportunities'>Create quotation</Link>
</Button>
<Button
variant={params.view === 'table' ? 'default' : 'outline'}
@@ -223,3 +223,4 @@ export function QuotationsTablePage() {
</div>
);
}

View File

@@ -12,14 +12,14 @@ import type {
CrmBranch,
Customer,
CustomerContact,
Enquiry,
Opportunity,
Quotation,
QuotationCustomerRole
} from '../api/types';
import { formatCurrency, formatDate, formatPercent } from '../utils/format';
import {
getCustomerStatusLabel,
getEnquiryStatusLabel,
getOpportunityStatusLabel,
getQuotationStatusLabel
} from '../utils/status';
@@ -96,7 +96,7 @@ export function CustomerStatusBadge({ status }: { status: Customer['customerStat
);
}
export function EnquiryStatusBadge({ status }: { status: Enquiry['status'] }) {
export function OpportunityStatusBadge({ status }: { status: Opportunity['status'] }) {
return (
<Badge
variant={
@@ -107,7 +107,7 @@ export function EnquiryStatusBadge({ status }: { status: Enquiry['status'] }) {
: 'outline'
}
>
{getEnquiryStatusLabel(status)}
{getOpportunityStatusLabel(status)}
</Badge>
);
}
@@ -393,3 +393,4 @@ export function EmptyState({ title, description }: { title: string; description:
</div>
);
}

View File

@@ -8,7 +8,7 @@ import type {
Customer,
CustomerContact,
DocumentSequence,
Enquiry,
Opportunity,
MasterOption,
Quotation,
QuotationTemplate
@@ -21,7 +21,7 @@ export interface CrmMockState {
contacts: CustomerContact[];
contactShares: ContactShare[];
contactShareLogs: ContactShareLog[];
enquiries: Enquiry[];
opportunities: Opportunity[];
quotations: Quotation[];
approvals: ApprovalItem[];
auditEvents: AuditEvent[];
@@ -259,7 +259,7 @@ export const initialCrmState: CrmMockState = {
createdAt: '2026-06-07T14:45:00.000Z'
}
],
enquiries: [
opportunities: [
{
id: 'enq-1',
code: 'ENQ2606-001',
@@ -509,7 +509,7 @@ export const initialCrmState: CrmMockState = {
{
id: 'enq-8-act-1',
type: 'CALL',
title: 'รับ enquiry ใหม่',
title: 'รับ opportunity ใหม่',
detail: 'ต้องการเสนอราคาใน 7 วัน',
actorName: 'Ton A.',
createdAt: '2026-06-09T13:00:00.000Z'
@@ -521,7 +521,7 @@ export const initialCrmState: CrmMockState = {
{
id: 'qt-1',
code: 'QT2606-001',
enquiryId: 'enq-1',
opportunityId: 'enq-1',
quotationDate: '2026-06-02',
validUntil: '2026-07-02',
quotationType: 'official',
@@ -627,7 +627,7 @@ export const initialCrmState: CrmMockState = {
{
id: 'qt-2',
code: 'QT2606-001-R01',
enquiryId: 'enq-1',
opportunityId: 'enq-1',
quotationDate: '2026-06-07',
validUntil: '2026-07-07',
quotationType: 'official',
@@ -721,7 +721,7 @@ export const initialCrmState: CrmMockState = {
{
id: 'qt-3',
code: 'QT2606-002',
enquiryId: 'enq-2',
opportunityId: 'enq-2',
quotationDate: '2026-06-05',
validUntil: '2026-07-05',
quotationType: 'budgetary',
@@ -795,7 +795,7 @@ export const initialCrmState: CrmMockState = {
{
id: 'qt-4',
code: 'QT2606-003',
enquiryId: 'enq-3',
opportunityId: 'enq-3',
quotationDate: '2026-06-03',
validUntil: '2026-07-03',
quotationType: 'official',
@@ -876,7 +876,7 @@ export const initialCrmState: CrmMockState = {
{
id: 'qt-5',
code: 'QT2606-004',
enquiryId: 'enq-5',
opportunityId: 'enq-5',
quotationDate: '2026-06-06',
validUntil: '2026-07-06',
quotationType: 'service',
@@ -938,7 +938,7 @@ export const initialCrmState: CrmMockState = {
{
id: 'qt-6',
code: 'QT2606-005',
enquiryId: 'enq-6',
opportunityId: 'enq-6',
quotationDate: '2026-05-26',
validUntil: '2026-06-25',
quotationType: 'budgetary',
@@ -977,7 +977,7 @@ export const initialCrmState: CrmMockState = {
{
id: 'qt-7',
code: 'QT2606-006',
enquiryId: 'enq-4',
opportunityId: 'enq-4',
quotationDate: '2026-06-09',
validUntil: '2026-07-09',
quotationType: 'official',
@@ -1037,7 +1037,7 @@ export const initialCrmState: CrmMockState = {
{
id: 'qt-8',
code: 'QT2606-007',
enquiryId: 'enq-8',
opportunityId: 'enq-8',
quotationDate: '2026-06-10',
validUntil: '2026-07-10',
quotationType: 'official',
@@ -1081,7 +1081,7 @@ export const initialCrmState: CrmMockState = {
{
id: 'qt-9',
code: 'QT2606-008',
enquiryId: 'enq-2',
opportunityId: 'enq-2',
quotationDate: '2026-06-08',
validUntil: '2026-07-08',
quotationType: 'official',
@@ -1135,7 +1135,7 @@ export const initialCrmState: CrmMockState = {
{
id: 'qt-10',
code: 'QT2606-009',
enquiryId: 'enq-5',
opportunityId: 'enq-5',
quotationDate: '2026-06-10',
validUntil: '2026-07-10',
quotationType: 'service',
@@ -1282,7 +1282,7 @@ export const initialCrmState: CrmMockState = {
sequences: [
{
id: 'seq-1',
documentType: 'enquiry',
documentType: 'opportunity',
prefix: 'ENQ',
period: '2606',
branchId: 'bkk',
@@ -1375,3 +1375,4 @@ export const initialCrmState: CrmMockState = {
}
]
};

View File

@@ -1,4 +1,4 @@
export const enquiryStatusOptions = [
export const opportunityStatusOptions = [
{ value: 'new', label: 'New' },
{ value: 'qualifying', label: 'Qualifying' },
{ value: 'requirement', label: 'Requirement' },
@@ -37,3 +37,4 @@ export const quotationTypeOptions = [
{ value: 'budgetary', label: 'Budgetary' },
{ value: 'service', label: 'Service' }
] as const;

View File

@@ -1,4 +1,4 @@
import type { Customer, EnquiryStatus, QuotationStatus } from '../api/types';
import type { Customer, OpportunityStatus, QuotationStatus } from '../api/types';
export function getCustomerStatusLabel(status: Customer['customerStatus']) {
return (
@@ -10,7 +10,7 @@ export function getCustomerStatusLabel(status: Customer['customerStatus']) {
);
}
export function getEnquiryStatusLabel(status: EnquiryStatus) {
export function getOpportunityStatusLabel(status: OpportunityStatus) {
return (
{
new: 'New',
@@ -39,3 +39,4 @@ export function getQuotationStatusLabel(status: QuotationStatus) {
}[status] ?? status
);
}

View File

@@ -117,7 +117,7 @@ export interface CustomerActivityRecord {
actorName: string | null;
}
export interface CustomerRelatedEnquiryItem {
export interface CustomerRelatedOpportunityItem {
id: string;
code: string;
title: string;
@@ -230,3 +230,4 @@ export interface MutationSuccessResponse {
success: boolean;
message: string;
}

View File

@@ -16,7 +16,7 @@ import { CustomerFormSheet } from './customer-form-sheet';
import { CustomerContactsTab } from './customer-contacts-tab';
import { CustomerOwnerCard } from './customer-owner-card';
import { CustomerStatusBadge } from './customer-status-badge';
import type { CustomerRelatedEnquiryItem } from '../api/types';
import type { CustomerRelatedOpportunityItem } from '../api/types';
import type { QuotationRelationItem } from '@/features/crm/quotations/api/types';
function FieldItem({ label, value }: { label: string; value: string | null | undefined }) {
@@ -37,7 +37,7 @@ export function CustomerDetail({
canReadContactShares,
canManageContactShares,
canManageOwner,
relatedEnquiries,
relatedOpportunities,
relatedQuotations
}: {
customerId: string;
@@ -52,7 +52,7 @@ export function CustomerDetail({
canReadContactShares: boolean;
canManageContactShares: boolean;
canManageOwner: boolean;
relatedEnquiries: CustomerRelatedEnquiryItem[];
relatedOpportunities: CustomerRelatedOpportunityItem[];
relatedQuotations: QuotationRelationItem[];
}) {
const { data } = useSuspenseQuery(customerByIdOptions(customerId));
@@ -239,23 +239,23 @@ export function CustomerDetail({
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
{!relatedEnquiries.length && !relatedQuotations.length ? (
{!relatedOpportunities.length && !relatedQuotations.length ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
</div>
) : (
<div className='space-y-3'>
{relatedEnquiries.map((enquiry) => (
{relatedOpportunities.map((opportunity) => (
<Link
key={enquiry.id}
href={`/dashboard/crm/enquiries/${enquiry.id}`}
key={opportunity.id}
href={`/dashboard/crm/opportunities/${opportunity.id}`}
className='block rounded-lg border p-4 transition-colors hover:bg-muted/40'
>
<div className='flex items-center justify-between gap-3'>
<div className='space-y-1'>
<div className='font-medium'>{enquiry.title}</div>
<div className='font-medium'>{opportunity.title}</div>
<div className='text-muted-foreground font-mono text-xs'>
{enquiry.code}
{opportunity.code}
</div>
</div>
<Icons.arrowRight className='text-muted-foreground h-4 w-4' />
@@ -297,7 +297,7 @@ export function CustomerDetail({
/>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardTitle>‚‰šš</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
@@ -319,3 +319,4 @@ export function CustomerDetail({
</div>
);
}

View File

@@ -16,7 +16,7 @@ import {
crmCustomerContacts,
crmCustomerOwnerHistory,
crmCustomers,
crmEnquiries,
crmOpportunities,
crmQuotations,
memberships,
msOptions,
@@ -476,20 +476,20 @@ async function resolveAccessibleCustomerRelationSets(
organizationId: string,
accessContext: CustomerAccessContext,
) {
const [enquiries, quotations] = await Promise.all([
const [opportunities, quotations] = await Promise.all([
db
.select({
customerId: crmEnquiries.customerId,
createdBy: crmEnquiries.createdBy,
assignedToUserId: crmEnquiries.assignedToUserId,
branchId: crmEnquiries.branchId,
productType: crmEnquiries.productType,
customerId: crmOpportunities.customerId,
createdBy: crmOpportunities.createdBy,
assignedToUserId: crmOpportunities.assignedToUserId,
branchId: crmOpportunities.branchId,
productType: crmOpportunities.productType,
})
.from(crmEnquiries)
.from(crmOpportunities)
.where(
and(
eq(crmEnquiries.organizationId, organizationId),
isNull(crmEnquiries.deletedAt),
eq(crmOpportunities.organizationId, organizationId),
isNull(crmOpportunities.deletedAt),
),
),
db
@@ -509,10 +509,10 @@ async function resolveAccessibleCustomerRelationSets(
),
]);
const enquiryCustomerIds = new Set<string>();
const opportunityCustomerIds = new Set<string>();
const quotationCustomerIds = new Set<string>();
for (const row of enquiries) {
for (const row of opportunities) {
if (!hasBranchScopeAccess(accessContext, row.branchId)) {
continue;
}
@@ -529,7 +529,7 @@ async function resolveAccessibleCustomerRelationSets(
row.createdBy === accessContext.userId ||
row.assignedToUserId === accessContext.userId
) {
enquiryCustomerIds.add(row.customerId);
opportunityCustomerIds.add(row.customerId);
}
}
@@ -553,7 +553,7 @@ async function resolveAccessibleCustomerRelationSets(
}
}
return { enquiryCustomerIds, quotationCustomerIds };
return { opportunityCustomerIds, quotationCustomerIds };
}
async function canAccessCustomerRow(
@@ -585,7 +585,7 @@ async function canAccessCustomerRow(
return true;
}
const { enquiryCustomerIds, quotationCustomerIds } =
const { opportunityCustomerIds, quotationCustomerIds } =
relationSets ??
(await resolveAccessibleCustomerRelationSets(
row.organizationId,
@@ -593,13 +593,13 @@ async function canAccessCustomerRow(
));
if (accessContext.ownershipScope === "monitor") {
return enquiryCustomerIds.has(row.id);
return opportunityCustomerIds.has(row.id);
}
return (
row.createdBy === accessContext.userId ||
row.ownerUserId === accessContext.userId ||
enquiryCustomerIds.has(row.id) ||
opportunityCustomerIds.has(row.id) ||
quotationCustomerIds.has(row.id)
);
}
@@ -1615,3 +1615,4 @@ export async function resolveCustomerOptionLabel(
return row?.label ?? null;
}

View File

@@ -32,14 +32,14 @@ export interface CrmDashboardSummary {
unassignedLeads: number;
averageLeadAgingDays: number;
};
enquiry: {
enquiryCount: number;
activeEnquiries: number;
opportunity: {
opportunityCount: number;
activeOpportunities: number;
wonCount: number;
lostCount: number;
winRate: number;
hotEnquiries: number;
enquiryValue: number;
hotOpportunities: number;
opportunityValue: number;
};
quotation: {
draftQuotations: number;
@@ -76,7 +76,7 @@ export interface CrmDashboardSalesRankingRow {
salesPersonId: string;
salesPersonName: string;
leadCount: number;
enquiryCount: number;
opportunityCount: number;
quotationCount: number;
approvedQuotations: number;
wonRevenue: number;
@@ -92,10 +92,10 @@ export interface CrmDashboardFollowupSummary {
export interface CrmDashboardFollowupRow {
id: string;
sourceType: 'enquiry' | 'quotation';
sourceType: 'opportunity' | 'quotation';
followupDate: string;
customerName: string;
enquiryName: string;
opportunityName: string;
assignedSalesName: string | null;
outcome: string | null;
}
@@ -118,8 +118,8 @@ export interface CrmDashboardApprovalRow {
}
export interface CrmDashboardHotProjectRow {
enquiryId: string;
enquiryCode: string;
opportunityId: string;
opportunityCode: string;
projectName: string;
customerName: string;
value: number;
@@ -161,3 +161,4 @@ export interface CrmDashboardResponse {
canViewApprovalData: boolean;
};
}

View File

@@ -38,7 +38,7 @@ export function DashboardFollowups({
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>ˆ</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -53,7 +53,7 @@ export function DashboardFollowups({
<TableRow key={`${row.sourceType}-${row.id}`}>
<TableCell>{new Date(row.followupDate).toLocaleDateString()}</TableCell>
<TableCell>{row.customerName}</TableCell>
<TableCell>{row.enquiryName}</TableCell>
<TableCell>{row.opportunityName}</TableCell>
<TableCell>{row.assignedSalesName ?? '-'}</TableCell>
<TableCell className='capitalize'>{row.sourceType}</TableCell>
</TableRow>
@@ -65,3 +65,4 @@ export function DashboardFollowups({
</Card>
);
}

View File

@@ -21,7 +21,7 @@ export function DashboardHotProjects({ rows }: { rows: CrmDashboardHotProjectRow
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className='text-right'></TableHead>
<TableHead className='text-right'>„ˆ</TableHead>
<TableHead></TableHead>
<TableHead className='text-right'></TableHead>
</TableRow>
@@ -35,10 +35,10 @@ export function DashboardHotProjects({ rows }: { rows: CrmDashboardHotProjectRow
</TableRow>
) : (
rows.map((row) => (
<TableRow key={row.enquiryId}>
<TableRow key={row.opportunityId}>
<TableCell>
<div className='font-medium'>{row.projectName}</div>
<div className='text-muted-foreground text-xs'>{row.enquiryCode}</div>
<div className='text-muted-foreground text-xs'>{row.opportunityCode}</div>
</TableCell>
<TableCell>{row.customerName}</TableCell>
<TableCell className='text-right'>{formatNumber(row.value)}</TableCell>
@@ -53,3 +53,4 @@ export function DashboardHotProjects({ rows }: { rows: CrmDashboardHotProjectRow
</Card>
);
}

View File

@@ -19,7 +19,7 @@ export function DashboardSalesRanking({ rows }: { rows: CrmDashboardSalesRanking
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className='text-right'></TableHead>
<TableHead className='text-right'></TableHead>
<TableHead className='text-right'></TableHead>
@@ -40,7 +40,7 @@ export function DashboardSalesRanking({ rows }: { rows: CrmDashboardSalesRanking
<TableRow key={row.salesPersonId}>
<TableCell className='font-medium'>{row.salesPersonName}</TableCell>
<TableCell className='text-right'>{formatNumber(row.leadCount)}</TableCell>
<TableCell className='text-right'>{formatNumber(row.enquiryCount)}</TableCell>
<TableCell className='text-right'>{formatNumber(row.opportunityCount)}</TableCell>
<TableCell className='text-right'>{formatNumber(row.quotationCount)}</TableCell>
<TableCell className='text-right'>{formatNumber(row.approvedQuotations)}</TableCell>
<TableCell className='text-right'>{formatNumber(row.wonRevenue)}</TableCell>

View File

@@ -65,12 +65,12 @@ export function DashboardSummaryCards({
<SummaryCard
title='โอกาสขาย'
description='รายการที่อยู่ในขั้นโอกาสขาย รวมถึงผลลัพธ์ชนะและแพ้'
href='/dashboard/crm/enquiries'
href='/dashboard/crm/opportunities'
items={[
{ label: 'จำนวนโอกาสขาย', value: summary.enquiry.enquiryCount },
{ label: 'โอกาสขายที่กำลังดำเนินการ', value: summary.enquiry.activeEnquiries },
{ label: 'จำนวนงานที่ชนะ', value: summary.enquiry.wonCount },
{ label: 'Win Rate', value: summary.enquiry.winRate, suffix: '%' }
{ label: 'จำนวนโอกาสขาย', value: summary.opportunity.opportunityCount },
{ label: 'โอกาสขายที่กำลังดำเนินการ', value: summary.opportunity.activeOpportunities },
{ label: 'จำนวนงานที่ชนะ', value: summary.opportunity.wonCount },
{ label: 'Win Rate', value: summary.opportunity.winRate, suffix: '%' }
]}
/>
{canViewCommercialData ? (
@@ -99,3 +99,4 @@ export function DashboardSummaryCards({
</div>
);
}

View File

@@ -12,8 +12,8 @@ import {
import {
crmApprovalRequests,
crmCustomers,
crmEnquiries,
crmEnquiryFollowups,
crmOpportunities,
crmOpportunityFollowups,
crmQuotationFollowups,
crmQuotations,
memberships,
@@ -53,7 +53,7 @@ import type {
CrmDashboardSummary
} from '../api/types';
const ENQUIRY_STATUS_CATEGORY = 'crm_enquiry_status';
const ENQUIRY_STATUS_CATEGORY = 'crm_opportunity_status';
const QUOTATION_STATUS_CATEGORY = 'crm_quotation_status';
const PRODUCT_TYPE_CATEGORY = 'crm_product_type';
const PROJECT_PARTY_ROLE_CATEGORY = 'crm_project_party_role';
@@ -81,7 +81,7 @@ type NormalizedFilters = {
};
type StatusMaps = {
enquiryStatusById: Map<string, string>;
opportunityStatusById: Map<string, string>;
quotationStatusById: Map<string, string>;
};
@@ -132,8 +132,8 @@ function hasDashboardFullScope(context: DashboardContext) {
return context.membershipRole === 'admin' || context.ownershipScope !== 'own';
}
function canAccessDashboardEnquiry(
row: typeof crmEnquiries.$inferSelect,
function canAccessDashboardOpportunity(
row: typeof crmOpportunities.$inferSelect,
context: DashboardContext
) {
if (!hasBranchScopeAccess(context, row.branchId)) {
@@ -179,13 +179,13 @@ function canAccessDashboardQuotation(
}
async function getStatusMaps(organizationId: string): Promise<StatusMaps> {
const [enquiryOptions, quotationOptions] = await Promise.all([
const [opportunityOptions, quotationOptions] = await Promise.all([
getActiveOptionsByCategory(ENQUIRY_STATUS_CATEGORY, { organizationId }),
getActiveOptionsByCategory(QUOTATION_STATUS_CATEGORY, { organizationId })
]);
return {
enquiryStatusById: new Map(enquiryOptions.map((item) => [item.id, item.code])),
opportunityStatusById: new Map(opportunityOptions.map((item) => [item.id, item.code])),
quotationStatusById: new Map(quotationOptions.map((item) => [item.id, item.code]))
};
}
@@ -219,8 +219,8 @@ async function getReferenceData(organizationId: string): Promise<CrmDashboardRef
};
}
function matchesEnquiryFilters(
row: typeof crmEnquiries.$inferSelect,
function matchesOpportunityFilters(
row: typeof crmOpportunities.$inferSelect,
filters: NormalizedFilters
) {
const { from, to } = toDayBounds(filters);
@@ -248,22 +248,22 @@ function matchesQuotationFilters(
}
function isPipelineStage(
enquiry: typeof crmEnquiries.$inferSelect,
stage: 'lead' | 'enquiry' | 'closed_won' | 'closed_lost'
opportunity: typeof crmOpportunities.$inferSelect,
stage: 'lead' | 'opportunity' | 'closed_won' | 'closed_lost'
) {
return enquiry.pipelineStage === stage;
return opportunity.pipelineStage === stage;
}
async function loadScopedRows(
context: DashboardContext,
filters: NormalizedFilters
) {
const [enquiries, quotations] = await Promise.all([
const [opportunities, quotations] = await Promise.all([
db
.select()
.from(crmEnquiries)
.from(crmOpportunities)
.where(
and(eq(crmEnquiries.organizationId, context.organizationId), isNull(crmEnquiries.deletedAt))
and(eq(crmOpportunities.organizationId, context.organizationId), isNull(crmOpportunities.deletedAt))
),
db
.select()
@@ -274,9 +274,9 @@ async function loadScopedRows(
]);
return {
enquiries: enquiries
.filter((row) => canAccessDashboardEnquiry(row, context))
.filter((row) => matchesEnquiryFilters(row, filters)),
opportunities: opportunities
.filter((row) => canAccessDashboardOpportunity(row, context))
.filter((row) => matchesOpportunityFilters(row, filters)),
quotations: quotations
.filter((row) => canAccessDashboardQuotation(row, context))
.filter((row) => matchesQuotationFilters(row, filters))
@@ -284,16 +284,16 @@ async function loadScopedRows(
}
function buildSummary(
scopedEnquiries: Array<typeof crmEnquiries.$inferSelect>,
scopedOpportunities: Array<typeof crmOpportunities.$inferSelect>,
scopedQuotations: Array<typeof crmQuotations.$inferSelect>,
statusMaps: StatusMaps,
outcomeMetrics: { wonValue: number; lostValue: number; winRate: number }
): CrmDashboardSummary {
const now = new Date();
const leadRows = scopedEnquiries.filter((row) => isPipelineStage(row, 'lead'));
const enquiryRows = scopedEnquiries.filter((row) => isPipelineStage(row, 'enquiry'));
const wonRows = scopedEnquiries.filter((row) => isPipelineStage(row, 'closed_won'));
const lostRows = scopedEnquiries.filter((row) => isPipelineStage(row, 'closed_lost'));
const leadRows = scopedOpportunities.filter((row) => isPipelineStage(row, 'lead'));
const opportunityRows = scopedOpportunities.filter((row) => isPipelineStage(row, 'opportunity'));
const wonRows = scopedOpportunities.filter((row) => isPipelineStage(row, 'closed_won'));
const lostRows = scopedOpportunities.filter((row) => isPipelineStage(row, 'closed_lost'));
const leadAges = leadRows.map((row) => (now.getTime() - row.createdAt.getTime()) / (1000 * 60 * 60 * 24));
const quotationStatusCodes = scopedQuotations.map((row) => statusMaps.quotationStatusById.get(row.status) ?? '');
@@ -305,14 +305,14 @@ function buildSummary(
unassignedLeads: leadRows.filter((row) => row.assignedToUserId === null).length,
averageLeadAgingDays: average(leadAges)
},
enquiry: {
enquiryCount: enquiryRows.length,
activeEnquiries: enquiryRows.length,
opportunity: {
opportunityCount: opportunityRows.length,
activeOpportunities: opportunityRows.length,
wonCount: wonRows.length,
lostCount: lostRows.length,
winRate: outcomeMetrics.winRate,
hotEnquiries: enquiryRows.filter((row) => row.isHotProject).length,
enquiryValue: round2(enquiryRows.reduce((sum, row) => sum + (row.estimatedValue ?? 0), 0))
hotOpportunities: opportunityRows.filter((row) => row.isHotProject).length,
opportunityValue: round2(opportunityRows.reduce((sum, row) => sum + (row.estimatedValue ?? 0), 0))
},
quotation: {
draftQuotations: quotationStatusCodes.filter((code) => code === 'draft').length,
@@ -345,10 +345,10 @@ function buildFunnel(
value: 0
},
{
key: 'enquiry',
label: getPipelineStageThaiLabel('enquiry'),
count: summary.enquiry.enquiryCount,
value: summary.enquiry.enquiryValue
key: 'opportunity',
label: getPipelineStageThaiLabel('opportunity'),
count: summary.opportunity.opportunityCount,
value: summary.opportunity.opportunityValue
},
{
key: 'quotation',
@@ -383,7 +383,7 @@ function buildFunnel(
{
key: 'closed_won',
label: getPipelineStageThaiLabel('closed_won'),
count: summary.enquiry.wonCount,
count: summary.opportunity.wonCount,
value: summary.revenue.wonValue
}
];
@@ -456,7 +456,7 @@ async function buildRevenueAnalytics(
}
async function buildSalesRanking(
scopedEnquiries: Array<typeof crmEnquiries.$inferSelect>,
scopedOpportunities: Array<typeof crmOpportunities.$inferSelect>,
scopedQuotations: Array<typeof crmQuotations.$inferSelect>,
statusMaps: StatusMaps,
wonRevenueRows: Array<{ assignedToUserId: string | null; revenue: number }>
@@ -464,7 +464,7 @@ async function buildSalesRanking(
const userIds = [
...new Set(
[
...scopedEnquiries.map((row) => row.assignedToUserId).filter(Boolean),
...scopedOpportunities.map((row) => row.assignedToUserId).filter(Boolean),
...scopedQuotations.map((row) => row.salesmanId).filter(Boolean)
] as string[]
)
@@ -475,7 +475,7 @@ async function buildSalesRanking(
const userMap = new Map(salesUsers.map((row) => [row.id, row.name]));
const rankingMap = new Map<string, CrmDashboardSalesRankingRow>();
for (const row of scopedEnquiries) {
for (const row of scopedOpportunities) {
if (!row.assignedToUserId) continue;
const current =
rankingMap.get(row.assignedToUserId) ??
@@ -483,7 +483,7 @@ async function buildSalesRanking(
salesPersonId: row.assignedToUserId,
salesPersonName: userMap.get(row.assignedToUserId) ?? 'Unknown',
leadCount: 0,
enquiryCount: 0,
opportunityCount: 0,
quotationCount: 0,
approvedQuotations: 0,
wonRevenue: 0,
@@ -494,8 +494,8 @@ async function buildSalesRanking(
current.leadCount += 1;
}
if (isPipelineStage(row, 'enquiry')) {
current.enquiryCount += 1;
if (isPipelineStage(row, 'opportunity')) {
current.opportunityCount += 1;
}
rankingMap.set(row.assignedToUserId, current);
@@ -509,7 +509,7 @@ async function buildSalesRanking(
salesPersonId: row.salesmanId,
salesPersonName: userMap.get(row.salesmanId) ?? 'Unknown',
leadCount: 0,
enquiryCount: 0,
opportunityCount: 0,
quotationCount: 0,
approvedQuotations: 0,
wonRevenue: 0,
@@ -539,7 +539,7 @@ async function buildSalesRanking(
salesPersonId: row.assignedToUserId,
salesPersonName: userMap.get(row.assignedToUserId) ?? 'Unknown',
leadCount: 0,
enquiryCount: 0,
opportunityCount: 0,
quotationCount: 0,
approvedQuotations: 0,
wonRevenue: 0,
@@ -570,17 +570,17 @@ async function buildFollowups(
filters: NormalizedFilters
): Promise<{ summary: CrmDashboardFollowupSummary; upcoming: CrmDashboardFollowupRow[] }> {
const allowQuotationFollowups = canViewCommercialData(context);
const [enquiryFollowups, quotationFollowups] = await Promise.all([
const [opportunityFollowups, quotationFollowups] = await Promise.all([
db
.select()
.from(crmEnquiryFollowups)
.from(crmOpportunityFollowups)
.where(
and(
eq(crmEnquiryFollowups.organizationId, context.organizationId),
isNull(crmEnquiryFollowups.deletedAt)
eq(crmOpportunityFollowups.organizationId, context.organizationId),
isNull(crmOpportunityFollowups.deletedAt)
)
)
.orderBy(asc(crmEnquiryFollowups.followupDate)),
.orderBy(asc(crmOpportunityFollowups.followupDate)),
allowQuotationFollowups
? db
.select()
@@ -599,49 +599,49 @@ async function buildFollowups(
const now = new Date();
const weekStart = startOfWeek(now, { weekStartsOn: 1 });
const weekEnd = endOfWeek(now, { weekStartsOn: 1 });
const enquiryIds = [...new Set(enquiryFollowups.map((row) => row.enquiryId))];
const opportunityIds = [...new Set(opportunityFollowups.map((row) => row.opportunityId))];
const quotationIds = [...new Set(quotationFollowups.map((row) => row.quotationId))];
const [enquiries, quotations] = await Promise.all([
enquiryIds.length ? db.select().from(crmEnquiries).where(inArray(crmEnquiries.id, enquiryIds)) : [],
const [opportunities, quotations] = await Promise.all([
opportunityIds.length ? db.select().from(crmOpportunities).where(inArray(crmOpportunities.id, opportunityIds)) : [],
quotationIds.length ? db.select().from(crmQuotations).where(inArray(crmQuotations.id, quotationIds)) : []
]);
const customerIds = [...new Set([...enquiries.map((row) => row.customerId), ...quotations.map((row) => row.customerId)])];
const customerIds = [...new Set([...opportunities.map((row) => row.customerId), ...quotations.map((row) => row.customerId)])];
const salesIds = [
...new Set(
[...enquiries.map((row) => row.assignedToUserId).filter(Boolean), ...quotations.map((row) => row.salesmanId).filter(Boolean)] as string[]
[...opportunities.map((row) => row.assignedToUserId).filter(Boolean), ...quotations.map((row) => row.salesmanId).filter(Boolean)] as string[]
)
];
const [customers, salesUsers] = await Promise.all([
customerIds.length ? db.select().from(crmCustomers).where(inArray(crmCustomers.id, customerIds)) : [],
salesIds.length ? db.select({ id: users.id, name: users.name }).from(users).where(inArray(users.id, salesIds)) : []
]);
const enquiryMap = new Map(enquiries.map((row) => [row.id, row]));
const opportunityMap = new Map(opportunities.map((row) => [row.id, row]));
const quotationMap = new Map(quotations.map((row) => [row.id, row]));
const customerMap = new Map(customers.map((row) => [row.id, row.name]));
const salesMap = new Map(salesUsers.map((row) => [row.id, row.name]));
const enquiryRows = enquiryFollowups
const opportunityRows = opportunityFollowups
.filter((row) => {
const enquiry = enquiryMap.get(row.enquiryId);
return enquiry ? canAccessDashboardEnquiry(enquiry, context) : false;
const opportunity = opportunityMap.get(row.opportunityId);
return opportunity ? canAccessDashboardOpportunity(opportunity, context) : false;
})
.filter((row) => !isBefore(row.followupDate, from) && !isAfter(row.followupDate, to))
.flatMap<CrmDashboardFollowupRow>((row) => {
const enquiry = enquiryMap.get(row.enquiryId);
const opportunity = opportunityMap.get(row.opportunityId);
if (!enquiry) {
if (!opportunity) {
return [];
}
return [
{
id: row.id,
sourceType: 'enquiry',
sourceType: 'opportunity',
followupDate: row.followupDate.toISOString(),
customerName: customerMap.get(enquiry.customerId) ?? 'Unknown customer',
enquiryName: enquiry.projectName ?? enquiry.title,
assignedSalesName: enquiry.assignedToUserId
? (salesMap.get(enquiry.assignedToUserId) ?? null)
customerName: customerMap.get(opportunity.customerId) ?? 'Unknown customer',
opportunityName: opportunity.projectName ?? opportunity.title,
assignedSalesName: opportunity.assignedToUserId
? (salesMap.get(opportunity.assignedToUserId) ?? null)
: null,
outcome: row.outcome
}
@@ -666,7 +666,7 @@ async function buildFollowups(
sourceType: 'quotation',
followupDate: row.followupDate.toISOString(),
customerName: customerMap.get(quotation.customerId) ?? 'Unknown customer',
enquiryName: quotation.projectName ?? quotation.code,
opportunityName: quotation.projectName ?? quotation.code,
assignedSalesName: quotation.salesmanId
? (salesMap.get(quotation.salesmanId) ?? null)
: null,
@@ -674,7 +674,7 @@ async function buildFollowups(
}
];
});
const rows: CrmDashboardFollowupRow[] = [...enquiryRows, ...quotationRows];
const rows: CrmDashboardFollowupRow[] = [...opportunityRows, ...quotationRows];
return {
summary: {
@@ -749,19 +749,19 @@ async function buildHotProjects(
): Promise<CrmDashboardHotProjectRow[]> {
const rows = await db
.select()
.from(crmEnquiries)
.from(crmOpportunities)
.where(
and(
eq(crmEnquiries.organizationId, context.organizationId),
isNull(crmEnquiries.deletedAt),
eq(crmEnquiries.isHotProject, true)
eq(crmOpportunities.organizationId, context.organizationId),
isNull(crmOpportunities.deletedAt),
eq(crmOpportunities.isHotProject, true)
)
)
.orderBy(desc(crmEnquiries.updatedAt));
.orderBy(desc(crmOpportunities.updatedAt));
const scoped = rows
.filter((row) => canAccessDashboardEnquiry(row, context))
.filter((row) => matchesEnquiryFilters(row, filters))
.filter((row) => isPipelineStage(row, 'enquiry'))
.filter((row) => canAccessDashboardOpportunity(row, context))
.filter((row) => matchesOpportunityFilters(row, filters))
.filter((row) => isPipelineStage(row, 'opportunity'))
.slice(0, 10);
const customerIds = [...new Set(scoped.map((row) => row.customerId))];
const salesIds = [...new Set(scoped.map((row) => row.assignedToUserId).filter(Boolean))] as string[];
@@ -773,8 +773,8 @@ async function buildHotProjects(
const salesMap = new Map(salesUsers.map((row) => [row.id, row.name]));
return scoped.map((row) => ({
enquiryId: row.id,
enquiryCode: row.code,
opportunityId: row.id,
opportunityCode: row.code,
projectName: row.projectName ?? row.title,
customerName: customerMap.get(row.customerId) ?? 'Unknown customer',
value: row.estimatedValue ?? 0,
@@ -812,7 +812,7 @@ export async function getCrmDashboardData(
getLostByCompetitor(context.organizationId, outcomeFilters)
])
: [[], [], 0, [], []];
const summary = buildSummary(scoped.enquiries, scoped.quotations, statusMaps, {
const summary = buildSummary(scoped.opportunities, scoped.quotations, statusMaps, {
wonValue: wonRevenueRows.reduce((sum, row) => sum + row.revenue, 0),
lostValue: lostRevenueRows.reduce((sum, row) => sum + row.revenue, 0),
winRate
@@ -827,7 +827,7 @@ export async function getCrmDashboardData(
topConsultants: []
}),
allowCommercialData
? buildSalesRanking(scoped.enquiries, scoped.quotations, statusMaps, wonRevenueRows)
? buildSalesRanking(scoped.opportunities, scoped.quotations, statusMaps, wonRevenueRows)
: Promise.resolve([]),
buildFollowups(context, filters),
allowApprovalData
@@ -871,3 +871,4 @@ export async function getCrmDashboardData(
}
};
}

View File

@@ -1,190 +0,0 @@
import { mutationOptions } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/query-client';
import {
assignEnquiry,
createEnquiry,
createEnquiryFollowup,
deleteEnquiry,
deleteEnquiryFollowup,
markEnquiryLost,
markEnquiryWon,
reassignEnquiry,
reopenEnquiry,
updateEnquiry,
updateEnquiryFollowup
} from './service';
import { enquiryKeys } from './queries';
import type {
EnquiryAssignmentMutationPayload,
EnquiryFollowupMutationPayload,
EnquiryMarkLostPayload,
EnquiryMarkWonPayload,
EnquiryMutationPayload
} from './types';
import { crmDashboardKeys } from '@/features/crm/dashboard/api/queries';
async function invalidateEnquiryLists() {
await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.lists() });
}
async function invalidateDashboardQueries() {
await getQueryClient().invalidateQueries({ queryKey: crmDashboardKeys.all });
}
async function invalidateEnquiryDetail(id: string) {
await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.detail(id) });
}
async function invalidateEnquiryProjectParties(id: string) {
await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.projectParties(id) });
}
async function invalidateEnquiryFollowups(id: string) {
await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.followups(id) });
}
async function invalidateEnquiryAttachments(id: string) {
await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.attachments(id) });
}
export async function invalidateEnquiryMutationQueries(id: string) {
await Promise.all([
invalidateEnquiryLists(),
invalidateEnquiryDetail(id),
invalidateEnquiryProjectParties(id),
invalidateEnquiryAttachments(id),
invalidateDashboardQueries()
]);
}
export async function invalidateEnquiryFollowupMutationQueries(enquiryId: string) {
await Promise.all([
invalidateEnquiryLists(),
invalidateEnquiryDetail(enquiryId),
invalidateEnquiryFollowups(enquiryId),
invalidateDashboardQueries()
]);
}
export const createEnquiryMutation = mutationOptions({
mutationFn: (data: EnquiryMutationPayload) => createEnquiry(data),
onSettled: async (_data, error) => {
if (!error) {
await Promise.all([invalidateEnquiryLists(), invalidateDashboardQueries()]);
}
}
});
export const updateEnquiryMutation = mutationOptions({
mutationFn: ({ id, values }: { id: string; values: EnquiryMutationPayload }) =>
updateEnquiry(id, values),
onSettled: async (_data, error, variables) => {
if (!error) {
await invalidateEnquiryMutationQueries(variables.id);
}
}
});
export const deleteEnquiryMutation = mutationOptions({
mutationFn: (id: string) => deleteEnquiry(id),
onSettled: async (_data, error, id) => {
if (!error) {
getQueryClient().removeQueries({ queryKey: enquiryKeys.detail(id) });
getQueryClient().removeQueries({ queryKey: enquiryKeys.projectParties(id) });
getQueryClient().removeQueries({ queryKey: enquiryKeys.followups(id) });
await Promise.all([invalidateEnquiryLists(), invalidateDashboardQueries()]);
}
}
});
export const assignEnquiryMutation = mutationOptions({
mutationFn: ({ id, values }: { id: string; values: EnquiryAssignmentMutationPayload }) =>
assignEnquiry(id, values),
onSettled: async (_data, error, variables) => {
if (!error) {
await invalidateEnquiryMutationQueries(variables.id);
}
}
});
export const reassignEnquiryMutation = mutationOptions({
mutationFn: ({ id, values }: { id: string; values: EnquiryAssignmentMutationPayload }) =>
reassignEnquiry(id, values),
onSettled: async (_data, error, variables) => {
if (!error) {
await invalidateEnquiryMutationQueries(variables.id);
}
}
});
export const createEnquiryFollowupMutation = mutationOptions({
mutationFn: ({
enquiryId,
values
}: {
enquiryId: string;
values: EnquiryFollowupMutationPayload;
}) => createEnquiryFollowup(enquiryId, values),
onSettled: async (_data, error, variables) => {
if (!error) {
await invalidateEnquiryFollowupMutationQueries(variables.enquiryId);
}
}
});
export const updateEnquiryFollowupMutation = mutationOptions({
mutationFn: ({
enquiryId,
followupId,
values
}: {
enquiryId: string;
followupId: string;
values: EnquiryFollowupMutationPayload;
}) => updateEnquiryFollowup(enquiryId, followupId, values),
onSettled: async (_data, error, variables) => {
if (!error) {
await invalidateEnquiryFollowupMutationQueries(variables.enquiryId);
}
}
});
export const deleteEnquiryFollowupMutation = mutationOptions({
mutationFn: ({ enquiryId, followupId }: { enquiryId: string; followupId: string }) =>
deleteEnquiryFollowup(enquiryId, followupId),
onSettled: async (_data, error, variables) => {
if (!error) {
await invalidateEnquiryFollowupMutationQueries(variables.enquiryId);
}
}
});
export const markEnquiryWonMutation = mutationOptions({
mutationFn: ({ id, values }: { id: string; values: EnquiryMarkWonPayload }) =>
markEnquiryWon(id, values),
onSettled: async (_data, error, variables) => {
if (!error) {
await invalidateEnquiryMutationQueries(variables.id);
}
}
});
export const markEnquiryLostMutation = mutationOptions({
mutationFn: ({ id, values }: { id: string; values: EnquiryMarkLostPayload }) =>
markEnquiryLost(id, values),
onSettled: async (_data, error, variables) => {
if (!error) {
await invalidateEnquiryMutationQueries(variables.id);
}
}
});
export const reopenEnquiryMutation = mutationOptions({
mutationFn: ({ id, reason }: { id: string; reason: string }) =>
reopenEnquiry(id, { reopenReason: reason }),
onSettled: async (_data, error, variables) => {
if (!error) {
await invalidateEnquiryMutationQueries(variables.id);
}
}
});

View File

@@ -1,53 +0,0 @@
import { queryOptions } from '@tanstack/react-query';
import {
getEnquiryAttachments,
getEnquiries,
getEnquiryById,
getEnquiryFollowups,
getEnquiryProjectParties
} from './service';
import type { EnquiryFilters } from './types';
export const enquiryKeys = {
all: ['crm-enquiries'] as const,
lists: () => [...enquiryKeys.all, 'list'] as const,
list: (filters: EnquiryFilters) => [...enquiryKeys.lists(), filters] as const,
details: () => [...enquiryKeys.all, 'detail'] as const,
detail: (id: string) => [...enquiryKeys.details(), id] as const,
projectPartiesRoot: () => [...enquiryKeys.all, 'project-parties'] as const,
projectParties: (id: string) => [...enquiryKeys.projectPartiesRoot(), id] as const,
followupsRoot: () => [...enquiryKeys.all, 'followups'] as const,
followups: (id: string) => [...enquiryKeys.followupsRoot(), id] as const,
attachmentsRoot: () => [...enquiryKeys.all, 'attachments'] as const,
attachments: (id: string) => [...enquiryKeys.attachmentsRoot(), id] as const
};
export const enquiriesQueryOptions = (filters: EnquiryFilters) =>
queryOptions({
queryKey: enquiryKeys.list(filters),
queryFn: () => getEnquiries(filters)
});
export const enquiryByIdOptions = (id: string) =>
queryOptions({
queryKey: enquiryKeys.detail(id),
queryFn: () => getEnquiryById(id)
});
export const enquiryProjectPartiesOptions = (id: string) =>
queryOptions({
queryKey: enquiryKeys.projectParties(id),
queryFn: () => getEnquiryProjectParties(id)
});
export const enquiryFollowupsOptions = (id: string) =>
queryOptions({
queryKey: enquiryKeys.followups(id),
queryFn: () => getEnquiryFollowups(id)
});
export const enquiryAttachmentsOptions = (id: string) =>
queryOptions({
queryKey: enquiryKeys.attachments(id),
queryFn: () => getEnquiryAttachments(id)
});

View File

@@ -1,133 +0,0 @@
import { apiClient } from '@/lib/api-client';
import type {
EnquiryAttachmentsResponse,
EnquiryAssignmentMutationPayload,
EnquiryDetailResponse,
EnquiryFilters,
EnquiryFollowupMutationPayload,
EnquiryFollowupsResponse,
EnquiryListResponse,
EnquiryMarkLostPayload,
EnquiryMarkWonPayload,
EnquiryMutationPayload,
EnquiryProjectPartiesResponse,
EnquiryReopenPayload,
MutationSuccessResponse
} from './types';
export async function getEnquiries(filters: EnquiryFilters): Promise<EnquiryListResponse> {
const searchParams = new URLSearchParams();
if (filters.page) searchParams.set('page', String(filters.page));
if (filters.limit) searchParams.set('limit', String(filters.limit));
if (filters.search) searchParams.set('search', filters.search);
if (filters.pipelineStage) searchParams.set('pipelineStage', filters.pipelineStage);
if (filters.status) searchParams.set('status', filters.status);
if (filters.productType) searchParams.set('productType', filters.productType);
if (filters.priority) searchParams.set('priority', filters.priority);
if (filters.branch) searchParams.set('branch', filters.branch);
if (filters.customer) searchParams.set('customer', filters.customer);
if (filters.sort) searchParams.set('sort', filters.sort);
const query = searchParams.toString();
return apiClient<EnquiryListResponse>(`/crm/enquiries${query ? `?${query}` : ''}`);
}
export async function getEnquiryById(id: string): Promise<EnquiryDetailResponse> {
return apiClient<EnquiryDetailResponse>(`/crm/enquiries/${id}`);
}
export async function getEnquiryProjectParties(id: string): Promise<EnquiryProjectPartiesResponse> {
return apiClient<EnquiryProjectPartiesResponse>(`/crm/enquiries/${id}/customers`);
}
export async function createEnquiry(data: EnquiryMutationPayload) {
return apiClient<MutationSuccessResponse>('/crm/enquiries', {
method: 'POST',
body: JSON.stringify(data)
});
}
export async function updateEnquiry(id: string, data: EnquiryMutationPayload) {
return apiClient<MutationSuccessResponse>(`/crm/enquiries/${id}`, {
method: 'PATCH',
body: JSON.stringify(data)
});
}
export async function deleteEnquiry(id: string) {
return apiClient<MutationSuccessResponse>(`/crm/enquiries/${id}`, {
method: 'DELETE'
});
}
export async function assignEnquiry(id: string, data: EnquiryAssignmentMutationPayload) {
return apiClient<MutationSuccessResponse>(`/crm/enquiries/${id}/assign`, {
method: 'POST',
body: JSON.stringify(data)
});
}
export async function reassignEnquiry(id: string, data: EnquiryAssignmentMutationPayload) {
return apiClient<MutationSuccessResponse>(`/crm/enquiries/${id}/reassign`, {
method: 'POST',
body: JSON.stringify(data)
});
}
export async function getEnquiryFollowups(id: string): Promise<EnquiryFollowupsResponse> {
return apiClient<EnquiryFollowupsResponse>(`/crm/enquiries/${id}/followups`);
}
export async function getEnquiryAttachments(id: string): Promise<EnquiryAttachmentsResponse> {
return apiClient<EnquiryAttachmentsResponse>(`/crm/enquiries/${id}/po-attachments`);
}
export async function createEnquiryFollowup(
enquiryId: string,
data: EnquiryFollowupMutationPayload
) {
return apiClient<MutationSuccessResponse>(`/crm/enquiries/${enquiryId}/followups`, {
method: 'POST',
body: JSON.stringify(data)
});
}
export async function updateEnquiryFollowup(
enquiryId: string,
followupId: string,
data: EnquiryFollowupMutationPayload
) {
return apiClient<MutationSuccessResponse>(`/crm/enquiries/${enquiryId}/followups/${followupId}`, {
method: 'PATCH',
body: JSON.stringify(data)
});
}
export async function deleteEnquiryFollowup(enquiryId: string, followupId: string) {
return apiClient<MutationSuccessResponse>(`/crm/enquiries/${enquiryId}/followups/${followupId}`, {
method: 'DELETE'
});
}
export async function markEnquiryWon(id: string, data: EnquiryMarkWonPayload) {
return apiClient<MutationSuccessResponse>(`/crm/enquiries/${id}/mark-won`, {
method: 'POST',
body: JSON.stringify(data)
});
}
export async function markEnquiryLost(id: string, data: EnquiryMarkLostPayload) {
return apiClient<MutationSuccessResponse>(`/crm/enquiries/${id}/mark-lost`, {
method: 'POST',
body: JSON.stringify(data)
});
}
export async function reopenEnquiry(id: string, data: EnquiryReopenPayload) {
return apiClient<MutationSuccessResponse>(`/crm/enquiries/${id}/reopen`, {
method: 'POST',
body: JSON.stringify(data)
});
}

View File

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

View File

@@ -0,0 +1,191 @@
import { mutationOptions } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/query-client';
import {
assignOpportunity,
createOpportunity,
createOpportunityFollowup,
deleteOpportunity,
deleteOpportunityFollowup,
markOpportunityLost,
markOpportunityWon,
reassignOpportunity,
reopenOpportunity,
updateOpportunity,
updateOpportunityFollowup
} from './service';
import { opportunityKeys } from './queries';
import type {
OpportunityAssignmentMutationPayload,
OpportunityFollowupMutationPayload,
OpportunityMarkLostPayload,
OpportunityMarkWonPayload,
OpportunityMutationPayload
} from './types';
import { crmDashboardKeys } from '@/features/crm/dashboard/api/queries';
async function invalidateOpportunityLists() {
await getQueryClient().invalidateQueries({ queryKey: opportunityKeys.lists() });
}
async function invalidateDashboardQueries() {
await getQueryClient().invalidateQueries({ queryKey: crmDashboardKeys.all });
}
async function invalidateOpportunityDetail(id: string) {
await getQueryClient().invalidateQueries({ queryKey: opportunityKeys.detail(id) });
}
async function invalidateOpportunityProjectParties(id: string) {
await getQueryClient().invalidateQueries({ queryKey: opportunityKeys.projectParties(id) });
}
async function invalidateOpportunityFollowups(id: string) {
await getQueryClient().invalidateQueries({ queryKey: opportunityKeys.followups(id) });
}
async function invalidateOpportunityAttachments(id: string) {
await getQueryClient().invalidateQueries({ queryKey: opportunityKeys.attachments(id) });
}
export async function invalidateOpportunityMutationQueries(id: string) {
await Promise.all([
invalidateOpportunityLists(),
invalidateOpportunityDetail(id),
invalidateOpportunityProjectParties(id),
invalidateOpportunityAttachments(id),
invalidateDashboardQueries()
]);
}
export async function invalidateOpportunityFollowupMutationQueries(opportunityId: string) {
await Promise.all([
invalidateOpportunityLists(),
invalidateOpportunityDetail(opportunityId),
invalidateOpportunityFollowups(opportunityId),
invalidateDashboardQueries()
]);
}
export const createOpportunityMutation = mutationOptions({
mutationFn: (data: OpportunityMutationPayload) => createOpportunity(data),
onSettled: async (_data, error) => {
if (!error) {
await Promise.all([invalidateOpportunityLists(), invalidateDashboardQueries()]);
}
}
});
export const updateOpportunityMutation = mutationOptions({
mutationFn: ({ id, values }: { id: string; values: OpportunityMutationPayload }) =>
updateOpportunity(id, values),
onSettled: async (_data, error, variables) => {
if (!error) {
await invalidateOpportunityMutationQueries(variables.id);
}
}
});
export const deleteOpportunityMutation = mutationOptions({
mutationFn: (id: string) => deleteOpportunity(id),
onSettled: async (_data, error, id) => {
if (!error) {
getQueryClient().removeQueries({ queryKey: opportunityKeys.detail(id) });
getQueryClient().removeQueries({ queryKey: opportunityKeys.projectParties(id) });
getQueryClient().removeQueries({ queryKey: opportunityKeys.followups(id) });
await Promise.all([invalidateOpportunityLists(), invalidateDashboardQueries()]);
}
}
});
export const assignOpportunityMutation = mutationOptions({
mutationFn: ({ id, values }: { id: string; values: OpportunityAssignmentMutationPayload }) =>
assignOpportunity(id, values),
onSettled: async (_data, error, variables) => {
if (!error) {
await invalidateOpportunityMutationQueries(variables.id);
}
}
});
export const reassignOpportunityMutation = mutationOptions({
mutationFn: ({ id, values }: { id: string; values: OpportunityAssignmentMutationPayload }) =>
reassignOpportunity(id, values),
onSettled: async (_data, error, variables) => {
if (!error) {
await invalidateOpportunityMutationQueries(variables.id);
}
}
});
export const createOpportunityFollowupMutation = mutationOptions({
mutationFn: ({
opportunityId,
values
}: {
opportunityId: string;
values: OpportunityFollowupMutationPayload;
}) => createOpportunityFollowup(opportunityId, values),
onSettled: async (_data, error, variables) => {
if (!error) {
await invalidateOpportunityFollowupMutationQueries(variables.opportunityId);
}
}
});
export const updateOpportunityFollowupMutation = mutationOptions({
mutationFn: ({
opportunityId,
followupId,
values
}: {
opportunityId: string;
followupId: string;
values: OpportunityFollowupMutationPayload;
}) => updateOpportunityFollowup(opportunityId, followupId, values),
onSettled: async (_data, error, variables) => {
if (!error) {
await invalidateOpportunityFollowupMutationQueries(variables.opportunityId);
}
}
});
export const deleteOpportunityFollowupMutation = mutationOptions({
mutationFn: ({ opportunityId, followupId }: { opportunityId: string; followupId: string }) =>
deleteOpportunityFollowup(opportunityId, followupId),
onSettled: async (_data, error, variables) => {
if (!error) {
await invalidateOpportunityFollowupMutationQueries(variables.opportunityId);
}
}
});
export const markOpportunityWonMutation = mutationOptions({
mutationFn: ({ id, values }: { id: string; values: OpportunityMarkWonPayload }) =>
markOpportunityWon(id, values),
onSettled: async (_data, error, variables) => {
if (!error) {
await invalidateOpportunityMutationQueries(variables.id);
}
}
});
export const markOpportunityLostMutation = mutationOptions({
mutationFn: ({ id, values }: { id: string; values: OpportunityMarkLostPayload }) =>
markOpportunityLost(id, values),
onSettled: async (_data, error, variables) => {
if (!error) {
await invalidateOpportunityMutationQueries(variables.id);
}
}
});
export const reopenOpportunityMutation = mutationOptions({
mutationFn: ({ id, reason }: { id: string; reason: string }) =>
reopenOpportunity(id, { reopenReason: reason }),
onSettled: async (_data, error, variables) => {
if (!error) {
await invalidateOpportunityMutationQueries(variables.id);
}
}
});

View File

@@ -0,0 +1,54 @@
import { queryOptions } from '@tanstack/react-query';
import {
getOpportunityAttachments,
getOpportunities,
getOpportunityById,
getOpportunityFollowups,
getOpportunityProjectParties
} from './service';
import type { OpportunityFilters } from './types';
export const opportunityKeys = {
all: ['crm-opportunities'] as const,
lists: () => [...opportunityKeys.all, 'list'] as const,
list: (filters: OpportunityFilters) => [...opportunityKeys.lists(), filters] as const,
details: () => [...opportunityKeys.all, 'detail'] as const,
detail: (id: string) => [...opportunityKeys.details(), id] as const,
projectPartiesRoot: () => [...opportunityKeys.all, 'project-parties'] as const,
projectParties: (id: string) => [...opportunityKeys.projectPartiesRoot(), id] as const,
followupsRoot: () => [...opportunityKeys.all, 'followups'] as const,
followups: (id: string) => [...opportunityKeys.followupsRoot(), id] as const,
attachmentsRoot: () => [...opportunityKeys.all, 'attachments'] as const,
attachments: (id: string) => [...opportunityKeys.attachmentsRoot(), id] as const
};
export const opportunitiesQueryOptions = (filters: OpportunityFilters) =>
queryOptions({
queryKey: opportunityKeys.list(filters),
queryFn: () => getOpportunities(filters)
});
export const opportunityByIdOptions = (id: string) =>
queryOptions({
queryKey: opportunityKeys.detail(id),
queryFn: () => getOpportunityById(id)
});
export const opportunityProjectPartiesOptions = (id: string) =>
queryOptions({
queryKey: opportunityKeys.projectParties(id),
queryFn: () => getOpportunityProjectParties(id)
});
export const opportunityFollowupsOptions = (id: string) =>
queryOptions({
queryKey: opportunityKeys.followups(id),
queryFn: () => getOpportunityFollowups(id)
});
export const opportunityAttachmentsOptions = (id: string) =>
queryOptions({
queryKey: opportunityKeys.attachments(id),
queryFn: () => getOpportunityAttachments(id)
});

View File

@@ -0,0 +1,134 @@
import { apiClient } from '@/lib/api-client';
import type {
OpportunityAttachmentsResponse,
OpportunityAssignmentMutationPayload,
OpportunityDetailResponse,
OpportunityFilters,
OpportunityFollowupMutationPayload,
OpportunityFollowupsResponse,
OpportunityListResponse,
OpportunityMarkLostPayload,
OpportunityMarkWonPayload,
OpportunityMutationPayload,
OpportunityProjectPartiesResponse,
OpportunityReopenPayload,
MutationSuccessResponse
} from './types';
export async function getOpportunities(filters: OpportunityFilters): Promise<OpportunityListResponse> {
const searchParams = new URLSearchParams();
if (filters.page) searchParams.set('page', String(filters.page));
if (filters.limit) searchParams.set('limit', String(filters.limit));
if (filters.search) searchParams.set('search', filters.search);
if (filters.pipelineStage) searchParams.set('pipelineStage', filters.pipelineStage);
if (filters.status) searchParams.set('status', filters.status);
if (filters.productType) searchParams.set('productType', filters.productType);
if (filters.priority) searchParams.set('priority', filters.priority);
if (filters.branch) searchParams.set('branch', filters.branch);
if (filters.customer) searchParams.set('customer', filters.customer);
if (filters.sort) searchParams.set('sort', filters.sort);
const query = searchParams.toString();
return apiClient<OpportunityListResponse>(`/crm/opportunities${query ? `?${query}` : ''}`);
}
export async function getOpportunityById(id: string): Promise<OpportunityDetailResponse> {
return apiClient<OpportunityDetailResponse>(`/crm/opportunities/${id}`);
}
export async function getOpportunityProjectParties(id: string): Promise<OpportunityProjectPartiesResponse> {
return apiClient<OpportunityProjectPartiesResponse>(`/crm/opportunities/${id}/customers`);
}
export async function createOpportunity(data: OpportunityMutationPayload) {
return apiClient<MutationSuccessResponse>('/crm/opportunities', {
method: 'POST',
body: JSON.stringify(data)
});
}
export async function updateOpportunity(id: string, data: OpportunityMutationPayload) {
return apiClient<MutationSuccessResponse>(`/crm/opportunities/${id}`, {
method: 'PATCH',
body: JSON.stringify(data)
});
}
export async function deleteOpportunity(id: string) {
return apiClient<MutationSuccessResponse>(`/crm/opportunities/${id}`, {
method: 'DELETE'
});
}
export async function assignOpportunity(id: string, data: OpportunityAssignmentMutationPayload) {
return apiClient<MutationSuccessResponse>(`/crm/opportunities/${id}/assign`, {
method: 'POST',
body: JSON.stringify(data)
});
}
export async function reassignOpportunity(id: string, data: OpportunityAssignmentMutationPayload) {
return apiClient<MutationSuccessResponse>(`/crm/opportunities/${id}/reassign`, {
method: 'POST',
body: JSON.stringify(data)
});
}
export async function getOpportunityFollowups(id: string): Promise<OpportunityFollowupsResponse> {
return apiClient<OpportunityFollowupsResponse>(`/crm/opportunities/${id}/followups`);
}
export async function getOpportunityAttachments(id: string): Promise<OpportunityAttachmentsResponse> {
return apiClient<OpportunityAttachmentsResponse>(`/crm/opportunities/${id}/po-attachments`);
}
export async function createOpportunityFollowup(
opportunityId: string,
data: OpportunityFollowupMutationPayload
) {
return apiClient<MutationSuccessResponse>(`/crm/opportunities/${opportunityId}/followups`, {
method: 'POST',
body: JSON.stringify(data)
});
}
export async function updateOpportunityFollowup(
opportunityId: string,
followupId: string,
data: OpportunityFollowupMutationPayload
) {
return apiClient<MutationSuccessResponse>(`/crm/opportunities/${opportunityId}/followups/${followupId}`, {
method: 'PATCH',
body: JSON.stringify(data)
});
}
export async function deleteOpportunityFollowup(opportunityId: string, followupId: string) {
return apiClient<MutationSuccessResponse>(`/crm/opportunities/${opportunityId}/followups/${followupId}`, {
method: 'DELETE'
});
}
export async function markOpportunityWon(id: string, data: OpportunityMarkWonPayload) {
return apiClient<MutationSuccessResponse>(`/crm/opportunities/${id}/mark-won`, {
method: 'POST',
body: JSON.stringify(data)
});
}
export async function markOpportunityLost(id: string, data: OpportunityMarkLostPayload) {
return apiClient<MutationSuccessResponse>(`/crm/opportunities/${id}/mark-lost`, {
method: 'POST',
body: JSON.stringify(data)
});
}
export async function reopenOpportunity(id: string, data: OpportunityReopenPayload) {
return apiClient<MutationSuccessResponse>(`/crm/opportunities/${id}/reopen`, {
method: 'POST',
body: JSON.stringify(data)
});
}

View File

@@ -1,14 +1,14 @@
export interface EnquiryOption {
export interface OpportunityOption {
id: string;
code: string;
label: string;
value: string | null;
}
export interface EnquiryProjectPartyRecord {
export interface OpportunityProjectPartyRecord {
id: string;
organizationId: string;
enquiryId: string;
opportunityId: string;
customerId: string;
role: string;
roleCode: string;
@@ -19,19 +19,19 @@ export interface EnquiryProjectPartyRecord {
deletedAt: string | null;
}
export interface EnquiryProjectPartyListItem extends EnquiryProjectPartyRecord {
export interface OpportunityProjectPartyListItem extends OpportunityProjectPartyRecord {
customerName: string;
customerCode: string;
}
export interface EnquiryBranchOption {
export interface OpportunityBranchOption {
id: string;
code: string;
name: string;
value: string | null;
}
export interface EnquiryCustomerLookup {
export interface OpportunityCustomerLookup {
id: string;
code: string;
name: string;
@@ -40,7 +40,7 @@ export interface EnquiryCustomerLookup {
ownerName: string | null;
}
export interface EnquiryContactLookup {
export interface OpportunityContactLookup {
id: string;
customerId: string;
name: string;
@@ -49,17 +49,17 @@ export interface EnquiryContactLookup {
isPrimary: boolean;
}
export interface EnquiryAssignableUserLookup {
export interface OpportunityAssignableUserLookup {
id: string;
name: string;
membershipRole: 'admin' | 'user';
businessRole: string;
}
export interface EnquiryAttachmentRecord {
export interface OpportunityAttachmentRecord {
id: string;
organizationId: string;
enquiryId: string;
opportunityId: string;
category: string;
fileName: string;
originalFileName: string;
@@ -75,9 +75,9 @@ export interface EnquiryAttachmentRecord {
deletedAt: string | null;
}
export type EnquiryPipelineStage = 'lead' | 'enquiry' | 'closed_won' | 'closed_lost';
export type OpportunityPipelineStage = 'lead' | 'opportunity' | 'closed_won' | 'closed_lost';
export interface EnquiryRecord {
export interface OpportunityRecord {
id: string;
organizationId: string;
branchId: string | null;
@@ -102,7 +102,7 @@ export interface EnquiryRecord {
notes: string | null;
isHotProject: boolean;
isActive: boolean;
pipelineStage: EnquiryPipelineStage;
pipelineStage: OpportunityPipelineStage;
closedWonAt: string | null;
closedLostAt: string | null;
closedByUserId: string | null;
@@ -129,7 +129,7 @@ export interface EnquiryRecord {
updatedBy: string;
}
export interface EnquiryListItem extends EnquiryRecord {
export interface OpportunityListItem extends OpportunityRecord {
customerName: string;
contactName: string | null;
sourceLeadCode?: string | null;
@@ -141,10 +141,10 @@ export interface EnquiryListItem extends EnquiryRecord {
createdByName: string | null;
}
export interface EnquiryFollowupRecord {
export interface OpportunityFollowupRecord {
id: string;
organizationId: string;
enquiryId: string;
opportunityId: string;
followupDate: string;
followupType: string;
contactId: string | null;
@@ -159,7 +159,7 @@ export interface EnquiryFollowupRecord {
updatedBy: string;
}
export interface EnquiryActivityRecord {
export interface OpportunityActivityRecord {
id: string;
action: string;
entityType: string;
@@ -169,25 +169,25 @@ export interface EnquiryActivityRecord {
actorName: string | null;
}
export interface EnquiryReferenceData {
branches: EnquiryBranchOption[];
statuses: EnquiryOption[];
productTypes: EnquiryOption[];
priorities: EnquiryOption[];
leadChannels: EnquiryOption[];
followupTypes: EnquiryOption[];
lostReasons: EnquiryOption[];
projectPartyRoles: EnquiryOption[];
customers: EnquiryCustomerLookup[];
contacts: EnquiryContactLookup[];
assignableUsers: EnquiryAssignableUserLookup[];
export interface OpportunityReferenceData {
branches: OpportunityBranchOption[];
statuses: OpportunityOption[];
productTypes: OpportunityOption[];
priorities: OpportunityOption[];
leadChannels: OpportunityOption[];
followupTypes: OpportunityOption[];
lostReasons: OpportunityOption[];
projectPartyRoles: OpportunityOption[];
customers: OpportunityCustomerLookup[];
contacts: OpportunityContactLookup[];
assignableUsers: OpportunityAssignableUserLookup[];
}
export interface EnquiryFilters {
export interface OpportunityFilters {
page?: number;
limit?: number;
search?: string;
pipelineStage?: EnquiryPipelineStage;
pipelineStage?: OpportunityPipelineStage;
status?: string;
productType?: string;
priority?: string;
@@ -196,52 +196,52 @@ export interface EnquiryFilters {
sort?: string;
}
export interface EnquiryListResponse {
export interface OpportunityListResponse {
success: boolean;
time: string;
message: string;
totalItems: number;
offset: number;
limit: number;
items: EnquiryListItem[];
items: OpportunityListItem[];
}
export interface EnquiryDetailResponse {
export interface OpportunityDetailResponse {
success: boolean;
time: string;
message: string;
enquiry: EnquiryRecord;
activity: EnquiryActivityRecord[];
opportunity: OpportunityRecord;
activity: OpportunityActivityRecord[];
}
export interface EnquiryProjectPartiesResponse {
export interface OpportunityProjectPartiesResponse {
success: boolean;
time: string;
message: string;
items: EnquiryProjectPartyListItem[];
items: OpportunityProjectPartyListItem[];
}
export interface EnquiryFollowupsResponse {
export interface OpportunityFollowupsResponse {
success: boolean;
time: string;
message: string;
items: EnquiryFollowupRecord[];
items: OpportunityFollowupRecord[];
}
export interface EnquiryAttachmentsResponse {
export interface OpportunityAttachmentsResponse {
success: boolean;
time: string;
message: string;
items: EnquiryAttachmentRecord[];
items: OpportunityAttachmentRecord[];
}
export interface EnquiryProjectPartyMutationPayload {
export interface OpportunityProjectPartyMutationPayload {
customerId: string;
role: string;
remark?: string | null;
}
export interface EnquiryMutationPayload {
export interface OpportunityMutationPayload {
customerId: string;
contactId?: string | null;
assignedToUserId?: string | null;
@@ -263,10 +263,10 @@ export interface EnquiryMutationPayload {
isHotProject?: boolean;
notes?: string;
isActive?: boolean;
projectParties?: EnquiryProjectPartyMutationPayload[];
projectParties?: OpportunityProjectPartyMutationPayload[];
}
export interface EnquiryFollowupMutationPayload {
export interface OpportunityFollowupMutationPayload {
followupDate: string;
followupType: string;
contactId?: string | null;
@@ -276,12 +276,12 @@ export interface EnquiryFollowupMutationPayload {
nextAction?: string;
}
export interface EnquiryAssignmentMutationPayload {
export interface OpportunityAssignmentMutationPayload {
assignedToUserId: string;
remark?: string;
}
export interface EnquiryMarkWonPayload {
export interface OpportunityMarkWonPayload {
poNumber: string;
poDate: string;
poAmount?: number | null;
@@ -289,17 +289,17 @@ export interface EnquiryMarkWonPayload {
remark?: string | null;
}
export interface EnquiryMarkLostPayload {
export interface OpportunityMarkLostPayload {
lostReason: string;
lostCompetitor?: string | null;
lostRemark?: string | null;
}
export interface EnquiryReopenPayload {
export interface OpportunityReopenPayload {
reopenReason: string;
}
export interface EnquiryCustomerRelationItem {
export interface OpportunityCustomerRelationItem {
id: string;
code: string;
title: string;
@@ -312,3 +312,4 @@ export interface MutationSuccessResponse {
success: boolean;
message: string;
}

View File

@@ -21,15 +21,15 @@ import {
SheetTitle
} from '@/components/ui/sheet';
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
import { createEnquiryFollowupMutation, updateEnquiryFollowupMutation } from '../api/mutations';
import { createOpportunityFollowupMutation, updateOpportunityFollowupMutation } from '../api/mutations';
import type {
EnquiryFollowupMutationPayload,
EnquiryFollowupRecord,
EnquiryReferenceData
OpportunityFollowupMutationPayload,
OpportunityFollowupRecord,
OpportunityReferenceData
} from '../api/types';
import { enquiryFollowupSchema, type EnquiryFollowupFormValues } from '../schemas/enquiry.schema';
import { opportunityFollowupSchema, type OpportunityFollowupFormValues } from '../schemas/opportunity.schema';
function toDefaultValues(followup: EnquiryFollowupRecord | undefined): EnquiryFollowupFormValues {
function toDefaultValues(followup: OpportunityFollowupRecord | undefined): OpportunityFollowupFormValues {
return {
followupDate: followup?.followupDate ? followup.followupDate.slice(0, 10) : '',
followupType: followup?.followupType ?? '',
@@ -42,28 +42,28 @@ function toDefaultValues(followup: EnquiryFollowupRecord | undefined): EnquiryFo
}
export function FollowupFormSheet({
enquiryId,
opportunityId,
followup,
open,
onOpenChange,
referenceData,
customerId
}: {
enquiryId: string;
followup?: EnquiryFollowupRecord;
opportunityId: string;
followup?: OpportunityFollowupRecord;
open: boolean;
onOpenChange: (open: boolean) => void;
referenceData: EnquiryReferenceData;
referenceData: OpportunityReferenceData;
customerId: string;
}) {
const isEdit = !!followup;
const defaultValues = useMemo(() => toDefaultValues(followup), [followup]);
const { FormTextField, FormTextareaField, FormDatePickerField } =
useFormFields<EnquiryFollowupFormValues>();
useFormFields<OpportunityFollowupFormValues>();
const contactOptions = referenceData.contacts.filter((item) => item.customerId === customerId);
const createMutation = useMutation({
...createEnquiryFollowupMutation,
...createOpportunityFollowupMutation,
onSuccess: () => {
toast.success('Follow-up created successfully');
onOpenChange(false);
@@ -73,7 +73,7 @@ export function FollowupFormSheet({
});
const updateMutation = useMutation({
...updateEnquiryFollowupMutation,
...updateOpportunityFollowupMutation,
onSuccess: () => {
toast.success('Follow-up updated successfully');
onOpenChange(false);
@@ -85,10 +85,10 @@ export function FollowupFormSheet({
const form = useAppForm({
defaultValues,
validators: {
onSubmit: enquiryFollowupSchema
onSubmit: opportunityFollowupSchema
},
onSubmit: async ({ value }) => {
const payload: EnquiryFollowupMutationPayload = {
const payload: OpportunityFollowupMutationPayload = {
followupDate: value.followupDate,
followupType: value.followupType,
contactId: value.contactId || null,
@@ -100,7 +100,7 @@ export function FollowupFormSheet({
if (isEdit && followup) {
await updateMutation.mutateAsync({
enquiryId,
opportunityId,
followupId: followup.id,
values: payload
});
@@ -108,7 +108,7 @@ export function FollowupFormSheet({
}
await createMutation.mutateAsync({
enquiryId,
opportunityId,
values: payload
});
}
@@ -130,13 +130,13 @@ export function FollowupFormSheet({
<SheetHeader>
<SheetTitle>{isEdit ? 'Edit Follow-up' : 'New Follow-up'}</SheetTitle>
<SheetDescription>
Keep the enquiry timeline current with the latest customer interaction and next step.
Keep the opportunity timeline current with the latest customer interaction and next step.
</SheetDescription>
</SheetHeader>
<div className='flex-1 overflow-auto'>
<form.AppForm>
<form.Form id='enquiry-followup-form' className='grid gap-4 md:grid-cols-2'>
<form.Form id='opportunity-followup-form' className='grid gap-4 md:grid-cols-2'>
<FormDatePickerField
name='followupDate'
label='Follow-up Date'
@@ -239,7 +239,7 @@ export function FollowupFormSheet({
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type='submit' form='enquiry-followup-form' isLoading={isPending}>
<Button type='submit' form='opportunity-followup-form' isLoading={isPending}>
<Icons.check className='mr-2 h-4 w-4' />
{isEdit ? 'Update Follow-up' : 'Create Follow-up'}
</Button>
@@ -248,3 +248,4 @@ export function FollowupFormSheet({
</Sheet>
);
}

View File

@@ -7,11 +7,11 @@ import { DataTable } from '@/components/ui/table/data-table';
import { DataTableToolbar } from '@/components/ui/table/data-table-toolbar';
import { useDataTable } from '@/hooks/use-data-table';
import { getSortingStateParser } from '@/lib/parsers';
import { enquiriesQueryOptions } from '../api/queries';
import type { EnquiryReferenceData } from '../api/types';
import { getEnquiryColumns } from './enquiry-columns';
import { opportunitiesQueryOptions } from '../api/queries';
import type { OpportunityReferenceData } from '../api/types';
import { getOpportunityColumns } from './opportunity-columns';
export function EnquiriesTable({
export function OpportunitiesTable({
referenceData,
workspace,
canUpdate,
@@ -19,8 +19,8 @@ export function EnquiriesTable({
canAssign,
canReassign
}: {
referenceData: EnquiryReferenceData;
workspace: 'lead' | 'enquiry';
referenceData: OpportunityReferenceData;
workspace: 'lead' | 'opportunity';
canUpdate: boolean;
canDelete: boolean;
canAssign: boolean;
@@ -28,7 +28,7 @@ export function EnquiriesTable({
}) {
const columns = useMemo(
() =>
getEnquiryColumns({
getOpportunityColumns({
referenceData,
workspace,
canUpdate,
@@ -65,7 +65,7 @@ export function EnquiriesTable({
...(params.sort.length > 0 && { sort: JSON.stringify(params.sort) })
};
const { data } = useSuspenseQuery(enquiriesQueryOptions(filters));
const { data } = useSuspenseQuery(opportunitiesQueryOptions(filters));
const pageCount = Math.max(1, Math.ceil((data.totalItems || 0) / params.perPage));
const { table } = useDataTable({
@@ -83,3 +83,4 @@ export function EnquiriesTable({
</DataTable>
);
}

View File

@@ -15,47 +15,47 @@ import {
import { Button } from '@/components/ui/button';
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
import { Icons } from '@/components/icons';
import { assignEnquiryMutation, reassignEnquiryMutation } from '../api/mutations';
import type { EnquiryRecord, EnquiryReferenceData } from '../api/types';
import { assignOpportunityMutation, reassignOpportunityMutation } from '../api/mutations';
import type { OpportunityRecord, OpportunityReferenceData } from '../api/types';
const enquiryAssignmentSchema = z.object({
const opportunityAssignmentSchema = z.object({
assignedToUserId: z.string().min(1, 'Sales user is required'),
remark: z.string().optional()
});
type EnquiryAssignmentFormValues = z.infer<typeof enquiryAssignmentSchema>;
type OpportunityAssignmentFormValues = z.infer<typeof opportunityAssignmentSchema>;
function toDefaultValues(
enquiry: EnquiryRecord,
referenceData: EnquiryReferenceData
): EnquiryAssignmentFormValues {
opportunity: OpportunityRecord,
referenceData: OpportunityReferenceData
): OpportunityAssignmentFormValues {
return {
assignedToUserId: enquiry.assignedToUserId ?? referenceData.assignableUsers[0]?.id ?? '',
remark: enquiry.assignmentRemark ?? ''
assignedToUserId: opportunity.assignedToUserId ?? referenceData.assignableUsers[0]?.id ?? '',
remark: opportunity.assignmentRemark ?? ''
};
}
export function EnquiryAssignmentDialog({
enquiry,
export function OpportunityAssignmentDialog({
opportunity,
referenceData,
mode,
open,
onOpenChange
}: {
enquiry: EnquiryRecord;
referenceData: EnquiryReferenceData;
opportunity: OpportunityRecord;
referenceData: OpportunityReferenceData;
mode: 'assign' | 'reassign';
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const defaultValues = useMemo(
() => toDefaultValues(enquiry, referenceData),
[enquiry, referenceData]
() => toDefaultValues(opportunity, referenceData),
[opportunity, referenceData]
);
const { FormSelectField, FormTextareaField } = useFormFields<EnquiryAssignmentFormValues>();
const { FormSelectField, FormTextareaField } = useFormFields<OpportunityAssignmentFormValues>();
const assignMutation = useMutation({
...assignEnquiryMutation,
...assignOpportunityMutation,
onSuccess: () => {
toast.success('ส่งต่อลีดไปยังฝ่ายขายสำเร็จ');
onOpenChange(false);
@@ -65,7 +65,7 @@ export function EnquiryAssignmentDialog({
});
const reassignMutation = useMutation({
...reassignEnquiryMutation,
...reassignOpportunityMutation,
onSuccess: () => {
toast.success('เปลี่ยนผู้ดูแลโอกาสขายสำเร็จ');
onOpenChange(false);
@@ -77,7 +77,7 @@ export function EnquiryAssignmentDialog({
const form = useAppForm({
defaultValues,
validators: {
onSubmit: enquiryAssignmentSchema
onSubmit: opportunityAssignmentSchema
},
onSubmit: async ({ value }) => {
const values = {
@@ -86,11 +86,11 @@ export function EnquiryAssignmentDialog({
};
if (mode === 'assign') {
await assignMutation.mutateAsync({ id: enquiry.id, values });
await assignMutation.mutateAsync({ id: opportunity.id, values });
return;
}
await reassignMutation.mutateAsync({ id: enquiry.id, values });
await reassignMutation.mutateAsync({ id: opportunity.id, values });
}
});
@@ -118,7 +118,7 @@ export function EnquiryAssignmentDialog({
</DialogHeader>
<form.AppForm>
<form.Form id='enquiry-assignment-form' className='px-0 md:px-0'>
<form.Form id='opportunity-assignment-form' className='px-0 md:px-0'>
{noUsersAvailable ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-4 text-sm'>
workspace
@@ -151,7 +151,7 @@ export function EnquiryAssignmentDialog({
</Button>
<Button
type='submit'
form='enquiry-assignment-form'
form='opportunity-assignment-form'
isLoading={isPending}
disabled={noUsersAvailable}
>
@@ -163,3 +163,4 @@ export function EnquiryAssignmentDialog({
</Dialog>
);
}

View File

@@ -14,11 +14,11 @@ import {
DropdownMenuLabel,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import { deleteEnquiryMutation } from '../api/mutations';
import type { EnquiryListItem, EnquiryReferenceData } from '../api/types';
import { EnquiryFormSheet } from './enquiry-form-sheet';
import { deleteOpportunityMutation } from '../api/mutations';
import type { OpportunityListItem, OpportunityReferenceData } from '../api/types';
import { OpportunityFormSheet } from './opportunity-form-sheet';
export function EnquiryCellAction({
export function OpportunityCellAction({
data,
referenceData,
workspace,
@@ -27,9 +27,9 @@ export function EnquiryCellAction({
canAssign: _canAssign,
canReassign: _canReassign
}: {
data: EnquiryListItem;
referenceData: EnquiryReferenceData;
workspace: 'lead' | 'enquiry';
data: OpportunityListItem;
referenceData: OpportunityReferenceData;
workspace: 'lead' | 'opportunity';
canUpdate: boolean;
canDelete: boolean;
canAssign: boolean;
@@ -40,7 +40,7 @@ export function EnquiryCellAction({
const [editOpen, setEditOpen] = useState(false);
const deleteMutation = useMutation({
...deleteEnquiryMutation,
...deleteOpportunityMutation,
onSuccess: () => {
toast.success(`${workspace === 'lead' ? 'ลบลีดสำเร็จ' : 'ลบโอกาสขายสำเร็จ'}`);
setDeleteOpen(false);
@@ -61,8 +61,8 @@ export function EnquiryCellAction({
onConfirm={() => deleteMutation.mutate(data.id)}
loading={deleteMutation.isPending}
/>
<EnquiryFormSheet
enquiry={data}
<OpportunityFormSheet
opportunity={data}
open={editOpen}
onOpenChange={setEditOpen}
referenceData={referenceData}
@@ -82,7 +82,7 @@ export function EnquiryCellAction({
router.push(
workspace === 'lead'
? `/dashboard/crm/leads/${data.id}`
: `/dashboard/crm/enquiries/${data.id}`
: `/dashboard/crm/opportunities/${data.id}`
)
}
>
@@ -92,10 +92,11 @@ export function EnquiryCellAction({
<Icons.edit className='mr-2 h-4 w-4' /> {workspace === 'lead' ? 'แก้ไขลีด' : 'แก้ไขโอกาสขาย'}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDeleteOpen(true)} disabled={!canDelete}>
<Icons.trash className='mr-2 h-4 w-4' />
<Icons.trash className='mr-2 h-4 w-4' /> š
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
);
}

View File

@@ -5,12 +5,12 @@ import type { Column, ColumnDef } from '@tanstack/react-table';
import { Icons } from '@/components/icons';
import { Badge } from '@/components/ui/badge';
import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header';
import type { EnquiryListItem, EnquiryReferenceData } from '../api/types';
import { EnquiryCellAction } from './enquiry-cell-action';
import { EnquiryStatusBadge } from './enquiry-status-badge';
import type { OpportunityListItem, OpportunityReferenceData } from '../api/types';
import { OpportunityCellAction } from './opportunity-cell-action';
import { OpportunityStatusBadge } from './opportunity-status-badge';
type SharedColumnsConfig = {
referenceData: EnquiryReferenceData;
referenceData: OpportunityReferenceData;
canUpdate: boolean;
canDelete: boolean;
canAssign: boolean;
@@ -23,7 +23,7 @@ function getLeadColumns({
canDelete,
canAssign,
canReassign
}: SharedColumnsConfig): ColumnDef<EnquiryListItem>[] {
}: SharedColumnsConfig): ColumnDef<OpportunityListItem>[] {
const statusMap = new Map(referenceData.statuses.map((item) => [item.id, item]));
const leadChannelMap = new Map(referenceData.leadChannels.map((item) => [item.id, item.label]));
@@ -31,7 +31,7 @@ function getLeadColumns({
{
id: 'code',
accessorKey: 'code',
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Code' />
),
cell: ({ row }) => (
@@ -53,7 +53,7 @@ function getLeadColumns({
{
id: 'leadChannel',
accessorFn: (row) => row.leadChannel ?? '',
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Lead Source' />
),
cell: ({ row }) => <span>{leadChannelMap.get(row.original.leadChannel ?? '') ?? '-'}</span>
@@ -61,7 +61,7 @@ function getLeadColumns({
{
id: 'customer',
accessorFn: (row) => row.customerId,
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Customer' />
),
cell: ({ row }) => <span>{row.original.customerName}</span>,
@@ -78,12 +78,12 @@ function getLeadColumns({
{
id: 'status',
accessorKey: 'status',
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Status' />
),
cell: ({ row }) => {
const status = statusMap.get(row.original.status);
return <EnquiryStatusBadge code={status?.code} label={status?.label ?? 'Unknown'} />;
return <OpportunityStatusBadge code={status?.code} label={status?.label ?? 'Unknown'} />;
},
meta: {
label: 'Status',
@@ -98,7 +98,7 @@ function getLeadColumns({
{
id: 'actions',
cell: ({ row }) => (
<EnquiryCellAction
<OpportunityCellAction
data={row.original}
referenceData={referenceData}
workspace='lead'
@@ -112,13 +112,13 @@ function getLeadColumns({
];
}
function getEnquiryWorkspaceColumns({
function getOpportunityWorkspaceColumns({
referenceData,
canUpdate,
canDelete,
canAssign,
canReassign
}: SharedColumnsConfig): ColumnDef<EnquiryListItem>[] {
}: SharedColumnsConfig): ColumnDef<OpportunityListItem>[] {
const statusMap = new Map(referenceData.statuses.map((item) => [item.id, item]));
const productTypeMap = new Map(referenceData.productTypes.map((item) => [item.id, item.label]));
@@ -126,13 +126,13 @@ function getEnquiryWorkspaceColumns({
{
id: 'code',
accessorKey: 'code',
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Opportunity Code' />
),
cell: ({ row }) => (
<div className='flex flex-col'>
<Link
href={`/dashboard/crm/enquiries/${row.original.id}`}
href={`/dashboard/crm/opportunities/${row.original.id}`}
className='font-medium hover:underline'
>
{row.original.code}
@@ -151,7 +151,7 @@ function getEnquiryWorkspaceColumns({
{
id: 'sourceLead',
accessorFn: (row) => (row.leadId ? 'linked' : 'direct'),
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Source Lead' />
),
cell: ({ row }) =>
@@ -164,7 +164,7 @@ function getEnquiryWorkspaceColumns({
{
id: 'customer',
accessorFn: (row) => row.customerId,
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Customer' />
),
cell: ({ row }) => <span>{row.original.customerName}</span>,
@@ -181,7 +181,7 @@ function getEnquiryWorkspaceColumns({
{
id: 'projectName',
accessorFn: (row) => row.projectName ?? '',
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Project Name' />
),
cell: ({ row }) => <span>{row.original.projectName ?? '-'}</span>
@@ -189,7 +189,7 @@ function getEnquiryWorkspaceColumns({
{
id: 'productType',
accessorFn: (row) => row.productType,
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Product Type' />
),
cell: ({ row }) => <span>{productTypeMap.get(row.original.productType) ?? row.original.productType}</span>,
@@ -206,7 +206,7 @@ function getEnquiryWorkspaceColumns({
{
id: 'assignedToName',
accessorFn: (row) => row.assignedToName ?? '',
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Sales Owner' />
),
cell: ({ row }) => <span>{row.original.assignedToName ?? '-'}</span>
@@ -214,12 +214,12 @@ function getEnquiryWorkspaceColumns({
{
id: 'status',
accessorKey: 'status',
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Status' />
),
cell: ({ row }) => {
const status = statusMap.get(row.original.status);
return <EnquiryStatusBadge code={status?.code} label={status?.label ?? 'Unknown'} />;
return <OpportunityStatusBadge code={status?.code} label={status?.label ?? 'Unknown'} />;
},
meta: {
label: 'Status',
@@ -234,7 +234,7 @@ function getEnquiryWorkspaceColumns({
{
id: 'estimatedValue',
accessorKey: 'estimatedValue',
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Estimated Value' />
),
cell: ({ row }) => (
@@ -246,7 +246,7 @@ function getEnquiryWorkspaceColumns({
{
id: 'chancePercent',
accessorKey: 'chancePercent',
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Chance %' />
),
cell: ({ row }) => <span>{row.original.chancePercent !== null ? `${row.original.chancePercent}%` : '-'}</span>
@@ -254,7 +254,7 @@ function getEnquiryWorkspaceColumns({
{
id: 'expectedCloseDate',
accessorKey: 'expectedCloseDate',
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Expected Close' />
),
cell: ({ row }) => (
@@ -268,7 +268,7 @@ function getEnquiryWorkspaceColumns({
{
id: 'nextFollowupDate',
accessorKey: 'nextFollowupDate',
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Next Follow-up' />
),
cell: ({ row }) => (
@@ -280,7 +280,7 @@ function getEnquiryWorkspaceColumns({
{
id: 'createdAt',
accessorKey: 'createdAt',
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
header: ({ column }: { column: Column<OpportunityListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Created Date' />
),
cell: ({ row }) => <span>{new Date(row.original.createdAt).toLocaleDateString()}</span>
@@ -288,10 +288,10 @@ function getEnquiryWorkspaceColumns({
{
id: 'actions',
cell: ({ row }) => (
<EnquiryCellAction
<OpportunityCellAction
data={row.original}
referenceData={referenceData}
workspace='enquiry'
workspace='opportunity'
canUpdate={canUpdate}
canDelete={canDelete}
canAssign={canAssign}
@@ -302,14 +302,15 @@ function getEnquiryWorkspaceColumns({
];
}
export function getEnquiryColumns({
export function getOpportunityColumns({
referenceData,
workspace,
canUpdate,
canDelete,
canAssign,
canReassign
}: SharedColumnsConfig & { workspace: 'lead' | 'enquiry' }): ColumnDef<EnquiryListItem>[] {
}: SharedColumnsConfig & { workspace: 'lead' | 'opportunity' }): ColumnDef<OpportunityListItem>[] {
const sharedConfig = { referenceData, canUpdate, canDelete, canAssign, canReassign };
return workspace === 'lead' ? getLeadColumns(sharedConfig) : getEnquiryWorkspaceColumns(sharedConfig);
return workspace === 'lead' ? getLeadColumns(sharedConfig) : getOpportunityWorkspaceColumns(sharedConfig);
}

View File

@@ -11,13 +11,13 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import type { QuotationReferenceData, QuotationRelationItem } from '@/features/crm/quotations/api/types';
import { QuotationFormSheet } from '@/features/crm/quotations/components/quotation-form-sheet';
import { getPipelineStageThaiLabel } from '@/features/crm/shared/terminology';
import { enquiryByIdOptions, enquiryProjectPartiesOptions } from '../api/queries';
import type { EnquiryRecord, EnquiryReferenceData } from '../api/types';
import { EnquiryAssignmentDialog } from './enquiry-assignment-dialog';
import { EnquiryFollowupsTab } from './enquiry-followups-tab';
import { EnquiryFormSheet } from './enquiry-form-sheet';
import { EnquiryOutcomeCard } from './enquiry-outcome-card';
import { EnquiryStatusBadge } from './enquiry-status-badge';
import { opportunityByIdOptions, opportunityProjectPartiesOptions } from '../api/queries';
import type { OpportunityRecord, OpportunityReferenceData } from '../api/types';
import { OpportunityAssignmentDialog } from './opportunity-assignment-dialog';
import { OpportunityFollowupsTab } from './opportunity-followups-tab';
import { OpportunityFormSheet } from './opportunity-form-sheet';
import { OpportunityOutcomeCard } from './opportunity-outcome-card';
import { OpportunityStatusBadge } from './opportunity-status-badge';
import { QuotationReadinessPanel } from './quotation-readiness-panel';
import { SourceLeadCard, type SourceLeadSummary } from './source-lead-card';
@@ -30,15 +30,15 @@ function FieldItem({ label, value }: { label: string; value: string | null | und
);
}
function getPipelineStageLabel(stage: EnquiryRecord['pipelineStage']) {
function getPipelineStageLabel(stage: OpportunityRecord['pipelineStage']) {
return getPipelineStageThaiLabel(stage);
}
interface EnquiryDetailProps {
enquiryId: string;
referenceData: EnquiryReferenceData;
interface OpportunityDetailProps {
opportunityId: string;
referenceData: OpportunityReferenceData;
relatedQuotations: QuotationRelationItem[];
workspace: 'lead' | 'enquiry';
workspace: 'lead' | 'opportunity';
canUpdate: boolean;
canAssign: boolean;
canReassign: boolean;
@@ -57,8 +57,8 @@ interface EnquiryDetailProps {
quotationReferenceData?: QuotationReferenceData | null;
}
export function EnquiryDetail({
enquiryId,
export function OpportunityDetail({
opportunityId,
referenceData,
relatedQuotations,
workspace,
@@ -74,13 +74,13 @@ export function EnquiryDetail({
canManageFollowups,
sourceLead = null,
quotationReferenceData = null
}: EnquiryDetailProps) {
const { data } = useSuspenseQuery(enquiryByIdOptions(enquiryId));
const { data: projectPartiesData } = useSuspenseQuery(enquiryProjectPartiesOptions(enquiryId));
}: OpportunityDetailProps) {
const { data } = useSuspenseQuery(opportunityByIdOptions(opportunityId));
const { data: projectPartiesData } = useSuspenseQuery(opportunityProjectPartiesOptions(opportunityId));
const [editOpen, setEditOpen] = useState(false);
const [assignmentOpen, setAssignmentOpen] = useState(false);
const [quotationOpen, setQuotationOpen] = useState(false);
const enquiry = data.enquiry;
const opportunity = data.opportunity;
const branchMap = useMemo(
() => new Map(referenceData.branches.map((item) => [item.id, item.name])),
@@ -107,24 +107,24 @@ export function EnquiryDetail({
[referenceData]
);
const customer = customerMap.get(enquiry.customerId);
const contact = enquiry.contactId ? contactMap.get(enquiry.contactId) : null;
const status = statusMap.get(enquiry.status);
const pipelineStageLabel = getPipelineStageLabel(enquiry.pipelineStage);
const canManageAssignment = enquiry.assignedToUserId ? canReassign : canAssign;
const assignmentMode = enquiry.assignedToUserId ? 'reassign' : 'assign';
const customer = customerMap.get(opportunity.customerId);
const contact = opportunity.contactId ? contactMap.get(opportunity.contactId) : null;
const status = statusMap.get(opportunity.status);
const pipelineStageLabel = getPipelineStageLabel(opportunity.pipelineStage);
const canManageAssignment = opportunity.assignedToUserId ? canReassign : canAssign;
const assignmentMode = opportunity.assignedToUserId ? 'reassign' : 'assign';
return (
<div className='space-y-6'>
<EnquiryFormSheet
<OpportunityFormSheet
open={editOpen}
onOpenChange={setEditOpen}
referenceData={referenceData}
workspace={workspace}
enquiry={enquiry}
opportunity={opportunity}
/>
<EnquiryAssignmentDialog
enquiry={enquiry}
<OpportunityAssignmentDialog
opportunity={opportunity}
referenceData={referenceData}
mode={assignmentMode}
open={assignmentOpen}
@@ -141,8 +141,8 @@ export function EnquiryDetail({
<div className='flex items-start justify-between gap-4'>
<div>
<div className='flex items-center gap-3'>
<h2 className='text-2xl font-semibold'>{enquiry.code}</h2>
<EnquiryStatusBadge code={status?.code} label={status?.label ?? enquiry.status} />
<h2 className='text-2xl font-semibold'>{opportunity.code}</h2>
<OpportunityStatusBadge code={status?.code} label={status?.label ?? opportunity.status} />
</div>
<p className='text-muted-foreground mt-2 text-sm'>
Opportunity detail for sales execution, follow-up continuity, and quotation readiness.
@@ -184,22 +184,22 @@ export function EnquiryDetail({
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-3'>
<FieldItem label='Customer' value={customer?.name} />
<FieldItem label='Contact' value={contact?.name} />
<FieldItem label='Project Name' value={enquiry.projectName} />
<FieldItem label='Project Location' value={enquiry.projectLocation} />
<FieldItem label='Project Name' value={opportunity.projectName} />
<FieldItem label='Project Location' value={opportunity.projectLocation} />
<FieldItem label='Pipeline Stage' value={pipelineStageLabel} />
<FieldItem label='Sales Owner' value={enquiry.assignedToName} />
<FieldItem label='Status' value={status?.label ?? enquiry.status} />
<FieldItem label='Sales Owner' value={opportunity.assignedToName} />
<FieldItem label='Status' value={status?.label ?? opportunity.status} />
<FieldItem
label='Expected Close Date'
value={
enquiry.expectedCloseDate
? new Date(enquiry.expectedCloseDate).toLocaleDateString()
opportunity.expectedCloseDate
? new Date(opportunity.expectedCloseDate).toLocaleDateString()
: null
}
/>
<FieldItem
label='Estimated Value'
value={enquiry.estimatedValue !== null ? enquiry.estimatedValue.toLocaleString() : null}
value={opportunity.estimatedValue !== null ? opportunity.estimatedValue.toLocaleString() : null}
/>
</CardContent>
</Card>
@@ -216,7 +216,7 @@ export function EnquiryDetail({
<FieldItem label='Contact Name' value={contact?.name} />
<FieldItem label='Contact Email' value={contact?.email} />
<FieldItem label='Contact Mobile' value={contact?.mobile} />
<FieldItem label='Branch' value={enquiry.branchId ? branchMap.get(enquiry.branchId) : null} />
<FieldItem label='Branch' value={opportunity.branchId ? branchMap.get(opportunity.branchId) : null} />
</CardContent>
</Card>
@@ -225,12 +225,12 @@ export function EnquiryDetail({
<CardTitle>Requirement</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
<FieldItem label='Title' value={enquiry.title} />
<FieldItem label='Description' value={enquiry.description} />
<FieldItem label='Requirement' value={enquiry.requirement} />
<FieldItem label='Title' value={opportunity.title} />
<FieldItem label='Description' value={opportunity.description} />
<FieldItem label='Requirement' value={opportunity.requirement} />
<div className='grid gap-4 md:grid-cols-2'>
<FieldItem label='Source' value={enquiry.source} />
<FieldItem label='Assignment Remark' value={enquiry.assignmentRemark} />
<FieldItem label='Source' value={opportunity.source} />
<FieldItem label='Assignment Remark' value={opportunity.assignmentRemark} />
</div>
</CardContent>
</Card>
@@ -240,18 +240,18 @@ export function EnquiryDetail({
<CardTitle>Sales Qualification</CardTitle>
</CardHeader>
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-3'>
<FieldItem label='Product Type' value={productTypeMap.get(enquiry.productType) ?? enquiry.productType} />
<FieldItem label='Priority' value={priorityMap.get(enquiry.priority) ?? enquiry.priority} />
<FieldItem label='Product Type' value={productTypeMap.get(opportunity.productType) ?? opportunity.productType} />
<FieldItem label='Priority' value={priorityMap.get(opportunity.priority) ?? opportunity.priority} />
<FieldItem
label='Chance %'
value={enquiry.chancePercent !== null ? `${enquiry.chancePercent}%` : null}
value={opportunity.chancePercent !== null ? `${opportunity.chancePercent}%` : null}
/>
<FieldItem label='Competitor' value={enquiry.competitor} />
<FieldItem label='Competitor' value={opportunity.competitor} />
<FieldItem
label='Assigned At'
value={enquiry.assignedAt ? new Date(enquiry.assignedAt).toLocaleString() : null}
value={opportunity.assignedAt ? new Date(opportunity.assignedAt).toLocaleString() : null}
/>
<FieldItem label='Assigned By' value={enquiry.assignedByName} />
<FieldItem label='Assigned By' value={opportunity.assignedByName} />
</CardContent>
</Card>
@@ -263,9 +263,9 @@ export function EnquiryDetail({
</TabsList>
<TabsContent value='followups'>
<EnquiryFollowupsTab
enquiryId={enquiryId}
customerId={enquiry.customerId}
<OpportunityFollowupsTab
opportunityId={opportunityId}
customerId={opportunity.customerId}
referenceData={referenceData}
canCreate={canManageFollowups.create}
canUpdate={canManageFollowups.update}
@@ -329,10 +329,10 @@ export function EnquiryDetail({
</div>
<div className='space-y-6'>
<QuotationReadinessPanel enquiry={enquiry} />
<QuotationReadinessPanel opportunity={opportunity} />
<EnquiryOutcomeCard
enquiryId={enquiryId}
<OpportunityOutcomeCard
opportunityId={opportunityId}
referenceData={referenceData}
canViewCommercialData={canViewOutcomeCommercialData}
canMarkWon={canMarkWon}
@@ -350,7 +350,7 @@ export function EnquiryDetail({
label='Project Parties'
value={String(projectPartiesData.items.length)}
/>
<FieldItem label='Record Type' value={workspace === 'enquiry' ? 'Opportunity' : 'Lead'} />
<FieldItem label='Record Type' value={workspace === 'opportunity' ? 'Opportunity' : 'Lead'} />
</CardContent>
</Card>
</div>
@@ -358,3 +358,4 @@ export function EnquiryDetail({
</div>
);
}

View File

@@ -8,27 +8,27 @@ import { Icons } from '@/components/icons';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { deleteEnquiryFollowupMutation } from '../api/mutations';
import { enquiryFollowupsOptions } from '../api/queries';
import type { EnquiryReferenceData } from '../api/types';
import { deleteOpportunityFollowupMutation } from '../api/mutations';
import { opportunityFollowupsOptions } from '../api/queries';
import type { OpportunityReferenceData } from '../api/types';
import { FollowupFormSheet } from './followup-form-sheet';
export function EnquiryFollowupsTab({
enquiryId,
export function OpportunityFollowupsTab({
opportunityId,
customerId,
referenceData,
canCreate,
canUpdate,
canDelete
}: {
enquiryId: string;
opportunityId: string;
customerId: string;
referenceData: EnquiryReferenceData;
referenceData: OpportunityReferenceData;
canCreate: boolean;
canUpdate: boolean;
canDelete: boolean;
}) {
const { data } = useSuspenseQuery(enquiryFollowupsOptions(enquiryId));
const { data } = useSuspenseQuery(opportunityFollowupsOptions(opportunityId));
const [selectedId, setSelectedId] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
@@ -36,7 +36,7 @@ export function EnquiryFollowupsTab({
const followupTypeMap = new Map(referenceData.followupTypes.map((item) => [item.id, item.label]));
const deleteMutation = useMutation({
...deleteEnquiryFollowupMutation,
...deleteOpportunityFollowupMutation,
onSuccess: () => {
toast.success('ลบงานติดตามสำเร็จ');
setDeleteOpen(false);
@@ -117,7 +117,7 @@ export function EnquiryFollowupsTab({
setDeleteOpen(true);
}}
>
<Icons.trash className='mr-2 h-4 w-4' />
<Icons.trash className='mr-2 h-4 w-4' /> š
</Button>
</div>
</div>
@@ -126,7 +126,7 @@ export function EnquiryFollowupsTab({
</CardContent>
<FollowupFormSheet
enquiryId={enquiryId}
opportunityId={opportunityId}
followup={selected}
open={open}
onOpenChange={setOpen}
@@ -138,7 +138,7 @@ export function EnquiryFollowupsTab({
onClose={() => setDeleteOpen(false)}
onConfirm={() => {
if (selectedId) {
deleteMutation.mutate({ enquiryId, followupId: selectedId });
deleteMutation.mutate({ opportunityId, followupId: selectedId });
}
}}
loading={deleteMutation.isPending}
@@ -146,3 +146,4 @@ export function EnquiryFollowupsTab({
</Card>
);
}

View File

@@ -26,58 +26,58 @@ import {
SheetTitle
} from '@/components/ui/sheet';
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
import { enquiryProjectPartiesOptions } from '../api/queries';
import { createEnquiryMutation, updateEnquiryMutation } from '../api/mutations';
import type { EnquiryMutationPayload, EnquiryRecord, EnquiryReferenceData } from '../api/types';
import { enquirySchema, type EnquiryFormValues } from '../schemas/enquiry.schema';
import { opportunityProjectPartiesOptions } from '../api/queries';
import { createOpportunityMutation, updateOpportunityMutation } from '../api/mutations';
import type { OpportunityMutationPayload, OpportunityRecord, OpportunityReferenceData } from '../api/types';
import { opportunitySchema, type OpportunityFormValues } from '../schemas/opportunity.schema';
function toDefaultValues(
enquiry: EnquiryRecord | undefined,
referenceData: EnquiryReferenceData
): EnquiryFormValues {
opportunity: OpportunityRecord | undefined,
referenceData: OpportunityReferenceData
): OpportunityFormValues {
return {
customerId: enquiry?.customerId ?? referenceData.customers[0]?.id ?? '',
contactId: enquiry?.contactId ?? undefined,
assignedToUserId: enquiry?.assignedToUserId ?? undefined,
title: enquiry?.title ?? '',
description: enquiry?.description ?? '',
requirement: enquiry?.requirement ?? '',
projectName: enquiry?.projectName ?? '',
projectLocation: enquiry?.projectLocation ?? '',
branchId: enquiry?.branchId ?? undefined,
productType: enquiry?.productType ?? referenceData.productTypes[0]?.id ?? '',
status: enquiry?.status ?? referenceData.statuses[0]?.id ?? '',
customerId: opportunity?.customerId ?? referenceData.customers[0]?.id ?? '',
contactId: opportunity?.contactId ?? undefined,
assignedToUserId: opportunity?.assignedToUserId ?? undefined,
title: opportunity?.title ?? '',
description: opportunity?.description ?? '',
requirement: opportunity?.requirement ?? '',
projectName: opportunity?.projectName ?? '',
projectLocation: opportunity?.projectLocation ?? '',
branchId: opportunity?.branchId ?? undefined,
productType: opportunity?.productType ?? referenceData.productTypes[0]?.id ?? '',
status: opportunity?.status ?? referenceData.statuses[0]?.id ?? '',
priority:
enquiry?.priority ?? referenceData.priorities[1]?.id ?? referenceData.priorities[0]?.id ?? '',
leadChannel: enquiry?.leadChannel ?? undefined,
estimatedValue: enquiry?.estimatedValue ?? undefined,
chancePercent: enquiry?.chancePercent ?? undefined,
expectedCloseDate: enquiry?.expectedCloseDate ? enquiry.expectedCloseDate.slice(0, 10) : '',
competitor: enquiry?.competitor ?? '',
source: enquiry?.source ?? '',
isHotProject: enquiry?.isHotProject ?? false,
notes: enquiry?.notes ?? '',
isActive: enquiry?.isActive ?? true
opportunity?.priority ?? referenceData.priorities[1]?.id ?? referenceData.priorities[0]?.id ?? '',
leadChannel: opportunity?.leadChannel ?? undefined,
estimatedValue: opportunity?.estimatedValue ?? undefined,
chancePercent: opportunity?.chancePercent ?? undefined,
expectedCloseDate: opportunity?.expectedCloseDate ? opportunity.expectedCloseDate.slice(0, 10) : '',
competitor: opportunity?.competitor ?? '',
source: opportunity?.source ?? '',
isHotProject: opportunity?.isHotProject ?? false,
notes: opportunity?.notes ?? '',
isActive: opportunity?.isActive ?? true
};
}
export function EnquiryFormSheet({
enquiry,
export function OpportunityFormSheet({
opportunity,
open,
onOpenChange,
referenceData,
workspace = 'enquiry'
workspace = 'opportunity'
}: {
enquiry?: EnquiryRecord;
opportunity?: OpportunityRecord;
open: boolean;
onOpenChange: (open: boolean) => void;
referenceData: EnquiryReferenceData;
workspace?: 'lead' | 'enquiry';
referenceData: OpportunityReferenceData;
workspace?: 'lead' | 'opportunity';
}) {
const isEdit = !!enquiry;
const isEdit = !!opportunity;
const defaultValues = useMemo(
() => toDefaultValues(enquiry, referenceData),
[enquiry, referenceData]
() => toDefaultValues(opportunity, referenceData),
[opportunity, referenceData]
);
const [selectedCustomerId, setSelectedCustomerId] = useState(defaultValues.customerId);
const [projectParties, setProjectParties] = useState<ProjectPartyEditorItem[]>([]);
@@ -89,14 +89,14 @@ export function EnquiryFormSheet({
FormDatePickerField,
FormCurrencyField,
FormPercentageField
} = useFormFields<EnquiryFormValues>();
} = useFormFields<OpportunityFormValues>();
const projectPartiesQuery = useQuery({
...enquiryProjectPartiesOptions(enquiry?.id ?? ''),
enabled: open && !!enquiry?.id
...opportunityProjectPartiesOptions(opportunity?.id ?? ''),
enabled: open && !!opportunity?.id
});
const createMutation = useMutation({
...createEnquiryMutation,
...createOpportunityMutation,
onSuccess: () => {
toast.success(`สร้าง${recordLabel}สำเร็จ`);
onOpenChange(false);
@@ -106,7 +106,7 @@ export function EnquiryFormSheet({
});
const updateMutation = useMutation({
...updateEnquiryMutation,
...updateOpportunityMutation,
onSuccess: () => {
toast.success(`อัปเดต${recordLabel}สำเร็จ`);
onOpenChange(false);
@@ -123,10 +123,10 @@ export function EnquiryFormSheet({
const form = useAppForm({
defaultValues,
validators: {
onSubmit: enquirySchema
onSubmit: opportunitySchema
},
onSubmit: async ({ value }) => {
const payload: EnquiryMutationPayload = {
const payload: OpportunityMutationPayload = {
customerId: value.customerId,
contactId: value.contactId || null,
assignedToUserId: value.assignedToUserId || null,
@@ -159,8 +159,8 @@ export function EnquiryFormSheet({
}))
};
if (isEdit && enquiry) {
await updateMutation.mutateAsync({ id: enquiry.id, values: payload });
if (isEdit && opportunity) {
await updateMutation.mutateAsync({ id: opportunity.id, values: payload });
return;
}
@@ -176,7 +176,7 @@ export function EnquiryFormSheet({
setSelectedCustomerId(defaultValues.customerId);
form.reset(defaultValues);
setProjectParties(
enquiry
opportunity
? (projectPartiesQuery.data?.items ?? []).map((item) => ({
key: item.id,
customerId: item.customerId,
@@ -185,7 +185,7 @@ export function EnquiryFormSheet({
}))
: []
);
}, [defaultValues, enquiry, form, open, projectPartiesQuery.data?.items]);
}, [defaultValues, opportunity, form, open, projectPartiesQuery.data?.items]);
useEffect(() => {
if (!open || isEdit) {
@@ -216,7 +216,7 @@ export function EnquiryFormSheet({
<div className='flex-1 overflow-auto'>
<form.AppForm>
<form.Form id='enquiry-form-sheet' className='grid gap-4 md:grid-cols-2'>
<form.Form id='opportunity-form-sheet' className='grid gap-4 md:grid-cols-2'>
<form.AppField
name='customerId'
children={(field) => (
@@ -289,7 +289,7 @@ export function EnquiryFormSheet({
description={
selectedCustomer?.ownerName
? `Suggested from customer owner: ${selectedCustomer.ownerName}`
: 'Optional sales owner suggestion for this lead or enquiry'
: 'Optional sales owner suggestion for this lead or opportunity'
}
options={referenceData.assignableUsers.map((user) => ({
value: user.id,
@@ -301,7 +301,7 @@ export function EnquiryFormSheet({
name='title'
label='ชื่อรายการ'
required
placeholder='Warehouse crane upgrade enquiry'
placeholder='Warehouse crane upgrade opportunity'
/>
<FormTextField
name='projectName'
@@ -429,7 +429,7 @@ export function EnquiryFormSheet({
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
</Button>
<Button type='submit' form='enquiry-form-sheet' isLoading={isPending}>
<Button type='submit' form='opportunity-form-sheet' isLoading={isPending}>
<Icons.check className='mr-2 h-4 w-4' />
{isEdit ? `อัปเดต${recordLabel}` : `สร้าง${recordLabel}`}
</Button>
@@ -439,12 +439,12 @@ export function EnquiryFormSheet({
);
}
export function EnquiryFormSheetTrigger({
export function OpportunityFormSheetTrigger({
referenceData,
workspace = 'enquiry'
workspace = 'opportunity'
}: {
referenceData: EnquiryReferenceData;
workspace?: 'lead' | 'enquiry';
referenceData: OpportunityReferenceData;
workspace?: 'lead' | 'opportunity';
}) {
const [open, setOpen] = React.useState(false);
@@ -453,7 +453,7 @@ export function EnquiryFormSheetTrigger({
<Button onClick={() => setOpen(true)}>
<Icons.add className='mr-2 h-4 w-4' /> {workspace === 'lead' ? 'เพิ่มลีด' : 'เพิ่มโอกาสขาย'}
</Button>
<EnquiryFormSheet
<OpportunityFormSheet
open={open}
onOpenChange={setOpen}
referenceData={referenceData}
@@ -462,3 +462,4 @@ export function EnquiryFormSheetTrigger({
</>
);
}

View File

@@ -1,11 +1,11 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/query-client';
import { searchParamsCache } from '@/lib/searchparams';
import type { EnquiryReferenceData } from '../api/types';
import { enquiriesQueryOptions } from '../api/queries';
import { EnquiriesTable } from './enquiries-table';
import type { OpportunityReferenceData } from '../api/types';
import { opportunitiesQueryOptions } from '../api/queries';
import { OpportunitiesTable } from './opportunities-table';
export default function EnquiryListing({
export default function OpportunityListing({
referenceData,
workspace,
canUpdate,
@@ -13,8 +13,8 @@ export default function EnquiryListing({
canAssign,
canReassign
}: {
referenceData: EnquiryReferenceData;
workspace: 'lead' | 'enquiry';
referenceData: OpportunityReferenceData;
workspace: 'lead' | 'opportunity';
canUpdate: boolean;
canDelete: boolean;
canAssign: boolean;
@@ -44,11 +44,11 @@ export default function EnquiryListing({
};
const queryClient = getQueryClient();
void queryClient.prefetchQuery(enquiriesQueryOptions(filters));
void queryClient.prefetchQuery(opportunitiesQueryOptions(filters));
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<EnquiriesTable
<OpportunitiesTable
referenceData={referenceData}
workspace={workspace}
canUpdate={canUpdate}
@@ -59,3 +59,4 @@ export default function EnquiryListing({
</HydrationBoundary>
);
}

View File

@@ -31,13 +31,13 @@ import {
CrmTextarea
} from '@/features/crm/components/crm-form-controls';
import {
invalidateEnquiryMutationQueries,
markEnquiryLostMutation,
markEnquiryWonMutation,
reopenEnquiryMutation
invalidateOpportunityMutationQueries,
markOpportunityLostMutation,
markOpportunityWonMutation,
reopenOpportunityMutation
} from '../api/mutations';
import { enquiryAttachmentsOptions, enquiryByIdOptions } from '../api/queries';
import type { EnquiryReferenceData } from '../api/types';
import { opportunityAttachmentsOptions, opportunityByIdOptions } from '../api/queries';
import type { OpportunityReferenceData } from '../api/types';
function FieldItem({ label, value }: { label: string; value: string | null | undefined }) {
return (
@@ -54,29 +54,29 @@ function getOutcomeLabel(stage: string) {
return 'Open';
}
type EnquiryOutcomeCardProps = {
enquiryId: string;
referenceData: EnquiryReferenceData;
type OpportunityOutcomeCardProps = {
opportunityId: string;
referenceData: OpportunityReferenceData;
canViewCommercialData: boolean;
canMarkWon: boolean;
canMarkLost: boolean;
canReopen: boolean;
};
export function EnquiryOutcomeCard({
enquiryId,
export function OpportunityOutcomeCard({
opportunityId,
referenceData,
canViewCommercialData,
canMarkWon,
canMarkLost,
canReopen
}: EnquiryOutcomeCardProps) {
const { data } = useSuspenseQuery(enquiryByIdOptions(enquiryId));
}: OpportunityOutcomeCardProps) {
const { data } = useSuspenseQuery(opportunityByIdOptions(opportunityId));
const attachmentsQuery = useQuery({
...enquiryAttachmentsOptions(enquiryId),
...opportunityAttachmentsOptions(opportunityId),
enabled: canViewCommercialData
});
const enquiry = data.enquiry;
const opportunity = data.opportunity;
const [wonOpen, setWonOpen] = useState(false);
const [lostOpen, setLostOpen] = useState(false);
const [reopenOpen, setReopenOpen] = useState(false);
@@ -93,31 +93,31 @@ export function EnquiryOutcomeCard({
const [poFileDescription, setPoFileDescription] = useState('');
const wonMutation = useMutation({
...markEnquiryWonMutation,
...markOpportunityWonMutation,
onSuccess: async () => {
toast.success('Marked enquiry as won');
toast.success('Marked opportunity as won');
setWonOpen(false);
await invalidateEnquiryMutationQueries(enquiryId);
await invalidateOpportunityMutationQueries(opportunityId);
},
onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to mark won')
});
const lostMutation = useMutation({
...markEnquiryLostMutation,
...markOpportunityLostMutation,
onSuccess: async () => {
toast.success('Marked enquiry as lost');
toast.success('Marked opportunity as lost');
setLostOpen(false);
await invalidateEnquiryMutationQueries(enquiryId);
await invalidateOpportunityMutationQueries(opportunityId);
},
onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to mark lost')
});
const reopenMutation = useMutation({
...reopenEnquiryMutation,
...reopenOpportunityMutation,
onSuccess: async () => {
toast.success('Reopened enquiry');
toast.success('Reopened opportunity');
setReopenOpen(false);
await invalidateEnquiryMutationQueries(enquiryId);
await invalidateOpportunityMutationQueries(opportunityId);
},
onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to reopen enquiry')
onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to reopen opportunity')
});
const uploadMutation = useMutation({
mutationFn: async () => {
@@ -128,7 +128,7 @@ export function EnquiryOutcomeCard({
const formData = new FormData();
formData.append('file', poFile);
formData.append('description', poFileDescription);
const response = await fetch(`/api/crm/enquiries/${enquiryId}/po-attachments`, {
const response = await fetch(`/api/crm/opportunities/${opportunityId}/po-attachments`, {
method: 'POST',
body: formData
});
@@ -144,26 +144,26 @@ export function EnquiryOutcomeCard({
toast.success('PO attachment uploaded');
setPoFile(null);
setPoFileDescription('');
await invalidateEnquiryMutationQueries(enquiryId);
await invalidateOpportunityMutationQueries(opportunityId);
},
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to upload PO attachment')
});
const closedDate = enquiry.closedWonAt ?? enquiry.closedLostAt;
const closedDate = opportunity.closedWonAt ?? opportunity.closedLostAt;
const lostReasonLabel = useMemo(() => {
if (!enquiry.lostReason) {
if (!opportunity.lostReason) {
return null;
}
return (
enquiry.lostReasonLabel ??
opportunity.lostReasonLabel ??
referenceData.lostReasons.find(
(item) => item.id === enquiry.lostReason || item.code === enquiry.lostReason
(item) => item.id === opportunity.lostReason || item.code === opportunity.lostReason
)?.label ??
enquiry.lostReason
opportunity.lostReason
);
}, [enquiry.lostReason, enquiry.lostReasonLabel, referenceData.lostReasons]);
}, [opportunity.lostReason, opportunity.lostReasonLabel, referenceData.lostReasons]);
const attachmentItems = attachmentsQuery.data?.items ?? [];
return (
@@ -175,16 +175,16 @@ export function EnquiryOutcomeCard({
</CardHeader>
<CardContent className='space-y-4'>
<div className='flex items-center justify-between gap-3'>
<Badge variant={enquiry.pipelineStage === 'closed_won' ? 'default' : enquiry.pipelineStage === 'closed_lost' ? 'destructive' : 'secondary'}>
{getOutcomeLabel(enquiry.pipelineStage)}
<Badge variant={opportunity.pipelineStage === 'closed_won' ? 'default' : opportunity.pipelineStage === 'closed_lost' ? 'destructive' : 'secondary'}>
{getOutcomeLabel(opportunity.pipelineStage)}
</Badge>
<div className='flex flex-wrap gap-2'>
{enquiry.pipelineStage === 'enquiry' && canMarkWon ? (
{opportunity.pipelineStage === 'opportunity' && canMarkWon ? (
<Button size='sm' onClick={() => {
setPoNumber(enquiry.poNumber ?? '');
setPoDate(enquiry.poDate ? enquiry.poDate.slice(0, 10) : '');
setPoAmount(enquiry.poAmount !== null && enquiry.poAmount !== undefined ? String(enquiry.poAmount) : '');
setPoCurrency(enquiry.poCurrency ?? 'THB');
setPoNumber(opportunity.poNumber ?? '');
setPoDate(opportunity.poDate ? opportunity.poDate.slice(0, 10) : '');
setPoAmount(opportunity.poAmount !== null && opportunity.poAmount !== undefined ? String(opportunity.poAmount) : '');
setPoCurrency(opportunity.poCurrency ?? 'THB');
setWonRemark('');
setWonOpen(true);
}}>
@@ -192,18 +192,18 @@ export function EnquiryOutcomeCard({
Mark Won
</Button>
) : null}
{enquiry.pipelineStage === 'enquiry' && canMarkLost ? (
{opportunity.pipelineStage === 'opportunity' && canMarkLost ? (
<Button variant='destructive' size='sm' onClick={() => {
setLostReason(enquiry.lostReason ?? '');
setLostCompetitor(enquiry.lostCompetitor ?? '');
setLostRemark(enquiry.lostRemark ?? '');
setLostReason(opportunity.lostReason ?? '');
setLostCompetitor(opportunity.lostCompetitor ?? '');
setLostRemark(opportunity.lostRemark ?? '');
setLostOpen(true);
}}>
<Icons.xCircle className='mr-2 h-4 w-4' />
Mark Lost
</Button>
) : null}
{enquiry.pipelineStage === 'closed_lost' && canReopen ? (
{opportunity.pipelineStage === 'closed_lost' && canReopen ? (
<Button variant='outline' size='sm' onClick={() => setReopenOpen(true)}>
Reopen
</Button>
@@ -212,32 +212,32 @@ export function EnquiryOutcomeCard({
</div>
<div className='grid gap-4 md:grid-cols-2'>
<FieldItem label='Closed By' value={enquiry.closedByName} />
<FieldItem label='Closed By' value={opportunity.closedByName} />
<FieldItem
label='Closed Date'
value={closedDate ? new Date(closedDate).toLocaleString() : null}
/>
<FieldItem label='PO Number' value={enquiry.poNumber} />
<FieldItem label='PO Number' value={opportunity.poNumber} />
<FieldItem
label='PO Date'
value={enquiry.poDate ? new Date(enquiry.poDate).toLocaleDateString() : null}
value={opportunity.poDate ? new Date(opportunity.poDate).toLocaleDateString() : null}
/>
<FieldItem
label='PO Amount'
value={
canViewCommercialData && enquiry.poAmount !== null
? `${enquiry.poAmount.toLocaleString()} ${enquiry.poCurrency ?? ''}`.trim()
canViewCommercialData && opportunity.poAmount !== null
? `${opportunity.poAmount.toLocaleString()} ${opportunity.poCurrency ?? ''}`.trim()
: canViewCommercialData
? null
: 'Restricted'
}
/>
<FieldItem label='Lost Reason' value={lostReasonLabel} />
<FieldItem label='Lost Competitor' value={enquiry.lostCompetitor} />
<FieldItem label='Remark' value={enquiry.lostRemark} />
<FieldItem label='Lost Competitor' value={opportunity.lostCompetitor} />
<FieldItem label='Remark' value={opportunity.lostRemark} />
</div>
{canViewCommercialData && enquiry.pipelineStage === 'closed_won' ? (
{canViewCommercialData && opportunity.pipelineStage === 'closed_won' ? (
<div className='space-y-4 rounded-lg border p-4'>
<div className='space-y-1'>
<div className='font-medium'>Purchase Order Attachment</div>
@@ -295,12 +295,12 @@ export function EnquiryOutcomeCard({
</div>
<div className='flex gap-2'>
<Button asChild variant='outline' size='sm'>
<Link href={`/api/crm/enquiries/${enquiryId}/po-attachments/${attachment.id}`}>
<Link href={`/api/crm/opportunities/${opportunityId}/po-attachments/${attachment.id}`}>
View
</Link>
</Button>
<Button asChild variant='outline' size='sm'>
<Link href={`/api/crm/enquiries/${enquiryId}/po-attachments/${attachment.id}?download=1`}>
<Link href={`/api/crm/opportunities/${opportunityId}/po-attachments/${attachment.id}?download=1`}>
Download
</Link>
</Button>
@@ -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({
</Button>
<Button
isLoading={reopenMutation.isPending}
onClick={() => reopenMutation.mutate({ id: enquiryId, reason: reopenReason })}
onClick={() => reopenMutation.mutate({ id: opportunityId, reason: reopenReason })}
>
Reopen
</Button>
@@ -482,3 +482,4 @@ export function EnquiryOutcomeCard({
</>
);
}

View File

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

View File

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

View File

@@ -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<typeof enquirySchema>;
export type EnquiryFollowupFormValues = z.input<typeof enquiryFollowupSchema>;
export type EnquiryMarkWonFormValues = z.input<typeof enquiryMarkWonSchema>;
export type EnquiryMarkLostFormValues = z.input<typeof enquiryMarkLostSchema>;
export type EnquiryReopenFormValues = z.input<typeof enquiryReopenSchema>;
export type OpportunityFormValues = z.input<typeof opportunitySchema>;
export type OpportunityFollowupFormValues = z.input<typeof opportunityFollowupSchema>;
export type OpportunityMarkWonFormValues = z.input<typeof opportunityMarkWonSchema>;
export type OpportunityMarkLostFormValues = z.input<typeof opportunityMarkLostSchema>;
export type OpportunityReopenFormValues = z.input<typeof opportunityReopenSchema>;

View File

@@ -28,7 +28,7 @@ export async function getQuotations(filters: QuotationFilters): Promise<Quotatio
if (filters.quotationType) searchParams.set('quotationType', filters.quotationType);
if (filters.branch) searchParams.set('branch', filters.branch);
if (filters.customer) searchParams.set('customer', filters.customer);
if (filters.enquiry) searchParams.set('enquiry', filters.enquiry);
if (filters.opportunity) searchParams.set('opportunity', filters.opportunity);
if (filters.hot) searchParams.set('hot', filters.hot);
if (filters.sort) searchParams.set('sort', filters.sort);
@@ -212,3 +212,4 @@ export async function createQuotationRevision(id: string, revisionRemark?: strin
body: JSON.stringify({ revisionRemark })
});
}

View File

@@ -28,7 +28,7 @@ export interface QuotationContactLookup {
isPrimary: boolean;
}
export interface QuotationEnquiryLookup {
export interface QuotationOpportunityLookup {
id: string;
code: string;
customerId: string;
@@ -64,7 +64,7 @@ export interface QuotationRecord {
organizationId: string;
branchId: string | null;
code: string;
enquiryId: string | null;
opportunityId: string | null;
customerId: string;
contactId: string | null;
quotationDate: string;
@@ -115,7 +115,7 @@ export interface QuotationRecord {
export interface QuotationListItem extends QuotationRecord {
customerName: string;
contactName: string | null;
enquiryCode: string | null;
opportunityCode: string | null;
itemCount: number;
revisionLabel: string;
}
@@ -253,7 +253,7 @@ export interface QuotationReferenceData {
productTypes: QuotationOption[];
customers: QuotationCustomerLookup[];
contacts: QuotationContactLookup[];
enquiries: QuotationEnquiryLookup[];
opportunities: QuotationOpportunityLookup[];
salesmen: QuotationSalesmanLookup[];
}
@@ -265,7 +265,7 @@ export interface QuotationFilters {
quotationType?: string;
branch?: string;
customer?: string;
enquiry?: string;
opportunity?: string;
hot?: string;
sort?: string;
}
@@ -331,7 +331,7 @@ export interface QuotationRevisionsResponse {
}
export interface QuotationMutationPayload {
enquiryId?: string | null;
opportunityId?: string | null;
customerId: string;
contactId?: string | null;
quotationDate: string;
@@ -420,3 +420,4 @@ export interface MutationSuccessResponse {
success: boolean;
message: string;
}

View File

@@ -114,16 +114,16 @@ export function getQuotationColumns({
enableColumnFilter: true
},
{
id: 'enquiry',
accessorFn: (row) => row.enquiryId ?? '',
id: 'opportunity',
accessorFn: (row) => row.opportunityId ?? '',
header: ({ column }: { column: Column<QuotationListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='โอกาสขาย' />
),
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({
}
];
}

View File

@@ -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({
<form id='quotation-form-sheet' onSubmit={onSubmit} className='flex-1 overflow-auto'>
<div className='grid gap-4 py-4 md:grid-cols-2'>
<Field label='Enquiry'>
<Select value={state.enquiryId || '__none__'} onValueChange={handleEnquiryChange}>
<Field label='Opportunity'>
<Select value={state.opportunityId || '__none__'} onValueChange={handleOpportunityChange}>
<SelectTrigger>
<SelectValue placeholder='Select enquiry' />
<SelectValue placeholder='Select opportunity' />
</SelectTrigger>
<SelectContent>
<SelectItem value='__none__'>No linked enquiry</SelectItem>
{referenceData.enquiries.map((item) => (
<SelectItem value='__none__'>No linked opportunity</SelectItem>
{referenceData.opportunities.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.code} - {item.title}
</SelectItem>
@@ -541,3 +541,4 @@ export function QuotationFormSheetTrigger({
</>
);
}

View File

@@ -21,7 +21,7 @@ export default function QuotationListing({
const quotationType = searchParamsCache.get('quotationType');
const branch = searchParamsCache.get('branch');
const customer = searchParamsCache.get('customer');
const enquiry = searchParamsCache.get('enquiry');
const opportunity = searchParamsCache.get('opportunity');
const hot = searchParamsCache.get('hot');
const sort = searchParamsCache.get('sort');
const filters = {
@@ -32,7 +32,7 @@ export default function QuotationListing({
...(quotationType && { quotationType }),
...(branch && { branch }),
...(customer && { customer }),
...(enquiry && { enquiry }),
...(opportunity && { opportunity }),
...(hot && { hot }),
...(sort && { sort })
};
@@ -46,3 +46,4 @@ export default function QuotationListing({
</HydrationBoundary>
);
}

View File

@@ -33,7 +33,7 @@ export function QuotationsTable({
quotationType: parseAsString,
branch: parseAsString,
customer: parseAsString,
enquiry: parseAsString,
opportunity: parseAsString,
hot: parseAsString,
sort: getSortingStateParser(columnIds).withDefault([])
});
@@ -46,7 +46,7 @@ export function QuotationsTable({
...(params.quotationType && { quotationType: params.quotationType }),
...(params.branch && { branch: params.branch }),
...(params.customer && { customer: params.customer }),
...(params.enquiry && { enquiry: params.enquiry }),
...(params.opportunity && { opportunity: params.opportunity }),
...(params.hot && { hot: params.hot }),
...(params.sort.length > 0 && { sort: JSON.stringify(params.sort) })
};
@@ -70,3 +70,4 @@ export function QuotationsTable({
</DataTable>
);
}

View File

@@ -39,7 +39,7 @@ function coerceRequiredNumber(message: string) {
}
export const quotationSchema = z.object({
enquiryId: z.string().optional().nullable(),
opportunityId: z.string().optional().nullable(),
customerId: z.string().min(1, 'Please select a customer'),
contactId: z.string().optional().nullable(),
quotationDate: z.string().min(1, 'Quotation date is required'),
@@ -159,3 +159,4 @@ export type QuotationCustomerFormValues = z.input<typeof quotationCustomerSchema
export type QuotationTopicFormValues = z.input<typeof quotationTopicSchema>;
export type QuotationFollowupFormValues = z.input<typeof quotationFollowupSchema>;
export type QuotationAttachmentFormValues = z.input<typeof quotationAttachmentSchema>;

View File

@@ -3,7 +3,7 @@ import {
crmDocumentArtifacts,
crmCustomerContacts,
crmCustomers,
crmEnquiries,
crmOpportunities,
crmQuotationAttachments,
crmQuotationCustomers,
crmQuotationFollowups,
@@ -25,7 +25,7 @@ import {
} from '@/features/crm/security/server/service';
import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service';
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
import { syncEnquiryPipelineStageFromQuotationStatus } from '@/features/crm/enquiries/server/service';
import { syncOpportunityPipelineStageFromQuotationStatus } from '@/features/crm/opportunities/server/service';
import type {
QuotationActivityRecord,
QuotationAttachmentMutationPayload,
@@ -37,7 +37,7 @@ import type {
QuotationCustomerLookup,
QuotationCustomerMutationPayload,
QuotationCustomerRecord,
QuotationEnquiryLookup,
QuotationOpportunityLookup,
QuotationFilters,
QuotationFollowupMutationPayload,
QuotationFollowupRecord,
@@ -128,7 +128,7 @@ function mapQuotationRecord(
organizationId: row.organizationId,
branchId: row.branchId,
code: row.code,
enquiryId: row.enquiryId,
opportunityId: row.opportunityId,
customerId: row.customerId,
contactId: row.contactId,
quotationDate: row.quotationDate.toISOString(),
@@ -437,24 +437,24 @@ export async function assertContactBelongsToOrganization(
return contact;
}
async function assertEnquiryBelongsToOrganization(id: string, organizationId: string) {
const [enquiry] = await db
async function assertOpportunityBelongsToOrganization(id: string, organizationId: string) {
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);
}
return enquiry;
return opportunity;
}
async function assertQuotationBelongsToOrganization(
@@ -665,11 +665,11 @@ async function validateQuotationPayload(
await assertContactBelongsToOrganization(payload.contactId, organizationId, payload.customerId);
}
if (payload.enquiryId) {
const enquiry = await assertEnquiryBelongsToOrganization(payload.enquiryId, organizationId);
if (payload.opportunityId) {
const opportunity = await assertOpportunityBelongsToOrganization(payload.opportunityId, organizationId);
if (enquiry.customerId !== payload.customerId) {
throw new AuthError('Selected enquiry belongs to a different customer', 400);
if (opportunity.customerId !== payload.customerId) {
throw new AuthError('Selected opportunity belongs to a different customer', 400);
}
}
@@ -941,7 +941,7 @@ function buildQuotationFilters(
const quotationTypes = splitFilterValue(filters.quotationType);
const branches = splitFilterValue(filters.branch);
const customers = splitFilterValue(filters.customer);
const enquiries = splitFilterValue(filters.enquiry);
const opportunities = splitFilterValue(filters.opportunity);
return [
eq(crmQuotations.organizationId, organizationId),
@@ -951,7 +951,7 @@ function buildQuotationFilters(
...(quotationTypes.length ? [inArray(crmQuotations.quotationType, quotationTypes)] : []),
...(branches.length ? [inArray(crmQuotations.branchId, branches)] : []),
...(customers.length ? [inArray(crmQuotations.customerId, customers)] : []),
...(enquiries.length ? [inArray(crmQuotations.enquiryId, enquiries)] : []),
...(opportunities.length ? [inArray(crmQuotations.opportunityId, opportunities)] : []),
...(filters.hot === 'true' ? [eq(crmQuotations.isHotProject, true)] : []),
...(filters.hot === 'false' ? [eq(crmQuotations.isHotProject, false)] : []),
...(filters.search
@@ -1035,7 +1035,7 @@ export async function getQuotationReferenceData(
productTypes,
customers,
contacts,
enquiries,
opportunities,
salesmen
] = await Promise.all([
getUserBranches(),
@@ -1066,9 +1066,9 @@ export async function getQuotationReferenceData(
.orderBy(desc(crmCustomerContacts.isPrimary), asc(crmCustomerContacts.name)),
db
.select()
.from(crmEnquiries)
.where(and(eq(crmEnquiries.organizationId, organizationId), isNull(crmEnquiries.deletedAt)))
.orderBy(desc(crmEnquiries.updatedAt)),
.from(crmOpportunities)
.where(and(eq(crmOpportunities.organizationId, organizationId), isNull(crmOpportunities.deletedAt)))
.orderBy(desc(crmOpportunities.updatedAt)),
db
.select({ id: users.id, name: users.name })
.from(memberships)
@@ -1103,15 +1103,15 @@ export async function getQuotationReferenceData(
mobile: contact.mobile,
isPrimary: contact.isPrimary
})),
enquiries: enquiries.map<QuotationEnquiryLookup>((enquiry) => ({
id: enquiry.id,
code: enquiry.code,
customerId: enquiry.customerId,
contactId: enquiry.contactId,
title: enquiry.title,
projectName: enquiry.projectName,
projectLocation: enquiry.projectLocation,
branchId: enquiry.branchId
opportunities: opportunities.map<QuotationOpportunityLookup>((opportunity) => ({
id: opportunity.id,
code: opportunity.code,
customerId: opportunity.customerId,
contactId: opportunity.contactId,
title: opportunity.title,
projectName: opportunity.projectName,
projectLocation: opportunity.projectLocation,
branchId: opportunity.branchId
})),
salesmen: salesmen.map<QuotationSalesmanLookup>((salesman) => ({
id: salesman.id,
@@ -1142,18 +1142,18 @@ export async function listQuotations(
const customerIds = [...new Set(rows.map((row) => row.customerId))];
const contactIds = [...new Set(rows.map((row) => row.contactId).filter(Boolean))] as string[];
const enquiryIds = [...new Set(rows.map((row) => row.enquiryId).filter(Boolean))] as string[];
const opportunityIds = [...new Set(rows.map((row) => row.opportunityId).filter(Boolean))] as string[];
const quotationIds = rows.map((row) => row.id);
const [customers, contacts, enquiries, itemCounts] = await Promise.all([
const [customers, contacts, opportunities, itemCounts] = await Promise.all([
customerIds.length
? db.select().from(crmCustomers).where(inArray(crmCustomers.id, customerIds))
: [],
contactIds.length
? db.select().from(crmCustomerContacts).where(inArray(crmCustomerContacts.id, contactIds))
: [],
enquiryIds.length
? db.select().from(crmEnquiries).where(inArray(crmEnquiries.id, enquiryIds))
opportunityIds.length
? db.select().from(crmOpportunities).where(inArray(crmOpportunities.id, opportunityIds))
: [],
quotationIds.length
? db
@@ -1172,7 +1172,7 @@ export async function listQuotations(
const customerMap = new Map(customers.map((item) => [item.id, item]));
const contactMap = new Map(contacts.map((item) => [item.id, item]));
const enquiryMap = new Map(enquiries.map((item) => [item.id, item]));
const opportunityMap = new Map(opportunities.map((item) => [item.id, item]));
const itemCountMap = new Map(itemCounts.map((item) => [item.quotationId, item.value]));
return {
@@ -1180,7 +1180,7 @@ export async function listQuotations(
...mapQuotationRecord(row),
customerName: customerMap.get(row.customerId)?.name ?? 'Unknown customer',
contactName: row.contactId ? (contactMap.get(row.contactId)?.name ?? null) : null,
enquiryCode: row.enquiryId ? (enquiryMap.get(row.enquiryId)?.code ?? null) : null,
opportunityCode: row.opportunityId ? (opportunityMap.get(row.opportunityId)?.code ?? null) : null,
itemCount: itemCountMap.get(row.id) ?? 0,
revisionLabel: toRevisionLabel(row.revision)
})),
@@ -1289,13 +1289,13 @@ export async function createQuotation(
payload.status
);
const enquiry = payload.enquiryId
? await assertEnquiryBelongsToOrganization(payload.enquiryId, organizationId)
const opportunity = payload.opportunityId
? await assertOpportunityBelongsToOrganization(payload.opportunityId, organizationId)
: null;
const documentCode = await generateNextDocumentCode({
organizationId,
documentType: 'quotation',
branchId: payload.branchId ?? enquiry?.branchId ?? ''
branchId: payload.branchId ?? opportunity?.branchId ?? ''
});
return db.transaction(async (tx) => {
@@ -1304,16 +1304,16 @@ export async function createQuotation(
.values({
id: crypto.randomUUID(),
organizationId,
branchId: payload.branchId ?? enquiry?.branchId ?? null,
branchId: payload.branchId ?? opportunity?.branchId ?? null,
code: documentCode.code,
enquiryId: payload.enquiryId ?? null,
opportunityId: payload.opportunityId ?? null,
customerId: payload.customerId,
contactId: payload.contactId ?? enquiry?.contactId ?? null,
contactId: payload.contactId ?? opportunity?.contactId ?? null,
quotationDate: new Date(payload.quotationDate),
validUntil: payload.validUntil ? new Date(payload.validUntil) : null,
quotationType: payload.quotationType,
projectName: payload.projectName?.trim() || enquiry?.projectName || null,
projectLocation: payload.projectLocation?.trim() || enquiry?.projectLocation || null,
projectName: payload.projectName?.trim() || opportunity?.projectName || null,
projectLocation: payload.projectLocation?.trim() || opportunity?.projectLocation || null,
attention: payload.attention?.trim() || null,
reference: payload.reference?.trim() || null,
notes: payload.notes?.trim() || null,
@@ -1325,9 +1325,9 @@ export async function createQuotation(
discount: payload.discount ?? 0,
discountType: payload.discountType ?? null,
taxRate: payload.taxRate ?? 0,
chancePercent: payload.chancePercent ?? enquiry?.chancePercent ?? null,
isHotProject: payload.isHotProject ?? enquiry?.isHotProject ?? false,
competitor: payload.competitor?.trim() || enquiry?.competitor || null,
chancePercent: payload.chancePercent ?? opportunity?.chancePercent ?? null,
isHotProject: payload.isHotProject ?? opportunity?.isHotProject ?? false,
competitor: payload.competitor?.trim() || opportunity?.competitor || null,
salesmanId: payload.salesmanId ?? null,
isSent: false,
sentVia: payload.sentVia ?? null,
@@ -1350,9 +1350,9 @@ export async function createQuotation(
payload.projectParties ?? []
);
if (created.enquiryId) {
await syncEnquiryPipelineStageFromQuotationStatus(
created.enquiryId,
if (created.opportunityId) {
await syncOpportunityPipelineStageFromQuotationStatus(
created.opportunityId,
organizationId,
quotationStatusCode
);
@@ -1376,23 +1376,23 @@ export async function updateQuotation(
payload.status
);
const existing = await assertQuotationBelongsToOrganization(id, organizationId, accessContext);
const enquiry = payload.enquiryId
? await assertEnquiryBelongsToOrganization(payload.enquiryId, organizationId)
const opportunity = payload.opportunityId
? await assertOpportunityBelongsToOrganization(payload.opportunityId, organizationId)
: null;
const updated = await db.transaction(async (tx) => {
const [row] = await tx
.update(crmQuotations)
.set({
branchId: payload.branchId ?? enquiry?.branchId ?? null,
enquiryId: payload.enquiryId ?? null,
branchId: payload.branchId ?? opportunity?.branchId ?? null,
opportunityId: payload.opportunityId ?? null,
customerId: payload.customerId,
contactId: payload.contactId ?? enquiry?.contactId ?? null,
contactId: payload.contactId ?? opportunity?.contactId ?? null,
quotationDate: new Date(payload.quotationDate),
validUntil: payload.validUntil ? new Date(payload.validUntil) : null,
quotationType: payload.quotationType,
projectName: payload.projectName?.trim() || enquiry?.projectName || null,
projectLocation: payload.projectLocation?.trim() || enquiry?.projectLocation || null,
projectName: payload.projectName?.trim() || opportunity?.projectName || null,
projectLocation: payload.projectLocation?.trim() || opportunity?.projectLocation || null,
attention: payload.attention?.trim() || null,
reference: payload.reference?.trim() || null,
notes: payload.notes?.trim() || null,
@@ -1403,9 +1403,9 @@ export async function updateQuotation(
discount: payload.discount ?? 0,
discountType: payload.discountType ?? null,
taxRate: payload.taxRate ?? 0,
chancePercent: payload.chancePercent ?? enquiry?.chancePercent ?? null,
chancePercent: payload.chancePercent ?? opportunity?.chancePercent ?? null,
isHotProject: payload.isHotProject ?? false,
competitor: payload.competitor?.trim() || enquiry?.competitor || null,
competitor: payload.competitor?.trim() || opportunity?.competitor || null,
salesmanId: payload.salesmanId ?? null,
sentVia: payload.sentVia ?? null,
isActive: payload.isActive ?? true,
@@ -1423,9 +1423,9 @@ export async function updateQuotation(
payload.projectParties ?? []
);
if (row.enquiryId) {
await syncEnquiryPipelineStageFromQuotationStatus(
row.enquiryId,
if (row.opportunityId) {
await syncOpportunityPipelineStageFromQuotationStatus(
row.opportunityId,
organizationId,
quotationStatusCode
);
@@ -2160,7 +2160,7 @@ export async function createQuotationRevision(
organizationId,
branchId: parent.branchId,
code: documentCode.code,
enquiryId: parent.enquiryId,
opportunityId: parent.opportunityId,
customerId: parent.customerId,
contactId: parent.contactId,
quotationDate: new Date(),
@@ -2291,8 +2291,8 @@ export async function listQuotationRevisions(id: string, organizationId: string)
}));
}
export async function listEnquiryQuotationRelations(
enquiryId: string,
export async function listOpportunityQuotationRelations(
opportunityId: string,
organizationId: string
): Promise<QuotationRelationItem[]> {
const rows = await db
@@ -2301,7 +2301,7 @@ export async function listEnquiryQuotationRelations(
.where(
and(
eq(crmQuotations.organizationId, organizationId),
eq(crmQuotations.enquiryId, enquiryId),
eq(crmQuotations.opportunityId, opportunityId),
isNull(crmQuotations.deletedAt)
)
)
@@ -2345,3 +2345,4 @@ export async function listCustomerQuotationRelations(
updatedAt: row.updatedAt.toISOString()
}));
}

View File

@@ -14,8 +14,8 @@ import {
} from 'drizzle-orm';
import {
crmCustomers,
crmEnquiries,
crmEnquiryCustomers,
crmOpportunities,
crmOpportunityCustomers,
crmQuotationCustomers,
crmQuotations
} from '@/db/schema';
@@ -41,7 +41,7 @@ interface NormalizedProjectParty {
}
type ProjectPartyTableAvailability = {
enquiryCustomers: boolean;
opportunityCustomers: boolean;
quotationCustomers: boolean;
};
@@ -62,7 +62,7 @@ function buildRevenueAttributionFilters(
const quotationTypes = splitFilterValue(filters.quotationType);
const branches = splitFilterValue(filters.branch);
const customers = splitFilterValue(filters.customer);
const enquiries = splitFilterValue(filters.enquiry);
const opportunities = splitFilterValue(filters.opportunity);
return [
eq(crmQuotations.organizationId, organizationId),
@@ -75,7 +75,7 @@ function buildRevenueAttributionFilters(
...(quotationTypes.length ? [inArray(crmQuotations.quotationType, quotationTypes)] : []),
...(branches.length ? [inArray(crmQuotations.branchId, branches)] : []),
...(customers.length ? [inArray(crmQuotations.customerId, customers)] : []),
...(enquiries.length ? [inArray(crmQuotations.enquiryId, enquiries)] : []),
...(opportunities.length ? [inArray(crmQuotations.opportunityId, opportunities)] : []),
...(filters.hot === 'true' ? [eq(crmQuotations.isHotProject, true)] : []),
...(filters.hot === 'false' ? [eq(crmQuotations.isHotProject, false)] : []),
...(filters.quotationDateFrom ? [gte(crmQuotations.quotationDate, new Date(filters.quotationDateFrom))] : []),
@@ -150,9 +150,9 @@ function groupNormalizedParties<TGroupKey extends string>(
function pickAttributedParties(
roleCode: SupportedRevenueRole,
quotationParties: NormalizedProjectParty[],
enquiryParties: NormalizedProjectParty[]
opportunityParties: NormalizedProjectParty[]
) {
const authoritativeParties = quotationParties.length > 0 ? quotationParties : enquiryParties;
const authoritativeParties = quotationParties.length > 0 ? quotationParties : opportunityParties;
if (roleCode === 'end_customer') {
const endCustomers = authoritativeParties.filter((party) => party.roleCode === 'end_customer');
@@ -176,13 +176,13 @@ async function getProjectPartyTableAvailability(): Promise<ProjectPartyTableAvai
select table_name as "tableName"
from information_schema.tables
where table_schema = 'public'
and table_name in ('crm_enquiry_customers', 'crm_quotation_customers')
and table_name in ('crm_opportunity_customers', 'crm_quotation_customers')
`);
const available = new Set(rows.map((row) => row.tableName));
projectPartyTableAvailability = {
enquiryCustomers: available.has('crm_enquiry_customers'),
opportunityCustomers: available.has('crm_opportunity_customers'),
quotationCustomers: available.has('crm_quotation_customers')
};
@@ -201,7 +201,7 @@ async function getRevenueByRole(
const quotations = await db
.select({
id: crmQuotations.id,
enquiryId: crmQuotations.enquiryId,
opportunityId: crmQuotations.opportunityId,
customerId: crmQuotations.customerId,
totalAmount: crmQuotations.totalAmount
})
@@ -214,9 +214,9 @@ async function getRevenueByRole(
}
const quotationIds = quotations.map((quotation) => quotation.id);
const enquiryIds = [...new Set(quotations.map((quotation) => quotation.enquiryId).filter(Boolean))] as string[];
const opportunityIds = [...new Set(quotations.map((quotation) => quotation.opportunityId).filter(Boolean))] as string[];
const [roleOptions, quotationParties, enquiryParties] = await Promise.all([
const [roleOptions, quotationParties, opportunityParties] = await Promise.all([
getActiveOptionsByCategory(PROJECT_PARTY_OPTION_CATEGORY, { organizationId }),
tableAvailability.quotationCustomers
? db
@@ -234,19 +234,19 @@ async function getRevenueByRole(
)
)
: [],
tableAvailability.enquiryCustomers && enquiryIds.length
tableAvailability.opportunityCustomers && opportunityIds.length
? db
.select({
enquiryId: crmEnquiryCustomers.enquiryId,
customerId: crmEnquiryCustomers.customerId,
role: crmEnquiryCustomers.role
opportunityId: crmOpportunityCustomers.opportunityId,
customerId: crmOpportunityCustomers.customerId,
role: crmOpportunityCustomers.role
})
.from(crmEnquiryCustomers)
.from(crmOpportunityCustomers)
.where(
and(
eq(crmEnquiryCustomers.organizationId, organizationId),
inArray(crmEnquiryCustomers.enquiryId, enquiryIds),
isNull(crmEnquiryCustomers.deletedAt)
eq(crmOpportunityCustomers.organizationId, organizationId),
inArray(crmOpportunityCustomers.opportunityId, opportunityIds),
isNull(crmOpportunityCustomers.deletedAt)
)
)
: []
@@ -261,9 +261,9 @@ async function getRevenueByRole(
roleOptions
);
const enquiryPartyMap = groupNormalizedParties(
enquiryParties.map((row) => ({
groupKey: row.enquiryId,
const opportunityPartyMap = groupNormalizedParties(
opportunityParties.map((row) => ({
groupKey: row.opportunityId,
customerId: row.customerId,
role: row.role
})),
@@ -275,12 +275,12 @@ async function getRevenueByRole(
for (const quotation of quotations) {
const quotationAttributedParties = quotationPartyMap.get(quotation.id) ?? [];
const enquiryAttributedParties = quotation.enquiryId
? (enquiryPartyMap.get(quotation.enquiryId) ?? [])
const opportunityAttributedParties = quotation.opportunityId
? (opportunityPartyMap.get(quotation.opportunityId) ?? [])
: [];
const attributedParties =
quotationAttributedParties.length > 0 || enquiryAttributedParties.length > 0
? pickAttributedParties(roleCode, quotationAttributedParties, enquiryAttributedParties)
quotationAttributedParties.length > 0 || opportunityAttributedParties.length > 0
? pickAttributedParties(roleCode, quotationAttributedParties, opportunityAttributedParties)
: roleCode === 'end_customer' || roleCode === 'billing_customer'
? [{ customerId: quotation.customerId, roleCode }]
: [];
@@ -391,15 +391,15 @@ function buildOutcomeRevenueFilters(
stage: 'closed_won' | 'closed_lost',
filters: OutcomeRevenueFilters
): SQL[] {
const dateColumn = stage === 'closed_won' ? crmEnquiries.closedWonAt : crmEnquiries.closedLostAt;
const dateColumn = stage === 'closed_won' ? crmOpportunities.closedWonAt : crmOpportunities.closedLostAt;
return [
eq(crmEnquiries.organizationId, organizationId),
isNull(crmEnquiries.deletedAt),
eq(crmEnquiries.pipelineStage, stage),
...(filters.branch ? [eq(crmEnquiries.branchId, filters.branch)] : []),
...(filters.salesman ? [eq(crmEnquiries.assignedToUserId, filters.salesman)] : []),
...(filters.productType ? [eq(crmEnquiries.productType, filters.productType)] : []),
eq(crmOpportunities.organizationId, organizationId),
isNull(crmOpportunities.deletedAt),
eq(crmOpportunities.pipelineStage, stage),
...(filters.branch ? [eq(crmOpportunities.branchId, filters.branch)] : []),
...(filters.salesman ? [eq(crmOpportunities.assignedToUserId, filters.salesman)] : []),
...(filters.productType ? [eq(crmOpportunities.productType, filters.productType)] : []),
...(filters.dateFrom ? [gte(dateColumn, new Date(filters.dateFrom))] : []),
...(filters.dateTo ? [lte(dateColumn, new Date(filters.dateTo))] : [])
];
@@ -412,38 +412,38 @@ async function getOutcomeRevenueRecords(
): Promise<OutcomeRevenueRecord[]> {
const whereFilters = buildOutcomeRevenueFilters(organizationId, stage, filters);
const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters);
const enquiries = await db.select().from(crmEnquiries).where(where);
const opportunities = await db.select().from(crmOpportunities).where(where);
if (!enquiries.length) {
if (!opportunities.length) {
return [];
}
const enquiryIds = enquiries.map((row) => row.id);
const opportunityIds = opportunities.map((row) => row.id);
const quotationRows = await db
.select({
enquiryId: crmQuotations.enquiryId,
opportunityId: crmQuotations.opportunityId,
totalAmount: crmQuotations.totalAmount
})
.from(crmQuotations)
.where(
and(
eq(crmQuotations.organizationId, organizationId),
inArray(crmQuotations.enquiryId, enquiryIds),
inArray(crmQuotations.opportunityId, opportunityIds),
isNull(crmQuotations.deletedAt)
)
);
const quotationTotals = new Map<string, number>();
for (const row of quotationRows) {
if (!row.enquiryId) {
if (!row.opportunityId) {
continue;
}
quotationTotals.set(row.enquiryId, (quotationTotals.get(row.enquiryId) ?? 0) + row.totalAmount);
quotationTotals.set(row.opportunityId, (quotationTotals.get(row.opportunityId) ?? 0) + row.totalAmount);
}
return enquiries.map((row) => ({
enquiryId: row.id,
return opportunities.map((row) => ({
opportunityId: row.id,
customerId: row.customerId,
branchId: row.branchId,
productType: row.productType,
@@ -473,17 +473,17 @@ export async function getLostByReason(
filters: OutcomeRevenueFilters = {}
): Promise<OutcomeBreakdownRow[]> {
const records = await getLostRevenue(organizationId, filters);
const enquiryIds = records.map((row) => row.enquiryId);
const opportunityIds = records.map((row) => row.opportunityId);
if (!enquiryIds.length) {
if (!opportunityIds.length) {
return [];
}
const [enquiries, lostReasonOptions] = await Promise.all([
const [opportunities, lostReasonOptions] = await Promise.all([
db
.select({ id: crmEnquiries.id, lostReason: crmEnquiries.lostReason })
.from(crmEnquiries)
.where(inArray(crmEnquiries.id, enquiryIds)),
.select({ id: crmOpportunities.id, lostReason: crmOpportunities.lostReason })
.from(crmOpportunities)
.where(inArray(crmOpportunities.id, opportunityIds)),
getActiveOptionsByCategory('crm_lost_reason', { organizationId })
]);
const lostReasonMap = new Map(
@@ -495,8 +495,8 @@ export async function getLostByReason(
const grouped = new Map<string, OutcomeBreakdownRow>();
for (const row of records) {
const enquiry = enquiries.find((item) => item.id === row.enquiryId);
const key = enquiry?.lostReason ?? 'unknown';
const opportunity = opportunities.find((item) => item.id === row.opportunityId);
const key = opportunity?.lostReason ?? 'unknown';
const current = grouped.get(key) ?? {
key,
label: lostReasonMap.get(key) ?? key,
@@ -516,21 +516,21 @@ export async function getLostByCompetitor(
filters: OutcomeRevenueFilters = {}
): Promise<OutcomeBreakdownRow[]> {
const records = await getLostRevenue(organizationId, filters);
const enquiryIds = records.map((row) => row.enquiryId);
const opportunityIds = records.map((row) => row.opportunityId);
if (!enquiryIds.length) {
if (!opportunityIds.length) {
return [];
}
const enquiries = await db
.select({ id: crmEnquiries.id, lostCompetitor: crmEnquiries.lostCompetitor })
.from(crmEnquiries)
.where(inArray(crmEnquiries.id, enquiryIds));
const opportunities = await db
.select({ id: crmOpportunities.id, lostCompetitor: crmOpportunities.lostCompetitor })
.from(crmOpportunities)
.where(inArray(crmOpportunities.id, opportunityIds));
const grouped = new Map<string, OutcomeBreakdownRow>();
for (const row of records) {
const enquiry = enquiries.find((item) => item.id === row.enquiryId);
const key = enquiry?.lostCompetitor?.trim() || 'unknown';
const opportunity = opportunities.find((item) => item.id === row.opportunityId);
const key = opportunity?.lostCompetitor?.trim() || 'unknown';
const current = grouped.get(key) ?? { key, label: key, count: 0, revenue: 0 };
current.count += 1;
current.revenue += row.revenue;
@@ -556,3 +556,4 @@ export async function getWinRate(
return Math.round((wonRows.length / denominator) * 10000) / 100;
}

View File

@@ -4,7 +4,7 @@ export interface RevenueAttributionFilters {
quotationType?: string;
branch?: string;
customer?: string;
enquiry?: string;
opportunity?: string;
hot?: string;
quotationDateFrom?: string | null;
quotationDateTo?: string | null;
@@ -30,7 +30,7 @@ export interface OutcomeRevenueFilters {
}
export interface OutcomeRevenueRecord {
enquiryId: string;
opportunityId: string;
customerId: string;
branchId: string | null;
productType: string;
@@ -45,3 +45,4 @@ export interface OutcomeBreakdownRow {
count: number;
revenue: number;
}

View File

@@ -1,7 +1,7 @@
import { queryOptions } from '@tanstack/react-query';
import type { CrmSharedReportFilters } from './types';
import {
getCrmEnquiryAgingReport,
getCrmOpportunityAgingReport,
getCrmLeadAgingReport,
getCrmPipelineReport,
getCrmReportCatalog,
@@ -18,8 +18,8 @@ export const crmReportKeys = {
pipeline: (filters: CrmSharedReportFilters) => [...crmReportKeys.pipelineRoot(), filters] as const,
leadAgingRoot: () => [...crmReportKeys.all, 'lead-aging'] as const,
leadAging: (filters: CrmSharedReportFilters) => [...crmReportKeys.leadAgingRoot(), filters] as const,
enquiryAgingRoot: () => [...crmReportKeys.all, 'enquiry-aging'] as const,
enquiryAging: (filters: CrmSharedReportFilters) => [...crmReportKeys.enquiryAgingRoot(), filters] as const
opportunityAgingRoot: () => [...crmReportKeys.all, 'opportunity-aging'] as const,
opportunityAging: (filters: CrmSharedReportFilters) => [...crmReportKeys.opportunityAgingRoot(), filters] as const
};
export const crmReportsQueryOptions = () =>
@@ -52,8 +52,9 @@ export const crmLeadAgingReportQueryOptions = (filters: CrmSharedReportFilters)
queryFn: () => getCrmLeadAgingReport(filters)
});
export const crmEnquiryAgingReportQueryOptions = (filters: CrmSharedReportFilters) =>
export const crmOpportunityAgingReportQueryOptions = (filters: CrmSharedReportFilters) =>
queryOptions({
queryKey: crmReportKeys.enquiryAging(filters),
queryFn: () => getCrmEnquiryAgingReport(filters)
queryKey: crmReportKeys.opportunityAging(filters),
queryFn: () => getCrmOpportunityAgingReport(filters)
});

View File

@@ -6,7 +6,7 @@ import type {
CrmSharedReportFilters,
CrmPipelineReportResponse,
CrmLeadAgingReportResponse,
CrmEnquiryAgingReportResponse
CrmOpportunityAgingReportResponse
} from './types';
function buildReportQuery(filters: CrmSharedReportFilters) {
@@ -51,9 +51,10 @@ export async function getCrmLeadAgingReport(filters: CrmSharedReportFilters) {
);
}
export async function getCrmEnquiryAgingReport(filters: CrmSharedReportFilters) {
export async function getCrmOpportunityAgingReport(filters: CrmSharedReportFilters) {
const query = buildReportQuery(filters);
return apiClient<CrmEnquiryAgingReportResponse>(
`/crm/reports/enquiry-aging${query ? `?${query}` : ''}`
return apiClient<CrmOpportunityAgingReportResponse>(
`/crm/reports/opportunity-aging${query ? `?${query}` : ''}`
);
}

View File

@@ -87,12 +87,12 @@ export interface CrmPipelineReportRow {
convertedLeads: number;
}
export interface CrmEnquiryPipelineReportRow {
export interface CrmOpportunityPipelineReportRow {
salesmanName: string;
branchLabel: string;
productTypeLabel: string;
customerOwnerName: string;
openEnquiries: number;
openOpportunities: number;
convertedToQuotation: number;
won: number;
lost: number;
@@ -104,21 +104,21 @@ export interface CrmLeadConversionReportRow {
productTypeLabel: string;
marketingUserName: string;
totalLeads: number;
convertedEnquiries: number;
convertedOpportunities: number;
conversionRate: number;
}
export interface CrmEnquiryConversionReportRow {
export interface CrmOpportunityConversionReportRow {
salesmanName: string;
branchLabel: string;
productTypeLabel: string;
totalEnquiries: number;
enquiriesWithQuotation: number;
totalOpportunities: number;
opportunitiesWithQuotation: number;
conversionRate: number;
}
export interface CrmPipelineFunnelRow {
stageKey: 'lead' | 'enquiry' | 'quotation' | 'closed_won' | 'closed_lost';
stageKey: 'lead' | 'opportunity' | 'quotation' | 'closed_won' | 'closed_lost';
stageLabel: string;
count: number;
value: number | null;
@@ -127,7 +127,7 @@ export interface CrmPipelineFunnelRow {
export interface CrmPipelineKpiSummary {
totalLeads: number;
openEnquiries: number;
openOpportunities: number;
quotations: number;
won: number;
lost: number;
@@ -141,9 +141,9 @@ export interface CrmPipelineReportResponse {
filters: Required<CrmSharedReportFilters>;
summary: CrmPipelineKpiSummary;
leadPipeline: CrmPipelineReportRow[];
enquiryPipeline: CrmEnquiryPipelineReportRow[];
opportunityPipeline: CrmOpportunityPipelineReportRow[];
leadConversion: CrmLeadConversionReportRow[];
enquiryConversion: CrmEnquiryConversionReportRow[];
opportunityConversion: CrmOpportunityConversionReportRow[];
funnel: CrmPipelineFunnelRow[];
}
@@ -176,25 +176,26 @@ export interface CrmLeadAgingReportResponse {
rows: CrmLeadAgingRow[];
}
export interface CrmEnquiryAgingRow {
enquiryId: string;
enquiryCode: string;
export interface CrmOpportunityAgingRow {
opportunityId: string;
opportunityCode: string;
customerName: string;
salesmanName: string | null;
lastFollowupDate: string | null;
agingDays: number;
}
export interface CrmEnquiryAgingReportResponse {
export interface CrmOpportunityAgingReportResponse {
success: boolean;
time: string;
message: string;
filters: Required<CrmSharedReportFilters>;
summary: {
openEnquiries: number;
openOpportunities: number;
averageAgingDays: number;
maximumAgingDays: number;
};
buckets: CrmAgingBucket[];
rows: CrmEnquiryAgingRow[];
rows: CrmOpportunityAgingRow[];
}

View File

@@ -13,11 +13,11 @@ import {
TableRow
} from '@/components/ui/table';
import type {
CrmEnquiryAgingReportResponse,
CrmOpportunityAgingReportResponse,
CrmLeadAgingReportResponse
} from '../api/types';
import {
crmEnquiryAgingReportQueryOptions,
crmOpportunityAgingReportQueryOptions,
crmLeadAgingReportQueryOptions,
crmReportFiltersQueryOptions
} from '../api/queries';
@@ -32,9 +32,9 @@ function AgingReportLayout({
canExport,
data
}: {
variant: 'lead' | 'enquiry';
variant: 'lead' | 'opportunity';
canExport: boolean;
data: CrmLeadAgingReportResponse | CrmEnquiryAgingReportResponse;
data: CrmLeadAgingReportResponse | CrmOpportunityAgingReportResponse;
}) {
const { data: filterData } = useSuspenseQuery(crmReportFiltersQueryOptions());
@@ -47,8 +47,8 @@ function AgingReportLayout({
]
: [
[
'Open Enquiries',
formatNumber('openEnquiries' in data.summary ? data.summary.openEnquiries : 0)
'Open Opportunities',
formatNumber('openOpportunities' in data.summary ? data.summary.openOpportunities : 0)
],
['Average Aging', `${data.summary.averageAgingDays} days`],
['Maximum Aging', `${data.summary.maximumAgingDays} days`]
@@ -64,11 +64,11 @@ function AgingReportLayout({
: ['dateFrom', 'dateTo', 'branch', 'productType', 'sales']
}
canExport={canExport}
reportCode={variant === 'lead' ? 'lead_aging' : 'enquiry_aging'}
reportCode={variant === 'lead' ? 'lead_aging' : 'opportunity_aging'}
pageLinks={[
{ href: '/dashboard/crm/reports/pipeline', label: 'Pipeline Suite' },
{ href: '/dashboard/crm/reports/lead-aging', label: 'Lead Aging', active: variant === 'lead' },
{ href: '/dashboard/crm/reports/enquiry-aging', label: 'Enquiry Aging', active: variant === 'enquiry' }
{ href: '/dashboard/crm/reports/opportunity-aging', label: 'Opportunity Aging', active: variant === 'opportunity' }
]}
/>
@@ -87,7 +87,7 @@ function AgingReportLayout({
<Card>
<CardHeader>
<CardTitle>{variant === 'lead' ? 'Lead Aging Buckets' : 'Enquiry Aging Buckets'}</CardTitle>
<CardTitle>{variant === 'lead' ? 'Lead Aging Buckets' : 'Opportunity Aging Buckets'}</CardTitle>
<CardDescription>Bucketed view to spot stagnant records quickly.</CardDescription>
</CardHeader>
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
@@ -102,11 +102,11 @@ function AgingReportLayout({
<Card>
<CardHeader>
<CardTitle>{variant === 'lead' ? 'Lead Drill-down' : 'Enquiry Drill-down'}</CardTitle>
<CardTitle>{variant === 'lead' ? 'Lead Drill-down' : 'Opportunity Drill-down'}</CardTitle>
<CardDescription>
{variant === 'lead'
? 'Open leads sorted from oldest to newest.'
: 'Open enquiries with latest follow-up visibility.'}
: 'Open opportunities with latest follow-up visibility.'}
</CardDescription>
</CardHeader>
<CardContent>
@@ -124,7 +124,7 @@ function AgingReportLayout({
</>
) : (
<>
<TableHead>Enquiry No</TableHead>
<TableHead>Opportunity No</TableHead>
<TableHead>Customer</TableHead>
<TableHead>Salesman</TableHead>
<TableHead>Last Follow-Up</TableHead>
@@ -136,7 +136,7 @@ function AgingReportLayout({
<TableBody>
{data.rows.length ? (
data.rows.map((row) => (
<TableRow key={'leadId' in row ? row.leadId : row.enquiryId}>
<TableRow key={'leadId' in row ? row.leadId : row.opportunityId}>
{'leadId' in row ? (
<>
<TableCell>{row.leadCode}</TableCell>
@@ -147,7 +147,7 @@ function AgingReportLayout({
</>
) : (
<>
<TableCell>{row.enquiryCode}</TableCell>
<TableCell>{row.opportunityCode}</TableCell>
<TableCell>{row.customerName}</TableCell>
<TableCell>{row.salesmanName ?? '-'}</TableCell>
<TableCell>
@@ -202,7 +202,7 @@ function LeadAgingReportInner({ canExport }: { canExport: boolean }) {
return <AgingReportLayout variant='lead' canExport={canExport} data={data} />;
}
function EnquiryAgingReportInner({ canExport }: { canExport: boolean }) {
function OpportunityAgingReportInner({ canExport }: { canExport: boolean }) {
const [params] = useQueryStates({
dateFrom: parseAsString,
dateTo: parseAsString,
@@ -222,21 +222,22 @@ function EnquiryAgingReportInner({ canExport }: { canExport: boolean }) {
}),
[params]
);
const { data } = useSuspenseQuery(crmEnquiryAgingReportQueryOptions(filters));
const { data } = useSuspenseQuery(crmOpportunityAgingReportQueryOptions(filters));
return <AgingReportLayout variant='enquiry' canExport={canExport} data={data} />;
return <AgingReportLayout variant='opportunity' canExport={canExport} data={data} />;
}
export function AgingReportView({
variant,
canExport
}: {
variant: 'lead' | 'enquiry';
variant: 'lead' | 'opportunity';
canExport: boolean;
}) {
return variant === 'lead' ? (
<LeadAgingReportInner canExport={canExport} />
) : (
<EnquiryAgingReportInner canExport={canExport} />
<OpportunityAgingReportInner canExport={canExport} />
);
}

View File

@@ -124,14 +124,14 @@ export function PipelineReportView({ canExport }: { canExport: boolean }) {
pageLinks={[
{ href: '/dashboard/crm/reports/pipeline', label: 'Pipeline Suite', active: true },
{ href: '/dashboard/crm/reports/lead-aging', label: 'Lead Aging' },
{ href: '/dashboard/crm/reports/enquiry-aging', label: 'Enquiry Aging' }
{ href: '/dashboard/crm/reports/opportunity-aging', label: 'Opportunity Aging' }
]}
/>
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-5'>
{[
['Total Leads', formatNumber(data.summary.totalLeads)],
['Open Enquiries', formatNumber(data.summary.openEnquiries)],
['Open Opportunities', formatNumber(data.summary.openOpportunities)],
['Quotations', formatNumber(data.summary.quotations)],
['Closed Won', formatNumber(data.summary.won)],
['Closed Lost', formatNumber(data.summary.lost)]
@@ -193,9 +193,9 @@ export function PipelineReportView({ canExport }: { canExport: boolean }) {
/>
<SectionTable
title='Enquiry Pipeline'
title='Opportunity Pipeline'
description='Sales pipeline visibility grouped by salesman, branch, product type, and customer owner.'
reportCode='enquiry_pipeline'
reportCode='opportunity_pipeline'
headers={[
'Salesman',
'Branch',
@@ -206,12 +206,12 @@ export function PipelineReportView({ canExport }: { canExport: boolean }) {
'Won',
'Lost'
]}
rows={data.enquiryPipeline.map((row) => [
rows={data.opportunityPipeline.map((row) => [
row.salesmanName,
row.branchLabel,
row.productTypeLabel,
row.customerOwnerName,
formatNumber(row.openEnquiries),
formatNumber(row.openOpportunities),
formatNumber(row.convertedToQuotation),
formatNumber(row.won),
formatNumber(row.lost)
@@ -220,7 +220,7 @@ export function PipelineReportView({ canExport }: { canExport: boolean }) {
<div className='grid gap-6 xl:grid-cols-2'>
<SectionTable
title='Lead to Enquiry Conversion'
title='Lead to Opportunity Conversion'
description='Marketing effectiveness by source, branch, product type, and creator.'
reportCode='lead_conversion'
headers={['Lead Source', 'Branch', 'Product Type', 'Marketing User', 'Total Leads', 'Converted', 'Conversion %']}
@@ -230,22 +230,22 @@ export function PipelineReportView({ canExport }: { canExport: boolean }) {
row.productTypeLabel,
row.marketingUserName,
formatNumber(row.totalLeads),
formatNumber(row.convertedEnquiries),
formatNumber(row.convertedOpportunities),
`${row.conversionRate}%`
])}
/>
<SectionTable
title='Enquiry to Quotation Conversion'
title='Opportunity to Quotation Conversion'
description='Sales effectiveness by salesman, branch, and product type.'
reportCode='enquiry_conversion'
headers={['Salesman', 'Branch', 'Product Type', 'Total Enquiries', 'With Quotation', 'Conversion %']}
rows={data.enquiryConversion.map((row) => [
reportCode='opportunity_conversion'
headers={['Salesman', 'Branch', 'Product Type', 'Total Opportunities', 'With Quotation', 'Conversion %']}
rows={data.opportunityConversion.map((row) => [
row.salesmanName,
row.branchLabel,
row.productTypeLabel,
formatNumber(row.totalEnquiries),
formatNumber(row.enquiriesWithQuotation),
formatNumber(row.totalOpportunities),
formatNumber(row.opportunitiesWithQuotation),
`${row.conversionRate}%`
])}
/>
@@ -253,3 +253,4 @@ export function PipelineReportView({ canExport }: { canExport: boolean }) {
</div>
);
}

View File

@@ -9,12 +9,12 @@ import { crmReportCatalogQueryOptions, crmReportFiltersQueryOptions } from '../a
const REPORT_LINKS: Record<string, string> = {
lead_pipeline: '/dashboard/crm/reports/pipeline',
enquiry_pipeline: '/dashboard/crm/reports/pipeline',
opportunity_pipeline: '/dashboard/crm/reports/pipeline',
lead_conversion: '/dashboard/crm/reports/pipeline',
enquiry_conversion: '/dashboard/crm/reports/pipeline',
opportunity_conversion: '/dashboard/crm/reports/pipeline',
pipeline_funnel: '/dashboard/crm/reports/pipeline',
lead_aging: '/dashboard/crm/reports/lead-aging',
enquiry_aging: '/dashboard/crm/reports/enquiry-aging'
opportunity_aging: '/dashboard/crm/reports/opportunity-aging'
};
export function ReportCatalog() {
@@ -88,3 +88,4 @@ export function ReportCatalog() {
</div>
);
}

View File

@@ -1,8 +1,8 @@
import { and, asc, count, eq, inArray, isNull, sql } from 'drizzle-orm';
import {
crmCustomers,
crmEnquiries,
crmEnquiryFollowups,
crmOpportunities,
crmOpportunityFollowups,
crmQuotations,
users
} from '@/db/schema';
@@ -89,46 +89,46 @@ export async function listPipelineReportDataset(
}> {
const requiredFilters = buildRequiredFilters(filters);
const { start, end } = getDateRange(requiredFilters);
const [branches, productTypes, leadSources, enquiryRows, quotationRows, followupRows] =
const [branches, productTypes, leadSources, opportunityRows, quotationRows, followupRows] =
await Promise.all([
getUserBranches(),
getActiveOptionsByCategory('crm_product_type', { organizationId: context.organizationId }),
getActiveOptionsByCategory('crm_lead_channel', { organizationId: context.organizationId }),
db
.select({
id: crmEnquiries.id,
code: crmEnquiries.code,
customerId: crmEnquiries.customerId,
branchId: crmEnquiries.branchId,
productType: crmEnquiries.productType,
leadChannel: crmEnquiries.leadChannel,
pipelineStage: crmEnquiries.pipelineStage,
createdAt: crmEnquiries.createdAt,
createdBy: crmEnquiries.createdBy,
assignedToUserId: crmEnquiries.assignedToUserId,
estimatedValue: crmEnquiries.estimatedValue,
poAmount: crmEnquiries.poAmount,
closedWonAt: crmEnquiries.closedWonAt,
closedLostAt: crmEnquiries.closedLostAt,
lostReason: crmEnquiries.lostReason,
id: crmOpportunities.id,
code: crmOpportunities.code,
customerId: crmOpportunities.customerId,
branchId: crmOpportunities.branchId,
productType: crmOpportunities.productType,
leadChannel: crmOpportunities.leadChannel,
pipelineStage: crmOpportunities.pipelineStage,
createdAt: crmOpportunities.createdAt,
createdBy: crmOpportunities.createdBy,
assignedToUserId: crmOpportunities.assignedToUserId,
estimatedValue: crmOpportunities.estimatedValue,
poAmount: crmOpportunities.poAmount,
closedWonAt: crmOpportunities.closedWonAt,
closedLostAt: crmOpportunities.closedLostAt,
lostReason: crmOpportunities.lostReason,
customerName: crmCustomers.name,
customerOwnerUserId: crmCustomers.ownerUserId
})
.from(crmEnquiries)
.innerJoin(crmCustomers, eq(crmCustomers.id, crmEnquiries.customerId))
.from(crmOpportunities)
.innerJoin(crmCustomers, eq(crmCustomers.id, crmOpportunities.customerId))
.where(
and(
eq(crmEnquiries.organizationId, context.organizationId),
isNull(crmEnquiries.deletedAt),
eq(crmEnquiries.isActive, true),
eq(crmOpportunities.organizationId, context.organizationId),
isNull(crmOpportunities.deletedAt),
eq(crmOpportunities.isActive, true),
isNull(crmCustomers.deletedAt),
eq(crmCustomers.isActive, true)
)
)
.orderBy(asc(crmEnquiries.createdAt)),
.orderBy(asc(crmOpportunities.createdAt)),
db
.select({
enquiryId: crmQuotations.enquiryId,
opportunityId: crmQuotations.opportunityId,
quotationCount: count(crmQuotations.id),
quotationValue: sql<number>`coalesce(sum(${crmQuotations.totalAmount}), 0)`
})
@@ -138,28 +138,28 @@ export async function listPipelineReportDataset(
eq(crmQuotations.organizationId, context.organizationId),
isNull(crmQuotations.deletedAt),
eq(crmQuotations.isActive, true),
sql`${crmQuotations.enquiryId} is not null`
sql`${crmQuotations.opportunityId} is not null`
)
)
.groupBy(crmQuotations.enquiryId),
.groupBy(crmQuotations.opportunityId),
db
.select({
enquiryId: crmEnquiryFollowups.enquiryId,
lastFollowupDate: sql<Date | null>`max(${crmEnquiryFollowups.followupDate})`
opportunityId: crmOpportunityFollowups.opportunityId,
lastFollowupDate: sql<Date | null>`max(${crmOpportunityFollowups.followupDate})`
})
.from(crmEnquiryFollowups)
.from(crmOpportunityFollowups)
.where(
and(
eq(crmEnquiryFollowups.organizationId, context.organizationId),
isNull(crmEnquiryFollowups.deletedAt)
eq(crmOpportunityFollowups.organizationId, context.organizationId),
isNull(crmOpportunityFollowups.deletedAt)
)
)
.groupBy(crmEnquiryFollowups.enquiryId)
.groupBy(crmOpportunityFollowups.opportunityId)
]);
const userIds = [
...new Set(
enquiryRows.flatMap((row) => [
opportunityRows.flatMap((row) => [
row.createdBy,
row.assignedToUserId,
row.customerOwnerUserId
@@ -178,19 +178,19 @@ export async function listPipelineReportDataset(
const leadSourceLabelMap = new Map(leadSources.map((item) => [item.id, item.label]));
const quotationMap = new Map(
quotationRows
.filter((row): row is typeof row & { enquiryId: string } => typeof row.enquiryId === 'string')
.filter((row): row is typeof row & { opportunityId: string } => typeof row.opportunityId === 'string')
.map((row) => [
row.enquiryId,
row.opportunityId,
{
quotationCount: Number(row.quotationCount ?? 0),
quotationValue: Number(row.quotationValue ?? 0)
}
])
);
const followupMap = new Map(followupRows.map((row) => [row.enquiryId, row.lastFollowupDate]));
const followupMap = new Map(followupRows.map((row) => [row.opportunityId, row.lastFollowupDate]));
const userMap = new Map(userRows.map((row) => [row.id, row.name]));
const rows = enquiryRows
const rows = opportunityRows
.filter((row) => canAccessRecord(context, row))
.filter((row) => matchesDateRange(row.createdAt, start, end))
.filter((row) => (requiredFilters.branch ? row.branchId === requiredFilters.branch : true))
@@ -222,8 +222,8 @@ export async function listPipelineReportDataset(
const quotation = quotationMap.get(row.id);
return {
enquiryId: row.id,
enquiryCode: row.code,
opportunityId: row.id,
opportunityCode: row.code,
customerId: row.customerId,
customerName: row.customerName,
branchId: row.branchId,
@@ -260,3 +260,4 @@ export async function listPipelineReportDataset(
rows
};
}

View File

@@ -9,7 +9,7 @@ import type { ResolvedReportContext } from '../types';
const PIPELINE_STAGE_OPTIONS = [
{ id: 'lead', code: 'lead', label: 'Lead' },
{ id: 'enquiry', code: 'enquiry', label: 'Enquiry' },
{ id: 'opportunity', code: 'opportunity', label: 'Opportunity' },
{ id: 'closed_won', code: 'closed_won', label: 'Won' },
{ id: 'closed_lost', code: 'closed_lost', label: 'Lost' }
] as const;
@@ -85,3 +85,4 @@ export function parseSharedReportFilters(searchParams: URLSearchParams): CrmShar
lostReason: searchParams.get('lostReason')
};
}

Some files were not shown because too many files have changed in this diff Show More