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,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.

602
plans/task-d.5.6.md Normal file
View File

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

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()
});
export async function GET(request: NextRequest) {
try {
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmOpportunityRead
});
const { searchParams } = request.nextUrl;
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,
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'
};
const result = await listOpportunities(
accessContext,
{
}
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 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;
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,7 +13,9 @@ export function DashboardSalesRanking({ rows }: { rows: CrmDashboardSalesRanking
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription> </CardDescription>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent>
<Table>

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