task-d.5.6

This commit is contained in:
phaichayon
2026-06-25 13:09:21 +07:00
parent c2a74b6764
commit 65dd0e2e56
27 changed files with 1583 additions and 179 deletions

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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({
}
}
});

View File

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

View File

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

View File

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

View File

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

View File

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

View 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>;

View File

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

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

View File

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

View File

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

View File

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

View File

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