task-d.5.6
This commit is contained in:
69
src/app/api/crm/opportunities/[id]/mark-cancelled/route.ts
Normal file
69
src/app/api/crm/opportunities/[id]/mark-cancelled/route.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { opportunityMarkCancelledSchema } from '@/features/crm/opportunities/schemas/lifecycle.schema';
|
||||
import { markOpportunityCancelled } from '@/features/crm/opportunities/server/lifecycle.service';
|
||||
import { getOpportunityDetail } from '@/features/crm/opportunities/server/service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmOpportunityMarkCancelled
|
||||
});
|
||||
|
||||
const payload = opportunityMarkCancelledSchema.parse(await request.json());
|
||||
const accessContext = {
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membershipRole: membership.role,
|
||||
businessRole: membership.businessRole,
|
||||
permissions: membership.permissions ?? [],
|
||||
branchScopeIds: membership.branchScopeIds ?? [],
|
||||
productTypeScopeIds: membership.productTypeScopeIds ?? [],
|
||||
ownershipScope: membership.ownershipScope ?? 'organization',
|
||||
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
|
||||
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
|
||||
};
|
||||
|
||||
const before = await getOpportunityDetail(id, organization.id, accessContext);
|
||||
const updated = await markOpportunityCancelled(
|
||||
id,
|
||||
organization.id,
|
||||
session.user.id,
|
||||
payload,
|
||||
accessContext
|
||||
);
|
||||
const after = await getOpportunityDetail(id, organization.id, accessContext);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Opportunity marked as cancelled successfully',
|
||||
before,
|
||||
opportunity: updated,
|
||||
after
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ message: error.issues[0]?.message ?? 'Invalid payload' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'Unable to mark opportunity as cancelled' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
opportunityMarkLostSchema
|
||||
} from '@/features/crm/opportunities/schemas/opportunity.schema';
|
||||
import { getOpportunityDetail, markOpportunityAsLost } from '@/features/crm/opportunities/server/service';
|
||||
import { opportunityMarkLostSchema } from '@/features/crm/opportunities/schemas/lifecycle.schema';
|
||||
import { markOpportunityLost } from '@/features/crm/opportunities/server/lifecycle.service';
|
||||
import { getOpportunityDetail } from '@/features/crm/opportunities/server/service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
|
||||
@@ -17,6 +16,7 @@ export async function POST(request: NextRequest, { params }: Params) {
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmOpportunityMarkLost
|
||||
});
|
||||
|
||||
const payload = opportunityMarkLostSchema.parse(await request.json());
|
||||
const accessContext = {
|
||||
organizationId: organization.id,
|
||||
@@ -30,8 +30,9 @@ export async function POST(request: NextRequest, { params }: Params) {
|
||||
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
|
||||
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
|
||||
};
|
||||
|
||||
const before = await getOpportunityDetail(id, organization.id, accessContext);
|
||||
const updated = await markOpportunityAsLost(id, organization.id, session.user.id, payload, accessContext);
|
||||
const updated = await markOpportunityLost(id, organization.id, session.user.id, payload, accessContext);
|
||||
const after = await getOpportunityDetail(id, organization.id, accessContext);
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -60,4 +61,3 @@ export async function POST(request: NextRequest, { params }: Params) {
|
||||
return NextResponse.json({ message: 'Unable to mark opportunity as lost' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { opportunityMarkNoQuotationSchema } from '@/features/crm/opportunities/schemas/lifecycle.schema';
|
||||
import { markOpportunityNoQuotation } from '@/features/crm/opportunities/server/lifecycle.service';
|
||||
import { getOpportunityDetail } from '@/features/crm/opportunities/server/service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
|
||||
type Params = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export async function POST(request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmOpportunityMarkNoQuotation
|
||||
});
|
||||
|
||||
const payload = opportunityMarkNoQuotationSchema.parse(await request.json());
|
||||
const accessContext = {
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membershipRole: membership.role,
|
||||
businessRole: membership.businessRole,
|
||||
permissions: membership.permissions ?? [],
|
||||
branchScopeIds: membership.branchScopeIds ?? [],
|
||||
productTypeScopeIds: membership.productTypeScopeIds ?? [],
|
||||
ownershipScope: membership.ownershipScope ?? 'organization',
|
||||
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
|
||||
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
|
||||
};
|
||||
|
||||
const before = await getOpportunityDetail(id, organization.id, accessContext);
|
||||
const updated = await markOpportunityNoQuotation(
|
||||
id,
|
||||
organization.id,
|
||||
session.user.id,
|
||||
payload,
|
||||
accessContext
|
||||
);
|
||||
const after = await getOpportunityDetail(id, organization.id, accessContext);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Opportunity marked as no quotation successfully',
|
||||
before,
|
||||
opportunity: updated,
|
||||
after
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
||||
}
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ message: error.issues[0]?.message ?? 'Invalid payload' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'Unable to mark opportunity as no quotation' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
opportunityMarkWonSchema
|
||||
} from '@/features/crm/opportunities/schemas/opportunity.schema';
|
||||
import { getOpportunityDetail, markOpportunityAsWon } from '@/features/crm/opportunities/server/service';
|
||||
import { opportunityMarkWonSchema } from '@/features/crm/opportunities/schemas/lifecycle.schema';
|
||||
import { markOpportunityWon } from '@/features/crm/opportunities/server/lifecycle.service';
|
||||
import { getOpportunityDetail } from '@/features/crm/opportunities/server/service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
|
||||
@@ -17,6 +16,7 @@ export async function POST(request: NextRequest, { params }: Params) {
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmOpportunityMarkWon
|
||||
});
|
||||
|
||||
const payload = opportunityMarkWonSchema.parse(await request.json());
|
||||
const accessContext = {
|
||||
organizationId: organization.id,
|
||||
@@ -30,8 +30,9 @@ export async function POST(request: NextRequest, { params }: Params) {
|
||||
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
|
||||
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
|
||||
};
|
||||
|
||||
const before = await getOpportunityDetail(id, organization.id, accessContext);
|
||||
const updated = await markOpportunityAsWon(id, organization.id, session.user.id, payload, accessContext);
|
||||
const updated = await markOpportunityWon(id, organization.id, session.user.id, payload, accessContext);
|
||||
const after = await getOpportunityDetail(id, organization.id, accessContext);
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -60,4 +61,3 @@ export async function POST(request: NextRequest, { params }: Params) {
|
||||
return NextResponse.json({ message: 'Unable to mark opportunity as won' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { opportunityReopenSchema } from '@/features/crm/opportunities/schemas/opportunity.schema';
|
||||
import { getOpportunityDetail, reopenLostOpportunity } from '@/features/crm/opportunities/server/service';
|
||||
import { opportunityReopenSchema } from '@/features/crm/opportunities/schemas/lifecycle.schema';
|
||||
import { reopenOpportunity } from '@/features/crm/opportunities/server/lifecycle.service';
|
||||
import { getOpportunityDetail } from '@/features/crm/opportunities/server/service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
|
||||
@@ -13,8 +14,9 @@ export async function POST(request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmOpportunityReopen
|
||||
permission: PERMISSIONS.crmOpportunityLifecycleReopen
|
||||
});
|
||||
|
||||
const payload = opportunityReopenSchema.parse(await request.json());
|
||||
const accessContext = {
|
||||
organizationId: organization.id,
|
||||
@@ -28,8 +30,9 @@ export async function POST(request: NextRequest, { params }: Params) {
|
||||
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
|
||||
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
|
||||
};
|
||||
|
||||
const before = await getOpportunityDetail(id, organization.id, accessContext);
|
||||
const updated = await reopenLostOpportunity(id, organization.id, session.user.id, payload, accessContext);
|
||||
const updated = await reopenOpportunity(id, organization.id, session.user.id, payload, accessContext);
|
||||
const after = await getOpportunityDetail(id, organization.id, accessContext);
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -58,4 +61,3 @@ export async function POST(request: NextRequest, { params }: Params) {
|
||||
return NextResponse.json({ message: 'Unable to reopen opportunity' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { auditAction, auditCreate } from '@/features/foundation/audit-log/service';
|
||||
import { createOpportunity, listOpportunities } from '@/features/crm/opportunities/server/service';
|
||||
import { getCustomerDetail } from '@/features/crm/customers/server/service';
|
||||
import { opportunitySchema } from '@/features/crm/opportunities/schemas/opportunity.schema';
|
||||
import { createOpportunity, listOpportunities } from '@/features/crm/opportunities/server/service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
|
||||
@@ -15,56 +15,62 @@ const opportunityRequestSchema = opportunitySchema.extend({
|
||||
expectedCloseDate: z.string().nullable().optional()
|
||||
});
|
||||
|
||||
function buildAccessContext(
|
||||
organizationId: string,
|
||||
sessionUserId: string,
|
||||
membership: Awaited<ReturnType<typeof requireOrganizationAccess>>['membership']
|
||||
) {
|
||||
return {
|
||||
organizationId,
|
||||
userId: sessionUserId,
|
||||
membershipRole: membership.role,
|
||||
businessRole: membership.businessRole,
|
||||
permissions: membership.permissions ?? [],
|
||||
branchScopeIds: membership.branchScopeIds ?? [],
|
||||
productTypeScopeIds: membership.productTypeScopeIds ?? [],
|
||||
ownershipScope: membership.ownershipScope ?? 'organization',
|
||||
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
|
||||
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmOpportunityRead
|
||||
});
|
||||
|
||||
const { searchParams } = request.nextUrl;
|
||||
const accessContext = buildAccessContext(organization.id, session.user.id, membership);
|
||||
const page = Number(searchParams.get('page') ?? 1);
|
||||
const limit = Number(searchParams.get('limit') ?? 10);
|
||||
const search = searchParams.get('search') ?? undefined;
|
||||
const pipelineStage = searchParams.get('pipelineStage') ?? undefined;
|
||||
const status = searchParams.get('status') ?? undefined;
|
||||
const productType = searchParams.get('productType') ?? undefined;
|
||||
const priority = searchParams.get('priority') ?? undefined;
|
||||
const branch = searchParams.get('branch') ?? undefined;
|
||||
const customer = searchParams.get('customer') ?? undefined;
|
||||
const sort = searchParams.get('sort') ?? undefined;
|
||||
const accessContext = {
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membershipRole: membership.role,
|
||||
businessRole: membership.businessRole,
|
||||
branchScopeIds: membership.branchScopeIds ?? [],
|
||||
productTypeScopeIds: membership.productTypeScopeIds ?? [],
|
||||
ownershipScope: membership.ownershipScope ?? 'organization',
|
||||
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
|
||||
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
|
||||
};
|
||||
const result = await listOpportunities(
|
||||
accessContext,
|
||||
{
|
||||
page,
|
||||
limit,
|
||||
search,
|
||||
pipelineStage: pipelineStage as 'lead' | 'opportunity' | 'closed_won' | 'closed_lost' | undefined,
|
||||
status,
|
||||
productType,
|
||||
priority,
|
||||
branch,
|
||||
customer,
|
||||
sort
|
||||
}
|
||||
);
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const result = await listOpportunities(accessContext, {
|
||||
page,
|
||||
limit,
|
||||
search: searchParams.get('search') ?? undefined,
|
||||
pipelineStage:
|
||||
(searchParams.get('pipelineStage') as 'lead' | 'opportunity' | 'closed_won' | 'closed_lost' | null) ??
|
||||
undefined,
|
||||
status: searchParams.get('status') ?? undefined,
|
||||
stage: searchParams.get('stage') ?? undefined,
|
||||
outcomeStatus: searchParams.get('outcomeStatus') ?? undefined,
|
||||
productType: searchParams.get('productType') ?? undefined,
|
||||
priority: searchParams.get('priority') ?? undefined,
|
||||
branch: searchParams.get('branch') ?? undefined,
|
||||
customer: searchParams.get('customer') ?? undefined,
|
||||
lostReason: searchParams.get('lostReason') ?? undefined,
|
||||
closedDateFrom: searchParams.get('closedDateFrom') ?? undefined,
|
||||
closedDateTo: searchParams.get('closedDateTo') ?? undefined,
|
||||
sort: searchParams.get('sort') ?? undefined
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
time: new Date().toISOString(),
|
||||
message: 'Opportunities loaded successfully',
|
||||
message: 'Success',
|
||||
totalItems: result.totalItems,
|
||||
offset,
|
||||
offset: (page - 1) * limit,
|
||||
limit,
|
||||
items: result.items
|
||||
});
|
||||
@@ -77,7 +83,7 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: 'Unable to load opportunities' }, { status: 500 });
|
||||
return NextResponse.json({ message: 'Unable to list opportunities' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,24 +92,10 @@ export async function POST(request: NextRequest) {
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmOpportunityCreate
|
||||
});
|
||||
|
||||
const payload = opportunityRequestSchema.parse(await request.json());
|
||||
const accessContext = {
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membershipRole: membership.role,
|
||||
businessRole: membership.businessRole,
|
||||
branchScopeIds: membership.branchScopeIds ?? [],
|
||||
productTypeScopeIds: membership.productTypeScopeIds ?? [],
|
||||
ownershipScope: membership.ownershipScope ?? 'organization',
|
||||
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
|
||||
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
|
||||
};
|
||||
const created = await createOpportunity(
|
||||
organization.id,
|
||||
session.user.id,
|
||||
accessContext,
|
||||
payload
|
||||
);
|
||||
const accessContext = buildAccessContext(organization.id, session.user.id, membership);
|
||||
const created = await createOpportunity(organization.id, session.user.id, accessContext, payload);
|
||||
const customer = await getCustomerDetail(payload.customerId, organization.id);
|
||||
|
||||
await auditCreate({
|
||||
@@ -161,4 +153,3 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ message: 'Unable to create opportunity' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -342,6 +342,7 @@ export const crmOpportunities = pgTable(
|
||||
projectLocation: text('project_location'),
|
||||
productType: text('product_type').notNull(),
|
||||
status: text('status').notNull(),
|
||||
outcomeStatus: text('outcome_status').default('open').notNull(),
|
||||
priority: text('priority').notNull(),
|
||||
leadChannel: text('lead_channel'),
|
||||
estimatedValue: doublePrecision('estimated_value'),
|
||||
@@ -353,6 +354,7 @@ export const crmOpportunities = pgTable(
|
||||
isHotProject: boolean('is_hot_project').default(false).notNull(),
|
||||
isActive: boolean('is_active').default(true).notNull(),
|
||||
pipelineStage: text('pipeline_stage').default('lead').notNull(),
|
||||
closedAt: timestamp('closed_at', { withTimezone: true }),
|
||||
closedWonAt: timestamp('closed_won_at', { withTimezone: true }),
|
||||
closedLostAt: timestamp('closed_lost_at', { withTimezone: true }),
|
||||
closedByUserId: text('closed_by_user_id'),
|
||||
@@ -361,8 +363,11 @@ export const crmOpportunities = pgTable(
|
||||
poAmount: doublePrecision('po_amount'),
|
||||
poCurrency: text('po_currency'),
|
||||
lostReason: text('lost_reason'),
|
||||
lostDetail: text('lost_detail'),
|
||||
lostCompetitor: text('lost_competitor'),
|
||||
lostRemark: text('lost_remark'),
|
||||
cancelReason: text('cancel_reason'),
|
||||
noQuotationReason: text('no_quotation_reason'),
|
||||
assignedToUserId: text('assigned_to_user_id'),
|
||||
assignedAt: timestamp('assigned_at', { withTimezone: true }),
|
||||
assignedBy: text('assigned_by'),
|
||||
@@ -804,4 +809,3 @@ export const crmDocumentTemplateTableColumns = pgTable(
|
||||
)
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -201,12 +201,60 @@ const FOUNDATION_OPTIONS = {
|
||||
],
|
||||
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 },
|
||||
{ code: 'follow_up', label: 'Follow Up', value: 'follow_up', sortOrder: 4 },
|
||||
{ code: 'converted', label: 'Converted', value: 'converted', sortOrder: 5 },
|
||||
{ code: 'closed_lost', label: 'Closed Lost', value: 'closed_lost', sortOrder: 6 },
|
||||
{ code: 'cancelled', label: 'Cancelled', value: 'cancelled', sortOrder: 7 }
|
||||
{ code: 'qualification', label: 'Qualification', value: 'qualification', sortOrder: 2 },
|
||||
{
|
||||
code: 'requirement_gathering',
|
||||
label: 'Requirement Gathering',
|
||||
value: 'requirement_gathering',
|
||||
sortOrder: 3
|
||||
},
|
||||
{ code: 'site_survey', label: 'Site Survey', value: 'site_survey', sortOrder: 4 },
|
||||
{
|
||||
code: 'proposal_preparation',
|
||||
label: 'Proposal Preparation',
|
||||
value: 'proposal_preparation',
|
||||
sortOrder: 5
|
||||
},
|
||||
{
|
||||
code: 'quotation_created',
|
||||
label: 'Quotation Created',
|
||||
value: 'quotation_created',
|
||||
sortOrder: 6
|
||||
},
|
||||
{ code: 'negotiation', label: 'Negotiation', value: 'negotiation', sortOrder: 7 },
|
||||
{ code: 'closed', label: 'Closed', value: 'closed', sortOrder: 8 }
|
||||
],
|
||||
crm_opportunity_stage: [
|
||||
{ code: 'new', label: 'New', value: 'new', sortOrder: 1 },
|
||||
{ code: 'qualification', label: 'Qualification', value: 'qualification', sortOrder: 2 },
|
||||
{
|
||||
code: 'requirement_gathering',
|
||||
label: 'Requirement Gathering',
|
||||
value: 'requirement_gathering',
|
||||
sortOrder: 3
|
||||
},
|
||||
{ code: 'site_survey', label: 'Site Survey', value: 'site_survey', sortOrder: 4 },
|
||||
{
|
||||
code: 'proposal_preparation',
|
||||
label: 'Proposal Preparation',
|
||||
value: 'proposal_preparation',
|
||||
sortOrder: 5
|
||||
},
|
||||
{
|
||||
code: 'quotation_created',
|
||||
label: 'Quotation Created',
|
||||
value: 'quotation_created',
|
||||
sortOrder: 6
|
||||
},
|
||||
{ code: 'negotiation', label: 'Negotiation', value: 'negotiation', sortOrder: 7 },
|
||||
{ code: 'closed', label: 'Closed', value: 'closed', sortOrder: 8 }
|
||||
],
|
||||
crm_opportunity_outcome_status: [
|
||||
{ code: 'open', label: 'Open', value: 'open', sortOrder: 1 },
|
||||
{ code: 'won', label: 'Won', value: 'won', sortOrder: 2 },
|
||||
{ code: 'lost', label: 'Lost', value: 'lost', sortOrder: 3 },
|
||||
{ code: 'cancelled', label: 'Cancelled', value: 'cancelled', sortOrder: 4 },
|
||||
{ code: 'no_quotation', label: 'No Quotation', value: 'no_quotation', sortOrder: 5 }
|
||||
],
|
||||
crm_quotation_status: [
|
||||
{ code: 'draft', label: 'Draft', value: 'draft', sortOrder: 1 },
|
||||
@@ -295,33 +343,111 @@ const FOUNDATION_OPTIONS = {
|
||||
crm_lost_reason: [
|
||||
{ code: 'competitor_won', label: 'Competitor Won', value: 'competitor_won', sortOrder: 1 },
|
||||
{
|
||||
code: 'budget_not_approved',
|
||||
label: 'Budget Not Approved',
|
||||
value: 'budget_not_approved',
|
||||
code: 'budget_cancelled',
|
||||
label: 'Budget Cancelled',
|
||||
value: 'budget_cancelled',
|
||||
sortOrder: 2
|
||||
},
|
||||
{
|
||||
code: 'project_cancelled',
|
||||
label: 'Project Cancelled',
|
||||
value: 'project_cancelled',
|
||||
code: 'timeline_not_match',
|
||||
label: 'Timeline Not Match',
|
||||
value: 'timeline_not_match',
|
||||
sortOrder: 3
|
||||
},
|
||||
{
|
||||
code: 'spec_not_match',
|
||||
label: 'Spec Not Match',
|
||||
value: 'spec_not_match',
|
||||
sortOrder: 4
|
||||
},
|
||||
{
|
||||
code: 'customer_no_response',
|
||||
label: 'Customer No Response',
|
||||
value: 'customer_no_response',
|
||||
sortOrder: 4
|
||||
},
|
||||
{
|
||||
code: 'technical_requirement_mismatch',
|
||||
label: 'Technical Requirement Mismatch',
|
||||
value: 'technical_requirement_mismatch',
|
||||
sortOrder: 5
|
||||
},
|
||||
{ code: 'price_too_high', label: 'Price Too High', value: 'price_too_high', sortOrder: 6 },
|
||||
{ code: 'timeline_not_fit', label: 'Timeline Not Fit', value: 'timeline_not_fit', sortOrder: 7 },
|
||||
{ code: 'other', label: 'Other', value: 'other', sortOrder: 8 }
|
||||
],
|
||||
crm_opportunity_lost_reason: [
|
||||
{ code: 'competitor_won', label: 'Competitor Won', value: 'competitor_won', sortOrder: 1 },
|
||||
{
|
||||
code: 'budget_cancelled',
|
||||
label: 'Budget Cancelled',
|
||||
value: 'budget_cancelled',
|
||||
sortOrder: 2
|
||||
},
|
||||
{
|
||||
code: 'timeline_not_match',
|
||||
label: 'Timeline Not Match',
|
||||
value: 'timeline_not_match',
|
||||
sortOrder: 3
|
||||
},
|
||||
{
|
||||
code: 'spec_not_match',
|
||||
label: 'Spec Not Match',
|
||||
value: 'spec_not_match',
|
||||
sortOrder: 4
|
||||
},
|
||||
{
|
||||
code: 'customer_no_response',
|
||||
label: 'Customer No Response',
|
||||
value: 'customer_no_response',
|
||||
sortOrder: 5
|
||||
},
|
||||
{ code: 'price_too_high', label: 'Price Too High', value: 'price_too_high', sortOrder: 6 },
|
||||
{ code: 'timeline_not_fit', label: 'Timeline Not Fit', value: 'timeline_not_fit', sortOrder: 7 },
|
||||
{ code: 'other', label: 'Other', value: 'other', sortOrder: 8 }
|
||||
],
|
||||
crm_opportunity_cancel_reason: [
|
||||
{
|
||||
code: 'customer_cancelled',
|
||||
label: 'Customer Cancelled',
|
||||
value: 'customer_cancelled',
|
||||
sortOrder: 1
|
||||
},
|
||||
{
|
||||
code: 'internal_hold',
|
||||
label: 'Internal Hold',
|
||||
value: 'internal_hold',
|
||||
sortOrder: 2
|
||||
},
|
||||
{
|
||||
code: 'scope_changed',
|
||||
label: 'Scope Changed',
|
||||
value: 'scope_changed',
|
||||
sortOrder: 3
|
||||
},
|
||||
{ code: 'other', label: 'Other', value: 'other', sortOrder: 4 }
|
||||
],
|
||||
crm_opportunity_no_quotation_reason: [
|
||||
{
|
||||
code: 'budget_not_ready',
|
||||
label: 'Budget Not Ready',
|
||||
value: 'budget_not_ready',
|
||||
sortOrder: 1
|
||||
},
|
||||
{
|
||||
code: 'qualification_failed',
|
||||
label: 'Qualification Failed',
|
||||
value: 'qualification_failed',
|
||||
sortOrder: 2
|
||||
},
|
||||
{
|
||||
code: 'duplicate_opportunity',
|
||||
label: 'Duplicate Opportunity',
|
||||
value: 'duplicate_opportunity',
|
||||
sortOrder: 3
|
||||
},
|
||||
{
|
||||
code: 'customer_stopped',
|
||||
label: 'Customer Stopped',
|
||||
value: 'customer_stopped',
|
||||
sortOrder: 4
|
||||
},
|
||||
{ code: 'other', label: 'Other', value: 'other', sortOrder: 5 }
|
||||
],
|
||||
crm_job_title: [
|
||||
{ code: 'sales', label: 'Sales Engineer', value: 'sales', sortOrder: 1 },
|
||||
{ code: 'sales_support', label: 'Sales Support', value: 'sales_support', sortOrder: 2 },
|
||||
|
||||
@@ -297,7 +297,7 @@ export function CustomerDetail({
|
||||
/>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>เธเนเธญเธกเธนเธฅเธฃเธฐเธเธ</CardTitle>
|
||||
<CardTitle>ข้อมูลระบบ</CardTitle>
|
||||
<CardDescription>ข้อมูลประกอบการติดตามและตรวจสอบของรายการลูกค้านี้</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
@@ -319,4 +319,3 @@ export function CustomerDetail({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,9 @@ export function DashboardFollowups({
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Dashboard งานติดตาม</CardTitle>
|
||||
<CardDescription>ภาพรวมงานติดตามที่กำลังจะถึงและที่ดำเนินการแล้วของโอกาสขายและใบเสนอราคา</CardDescription>
|
||||
<CardDescription>
|
||||
ภาพรวมงานติดตามที่กำลังจะถึงและที่ดำเนินการแล้วของโอกาสขายและใบเสนอราคา
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-6'>
|
||||
<div className='grid gap-4 sm:grid-cols-2 xl:grid-cols-4'>
|
||||
@@ -38,7 +40,7 @@ export function DashboardFollowups({
|
||||
<TableHead>ลูกค้า</TableHead>
|
||||
<TableHead>โอกาสขาย</TableHead>
|
||||
<TableHead>ผู้ดูแลโอกาสขาย</TableHead>
|
||||
<TableHead>เธ—เธตเนเธกเธฒ</TableHead>
|
||||
<TableHead>ที่มา</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -65,4 +67,3 @@ export function DashboardFollowups({
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,9 @@ export function DashboardHotProjects({ rows }: { rows: CrmDashboardHotProjectRow
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>โอกาสขายเด่นที่กำลังดำเนินการ</CardTitle>
|
||||
<CardDescription>จัดอันดับโอกาสขายสำคัญตามมูลค่าที่ประเมินไว้</CardDescription>
|
||||
<CardDescription>
|
||||
จัดอันดับโอกาสขายสำคัญตามมูลค่าที่ประเมินไว้
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
@@ -21,7 +23,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>
|
||||
@@ -53,4 +55,3 @@ export function DashboardHotProjects({ rows }: { rows: CrmDashboardHotProjectRow
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,13 +13,15 @@ export function DashboardSalesRanking({ rows }: { rows: CrmDashboardSalesRanking
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>อันดับผลงานฝ่ายขาย</CardTitle>
|
||||
<CardDescription>การนับลีดและโอกาสขายยึดตามผู้รับผิดชอบ ส่วนใบเสนอราคายึดตามผู้ขาย</CardDescription>
|
||||
<CardDescription>
|
||||
การนับลีดและโอกาสขายยึดตามผู้รับผิดชอบ ส่วนใบเสนอราคาและงานที่ชนะยึดตามผู้ขาย
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ผู้ขาย</TableHead>
|
||||
<TableHead>ผู้ขาย</TableHead>
|
||||
<TableHead className='text-right'>ลีด</TableHead>
|
||||
<TableHead className='text-right'>โอกาสขาย</TableHead>
|
||||
<TableHead className='text-right'>ใบเสนอราคา</TableHead>
|
||||
|
||||
@@ -1,27 +1,31 @@
|
||||
import { mutationOptions } from '@tanstack/react-query';
|
||||
import { crmDashboardKeys } from '@/features/crm/dashboard/api/queries';
|
||||
import { getQueryClient } from '@/lib/query-client';
|
||||
import type {
|
||||
OpportunityAssignmentMutationPayload,
|
||||
OpportunityFollowupMutationPayload,
|
||||
OpportunityMarkCancelledPayload,
|
||||
OpportunityMarkLostPayload,
|
||||
OpportunityMarkNoQuotationPayload,
|
||||
OpportunityMarkWonPayload,
|
||||
OpportunityMutationPayload
|
||||
} from './types';
|
||||
import { opportunityKeys } from './queries';
|
||||
import {
|
||||
assignOpportunity,
|
||||
createOpportunity,
|
||||
createOpportunityFollowup,
|
||||
deleteOpportunity,
|
||||
deleteOpportunityFollowup,
|
||||
markOpportunityCancelled,
|
||||
markOpportunityLost,
|
||||
markOpportunityNoQuotation,
|
||||
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() });
|
||||
@@ -67,7 +71,7 @@ export async function invalidateOpportunityFollowupMutationQueries(opportunityId
|
||||
}
|
||||
|
||||
export const createOpportunityMutation = mutationOptions({
|
||||
mutationFn: (data: OpportunityMutationPayload) => createOpportunity(data),
|
||||
mutationFn: (values: OpportunityMutationPayload) => createOpportunity(values),
|
||||
onSettled: async (_data, error) => {
|
||||
if (!error) {
|
||||
await Promise.all([invalidateOpportunityLists(), invalidateDashboardQueries()]);
|
||||
@@ -89,10 +93,11 @@ 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()]);
|
||||
await Promise.all([
|
||||
invalidateOpportunityLists(),
|
||||
invalidateDashboardQueries(),
|
||||
getQueryClient().removeQueries({ queryKey: opportunityKeys.detail(id) })
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -179,6 +184,26 @@ export const markOpportunityLostMutation = mutationOptions({
|
||||
}
|
||||
});
|
||||
|
||||
export const markOpportunityCancelledMutation = mutationOptions({
|
||||
mutationFn: ({ id, values }: { id: string; values: OpportunityMarkCancelledPayload }) =>
|
||||
markOpportunityCancelled(id, values),
|
||||
onSettled: async (_data, error, variables) => {
|
||||
if (!error) {
|
||||
await invalidateOpportunityMutationQueries(variables.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const markOpportunityNoQuotationMutation = mutationOptions({
|
||||
mutationFn: ({ id, values }: { id: string; values: OpportunityMarkNoQuotationPayload }) =>
|
||||
markOpportunityNoQuotation(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 }),
|
||||
@@ -188,4 +213,3 @@ export const reopenOpportunityMutation = mutationOptions({
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
import type { OpportunityFilters } from './types';
|
||||
import {
|
||||
getOpportunityAttachments,
|
||||
getOpportunities,
|
||||
getOpportunityById,
|
||||
getOpportunityFollowups,
|
||||
getOpportunityProjectParties
|
||||
getOpportunityProjectParties,
|
||||
getOpportunities
|
||||
} from './service';
|
||||
import type { OpportunityFilters } from './types';
|
||||
|
||||
export const opportunityKeys = {
|
||||
all: ['crm-opportunities'] as const,
|
||||
@@ -51,4 +51,3 @@ export const opportunityAttachmentsOptions = (id: string) =>
|
||||
queryKey: opportunityKeys.attachments(id),
|
||||
queryFn: () => getOpportunityAttachments(id)
|
||||
});
|
||||
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import type {
|
||||
OpportunityAttachmentsResponse,
|
||||
MutationSuccessResponse,
|
||||
OpportunityAssignmentMutationPayload,
|
||||
OpportunityAttachmentsResponse,
|
||||
OpportunityDetailResponse,
|
||||
OpportunityFilters,
|
||||
OpportunityFollowupMutationPayload,
|
||||
OpportunityFollowupsResponse,
|
||||
OpportunityListResponse,
|
||||
OpportunityMarkCancelledPayload,
|
||||
OpportunityMarkLostPayload,
|
||||
OpportunityMarkNoQuotationPayload,
|
||||
OpportunityMarkWonPayload,
|
||||
OpportunityMutationPayload,
|
||||
OpportunityProjectPartiesResponse,
|
||||
OpportunityReopenPayload,
|
||||
MutationSuccessResponse
|
||||
OpportunityReopenPayload
|
||||
} from './types';
|
||||
|
||||
export async function getOpportunities(filters: OpportunityFilters): Promise<OpportunityListResponse> {
|
||||
@@ -23,14 +25,18 @@ export async function getOpportunities(filters: OpportunityFilters): Promise<Opp
|
||||
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.stage) searchParams.set('stage', filters.stage);
|
||||
if (filters.outcomeStatus) searchParams.set('outcomeStatus', filters.outcomeStatus);
|
||||
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.lostReason) searchParams.set('lostReason', filters.lostReason);
|
||||
if (filters.closedDateFrom) searchParams.set('closedDateFrom', filters.closedDateFrom);
|
||||
if (filters.closedDateTo) searchParams.set('closedDateTo', filters.closedDateTo);
|
||||
if (filters.sort) searchParams.set('sort', filters.sort);
|
||||
|
||||
const query = searchParams.toString();
|
||||
|
||||
return apiClient<OpportunityListResponse>(`/crm/opportunities${query ? `?${query}` : ''}`);
|
||||
}
|
||||
|
||||
@@ -125,10 +131,23 @@ export async function markOpportunityLost(id: string, data: OpportunityMarkLostP
|
||||
});
|
||||
}
|
||||
|
||||
export async function markOpportunityCancelled(id: string, data: OpportunityMarkCancelledPayload) {
|
||||
return apiClient<MutationSuccessResponse>(`/crm/opportunities/${id}/mark-cancelled`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
export async function markOpportunityNoQuotation(id: string, data: OpportunityMarkNoQuotationPayload) {
|
||||
return apiClient<MutationSuccessResponse>(`/crm/opportunities/${id}/mark-no-quotation`, {
|
||||
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)
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ export interface OpportunityAttachmentRecord {
|
||||
}
|
||||
|
||||
export type OpportunityPipelineStage = 'lead' | 'opportunity' | 'closed_won' | 'closed_lost';
|
||||
export type OpportunityOutcomeStatus = 'open' | 'won' | 'lost' | 'cancelled' | 'no_quotation';
|
||||
|
||||
export interface OpportunityRecord {
|
||||
id: string;
|
||||
@@ -92,6 +93,8 @@ export interface OpportunityRecord {
|
||||
projectLocation: string | null;
|
||||
productType: string;
|
||||
status: string;
|
||||
stage: string;
|
||||
outcomeStatus: OpportunityOutcomeStatus;
|
||||
priority: string;
|
||||
leadChannel: string | null;
|
||||
estimatedValue: number | null;
|
||||
@@ -103,6 +106,7 @@ export interface OpportunityRecord {
|
||||
isHotProject: boolean;
|
||||
isActive: boolean;
|
||||
pipelineStage: OpportunityPipelineStage;
|
||||
closedAt: string | null;
|
||||
closedWonAt: string | null;
|
||||
closedLostAt: string | null;
|
||||
closedByUserId: string | null;
|
||||
@@ -114,8 +118,11 @@ export interface OpportunityRecord {
|
||||
lostReason: string | null;
|
||||
lostReasonCode: string | null;
|
||||
lostReasonLabel: string | null;
|
||||
lostDetail: string | null;
|
||||
lostCompetitor: string | null;
|
||||
lostRemark: string | null;
|
||||
cancelReason: string | null;
|
||||
noQuotationReason: string | null;
|
||||
assignedToUserId: string | null;
|
||||
assignedToName: string | null;
|
||||
assignedAt: string | null;
|
||||
@@ -172,11 +179,15 @@ export interface OpportunityActivityRecord {
|
||||
export interface OpportunityReferenceData {
|
||||
branches: OpportunityBranchOption[];
|
||||
statuses: OpportunityOption[];
|
||||
stages: OpportunityOption[];
|
||||
outcomeStatuses: OpportunityOption[];
|
||||
productTypes: OpportunityOption[];
|
||||
priorities: OpportunityOption[];
|
||||
leadChannels: OpportunityOption[];
|
||||
followupTypes: OpportunityOption[];
|
||||
lostReasons: OpportunityOption[];
|
||||
cancelReasons: OpportunityOption[];
|
||||
noQuotationReasons: OpportunityOption[];
|
||||
projectPartyRoles: OpportunityOption[];
|
||||
customers: OpportunityCustomerLookup[];
|
||||
contacts: OpportunityContactLookup[];
|
||||
@@ -189,10 +200,15 @@ export interface OpportunityFilters {
|
||||
search?: string;
|
||||
pipelineStage?: OpportunityPipelineStage;
|
||||
status?: string;
|
||||
stage?: string;
|
||||
outcomeStatus?: string;
|
||||
productType?: string;
|
||||
priority?: string;
|
||||
branch?: string;
|
||||
customer?: string;
|
||||
lostReason?: string;
|
||||
closedDateFrom?: string;
|
||||
closedDateTo?: string;
|
||||
sort?: string;
|
||||
}
|
||||
|
||||
@@ -291,10 +307,19 @@ export interface OpportunityMarkWonPayload {
|
||||
|
||||
export interface OpportunityMarkLostPayload {
|
||||
lostReason: string;
|
||||
lostDetail?: string | null;
|
||||
lostCompetitor?: string | null;
|
||||
lostRemark?: string | null;
|
||||
}
|
||||
|
||||
export interface OpportunityMarkCancelledPayload {
|
||||
cancelReason: string;
|
||||
}
|
||||
|
||||
export interface OpportunityMarkNoQuotationPayload {
|
||||
noQuotationReason: string;
|
||||
}
|
||||
|
||||
export interface OpportunityReopenPayload {
|
||||
reopenReason: string;
|
||||
}
|
||||
@@ -312,4 +337,3 @@ export interface MutationSuccessResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -92,11 +92,10 @@ export function OpportunityCellAction({
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ export function OpportunityFollowupsTab({
|
||||
setDeleteOpen(true);
|
||||
}}
|
||||
>
|
||||
<Icons.trash className='mr-2 h-4 w-4' /> เธฅเธ
|
||||
<Icons.trash className='mr-2 h-4 w-4' /> ลบ
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -146,4 +146,3 @@ export function OpportunityFollowupsTab({
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
60
src/features/crm/opportunities/schemas/lifecycle.schema.ts
Normal file
60
src/features/crm/opportunities/schemas/lifecycle.schema.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
function coerceOptionalNumber(message: string) {
|
||||
return z.preprocess((value) => {
|
||||
if (value === '' || value === null || value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return Number(trimmed);
|
||||
}
|
||||
|
||||
return value;
|
||||
}, z.number().refine((value) => !Number.isNaN(value), message).optional());
|
||||
}
|
||||
|
||||
export const opportunityMarkWonSchema = z.object({
|
||||
poNumber: z.string().min(1, 'PO number required'),
|
||||
poDate: z.string().min(1, 'PO date required'),
|
||||
poAmount: coerceOptionalNumber('PO amount must be number').refine(
|
||||
(value) => value === undefined || value >= 0,
|
||||
'PO amount must be 0 or greater'
|
||||
),
|
||||
poCurrency: z.string().optional().nullable(),
|
||||
remark: z.string().optional().nullable()
|
||||
});
|
||||
|
||||
export const opportunityMarkLostSchema = z.object({
|
||||
lostReason: z.string().min(1, 'Lost reason required'),
|
||||
lostDetail: z.string().optional().nullable(),
|
||||
lostCompetitor: z.string().optional().nullable(),
|
||||
lostRemark: z.string().optional().nullable()
|
||||
});
|
||||
|
||||
export const opportunityMarkCancelledSchema = z.object({
|
||||
cancelReason: z.string().min(1, 'Cancel reason required')
|
||||
});
|
||||
|
||||
export const opportunityMarkNoQuotationSchema = z.object({
|
||||
noQuotationReason: z.string().min(1, 'No quotation reason required')
|
||||
});
|
||||
|
||||
export const opportunityReopenSchema = z.object({
|
||||
reopenReason: z.string().min(3, 'Reopen reason required')
|
||||
});
|
||||
|
||||
export type OpportunityMarkWonFormValues = z.input<typeof opportunityMarkWonSchema>;
|
||||
export type OpportunityMarkLostFormValues = z.input<typeof opportunityMarkLostSchema>;
|
||||
export type OpportunityMarkCancelledFormValues = z.input<typeof opportunityMarkCancelledSchema>;
|
||||
export type OpportunityMarkNoQuotationFormValues = z.input<typeof opportunityMarkNoQuotationSchema>;
|
||||
export type OpportunityReopenFormValues = z.input<typeof opportunityReopenSchema>;
|
||||
@@ -1,5 +1,18 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
export {
|
||||
opportunityMarkCancelledSchema,
|
||||
opportunityMarkLostSchema,
|
||||
opportunityMarkNoQuotationSchema,
|
||||
opportunityMarkWonSchema,
|
||||
opportunityReopenSchema,
|
||||
type OpportunityMarkCancelledFormValues,
|
||||
type OpportunityMarkLostFormValues,
|
||||
type OpportunityMarkNoQuotationFormValues,
|
||||
type OpportunityMarkWonFormValues,
|
||||
type OpportunityReopenFormValues
|
||||
} from './lifecycle.schema';
|
||||
|
||||
function coerceOptionalNumber(message: string) {
|
||||
return z.preprocess((value) => {
|
||||
if (value === '' || value === null || value === undefined) {
|
||||
@@ -12,7 +25,6 @@ function coerceOptionalNumber(message: string) {
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -25,7 +37,7 @@ function coerceOptionalNumber(message: string) {
|
||||
}
|
||||
|
||||
export const opportunitySchema = z.object({
|
||||
customerId: z.string().min(1, 'Please select a customer'),
|
||||
customerId: z.string().min(1, 'Please select customer'),
|
||||
contactId: z.string().optional().nullable(),
|
||||
assignedToUserId: z.string().optional().nullable(),
|
||||
title: z.string().min(2, 'Title must be at least 2 characters'),
|
||||
@@ -34,9 +46,9 @@ export const opportunitySchema = z.object({
|
||||
projectName: z.string().optional(),
|
||||
projectLocation: z.string().optional(),
|
||||
branchId: z.string().optional().nullable(),
|
||||
productType: z.string().min(1, 'Please select a product type'),
|
||||
status: z.string().min(1, 'Please select a status'),
|
||||
priority: z.string().min(1, 'Please select a priority'),
|
||||
productType: z.string().min(1, 'Please select product type'),
|
||||
status: z.string().min(1, 'Please select stage'),
|
||||
priority: z.string().min(1, 'Please select priority'),
|
||||
leadChannel: z.string().optional().nullable(),
|
||||
estimatedValue: coerceOptionalNumber('Estimated value must be a number').refine(
|
||||
(value) => value === undefined || value >= 0,
|
||||
@@ -55,8 +67,8 @@ export const opportunitySchema = z.object({
|
||||
projectParties: z
|
||||
.array(
|
||||
z.object({
|
||||
customerId: z.string().min(1, 'Please select a customer'),
|
||||
role: z.string().min(1, 'Please select a role'),
|
||||
customerId: z.string().min(1, 'Please select customer'),
|
||||
role: z.string().min(1, 'Please select role'),
|
||||
remark: z.string().nullable().optional()
|
||||
})
|
||||
)
|
||||
@@ -64,8 +76,8 @@ export const opportunitySchema = 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'),
|
||||
followupDate: z.string().min(1, 'Follow-up date required'),
|
||||
followupType: z.string().min(1, 'Please select follow-up type'),
|
||||
contactId: z.string().optional().nullable(),
|
||||
outcome: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
@@ -73,30 +85,5 @@ export const opportunityFollowupSchema = z.object({
|
||||
nextAction: z.string().optional()
|
||||
});
|
||||
|
||||
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(
|
||||
(value) => value === undefined || value >= 0,
|
||||
'PO amount must be 0 or greater'
|
||||
),
|
||||
poCurrency: z.string().optional().nullable(),
|
||||
remark: z.string().optional().nullable()
|
||||
});
|
||||
|
||||
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 opportunityReopenSchema = z.object({
|
||||
reopenReason: z.string().min(3, 'Reopen reason is required')
|
||||
});
|
||||
|
||||
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>;
|
||||
|
||||
|
||||
243
src/features/crm/opportunities/server/lifecycle.service.ts
Normal file
243
src/features/crm/opportunities/server/lifecycle.service.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { crmOpportunities } from '@/db/schema';
|
||||
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||
import { db } from '@/lib/db';
|
||||
import { AuthError } from '@/lib/auth/session';
|
||||
import type {
|
||||
OpportunityMarkCancelledPayload,
|
||||
OpportunityMarkLostPayload,
|
||||
OpportunityMarkNoQuotationPayload,
|
||||
OpportunityMarkWonPayload,
|
||||
OpportunityReopenPayload
|
||||
} from '../api/types';
|
||||
import {
|
||||
assertOpportunityBelongsToOrganization,
|
||||
type OpportunityAccessContext
|
||||
} from './service';
|
||||
|
||||
type OpportunityRow = typeof crmOpportunities.$inferSelect;
|
||||
|
||||
function isClosed(opportunity: OpportunityRow) {
|
||||
return (opportunity.outcomeStatus ?? 'open') !== 'open';
|
||||
}
|
||||
|
||||
function assertCanClose(opportunity: OpportunityRow) {
|
||||
if (opportunity.pipelineStage !== 'opportunity') {
|
||||
throw new AuthError('Opportunity must be in opportunity stage', 400);
|
||||
}
|
||||
|
||||
if (isClosed(opportunity)) {
|
||||
throw new AuthError('Opportunity is already closed', 400);
|
||||
}
|
||||
}
|
||||
|
||||
function buildLegacyPipelineStage(outcomeStatus: OpportunityRow['outcomeStatus']) {
|
||||
if (outcomeStatus === 'won') return 'closed_won' as const;
|
||||
if (outcomeStatus === 'lost') return 'closed_lost' as const;
|
||||
return 'opportunity' as const;
|
||||
}
|
||||
|
||||
async function updateClosedOpportunity(
|
||||
opportunity: OpportunityRow,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload:
|
||||
| { outcomeStatus: 'won'; reason: string | null; action: 'mark_opportunity_won'; values: OpportunityMarkWonPayload }
|
||||
| { outcomeStatus: 'lost'; reason: string; action: 'mark_opportunity_lost'; values: OpportunityMarkLostPayload }
|
||||
| {
|
||||
outcomeStatus: 'cancelled';
|
||||
reason: string;
|
||||
action: 'mark_opportunity_cancelled';
|
||||
values: OpportunityMarkCancelledPayload;
|
||||
}
|
||||
| {
|
||||
outcomeStatus: 'no_quotation';
|
||||
reason: string;
|
||||
action: 'mark_opportunity_no_quotation';
|
||||
values: OpportunityMarkNoQuotationPayload;
|
||||
}
|
||||
) {
|
||||
assertCanClose(opportunity);
|
||||
|
||||
const now = new Date();
|
||||
const [updated] = await db
|
||||
.update(crmOpportunities)
|
||||
.set({
|
||||
status: 'closed',
|
||||
outcomeStatus: payload.outcomeStatus,
|
||||
pipelineStage: buildLegacyPipelineStage(payload.outcomeStatus),
|
||||
closedAt: now,
|
||||
closedWonAt: payload.outcomeStatus === 'won' ? now : null,
|
||||
closedLostAt: payload.outcomeStatus === 'lost' ? now : null,
|
||||
closedByUserId: userId,
|
||||
poNumber: payload.outcomeStatus === 'won' ? payload.values.poNumber.trim() : null,
|
||||
poDate:
|
||||
payload.outcomeStatus === 'won' && payload.values.poDate
|
||||
? new Date(payload.values.poDate)
|
||||
: null,
|
||||
poAmount: payload.outcomeStatus === 'won' ? payload.values.poAmount ?? null : null,
|
||||
poCurrency: payload.outcomeStatus === 'won' ? payload.values.poCurrency?.trim() || null : null,
|
||||
lostReason: payload.outcomeStatus === 'lost' ? payload.values.lostReason : null,
|
||||
lostDetail: payload.outcomeStatus === 'lost' ? payload.values.lostDetail?.trim() || null : null,
|
||||
lostCompetitor:
|
||||
payload.outcomeStatus === 'lost' ? payload.values.lostCompetitor?.trim() || null : null,
|
||||
lostRemark: payload.outcomeStatus === 'lost' ? payload.values.lostRemark?.trim() || null : null,
|
||||
cancelReason: payload.outcomeStatus === 'cancelled' ? payload.values.cancelReason : null,
|
||||
noQuotationReason:
|
||||
payload.outcomeStatus === 'no_quotation' ? payload.values.noQuotationReason : null,
|
||||
updatedAt: now,
|
||||
updatedBy: userId
|
||||
})
|
||||
.where(eq(crmOpportunities.id, opportunity.id))
|
||||
.returning();
|
||||
|
||||
await auditAction({
|
||||
organizationId,
|
||||
branchId: updated.branchId,
|
||||
userId,
|
||||
entityType: 'crm_opportunity',
|
||||
entityId: updated.id,
|
||||
action: payload.action,
|
||||
afterData: {
|
||||
previousStage: opportunity.status,
|
||||
previousOutcomeStatus: opportunity.outcomeStatus ?? 'open',
|
||||
newStage: updated.status,
|
||||
newOutcomeStatus: updated.outcomeStatus,
|
||||
reason: payload.reason,
|
||||
actedBy: userId,
|
||||
actedAt: now.toISOString(),
|
||||
closedAt: updated.closedAt?.toISOString() ?? null
|
||||
}
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function markOpportunityWon(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: OpportunityMarkWonPayload,
|
||||
accessContext?: OpportunityAccessContext
|
||||
) {
|
||||
const opportunity = await assertOpportunityBelongsToOrganization(id, organizationId, accessContext);
|
||||
if (!payload.poNumber.trim()) {
|
||||
throw new AuthError('PO number required', 400);
|
||||
}
|
||||
|
||||
return updateClosedOpportunity(opportunity, organizationId, userId, {
|
||||
outcomeStatus: 'won',
|
||||
action: 'mark_opportunity_won',
|
||||
reason: payload.remark?.trim() || null,
|
||||
values: payload
|
||||
});
|
||||
}
|
||||
|
||||
export async function markOpportunityLost(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: OpportunityMarkLostPayload,
|
||||
accessContext?: OpportunityAccessContext
|
||||
) {
|
||||
const opportunity = await assertOpportunityBelongsToOrganization(id, organizationId, accessContext);
|
||||
return updateClosedOpportunity(opportunity, organizationId, userId, {
|
||||
outcomeStatus: 'lost',
|
||||
action: 'mark_opportunity_lost',
|
||||
reason: payload.lostReason,
|
||||
values: payload
|
||||
});
|
||||
}
|
||||
|
||||
export async function markOpportunityCancelled(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: OpportunityMarkCancelledPayload,
|
||||
accessContext?: OpportunityAccessContext
|
||||
) {
|
||||
const opportunity = await assertOpportunityBelongsToOrganization(id, organizationId, accessContext);
|
||||
return updateClosedOpportunity(opportunity, organizationId, userId, {
|
||||
outcomeStatus: 'cancelled',
|
||||
action: 'mark_opportunity_cancelled',
|
||||
reason: payload.cancelReason,
|
||||
values: payload
|
||||
});
|
||||
}
|
||||
|
||||
export async function markOpportunityNoQuotation(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: OpportunityMarkNoQuotationPayload,
|
||||
accessContext?: OpportunityAccessContext
|
||||
) {
|
||||
const opportunity = await assertOpportunityBelongsToOrganization(id, organizationId, accessContext);
|
||||
return updateClosedOpportunity(opportunity, organizationId, userId, {
|
||||
outcomeStatus: 'no_quotation',
|
||||
action: 'mark_opportunity_no_quotation',
|
||||
reason: payload.noQuotationReason,
|
||||
values: payload
|
||||
});
|
||||
}
|
||||
|
||||
export async function reopenOpportunity(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: OpportunityReopenPayload,
|
||||
accessContext?: OpportunityAccessContext
|
||||
) {
|
||||
const opportunity = await assertOpportunityBelongsToOrganization(id, organizationId, accessContext);
|
||||
|
||||
if (!isClosed(opportunity)) {
|
||||
throw new AuthError('Only closed opportunities can be reopened', 400);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const [updated] = await db
|
||||
.update(crmOpportunities)
|
||||
.set({
|
||||
status: 'quotation_created',
|
||||
outcomeStatus: 'open',
|
||||
pipelineStage: 'opportunity',
|
||||
closedAt: null,
|
||||
closedWonAt: null,
|
||||
closedLostAt: null,
|
||||
closedByUserId: null,
|
||||
poNumber: null,
|
||||
poDate: null,
|
||||
poAmount: null,
|
||||
poCurrency: null,
|
||||
lostReason: null,
|
||||
lostDetail: null,
|
||||
lostCompetitor: null,
|
||||
lostRemark: null,
|
||||
cancelReason: null,
|
||||
noQuotationReason: null,
|
||||
updatedAt: now,
|
||||
updatedBy: userId
|
||||
})
|
||||
.where(eq(crmOpportunities.id, opportunity.id))
|
||||
.returning();
|
||||
|
||||
await auditAction({
|
||||
organizationId,
|
||||
branchId: updated.branchId,
|
||||
userId,
|
||||
entityType: 'crm_opportunity',
|
||||
entityId: updated.id,
|
||||
action: 'reopen_opportunity',
|
||||
afterData: {
|
||||
previousStage: opportunity.status,
|
||||
previousOutcomeStatus: opportunity.outcomeStatus ?? 'open',
|
||||
newStage: updated.status,
|
||||
newOutcomeStatus: updated.outcomeStatus,
|
||||
reason: payload.reopenReason.trim(),
|
||||
actedBy: userId,
|
||||
actedAt: now.toISOString()
|
||||
}
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, asc, count, desc, eq, ilike, inArray, isNull, or, type SQL } from 'drizzle-orm';
|
||||
import { and, asc, count, desc, eq, ilike, inArray, isNull, or, sql, type SQL } from 'drizzle-orm';
|
||||
import {
|
||||
crmCustomerContacts,
|
||||
crmCustomers,
|
||||
@@ -29,7 +29,9 @@ import type {
|
||||
OpportunityFollowupMutationPayload,
|
||||
OpportunityFollowupRecord,
|
||||
OpportunityListItem,
|
||||
OpportunityMarkCancelledPayload,
|
||||
OpportunityMarkLostPayload,
|
||||
OpportunityMarkNoQuotationPayload,
|
||||
OpportunityMarkWonPayload,
|
||||
OpportunityMutationPayload,
|
||||
OpportunityOption,
|
||||
@@ -49,12 +51,15 @@ import {
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
|
||||
const ENQUIRY_OPTION_CATEGORIES = {
|
||||
status: 'crm_opportunity_status',
|
||||
status: 'crm_opportunity_stage',
|
||||
productType: 'crm_product_type',
|
||||
priority: 'crm_priority',
|
||||
leadChannel: 'crm_lead_channel',
|
||||
followupType: 'crm_followup_type',
|
||||
lostReason: 'crm_lost_reason',
|
||||
outcomeStatus: 'crm_opportunity_outcome_status',
|
||||
lostReason: 'crm_opportunity_lost_reason',
|
||||
cancelReason: 'crm_opportunity_cancel_reason',
|
||||
noQuotationReason: 'crm_opportunity_no_quotation_reason',
|
||||
projectPartyRole: PROJECT_PARTY_OPTION_CATEGORY
|
||||
} as const;
|
||||
|
||||
@@ -136,6 +141,8 @@ function mapOpportunityRecord(
|
||||
projectLocation: row.projectLocation,
|
||||
productType: row.productType,
|
||||
status: row.status,
|
||||
stage: row.status,
|
||||
outcomeStatus: (row.outcomeStatus ?? 'open') as OpportunityRecord['outcomeStatus'],
|
||||
priority: row.priority,
|
||||
leadChannel: row.leadChannel,
|
||||
estimatedValue: row.estimatedValue,
|
||||
@@ -147,6 +154,7 @@ function mapOpportunityRecord(
|
||||
isHotProject: row.isHotProject,
|
||||
isActive: row.isActive,
|
||||
pipelineStage: row.pipelineStage as OpportunityPipelineStage,
|
||||
closedAt: row.closedAt?.toISOString() ?? null,
|
||||
closedWonAt: row.closedWonAt?.toISOString() ?? null,
|
||||
closedLostAt: row.closedLostAt?.toISOString() ?? null,
|
||||
closedByUserId: row.closedByUserId,
|
||||
@@ -158,8 +166,11 @@ function mapOpportunityRecord(
|
||||
lostReason: row.lostReason,
|
||||
lostReasonCode: options?.lostReason?.code ?? null,
|
||||
lostReasonLabel: options?.lostReason?.label ?? null,
|
||||
lostDetail: row.lostDetail,
|
||||
lostCompetitor: row.lostCompetitor,
|
||||
lostRemark: row.lostRemark,
|
||||
cancelReason: row.cancelReason,
|
||||
noQuotationReason: row.noQuotationReason,
|
||||
assignedToUserId: row.assignedToUserId,
|
||||
assignedToName: options?.assignedToName ?? null,
|
||||
assignedAt: row.assignedAt?.toISOString() ?? null,
|
||||
@@ -468,7 +479,7 @@ export async function assertAssignableUserBelongsToOrganization(
|
||||
return membership;
|
||||
}
|
||||
|
||||
async function assertOpportunityBelongsToOrganization(
|
||||
export async function assertOpportunityBelongsToOrganization(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
accessContext?: OpportunityAccessContext
|
||||
@@ -496,6 +507,16 @@ async function assertOpportunityBelongsToOrganization(
|
||||
return opportunity;
|
||||
}
|
||||
|
||||
function isClosedOpportunity(opportunity: typeof crmOpportunities.$inferSelect) {
|
||||
return (opportunity.outcomeStatus ?? 'open') !== 'open';
|
||||
}
|
||||
|
||||
function assertOpportunityOpenForEditing(opportunity: typeof crmOpportunities.$inferSelect) {
|
||||
if (isClosedOpportunity(opportunity)) {
|
||||
throw new AuthError('Closed opportunities must be reopened before editing', 400);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertFollowupBelongsToOpportunity(
|
||||
followupId: string,
|
||||
opportunityId: string,
|
||||
@@ -586,6 +607,7 @@ async function validateFollowupPayload(
|
||||
organizationId,
|
||||
accessContext
|
||||
);
|
||||
assertOpportunityOpenForEditing(opportunity);
|
||||
|
||||
await assertMasterOptionValue(
|
||||
organizationId,
|
||||
@@ -601,10 +623,13 @@ async function validateFollowupPayload(
|
||||
function buildOpportunityFilters(context: OpportunityAccessContext, filters: OpportunityFilters): SQL[] {
|
||||
const pipelineStages = splitFilterValue(filters.pipelineStage);
|
||||
const statuses = splitFilterValue(filters.status);
|
||||
const stages = splitFilterValue(filters.stage);
|
||||
const outcomeStatuses = splitFilterValue(filters.outcomeStatus);
|
||||
const productTypes = splitFilterValue(filters.productType);
|
||||
const priorities = splitFilterValue(filters.priority);
|
||||
const branches = splitFilterValue(filters.branch);
|
||||
const customers = splitFilterValue(filters.customer);
|
||||
const lostReasons = splitFilterValue(filters.lostReason);
|
||||
|
||||
return [
|
||||
eq(crmOpportunities.organizationId, context.organizationId),
|
||||
@@ -612,10 +637,15 @@ function buildOpportunityFilters(context: OpportunityAccessContext, filters: Opp
|
||||
...buildAccessScopedFilters(context),
|
||||
...(pipelineStages.length ? [inArray(crmOpportunities.pipelineStage, pipelineStages)] : []),
|
||||
...(statuses.length ? [inArray(crmOpportunities.status, statuses)] : []),
|
||||
...(stages.length ? [inArray(crmOpportunities.status, stages)] : []),
|
||||
...(outcomeStatuses.length ? [inArray(crmOpportunities.outcomeStatus, outcomeStatuses)] : []),
|
||||
...(productTypes.length ? [inArray(crmOpportunities.productType, productTypes)] : []),
|
||||
...(priorities.length ? [inArray(crmOpportunities.priority, priorities)] : []),
|
||||
...(branches.length ? [inArray(crmOpportunities.branchId, branches)] : []),
|
||||
...(customers.length ? [inArray(crmOpportunities.customerId, customers)] : []),
|
||||
...(lostReasons.length ? [inArray(crmOpportunities.lostReason, lostReasons)] : []),
|
||||
...(filters.closedDateFrom ? [sql`${crmOpportunities.closedAt} >= ${new Date(filters.closedDateFrom)}`] : []),
|
||||
...(filters.closedDateTo ? [sql`${crmOpportunities.closedAt} <= ${new Date(filters.closedDateTo)}`] : []),
|
||||
...(filters.search
|
||||
? [
|
||||
or(
|
||||
@@ -658,12 +688,15 @@ export async function getOpportunityReferenceData(
|
||||
): Promise<OpportunityReferenceData> {
|
||||
const [
|
||||
branches,
|
||||
statuses,
|
||||
stages,
|
||||
outcomeStatuses,
|
||||
productTypes,
|
||||
priorities,
|
||||
leadChannels,
|
||||
followupTypes,
|
||||
lostReasons,
|
||||
cancelReasons,
|
||||
noQuotationReasons,
|
||||
projectPartyRoles,
|
||||
customers,
|
||||
contacts,
|
||||
@@ -671,11 +704,14 @@ export async function getOpportunityReferenceData(
|
||||
] = await Promise.all([
|
||||
getUserBranches(),
|
||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.status, { organizationId }),
|
||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.outcomeStatus, { organizationId }),
|
||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.productType, { organizationId }),
|
||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.priority, { organizationId }),
|
||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.leadChannel, { organizationId }),
|
||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.followupType, { organizationId }),
|
||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.lostReason, { organizationId }),
|
||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.cancelReason, { organizationId }),
|
||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.noQuotationReason, { organizationId }),
|
||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.projectPartyRole, { organizationId }),
|
||||
db
|
||||
.select({
|
||||
@@ -711,12 +747,16 @@ export async function getOpportunityReferenceData(
|
||||
|
||||
return {
|
||||
branches: branches.map(mapBranch),
|
||||
statuses: statuses.map(mapOption),
|
||||
statuses: stages.map(mapOption),
|
||||
stages: stages.map(mapOption),
|
||||
outcomeStatuses: outcomeStatuses.map(mapOption),
|
||||
productTypes: productTypes.map(mapOption),
|
||||
priorities: priorities.map(mapOption),
|
||||
leadChannels: leadChannels.map(mapOption),
|
||||
followupTypes: followupTypes.map(mapOption),
|
||||
lostReasons: lostReasons.map(mapOption),
|
||||
cancelReasons: cancelReasons.map(mapOption),
|
||||
noQuotationReasons: noQuotationReasons.map(mapOption),
|
||||
projectPartyRoles: projectPartyRoles.map(mapOption),
|
||||
customers: customers.map((customer) => ({
|
||||
id: customer.id,
|
||||
@@ -1173,6 +1213,7 @@ export async function updateOpportunity(
|
||||
) {
|
||||
await validateOpportunityPayload(organizationId, payload, accessContext);
|
||||
const current = await assertOpportunityBelongsToOrganization(id, organizationId, accessContext);
|
||||
assertOpportunityOpenForEditing(current);
|
||||
const pipelineStage = await resolvePipelineStageForUpdate(current);
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
@@ -1755,8 +1796,37 @@ export async function syncOpportunityPipelineStageFromQuotationStatus(
|
||||
organizationId: string,
|
||||
quotationStatusCode: string | null
|
||||
) {
|
||||
void opportunityId;
|
||||
void organizationId;
|
||||
void quotationStatusCode;
|
||||
}
|
||||
const [opportunity] = await db
|
||||
.select()
|
||||
.from(crmOpportunities)
|
||||
.where(
|
||||
and(
|
||||
eq(crmOpportunities.id, opportunityId),
|
||||
eq(crmOpportunities.organizationId, organizationId),
|
||||
isNull(crmOpportunities.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!opportunity) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((opportunity.outcomeStatus ?? 'open') !== 'open') {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextStage = quotationStatusCode ? 'quotation_created' : opportunity.status;
|
||||
if (nextStage === opportunity.status && opportunity.pipelineStage === 'opportunity') {
|
||||
return;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(crmOpportunities)
|
||||
.set({
|
||||
status: nextStage,
|
||||
pipelineStage: 'opportunity',
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(crmOpportunities.id, opportunityId));
|
||||
}
|
||||
|
||||
@@ -457,6 +457,14 @@ async function assertOpportunityBelongsToOrganization(id: string, organizationId
|
||||
return opportunity;
|
||||
}
|
||||
|
||||
function assertOpportunityOpenForQuotation(
|
||||
opportunity: Awaited<ReturnType<typeof assertOpportunityBelongsToOrganization>>
|
||||
) {
|
||||
if ((opportunity.outcomeStatus ?? 'open') !== 'open') {
|
||||
throw new AuthError('Closed opportunities cannot create quotations', 400);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertQuotationBelongsToOrganization(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
@@ -1292,6 +1300,9 @@ export async function createQuotation(
|
||||
const opportunity = payload.opportunityId
|
||||
? await assertOpportunityBelongsToOrganization(payload.opportunityId, organizationId)
|
||||
: null;
|
||||
if (opportunity) {
|
||||
assertOpportunityOpenForQuotation(opportunity);
|
||||
}
|
||||
const documentCode = await generateNextDocumentCode({
|
||||
organizationId,
|
||||
documentType: 'quotation',
|
||||
@@ -2345,4 +2356,3 @@ export async function listCustomerQuotationRelations(
|
||||
updatedAt: row.updatedAt.toISOString()
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,12 @@ export const CRM_MASTER_OPTION_CATEGORIES = [
|
||||
'crm_customer_status',
|
||||
'crm_customer_type',
|
||||
'crm_opportunity_status',
|
||||
'crm_opportunity_stage',
|
||||
'crm_opportunity_outcome_status',
|
||||
'crm_lost_reason',
|
||||
'crm_opportunity_lost_reason',
|
||||
'crm_opportunity_cancel_reason',
|
||||
'crm_opportunity_no_quotation_reason',
|
||||
'crm_lead_awareness',
|
||||
'crm_lead_status',
|
||||
'crm_lead_followup_status',
|
||||
@@ -59,4 +65,3 @@ export interface MasterOptionListResult {
|
||||
items: MasterOptionRecord[];
|
||||
totalItems: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -54,8 +54,12 @@ export const PERMISSIONS = {
|
||||
crmOpportunityDelete: 'crm.opportunity.delete',
|
||||
crmOpportunityAssign: 'crm.opportunity.assign',
|
||||
crmOpportunityReassign: 'crm.opportunity.reassign',
|
||||
crmOpportunityLifecycleUpdate: 'crm.opportunity.lifecycle.update',
|
||||
crmOpportunityLifecycleReopen: 'crm.opportunity.lifecycle.reopen',
|
||||
crmOpportunityMarkWon: 'crm.opportunity.mark_won',
|
||||
crmOpportunityMarkLost: 'crm.opportunity.mark_lost',
|
||||
crmOpportunityMarkCancelled: 'crm.opportunity.mark_cancelled',
|
||||
crmOpportunityMarkNoQuotation: 'crm.opportunity.mark_no_quotation',
|
||||
crmOpportunityReopen: 'crm.opportunity.reopen',
|
||||
crmOpportunityFollowupRead: 'crm.opportunity.followup.read',
|
||||
crmOpportunityFollowupCreate: 'crm.opportunity.followup.create',
|
||||
@@ -173,8 +177,11 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
|
||||
PERMISSIONS.crmOpportunityRead,
|
||||
PERMISSIONS.crmOpportunityCreate,
|
||||
PERMISSIONS.crmOpportunityUpdate,
|
||||
PERMISSIONS.crmOpportunityLifecycleUpdate,
|
||||
PERMISSIONS.crmOpportunityMarkWon,
|
||||
PERMISSIONS.crmOpportunityMarkLost,
|
||||
PERMISSIONS.crmOpportunityMarkCancelled,
|
||||
PERMISSIONS.crmOpportunityMarkNoQuotation,
|
||||
PERMISSIONS.crmOpportunityFollowupRead,
|
||||
PERMISSIONS.crmOpportunityFollowupCreate,
|
||||
PERMISSIONS.crmOpportunityFollowupUpdate,
|
||||
@@ -213,8 +220,11 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
|
||||
PERMISSIONS.crmContactRead,
|
||||
PERMISSIONS.crmOpportunityRead,
|
||||
PERMISSIONS.crmOpportunityUpdate,
|
||||
PERMISSIONS.crmOpportunityLifecycleUpdate,
|
||||
PERMISSIONS.crmOpportunityMarkWon,
|
||||
PERMISSIONS.crmOpportunityMarkLost,
|
||||
PERMISSIONS.crmOpportunityMarkCancelled,
|
||||
PERMISSIONS.crmOpportunityMarkNoQuotation,
|
||||
PERMISSIONS.crmOpportunityFollowupRead,
|
||||
PERMISSIONS.crmOpportunityFollowupCreate,
|
||||
PERMISSIONS.crmOpportunityFollowupUpdate,
|
||||
@@ -268,8 +278,12 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
|
||||
PERMISSIONS.crmOpportunityUpdate,
|
||||
PERMISSIONS.crmOpportunityAssign,
|
||||
PERMISSIONS.crmOpportunityReassign,
|
||||
PERMISSIONS.crmOpportunityLifecycleUpdate,
|
||||
PERMISSIONS.crmOpportunityLifecycleReopen,
|
||||
PERMISSIONS.crmOpportunityMarkWon,
|
||||
PERMISSIONS.crmOpportunityMarkLost,
|
||||
PERMISSIONS.crmOpportunityMarkCancelled,
|
||||
PERMISSIONS.crmOpportunityMarkNoQuotation,
|
||||
PERMISSIONS.crmOpportunityReopen,
|
||||
PERMISSIONS.crmOpportunityFollowupRead,
|
||||
PERMISSIONS.crmOpportunityFollowupCreate,
|
||||
@@ -319,8 +333,12 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
|
||||
PERMISSIONS.crmOpportunityRead,
|
||||
PERMISSIONS.crmOpportunityAssign,
|
||||
PERMISSIONS.crmOpportunityReassign,
|
||||
PERMISSIONS.crmOpportunityLifecycleUpdate,
|
||||
PERMISSIONS.crmOpportunityLifecycleReopen,
|
||||
PERMISSIONS.crmOpportunityMarkWon,
|
||||
PERMISSIONS.crmOpportunityMarkLost,
|
||||
PERMISSIONS.crmOpportunityMarkCancelled,
|
||||
PERMISSIONS.crmOpportunityMarkNoQuotation,
|
||||
PERMISSIONS.crmOpportunityReopen,
|
||||
PERMISSIONS.crmOpportunityFollowupRead,
|
||||
PERMISSIONS.crmQuotationRead,
|
||||
@@ -391,8 +409,12 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
|
||||
PERMISSIONS.crmDashboardRead,
|
||||
PERMISSIONS.crmReportRead,
|
||||
PERMISSIONS.crmReportExport,
|
||||
PERMISSIONS.crmOpportunityLifecycleUpdate,
|
||||
PERMISSIONS.crmOpportunityLifecycleReopen,
|
||||
PERMISSIONS.crmOpportunityMarkWon,
|
||||
PERMISSIONS.crmOpportunityMarkLost,
|
||||
PERMISSIONS.crmOpportunityMarkCancelled,
|
||||
PERMISSIONS.crmOpportunityMarkNoQuotation,
|
||||
PERMISSIONS.crmOpportunityReopen,
|
||||
PERMISSIONS.crmCustomerOwnerRead,
|
||||
PERMISSIONS.crmCustomerOwnerManage,
|
||||
@@ -495,8 +517,12 @@ export const CRM_PERMISSION_GROUPS: PermissionGroup[] = [
|
||||
{ key: PERMISSIONS.crmOpportunityDelete, label: 'Delete opportunities' },
|
||||
{ key: PERMISSIONS.crmOpportunityAssign, label: 'Assign opportunities' },
|
||||
{ key: PERMISSIONS.crmOpportunityReassign, label: 'Reassign opportunities' },
|
||||
{ key: PERMISSIONS.crmOpportunityLifecycleUpdate, label: 'Update opportunity lifecycle' },
|
||||
{ key: PERMISSIONS.crmOpportunityLifecycleReopen, label: 'Reopen opportunity lifecycle' },
|
||||
{ key: PERMISSIONS.crmOpportunityMarkWon, label: 'Mark opportunities as won' },
|
||||
{ key: PERMISSIONS.crmOpportunityMarkLost, label: 'Mark opportunities as lost' },
|
||||
{ key: PERMISSIONS.crmOpportunityMarkCancelled, label: 'Mark opportunities as cancelled' },
|
||||
{ key: PERMISSIONS.crmOpportunityMarkNoQuotation, label: 'Mark opportunities as no quotation' },
|
||||
{ key: PERMISSIONS.crmOpportunityReopen, label: 'Reopen lost opportunities' },
|
||||
{ key: PERMISSIONS.crmOpportunityFollowupRead, label: 'Read opportunity follow-ups' },
|
||||
{ key: PERMISSIONS.crmOpportunityFollowupCreate, label: 'Create opportunity follow-ups' },
|
||||
@@ -705,4 +731,3 @@ export function isRoleAssignmentScopeMode(value: string): value is CrmRoleAssign
|
||||
export function isApprovalAuthority(value: string): value is CrmApprovalAuthority {
|
||||
return APPROVAL_AUTHORITIES.includes(value as CrmApprovalAuthority);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user