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