diff --git a/docs/implementation/task-d56-opportunity-lifecycle-won-lost-alignment.md b/docs/implementation/task-d56-opportunity-lifecycle-won-lost-alignment.md new file mode 100644 index 0000000..0b4dbcd --- /dev/null +++ b/docs/implementation/task-d56-opportunity-lifecycle-won-lost-alignment.md @@ -0,0 +1,74 @@ +# Task D.5.6 Opportunity Lifecycle / Won-Lost Alignment + +## Summary + +- Added explicit opportunity outcome fields in `crm_opportunities`: + - `outcomeStatus` + - `closedAt` + - `lostDetail` + - `cancelReason` + - `noQuotationReason` +- Preserved legacy compatibility fields: + - `pipelineStage` + - `closedWonAt` + - `closedLostAt` + +## Lifecycle Model + +- Working stage continues to use `crm_opportunities.status` for now. +- UI/API now expose `stage` as an alias of `status`. +- Final result is tracked with `outcomeStatus`. + +### Closed outcomes + +- `won` +- `lost` +- `cancelled` +- `no_quotation` + +### Reopen behavior + +- Reopen clears final outcome data. +- Reopen returns the opportunity to: + - `status = quotation_created` + - `outcomeStatus = open` + - `pipelineStage = opportunity` + +## Master Options + +- Added/seeded: + - `crm_opportunity_stage` + - `crm_opportunity_outcome_status` + - `crm_opportunity_lost_reason` + - `crm_opportunity_cancel_reason` + - `crm_opportunity_no_quotation_reason` +- Kept legacy compatibility categories: + - `crm_opportunity_status` + - `crm_lost_reason` + +## API Routes + +- `POST /api/crm/opportunities/[id]/mark-won` +- `POST /api/crm/opportunities/[id]/mark-lost` +- `POST /api/crm/opportunities/[id]/mark-cancelled` +- `POST /api/crm/opportunities/[id]/mark-no-quotation` +- `POST /api/crm/opportunities/[id]/reopen` + +## Quotation Alignment + +- Quotation creation is blocked when linked opportunity has a closed `outcomeStatus`. +- Quotation creation keeps outcome as `open`. +- Opportunity stage sync remains compatibility-first and moves to `quotation_created`. + +## Audit Actions + +- `mark_opportunity_won` +- `mark_opportunity_lost` +- `mark_opportunity_cancelled` +- `mark_opportunity_no_quotation` +- `reopen_opportunity` + +## Encoding Guard + +- Verified repository text encoding with `npm run verify:encoding`. +- Repaired mojibake on CRM dashboard components in the sales ranking, follow-up, and hot-project sections. diff --git a/plans/task-d.5.6.md b/plans/task-d.5.6.md new file mode 100644 index 0000000..168ab32 --- /dev/null +++ b/plans/task-d.5.6.md @@ -0,0 +1,602 @@ +# Task D.5.6: Opportunity Lifecycle / Won-Lost Alignment + +## Status + +Planned + +## Objective + +Establish the official Opportunity lifecycle and business outcome rules after the Enquiry domain has been force-renamed to Opportunity. + +This task aligns: + +```txt +Lead → Opportunity → Quotation → PO / Lost / Cancelled / No Quotation +``` + +without changing dashboard/report datasets yet. + +The goal is to make Opportunity outcome states consistent before KPI, pipeline reporting, and dashboard work begins. + +--- + +## Mandatory Review + +Review before implementation: + +* `AGENTS.md` +* `plans/task-d.5.6.md` +* `docs/standards/project-foundations.md` +* `docs/standards/task-catalog.md` +* `docs/adr/0018-lead-enquiry-domain-separation.md` +* `docs/implementation/task-d55-opportunity-sales-workspace-refinement.md` +* `docs/implementation/task-d551-force-rename-enquiry-domain-opportunity.md` +* `docs/security/crm-authorization-boundaries.md` +* `src/db/schema.ts` +* `src/features/crm/opportunities/**` +* `src/features/crm/quotations/**` +* `src/features/foundation/master-options/**` +* `src/features/foundation/audit-log/**` + +--- + +## Current Foundation + +Already available: + +```txt +crm_leads +crm_opportunities +crm_opportunity_followups +crm_opportunity_customers + +/api/crm/leads +/api/crm/opportunities +/api/crm/quotations + +Lead → Opportunity assignment +Opportunity UI +Quotation flow +Approval flow +Audit foundation +Master option foundation +``` + +Do not recreate: + +```txt +lead domain +opportunity domain +quotation domain +approval domain +dashboard +reports +``` + +--- + +## Business Definitions + +### Opportunity + +A sales-owned execution record created from Lead assignment or direct sales capture. + +### Opportunity Stage + +The current sales working phase. + +Example: + +```txt +new +qualification +requirement_gathering +site_survey +proposal_preparation +quotation_created +negotiation +closed +``` + +### Opportunity Outcome + +The final business result. + +Example: + +```txt +open +won +lost +cancelled +no_quotation +``` + +Stage answers: + +```txt +Where is Sales working now? +``` + +Outcome answers: + +```txt +What was the final result? +``` + +--- + +## Scope D5.6.1 Lifecycle Model + +Define official lifecycle model. + +Recommended fields if already available: + +```txt +status +processStatus +businessStatus +outcomeStatus +lostReason +closedAt +closedBy +``` + +If schema does not yet support outcome fields, add a migration. + +Recommended DB additions to `crm_opportunities`: + +```txt +stage text not null default 'new' +outcome_status text not null default 'open' +lost_reason text null +lost_detail text null +closed_at timestamptz null +closed_by text null +cancel_reason text null +no_quotation_reason text null +``` + +Rules: + +* `stage` = working lifecycle +* `outcomeStatus` = final result +* only one final outcome allowed +* final opportunities should not allow normal editing unless reopened by permitted user + +--- + +## Scope D5.6.2 Master Options + +Add or verify master option categories: + +```txt +crm_opportunity_stage +crm_opportunity_outcome_status +crm_opportunity_lost_reason +crm_opportunity_cancel_reason +crm_opportunity_no_quotation_reason +``` + +Recommended stages: + +```txt +new +qualification +requirement_gathering +site_survey +proposal_preparation +quotation_created +negotiation +closed +``` + +Recommended outcome statuses: + +```txt +open +won +lost +cancelled +no_quotation +``` + +Recommended lost reasons: + +```txt +price_too_high +competitor_won +budget_cancelled +timeline_not_match +spec_not_match +customer_no_response +other +``` + +Rules: + +* no hardcoded labels in UI +* use master-option foundation +* seed all required options + +--- + +## Scope D5.6.3 Opportunity Outcome Service + +Add service operations: + +```ts +markOpportunityWon() +markOpportunityLost() +markOpportunityCancelled() +markOpportunityNoQuotation() +reopenOpportunity() +``` + +Location: + +```txt +src/features/crm/opportunities/server/lifecycle.service.ts +``` + +Rules: + +* all operations must enforce CRM access +* all operations must audit +* closed opportunities cannot be closed again +* reopening requires explicit permission +* outcome updates must be transactional + +--- + +## Scope D5.6.4 Outcome API Routes + +Add routes: + +```txt +POST /api/crm/opportunities/[id]/mark-won +POST /api/crm/opportunities/[id]/mark-lost +POST /api/crm/opportunities/[id]/mark-cancelled +POST /api/crm/opportunities/[id]/mark-no-quotation +POST /api/crm/opportunities/[id]/reopen +``` + +Request examples: + +```ts +// mark lost +{ + lostReason: string; + lostDetail?: string; +} + +// mark cancelled +{ + cancelReason: string; +} + +// mark no quotation +{ + noQuotationReason: string; +} + +// reopen +{ + reason: string; +} +``` + +Response: + +```ts +{ + opportunityId: string; + outcomeStatus: string; + stage: string; + closedAt?: string | null; +} +``` + +--- + +## Scope D5.6.5 Quotation Alignment + +Align opportunity outcome with quotation state. + +Rules: + +* When quotation is created from opportunity: + + * opportunity stage may become `quotation_created` + * outcome remains `open` +* When quotation is accepted / PO received: + + * opportunity can be marked `won` +* When quotation is rejected / lost: + + * opportunity can be marked `lost` +* Multiple quotations may exist under one opportunity +* A single won quotation is enough to mark opportunity as `won` +* If opportunity is closed, prevent creating new quotation unless reopened + +Do not rewrite quotation service heavily in this task. + +Add minimal guard/hooks only. + +--- + +## Scope D5.6.6 UI Outcome Actions + +Update Opportunity Detail. + +Add outcome action area: + +```txt +Mark Won +Mark Lost +Mark Cancelled +Mark No Quotation +Reopen +``` + +Rules: + +* show actions based on permission and current state +* require reason for lost/cancelled/no quotation +* require reason for reopen +* closed opportunities show locked/closed state +* outcome actions should not appear on lead page + +--- + +## Scope D5.6.7 Opportunity List Outcome Display + +Update Opportunity List. + +Add columns or badges: + +```txt +Stage +Outcome +Lost Reason +Closed Date +``` + +Filters: + +```txt +stage +outcomeStatus +lostReason +closedAt range +``` + +Rules: + +* open opportunities remain default view +* closed outcomes can be filtered +* do not change dashboard/report datasets yet + +--- + +## Scope D5.6.8 Audit Logging + +Required audit actions: + +```txt +mark_opportunity_won +mark_opportunity_lost +mark_opportunity_cancelled +mark_opportunity_no_quotation +reopen_opportunity +``` + +Audit should capture: + +```txt +previousStage +previousOutcomeStatus +newStage +newOutcomeStatus +reason +actedBy +actedAt +``` + +--- + +## Scope D5.6.9 Permissions + +Add or verify permissions: + +```txt +crm.opportunity.lifecycle.update +crm.opportunity.lifecycle.reopen +``` + +Optional granular permissions: + +```txt +crm.opportunity.mark_won +crm.opportunity.mark_lost +crm.opportunity.mark_cancelled +crm.opportunity.mark_no_quotation +crm.opportunity.reopen +``` + +Recommendation: + +Use granular permissions if current permission model supports it. + +Rules: + +* do not check role strings directly +* use resolved CRM access +* backend remains source of truth + +--- + +## Scope D5.6.10 Documentation + +Create: + +```txt +docs/implementation/task-d56-opportunity-lifecycle-won-lost-alignment.md +``` + +Document: + +```txt +stage model +outcome model +state transition rules +permission rules +quotation alignment +known limitations +``` + +--- + +## State Transition Rules + +Recommended: + +```txt +open → won +open → lost +open → cancelled +open → no_quotation + +won → open only via reopen +lost → open only via reopen +cancelled → open only via reopen +no_quotation → open only via reopen +``` + +Closed states: + +```txt +won +lost +cancelled +no_quotation +``` + +Closed opportunities: + +```txt +cannot edit normal fields +cannot create quotation +cannot add sales follow-up +can view history +can reopen if permitted +``` + +--- + +## Deliverables + +New or updated: + +```txt +src/features/crm/opportunities/server/lifecycle.service.ts +src/features/crm/opportunities/schemas/lifecycle.schema.ts + +src/app/api/crm/opportunities/[id]/mark-won/route.ts +src/app/api/crm/opportunities/[id]/mark-lost/route.ts +src/app/api/crm/opportunities/[id]/mark-cancelled/route.ts +src/app/api/crm/opportunities/[id]/mark-no-quotation/route.ts +src/app/api/crm/opportunities/[id]/reopen/route.ts +``` + +Updated: + +```txt +src/db/schema.ts +src/db/seeds/foundation.seed.ts +src/features/crm/opportunities/types.ts +src/features/crm/opportunities/server/service.ts +src/features/crm/opportunities/components/opportunity-detail.tsx +src/features/crm/opportunities/components/opportunities-table.tsx +src/features/crm/opportunities/components/opportunity-status-badge.tsx +src/features/crm/opportunities/api/service.ts +src/features/crm/opportunities/api/mutations.ts +src/features/crm/opportunities/api/queries.ts +``` + +Documentation: + +```txt +docs/implementation/task-d56-opportunity-lifecycle-won-lost-alignment.md +``` + +--- + +## Explicit Non-Scope + +Do not: + +* rename opportunity domain again +* change lead domain +* rewrite quotation approval +* create dashboard datasets +* create reports +* implement sales KPI +* implement PO module +* migrate historical production data +* create Sales Enquiry document + +These belong to later phases. + +--- + +## Verification + +Run: + +```txt +npm exec tsc --noEmit +npm run build +``` + +Manual verification: + +```txt +Create opportunity +Create quotation from opportunity +Mark opportunity won +Mark opportunity lost with reason +Mark opportunity cancelled with reason +Mark opportunity no quotation with reason +Reopen opportunity +Blocked quotation creation on closed opportunity +Opportunity list filters by outcome +Audit logs exist for lifecycle actions +Lead → Opportunity still works +Quotation flow still works +Approval flow still works +``` + +--- + +## Definition of Done + +Task is complete when: + +* Opportunity has official stage and outcome model +* Won/Lost/Cancelled/No Quotation actions work +* Reopen flow works with permission +* Opportunity list/detail show lifecycle state +* Closed opportunities are guarded +* Quotation alignment is minimally enforced +* Audit logs capture lifecycle transitions +* Existing Lead, Quotation, and Approval flows remain operational + +Result: + +```txt +Opportunity Lifecycle = Established +Won/Lost Alignment = Established +Pipeline Outcome Foundation = Ready +Ready for Pipeline KPI / Dashboard Foundation +``` diff --git a/src/app/api/crm/opportunities/[id]/mark-cancelled/route.ts b/src/app/api/crm/opportunities/[id]/mark-cancelled/route.ts new file mode 100644 index 0000000..f44f2b4 --- /dev/null +++ b/src/app/api/crm/opportunities/[id]/mark-cancelled/route.ts @@ -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 }); + } +} diff --git a/src/app/api/crm/opportunities/[id]/mark-lost/route.ts b/src/app/api/crm/opportunities/[id]/mark-lost/route.ts index c27ab15..eca0de8 100644 --- a/src/app/api/crm/opportunities/[id]/mark-lost/route.ts +++ b/src/app/api/crm/opportunities/[id]/mark-lost/route.ts @@ -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 }); } } - diff --git a/src/app/api/crm/opportunities/[id]/mark-no-quotation/route.ts b/src/app/api/crm/opportunities/[id]/mark-no-quotation/route.ts new file mode 100644 index 0000000..505427c --- /dev/null +++ b/src/app/api/crm/opportunities/[id]/mark-no-quotation/route.ts @@ -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 }); + } +} diff --git a/src/app/api/crm/opportunities/[id]/mark-won/route.ts b/src/app/api/crm/opportunities/[id]/mark-won/route.ts index 5d99eb0..37539de 100644 --- a/src/app/api/crm/opportunities/[id]/mark-won/route.ts +++ b/src/app/api/crm/opportunities/[id]/mark-won/route.ts @@ -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 }); } } - diff --git a/src/app/api/crm/opportunities/[id]/reopen/route.ts b/src/app/api/crm/opportunities/[id]/reopen/route.ts index 83b9b4b..f493f53 100644 --- a/src/app/api/crm/opportunities/[id]/reopen/route.ts +++ b/src/app/api/crm/opportunities/[id]/reopen/route.ts @@ -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 }); } } - diff --git a/src/app/api/crm/opportunities/route.ts b/src/app/api/crm/opportunities/route.ts index 499293c..0ba44f2 100644 --- a/src/app/api/crm/opportunities/route.ts +++ b/src/app/api/crm/opportunities/route.ts @@ -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>['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 }); } } - diff --git a/src/db/schema.ts b/src/db/schema.ts index 400491a..c5b42dd 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -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( ) }) ); - diff --git a/src/db/seeds/foundation.seed.ts b/src/db/seeds/foundation.seed.ts index 3db9c89..173807c 100644 --- a/src/db/seeds/foundation.seed.ts +++ b/src/db/seeds/foundation.seed.ts @@ -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 }, diff --git a/src/features/crm/customers/components/customer-detail.tsx b/src/features/crm/customers/components/customer-detail.tsx index 435a86c..28b0c88 100644 --- a/src/features/crm/customers/components/customer-detail.tsx +++ b/src/features/crm/customers/components/customer-detail.tsx @@ -297,7 +297,7 @@ export function CustomerDetail({ /> - เธ‚เน‰เธญเธกเธนเธฅเธฃเธฐเธšเธš + ข้อมูลระบบ ข้อมูลประกอบการติดตามและตรวจสอบของรายการลูกค้านี้ @@ -319,4 +319,3 @@ export function CustomerDetail({ ); } - diff --git a/src/features/crm/dashboard/components/dashboard-followups.tsx b/src/features/crm/dashboard/components/dashboard-followups.tsx index 18ac789..2fb0e49 100644 --- a/src/features/crm/dashboard/components/dashboard-followups.tsx +++ b/src/features/crm/dashboard/components/dashboard-followups.tsx @@ -22,7 +22,9 @@ export function DashboardFollowups({ Dashboard งานติดตาม - ภาพรวมงานติดตามที่กำลังจะถึงและที่ดำเนินการแล้วของโอกาสขายและใบเสนอราคา + + ภาพรวมงานติดตามที่กำลังจะถึงและที่ดำเนินการแล้วของโอกาสขายและใบเสนอราคา +
@@ -38,7 +40,7 @@ export function DashboardFollowups({ ลูกค้า โอกาสขาย ผู้ดูแลโอกาสขาย - เธ—เธตเนˆเธกเธฒ + ที่มา @@ -65,4 +67,3 @@ export function DashboardFollowups({ ); } - diff --git a/src/features/crm/dashboard/components/dashboard-hot-projects.tsx b/src/features/crm/dashboard/components/dashboard-hot-projects.tsx index 7815865..a4bf1c5 100644 --- a/src/features/crm/dashboard/components/dashboard-hot-projects.tsx +++ b/src/features/crm/dashboard/components/dashboard-hot-projects.tsx @@ -13,7 +13,9 @@ export function DashboardHotProjects({ rows }: { rows: CrmDashboardHotProjectRow โอกาสขายเด่นที่กำลังดำเนินการ - จัดอันดับโอกาสขายสำคัญตามมูลค่าที่ประเมินไว้ + + จัดอันดับโอกาสขายสำคัญตามมูลค่าที่ประเมินไว้ + @@ -21,7 +23,7 @@ export function DashboardHotProjects({ rows }: { rows: CrmDashboardHotProjectRow โครงการ ลูกค้า - เธกเธนเธฅเธ„เนˆเธฒ + มูลค่า ผู้ดูแลโอกาสขาย ความน่าจะเป็น @@ -53,4 +55,3 @@ export function DashboardHotProjects({ rows }: { rows: CrmDashboardHotProjectRow ); } - diff --git a/src/features/crm/dashboard/components/dashboard-sales-ranking.tsx b/src/features/crm/dashboard/components/dashboard-sales-ranking.tsx index 676b8f3..40cd2dd 100644 --- a/src/features/crm/dashboard/components/dashboard-sales-ranking.tsx +++ b/src/features/crm/dashboard/components/dashboard-sales-ranking.tsx @@ -13,13 +13,15 @@ export function DashboardSalesRanking({ rows }: { rows: CrmDashboardSalesRanking อันดับผลงานฝ่ายขาย - การนับลีดและโอกาสขายยึดตามผู้รับผิดชอบ ส่วนใบเสนอราคายึดตามผู้ขาย + + การนับลีดและโอกาสขายยึดตามผู้รับผิดชอบ ส่วนใบเสนอราคาและงานที่ชนะยึดตามผู้ขาย +
- ผู้ขาย + ผู้ขาย ลีด โอกาสขาย ใบเสนอราคา diff --git a/src/features/crm/opportunities/api/mutations.ts b/src/features/crm/opportunities/api/mutations.ts index 6e5698d..d2bb320 100644 --- a/src/features/crm/opportunities/api/mutations.ts +++ b/src/features/crm/opportunities/api/mutations.ts @@ -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({ } } }); - diff --git a/src/features/crm/opportunities/api/queries.ts b/src/features/crm/opportunities/api/queries.ts index c16db7c..a463616 100644 --- a/src/features/crm/opportunities/api/queries.ts +++ b/src/features/crm/opportunities/api/queries.ts @@ -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) }); - diff --git a/src/features/crm/opportunities/api/service.ts b/src/features/crm/opportunities/api/service.ts index 510bf16..c69d6c6 100644 --- a/src/features/crm/opportunities/api/service.ts +++ b/src/features/crm/opportunities/api/service.ts @@ -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 { @@ -23,14 +25,18 @@ export async function getOpportunities(filters: OpportunityFilters): Promise(`/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(`/crm/opportunities/${id}/mark-cancelled`, { + method: 'POST', + body: JSON.stringify(data) + }); +} + +export async function markOpportunityNoQuotation(id: string, data: OpportunityMarkNoQuotationPayload) { + return apiClient(`/crm/opportunities/${id}/mark-no-quotation`, { + method: 'POST', + body: JSON.stringify(data) + }); +} + export async function reopenOpportunity(id: string, data: OpportunityReopenPayload) { return apiClient(`/crm/opportunities/${id}/reopen`, { method: 'POST', body: JSON.stringify(data) }); } - diff --git a/src/features/crm/opportunities/api/types.ts b/src/features/crm/opportunities/api/types.ts index 6f2bfe6..aec162d 100644 --- a/src/features/crm/opportunities/api/types.ts +++ b/src/features/crm/opportunities/api/types.ts @@ -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; } - diff --git a/src/features/crm/opportunities/components/opportunity-cell-action.tsx b/src/features/crm/opportunities/components/opportunity-cell-action.tsx index 094c4d0..690ff11 100644 --- a/src/features/crm/opportunities/components/opportunity-cell-action.tsx +++ b/src/features/crm/opportunities/components/opportunity-cell-action.tsx @@ -92,11 +92,10 @@ export function OpportunityCellAction({ {workspace === 'lead' ? 'แก้ไขลีด' : 'แก้ไขโอกาสขาย'} setDeleteOpen(true)} disabled={!canDelete}> - เธฅเธš + ลบ ); } - diff --git a/src/features/crm/opportunities/components/opportunity-followups-tab.tsx b/src/features/crm/opportunities/components/opportunity-followups-tab.tsx index db662fe..72c5c21 100644 --- a/src/features/crm/opportunities/components/opportunity-followups-tab.tsx +++ b/src/features/crm/opportunities/components/opportunity-followups-tab.tsx @@ -117,7 +117,7 @@ export function OpportunityFollowupsTab({ setDeleteOpen(true); }} > - เธฅเธš + ลบ @@ -146,4 +146,3 @@ export function OpportunityFollowupsTab({ ); } - diff --git a/src/features/crm/opportunities/schemas/lifecycle.schema.ts b/src/features/crm/opportunities/schemas/lifecycle.schema.ts new file mode 100644 index 0000000..ad29579 --- /dev/null +++ b/src/features/crm/opportunities/schemas/lifecycle.schema.ts @@ -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; +export type OpportunityMarkLostFormValues = z.input; +export type OpportunityMarkCancelledFormValues = z.input; +export type OpportunityMarkNoQuotationFormValues = z.input; +export type OpportunityReopenFormValues = z.input; diff --git a/src/features/crm/opportunities/schemas/opportunity.schema.ts b/src/features/crm/opportunities/schemas/opportunity.schema.ts index de25664..781d6fb 100644 --- a/src/features/crm/opportunities/schemas/opportunity.schema.ts +++ b/src/features/crm/opportunities/schemas/opportunity.schema.ts @@ -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; export type OpportunityFollowupFormValues = z.input; -export type OpportunityMarkWonFormValues = z.input; -export type OpportunityMarkLostFormValues = z.input; -export type OpportunityReopenFormValues = z.input; - diff --git a/src/features/crm/opportunities/server/lifecycle.service.ts b/src/features/crm/opportunities/server/lifecycle.service.ts new file mode 100644 index 0000000..6e557b6 --- /dev/null +++ b/src/features/crm/opportunities/server/lifecycle.service.ts @@ -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; +} diff --git a/src/features/crm/opportunities/server/service.ts b/src/features/crm/opportunities/server/service.ts index 2497028..00156c6 100644 --- a/src/features/crm/opportunities/server/service.ts +++ b/src/features/crm/opportunities/server/service.ts @@ -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 { 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)); +} diff --git a/src/features/crm/quotations/server/service.ts b/src/features/crm/quotations/server/service.ts index 4289831..00e10fc 100644 --- a/src/features/crm/quotations/server/service.ts +++ b/src/features/crm/quotations/server/service.ts @@ -457,6 +457,14 @@ async function assertOpportunityBelongsToOrganization(id: string, organizationId return opportunity; } +function assertOpportunityOpenForQuotation( + opportunity: Awaited> +) { + 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() })); } - diff --git a/src/features/foundation/master-options/types.ts b/src/features/foundation/master-options/types.ts index 7234620..7773d0a 100644 --- a/src/features/foundation/master-options/types.ts +++ b/src/features/foundation/master-options/types.ts @@ -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; } - diff --git a/src/lib/auth/rbac.ts b/src/lib/auth/rbac.ts index 8390dbf..9ab79e0 100644 --- a/src/lib/auth/rbac.ts +++ b/src/lib/auth/rbac.ts @@ -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