This commit is contained in:
phaichayon
2026-06-22 16:57:02 +07:00
parent 3f28fde39f
commit ffa5de8311
33 changed files with 6706 additions and 101 deletions

View File

@@ -0,0 +1,58 @@
# ADR 0016: Won / Lost Lifecycle Governance
## Status
Accepted
## Context
CRM dashboard and reporting previously treated quotation acceptance and rejection as proxies for opportunity outcomes. That made business outcome data unstable because:
- quotation approval/acceptance does not equal PO received
- lost opportunities could be inferred without a required lost reason
- reopen rules were undefined
- revenue and win/loss KPI logic depended on quotation status instead of the enquiry lifecycle
Business Discovery 3.0 froze the production rule set:
- `closed_won` means PO received
- `closed_lost` requires a lost reason
- won cannot reopen
- lost can reopen with audit
- won revenue prefers PO amount and falls back to quotation total
- lost revenue uses quotation total
## Decision
We use `crm_enquiries` as the official business outcome record.
We add outcome fields on `crm_enquiries`:
- `closed_won_at`
- `closed_lost_at`
- `closed_by_user_id`
- `po_number`
- `po_date`
- `po_amount`
- `po_currency`
- `lost_reason`
- `lost_competitor`
- `lost_remark`
We add `crm_enquiry_attachments` for purchase order files with category `purchase_order`.
All lifecycle transitions must go through the enquiry outcome service layer:
- `markEnquiryAsWon()`
- `markEnquiryAsLost()`
- `reopenLostEnquiry()`
Quotation status is no longer allowed to mutate enquiry outcome automatically.
## Consequences
- dashboard won/lost KPI must read enquiry `pipeline_stage`
- marketing can see outcome state but PO amount remains server-side restricted
- PO attachment access follows commercial-data visibility rules
- audit coverage expands to `mark_won`, `mark_lost`, `reopen`, and `upload_po`
- downstream reporting can reuse a stable outcome model for Task K

View File

@@ -0,0 +1,41 @@
# Task D.4 Implementation Summary
## Completed
- extended `crm_enquiries` with official won/lost lifecycle fields
- added `crm_enquiry_attachments` for purchase order files
- seeded `crm_lost_reason` master data
- added permissions:
- `crm.enquiry.mark_won`
- `crm.enquiry.mark_lost`
- `crm.enquiry.reopen`
- added outcome service operations for:
- mark won
- mark lost
- reopen lost opportunity
- added API routes for outcome transitions and PO attachment upload/view/download
- added enquiry detail outcome card with:
- open / won / lost state
- PO data
- lost data
- closed by / closed date
- mark won / mark lost / reopen actions
- PO attachment upload and retrieval
- removed quotation-status-driven mutation of enquiry pipeline outcome
- migrated dashboard summary/funnel/sales won revenue to enquiry outcome helpers
- added reporting helpers:
- `getWonRevenue()`
- `getLostRevenue()`
- `getLostByReason()`
- `getLostByCompetitor()`
- `getWinRate()`
## Verification
- `npm exec tsc --noEmit`
## Notes
- marketing users still receive outcome state, but PO amount is redacted server-side unless commercial pricing visibility exists
- PO attachment endpoints are also restricted to commercial-data viewers
- revenue analytics cards still use existing quotation-attribution views for top-party ranking, but won/lost headline KPI and sales won revenue now follow the D.4 lifecycle rule

View File

@@ -0,0 +1,30 @@
ALTER TABLE "crm_enquiries"
ADD COLUMN IF NOT EXISTS "closed_won_at" timestamp with time zone,
ADD COLUMN IF NOT EXISTS "closed_lost_at" timestamp with time zone,
ADD COLUMN IF NOT EXISTS "closed_by_user_id" text,
ADD COLUMN IF NOT EXISTS "po_number" text,
ADD COLUMN IF NOT EXISTS "po_date" timestamp with time zone,
ADD COLUMN IF NOT EXISTS "po_amount" double precision,
ADD COLUMN IF NOT EXISTS "po_currency" text,
ADD COLUMN IF NOT EXISTS "lost_reason" text,
ADD COLUMN IF NOT EXISTS "lost_competitor" text,
ADD COLUMN IF NOT EXISTS "lost_remark" text;
CREATE TABLE IF NOT EXISTS "crm_enquiry_attachments" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"enquiry_id" text NOT NULL,
"category" text NOT NULL,
"file_name" text NOT NULL,
"original_file_name" text NOT NULL,
"storage_provider" text NOT NULL,
"storage_key" text NOT NULL,
"file_size" integer,
"file_type" text,
"description" text,
"uploaded_at" timestamp with time zone DEFAULT now() NOT NULL,
"uploaded_by" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone
);

View File

@@ -0,0 +1,61 @@
CREATE TABLE "crm_contact_shares" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"contact_id" text NOT NULL,
"shared_to_user_id" text NOT NULL,
"shared_by_user_id" text NOT NULL,
"shared_at" timestamp with time zone DEFAULT now() NOT NULL,
"is_active" boolean DEFAULT true NOT NULL,
"remark" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "crm_customer_owner_history" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"customer_id" text NOT NULL,
"old_owner_user_id" text,
"new_owner_user_id" text,
"changed_by" text NOT NULL,
"changed_at" timestamp with time zone DEFAULT now() NOT NULL,
"remark" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "crm_enquiry_attachments" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"enquiry_id" text NOT NULL,
"category" text NOT NULL,
"file_name" text NOT NULL,
"original_file_name" text NOT NULL,
"storage_provider" text NOT NULL,
"storage_key" text NOT NULL,
"file_size" integer,
"file_type" text,
"description" text,
"uploaded_at" timestamp with time zone DEFAULT now() NOT NULL,
"uploaded_by" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "crm_customers" ADD COLUMN "owner_user_id" text;--> statement-breakpoint
ALTER TABLE "crm_customers" ADD COLUMN "owner_assigned_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "crm_customers" ADD COLUMN "owner_assigned_by" text;--> statement-breakpoint
ALTER TABLE "crm_enquiries" ADD COLUMN "closed_won_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "crm_enquiries" ADD COLUMN "closed_lost_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "crm_enquiries" ADD COLUMN "closed_by_user_id" text;--> statement-breakpoint
ALTER TABLE "crm_enquiries" ADD COLUMN "po_number" text;--> statement-breakpoint
ALTER TABLE "crm_enquiries" ADD COLUMN "po_date" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "crm_enquiries" ADD COLUMN "po_amount" double precision;--> statement-breakpoint
ALTER TABLE "crm_enquiries" ADD COLUMN "po_currency" text;--> statement-breakpoint
ALTER TABLE "crm_enquiries" ADD COLUMN "lost_reason" text;--> statement-breakpoint
ALTER TABLE "crm_enquiries" ADD COLUMN "lost_competitor" text;--> statement-breakpoint
ALTER TABLE "crm_enquiries" ADD COLUMN "lost_remark" text;--> statement-breakpoint
CREATE UNIQUE INDEX "crm_contact_shares_org_contact_shared_user_idx" ON "crm_contact_shares" USING btree ("organization_id","contact_id","shared_to_user_id");

File diff suppressed because it is too large Load Diff

View File

@@ -113,6 +113,20 @@
"when": 1782549600000, "when": 1782549600000,
"tag": "0015_customer_ownership_contact_shares", "tag": "0015_customer_ownership_contact_shares",
"breakpoints": true "breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1782795600000,
"tag": "0016_won_lost_lifecycle_governance",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1782121929212,
"tag": "0017_short_swordsman",
"breakpoints": true
} }
] ]
} }

710
plans/task-d.4.md Normal file
View File

@@ -0,0 +1,710 @@
# Task D.4: Won / Lost Lifecycle Governance
## Objective
Formalize CRM opportunity outcomes by introducing production-grade:
* Closed Won lifecycle
* Closed Lost lifecycle
* PO tracking
* Lost reason governance
* Outcome analytics foundation
This task replaces temporary dashboard assumptions and becomes the official business outcome model for CRM.
---
# Background
Business Discovery 3.0 has frozen:
## Closed Won
Definition:
```txt
PO Received
```
Only a received Purchase Order can move an enquiry to:
```txt
closed_won
```
Quotation approval or quotation acceptance alone does not create a Won outcome.
---
## Closed Lost
Definition:
```txt
Sales opportunity permanently lost
```
Lost requires:
```txt
Lost Reason
```
Optional:
```txt
Lost Competitor
Lost Remark
```
---
# Scope D4.1 Outcome Schema
Extend:
```txt
crm_enquiries
```
Add:
```txt
closedWonAt
closedLostAt
closedByUserId
poNumber
poDate
poAmount
poCurrency
lostReason
lostCompetitor
lostRemark
```
Rules:
```txt
Won
OR
Lost
```
never both.
---
# Scope D4.2 Lost Reason Master Data
Add master option category:
```txt
crm_lost_reason
```
Seed:
```txt
competitor_won
budget_not_approved
project_cancelled
customer_no_response
technical_requirement_mismatch
price_too_high
timeline_not_fit
other
```
Rules:
```txt
Lost Reason
```
required when closing lost.
---
# Scope D4.3 Outcome Service Layer
Create centralized lifecycle service.
Supported actions:
```txt
markWon()
markLost()
reopenOpportunity()
```
All outcome transitions must go through service layer.
No direct status updates.
---
# Scope D4.4 Mark Won Workflow
Action:
```txt
Mark Won
```
Available only when:
```txt
pipelineStage = enquiry
```
Required fields:
```txt
PO Number
PO Date
```
Optional:
```txt
PO Amount
PO Currency
PO Attachment
Remark
```
System behavior:
```txt
pipelineStage = closed_won
closedWonAt = now
closedByUserId = current user
```
Audit:
```txt
crm_enquiry
action = mark_won
```
---
# Scope D4.5 PO Attachment
Add attachment support.
Allowed:
```txt
PDF
Image
Excel
```
Relationship:
```txt
crm_enquiry
```
Attachment category:
```txt
purchase_order
```
UI:
```txt
Upload PO
View PO
Download PO
```
---
# Scope D4.6 Mark Lost Workflow
Action:
```txt
Mark Lost
```
Available only when:
```txt
pipelineStage = enquiry
```
Required:
```txt
Lost Reason
```
Optional:
```txt
Lost Competitor
Lost Remark
```
System behavior:
```txt
pipelineStage = closed_lost
closedLostAt = now
closedByUserId = current user
```
Audit:
```txt
crm_enquiry
action = mark_lost
```
---
# Scope D4.7 Reopen Opportunity
Allow controlled reopen.
Available for:
```txt
closed_lost
```
Only.
Not allowed for:
```txt
closed_won
```
Reason:
```txt
Won must remain final once PO exists.
```
Required:
```txt
Reopen Reason
```
Audit:
```txt
action = reopen
```
---
# Scope D4.8 UI Changes
Enquiry Detail
Add:
```txt
Outcome
```
Section.
Display:
```txt
Won
Lost
Open
```
Fields:
```txt
PO Information
Lost Information
Closed By
Closed Date
```
Actions:
```txt
Mark Won
Mark Lost
Reopen
```
---
# Scope D4.9 Dashboard KPI Migration
Replace temporary logic.
Current:
```txt
quotation accepted
```
Proxy.
New:
```txt
pipelineStage = closed_won
```
Official.
---
Update:
## Won Count
```txt
closed_won
```
---
## Lost Count
```txt
closed_lost
```
---
## Win Rate
```txt
Won / (Won + Lost)
```
---
## Lost Analysis
```txt
Lost By Reason
Lost By Competitor
Lost By Sales
Lost By Product Type
Lost By Branch
```
---
# Scope D4.10 Revenue Rules
Freeze:
## Won Revenue
Priority:
```txt
PO Amount
```
If available.
Fallback:
```txt
Quotation Total
```
if PO Amount is empty.
---
Lost Revenue:
```txt
Quotation Total
```
for reporting opportunity loss.
---
# Scope D4.11 Report Foundation
Prepare data for Task K.
Add reusable reporting helpers:
```txt
getWonRevenue()
getLostRevenue()
getLostByReason()
getLostByCompetitor()
getWinRate()
```
Organization scoped.
---
# Scope D4.12 Permissions
Add:
```txt
crm.enquiry.mark_won
crm.enquiry.mark_lost
crm.enquiry.reopen
```
Default:
```txt
sales
sales_manager
department_manager
crm_admin
```
Not granted:
```txt
marketing
```
---
# Scope D4.13 Security
Marketing may see:
```txt
Won
Lost
Outcome Summary
```
Marketing may not see:
```txt
PO Amount
Revenue
Quotation Pricing
```
Enforce server-side.
---
# Scope D4.14 Audit
Entity:
```txt
crm_enquiry
```
Actions:
```txt
mark_won
mark_lost
reopen
upload_po
```
Payload:
```txt
poNumber
poAmount
lostReason
lostCompetitor
closedBy
closedAt
```
---
# Scope D4.15 Verification
Scenario 1
```txt
Enquiry
→ Mark Won
```
Expected:
```txt
closed_won
PO stored
audit created
```
---
Scenario 2
```txt
Enquiry
→ Mark Lost
```
Expected:
```txt
closed_lost
lost reason stored
audit created
```
---
Scenario 3
```txt
Lost
→ Reopen
```
Expected:
```txt
enquiry
reopen audit created
```
---
Scenario 4
```txt
Won
→ Reopen
```
Expected:
```txt
blocked
```
---
Scenario 5
Marketing User
Expected:
```txt
see outcome
cannot see PO amount
```
---
# Documentation
Create:
```txt
docs/adr/0016-won-lost-lifecycle-governance.md
docs/implementation/task-d4-won-lost-lifecycle-governance.md
```
Freeze:
```txt
PO = Won
Lost Reason required
Won cannot reopen
Lost can reopen
Revenue attribution rule
Win/Loss KPI rule
```
---
# Explicit Non-Scope
Do NOT implement:
```txt
Sales Order
Project Handover
Invoice
Delivery Note
ERP Integration
Forecasting
Probability Scoring
```
---
# Deliverables
1. Won Lifecycle
2. Lost Lifecycle
3. PO Tracking
4. Lost Reason Governance
5. Outcome Analytics Foundation
6. Dashboard KPI Migration
7. Security Rules
8. Audit Coverage
9. ADR 0016
---
# Definition of Done
Task D.4 is complete when:
* PO creates Closed Won
* Lost requires Lost Reason
* Won/Lost KPIs use pipelineStage
* Lost analytics exist
* Revenue rules are frozen
* Marketing visibility restrictions are enforced
* Audit logs exist
* ADR 0016 exists
Result:
```txt
Lead
→ Enquiry
→ Quotation
→ Closed Won (PO)
Lead
→ Enquiry
→ Quotation
→ Closed Lost
```
becomes the official CRM business outcome lifecycle.

View File

@@ -22,6 +22,7 @@ export async function POST(request: NextRequest, { params }: Params) {
userId: session.user.id, userId: session.user.id,
membershipRole: membership.role, membershipRole: membership.role,
businessRole: membership.businessRole, businessRole: membership.businessRole,
permissions: membership.permissions ?? [],
branchScopeIds: membership.branchScopeIds ?? [], branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [], productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization', ownershipScope: membership.ownershipScope ?? 'organization',

View File

@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import {
enquiryMarkLostSchema
} from '@/features/crm/enquiries/schemas/enquiry.schema';
import { getEnquiryDetail, markEnquiryAsLost } from '@/features/crm/enquiries/server/service';
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.crmEnquiryMarkLost
});
const payload = enquiryMarkLostSchema.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 getEnquiryDetail(id, organization.id, accessContext);
const updated = await markEnquiryAsLost(id, organization.id, session.user.id, payload, accessContext);
const after = await getEnquiryDetail(id, organization.id, accessContext);
return NextResponse.json({
success: true,
message: 'Enquiry marked as lost successfully',
before,
enquiry: 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 enquiry as lost' }, { status: 500 });
}
}

View File

@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import {
enquiryMarkWonSchema
} from '@/features/crm/enquiries/schemas/enquiry.schema';
import { getEnquiryDetail, markEnquiryAsWon } from '@/features/crm/enquiries/server/service';
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.crmEnquiryMarkWon
});
const payload = enquiryMarkWonSchema.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 getEnquiryDetail(id, organization.id, accessContext);
const updated = await markEnquiryAsWon(id, organization.id, session.user.id, payload, accessContext);
const after = await getEnquiryDetail(id, organization.id, accessContext);
return NextResponse.json({
success: true,
message: 'Enquiry marked as won successfully',
before,
enquiry: 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 enquiry as won' }, { status: 500 });
}
}

View File

@@ -0,0 +1,83 @@
import { NextRequest, NextResponse } from 'next/server';
import { getEnquiryAttachment } from '@/features/crm/enquiries/server/service';
import { getStorageProvider } from '@/features/foundation/storage/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string; attachmentId: string }>;
};
function buildAccessContext(input: {
organizationId: string;
userId: string;
membership: {
role: string;
businessRole: string;
permissions?: string[] | null;
branchScopeIds?: string[] | null;
productTypeScopeIds?: string[] | null;
ownershipScope?: string | null;
branchScopeMode?: string | null;
productScopeMode?: string | null;
};
}) {
return {
organizationId: input.organizationId,
userId: input.userId,
membershipRole: input.membership.role,
businessRole: input.membership.businessRole,
permissions: input.membership.permissions ?? [],
branchScopeIds: input.membership.branchScopeIds ?? [],
productTypeScopeIds: input.membership.productTypeScopeIds ?? [],
ownershipScope: input.membership.ownershipScope ?? 'organization',
branchScopeMode: input.membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: input.membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
}
export async function GET(request: NextRequest, { params }: Params) {
try {
const { id, attachmentId } = await params;
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryRead
});
if (
membership.role !== 'admin' &&
!(membership.permissions ?? []).includes(PERMISSIONS.crmQuotationPricingRead)
) {
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
}
const attachment = await getEnquiryAttachment(
id,
attachmentId,
organization.id,
buildAccessContext({
organizationId: organization.id,
userId: session.user.id,
membership
})
);
const object = await getStorageProvider().getObject({ key: attachment.storageKey });
const download = request.nextUrl.searchParams.get('download') === '1';
return new NextResponse(object.body, {
headers: {
'Content-Type': attachment.fileType ?? object.contentType,
'Content-Disposition': `${download ? 'attachment' : 'inline'}; filename="${attachment.originalFileName}"`
}
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to load PO attachment' }, { status: 500 });
}
}

View File

@@ -0,0 +1,141 @@
import { NextRequest, NextResponse } from 'next/server';
import {
listEnquiryAttachments,
uploadPurchaseOrderAttachment
} from '@/features/crm/enquiries/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
function buildAccessContext(input: {
organizationId: string;
userId: string;
membership: {
role: string;
businessRole: string;
permissions?: string[] | null;
branchScopeIds?: string[] | null;
productTypeScopeIds?: string[] | null;
ownershipScope?: string | null;
branchScopeMode?: string | null;
productScopeMode?: string | null;
};
}) {
return {
organizationId: input.organizationId,
userId: input.userId,
membershipRole: input.membership.role,
businessRole: input.membership.businessRole,
permissions: input.membership.permissions ?? [],
branchScopeIds: input.membership.branchScopeIds ?? [],
productTypeScopeIds: input.membership.productTypeScopeIds ?? [],
ownershipScope: input.membership.ownershipScope ?? 'organization',
branchScopeMode: input.membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: input.membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
};
}
function canViewCommercialData(membership: { role: string; permissions?: string[] | null }) {
return (
membership.role === 'admin' ||
(membership.permissions ?? []).includes(PERMISSIONS.crmQuotationPricingRead)
);
}
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryRead
});
if (!canViewCommercialData(membership)) {
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
}
const items = await listEnquiryAttachments(
id,
organization.id,
buildAccessContext({
organizationId: organization.id,
userId: session.user.id,
membership
})
);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Enquiry purchase order attachments loaded successfully',
items
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to load PO attachments' }, { status: 500 });
}
}
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryMarkWon
});
if (!canViewCommercialData(membership)) {
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
}
const formData = await request.formData();
const file = formData.get('file');
if (!(file instanceof File)) {
return NextResponse.json({ message: 'PO attachment file is required' }, { status: 400 });
}
const created = await uploadPurchaseOrderAttachment({
enquiryId: id,
organizationId: organization.id,
userId: session.user.id,
description:
typeof formData.get('description') === 'string' ? (formData.get('description') as string) : null,
file: {
name: file.name,
type: file.type,
size: file.size,
buffer: Buffer.from(await file.arrayBuffer())
},
accessContext: buildAccessContext({
organizationId: organization.id,
userId: session.user.id,
membership
})
});
return NextResponse.json({
success: true,
message: 'PO attachment uploaded successfully',
attachment: created
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to upload PO attachment' }, { status: 500 });
}
}

View File

@@ -22,6 +22,7 @@ export async function POST(request: NextRequest, { params }: Params) {
userId: session.user.id, userId: session.user.id,
membershipRole: membership.role, membershipRole: membership.role,
businessRole: membership.businessRole, businessRole: membership.businessRole,
permissions: membership.permissions ?? [],
branchScopeIds: membership.branchScopeIds ?? [], branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [], productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization', ownershipScope: membership.ownershipScope ?? 'organization',

View File

@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { enquiryReopenSchema } from '@/features/crm/enquiries/schemas/enquiry.schema';
import { getEnquiryDetail, reopenLostEnquiry } from '@/features/crm/enquiries/server/service';
import { 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.crmEnquiryReopen
});
const payload = enquiryReopenSchema.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 getEnquiryDetail(id, organization.id, accessContext);
const updated = await reopenLostEnquiry(id, organization.id, session.user.id, payload, accessContext);
const after = await getEnquiryDetail(id, organization.id, accessContext);
return NextResponse.json({
success: true,
message: 'Enquiry reopened successfully',
before,
enquiry: 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 reopen enquiry' }, { status: 500 });
}
}

View File

@@ -33,6 +33,7 @@ export async function GET(_request: NextRequest, { params }: Params) {
userId: session.user.id, userId: session.user.id,
membershipRole: membership.role, membershipRole: membership.role,
businessRole: membership.businessRole, businessRole: membership.businessRole,
permissions: membership.permissions ?? [],
branchScopeIds: membership.branchScopeIds ?? [], branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [], productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization', ownershipScope: membership.ownershipScope ?? 'organization',
@@ -76,6 +77,7 @@ export async function PATCH(request: NextRequest, { params }: Params) {
userId: session.user.id, userId: session.user.id,
membershipRole: membership.role, membershipRole: membership.role,
businessRole: membership.businessRole, businessRole: membership.businessRole,
permissions: membership.permissions ?? [],
branchScopeIds: membership.branchScopeIds ?? [], branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [], productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization', ownershipScope: membership.ownershipScope ?? 'organization',
@@ -131,6 +133,7 @@ export async function DELETE(_request: NextRequest, { params }: Params) {
userId: session.user.id, userId: session.user.id,
membershipRole: membership.role, membershipRole: membership.role,
businessRole: membership.businessRole, businessRole: membership.businessRole,
permissions: membership.permissions ?? [],
branchScopeIds: membership.branchScopeIds ?? [], branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [], productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization', ownershipScope: membership.ownershipScope ?? 'organization',

View File

@@ -59,6 +59,26 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
(!!session?.user?.activeOrganizationId && (!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' || (session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationRead))); session.user.activePermissions.includes(PERMISSIONS.crmQuotationRead)));
const canViewOutcomeCommercialData =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationPricingRead)));
const canMarkWon =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryMarkWon)));
const canMarkLost =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryMarkLost)));
const canReopen =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryReopen)));
const queryClient = getQueryClient(); const queryClient = getQueryClient();
if (canRead) { if (canRead) {
@@ -98,6 +118,10 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
canAssign={canAssign} canAssign={canAssign}
canReassign={canReassign} canReassign={canReassign}
canViewRelatedQuotations={canReadQuotation} canViewRelatedQuotations={canReadQuotation}
canViewOutcomeCommercialData={canViewOutcomeCommercialData}
canMarkWon={canMarkWon}
canMarkLost={canMarkLost}
canReopen={canReopen}
canManageFollowups={{ canManageFollowups={{
create: canFollowupCreate, create: canFollowupCreate,
update: canFollowupUpdate, update: canFollowupUpdate,

View File

@@ -46,6 +46,7 @@ export default async function LeadDetailRoute({ params }: PageProps) {
userId: session.user.id, userId: session.user.id,
membershipRole: session.user.activeMembershipRole ?? 'user', membershipRole: session.user.activeMembershipRole ?? 'user',
businessRole: session.user.activeBusinessRole ?? 'sales_support', businessRole: session.user.activeBusinessRole ?? 'sales_support',
permissions: session.user.activePermissions ?? [],
branchScopeIds: session.user.activeBranchScopeIds ?? [], branchScopeIds: session.user.activeBranchScopeIds ?? [],
productTypeScopeIds: session.user.activeProductTypeScopeIds ?? [], productTypeScopeIds: session.user.activeProductTypeScopeIds ?? [],
ownershipScope: session.user.activeOwnershipScope ?? 'organization', ownershipScope: session.user.activeOwnershipScope ?? 'organization',
@@ -91,6 +92,10 @@ export default async function LeadDetailRoute({ params }: PageProps) {
canAssign={canAssign} canAssign={canAssign}
canReassign={false} canReassign={false}
canViewRelatedQuotations={false} canViewRelatedQuotations={false}
canViewOutcomeCommercialData={false}
canMarkWon={false}
canMarkLost={false}
canReopen={false}
canManageFollowups={{ canManageFollowups={{
create: false, create: false,
update: false, update: false,

View File

@@ -317,6 +317,16 @@ export const crmEnquiries = pgTable(
isHotProject: boolean('is_hot_project').default(false).notNull(), isHotProject: boolean('is_hot_project').default(false).notNull(),
isActive: boolean('is_active').default(true).notNull(), isActive: boolean('is_active').default(true).notNull(),
pipelineStage: text('pipeline_stage').default('lead').notNull(), pipelineStage: text('pipeline_stage').default('lead').notNull(),
closedWonAt: timestamp('closed_won_at', { withTimezone: true }),
closedLostAt: timestamp('closed_lost_at', { withTimezone: true }),
closedByUserId: text('closed_by_user_id'),
poNumber: text('po_number'),
poDate: timestamp('po_date', { withTimezone: true }),
poAmount: doublePrecision('po_amount'),
poCurrency: text('po_currency'),
lostReason: text('lost_reason'),
lostCompetitor: text('lost_competitor'),
lostRemark: text('lost_remark'),
assignedToUserId: text('assigned_to_user_id'), assignedToUserId: text('assigned_to_user_id'),
assignedAt: timestamp('assigned_at', { withTimezone: true }), assignedAt: timestamp('assigned_at', { withTimezone: true }),
assignedBy: text('assigned_by'), assignedBy: text('assigned_by'),
@@ -365,6 +375,25 @@ export const crmEnquiryCustomers = pgTable('crm_enquiry_customers', {
deletedAt: timestamp('deleted_at', { withTimezone: true }) deletedAt: timestamp('deleted_at', { withTimezone: true })
}); });
export const crmEnquiryAttachments = pgTable('crm_enquiry_attachments', {
id: text('id').primaryKey(),
organizationId: text('organization_id').notNull(),
enquiryId: text('enquiry_id').notNull(),
category: text('category').notNull(),
fileName: text('file_name').notNull(),
originalFileName: text('original_file_name').notNull(),
storageProvider: text('storage_provider').notNull(),
storageKey: text('storage_key').notNull(),
fileSize: integer('file_size'),
fileType: text('file_type'),
description: text('description'),
uploadedAt: timestamp('uploaded_at', { withTimezone: true }).defaultNow().notNull(),
uploadedBy: text('uploaded_by').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
deletedAt: timestamp('deleted_at', { withTimezone: true })
});
export const crmQuotations = pgTable( export const crmQuotations = pgTable(
'crm_quotations', 'crm_quotations',
{ {

View File

@@ -285,6 +285,36 @@ const FOUNDATION_OPTIONS = {
{ code: 'email', label: 'Email', value: 'email', sortOrder: 4 }, { code: 'email', label: 'Email', value: 'email', sortOrder: 4 },
{ code: 'line', label: 'LINE', value: 'line', sortOrder: 5 } { code: 'line', label: 'LINE', value: 'line', sortOrder: 5 }
], ],
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',
sortOrder: 2
},
{
code: 'project_cancelled',
label: 'Project Cancelled',
value: 'project_cancelled',
sortOrder: 3
},
{
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_job_title: [ crm_job_title: [
{ code: 'sales', label: 'Sales Engineer', value: 'sales', sortOrder: 1 }, { code: 'sales', label: 'Sales Engineer', value: 'sales', sortOrder: 1 },
{ code: 'sales_support', label: 'Sales Support', value: 'sales_support', sortOrder: 2 }, { code: 'sales_support', label: 'Sales Support', value: 'sales_support', sortOrder: 2 },

View File

@@ -37,6 +37,7 @@ export interface CrmDashboardSummary {
activeEnquiries: number; activeEnquiries: number;
wonCount: number; wonCount: number;
lostCount: number; lostCount: number;
winRate: number;
hotEnquiries: number; hotEnquiries: number;
enquiryValue: number; enquiryValue: number;
}; };
@@ -150,6 +151,11 @@ export interface CrmDashboardResponse {
myPendingApprovals: CrmDashboardApprovalRow[]; myPendingApprovals: CrmDashboardApprovalRow[];
}; };
hotProjects: CrmDashboardHotProjectRow[]; hotProjects: CrmDashboardHotProjectRow[];
outcomeAnalytics: {
winRate: number;
lostByReason: Array<{ key: string; label: string; count: number; revenue: number }>;
lostByCompetitor: Array<{ key: string; label: string; count: number; revenue: number }>;
};
visibility: { visibility: {
canViewCommercialData: boolean; canViewCommercialData: boolean;
canViewApprovalData: boolean; canViewApprovalData: boolean;

View File

@@ -70,7 +70,7 @@ export function DashboardSummaryCards({
{ label: 'จำนวนโอกาสขาย', value: summary.enquiry.enquiryCount }, { label: 'จำนวนโอกาสขาย', value: summary.enquiry.enquiryCount },
{ label: 'โอกาสขายที่กำลังดำเนินการ', value: summary.enquiry.activeEnquiries }, { label: 'โอกาสขายที่กำลังดำเนินการ', value: summary.enquiry.activeEnquiries },
{ label: 'จำนวนงานที่ชนะ', value: summary.enquiry.wonCount }, { label: 'จำนวนงานที่ชนะ', value: summary.enquiry.wonCount },
{ label: 'จำนวนงานที่แพ้', value: summary.enquiry.lostCount } { label: 'Win Rate', value: summary.enquiry.winRate, suffix: '%' }
]} ]}
/> />
{canViewCommercialData ? ( {canViewCommercialData ? (

View File

@@ -30,7 +30,12 @@ import {
getRevenueByBillingCustomer, getRevenueByBillingCustomer,
getRevenueByConsultant, getRevenueByConsultant,
getRevenueByContractor, getRevenueByContractor,
getRevenueByEndCustomer getRevenueByEndCustomer,
getLostByCompetitor,
getLostByReason,
getLostRevenue,
getWinRate,
getWonRevenue
} from '@/features/crm/reporting/server/service'; } from '@/features/crm/reporting/server/service';
import { getPipelineStageThaiLabel, getProjectPartyRoleThaiLabel } from '@/features/crm/shared/terminology'; import { getPipelineStageThaiLabel, getProjectPartyRoleThaiLabel } from '@/features/crm/shared/terminology';
import type { import type {
@@ -281,7 +286,8 @@ async function loadScopedRows(
function buildSummary( function buildSummary(
scopedEnquiries: Array<typeof crmEnquiries.$inferSelect>, scopedEnquiries: Array<typeof crmEnquiries.$inferSelect>,
scopedQuotations: Array<typeof crmQuotations.$inferSelect>, scopedQuotations: Array<typeof crmQuotations.$inferSelect>,
statusMaps: StatusMaps statusMaps: StatusMaps,
outcomeMetrics: { wonValue: number; lostValue: number; winRate: number }
): CrmDashboardSummary { ): CrmDashboardSummary {
const now = new Date(); const now = new Date();
const leadRows = scopedEnquiries.filter((row) => isPipelineStage(row, 'lead')); const leadRows = scopedEnquiries.filter((row) => isPipelineStage(row, 'lead'));
@@ -304,6 +310,7 @@ function buildSummary(
activeEnquiries: enquiryRows.length, activeEnquiries: enquiryRows.length,
wonCount: wonRows.length, wonCount: wonRows.length,
lostCount: lostRows.length, lostCount: lostRows.length,
winRate: outcomeMetrics.winRate,
hotEnquiries: enquiryRows.filter((row) => row.isHotProject).length, hotEnquiries: enquiryRows.filter((row) => row.isHotProject).length,
enquiryValue: round2(enquiryRows.reduce((sum, row) => sum + (row.estimatedValue ?? 0), 0)) enquiryValue: round2(enquiryRows.reduce((sum, row) => sum + (row.estimatedValue ?? 0), 0))
}, },
@@ -319,18 +326,8 @@ function buildSummary(
.filter((row) => (statusMaps.quotationStatusById.get(row.status) ?? '') !== 'cancelled') .filter((row) => (statusMaps.quotationStatusById.get(row.status) ?? '') !== 'cancelled')
.reduce((sum, row) => sum + row.totalAmount, 0) .reduce((sum, row) => sum + row.totalAmount, 0)
), ),
wonValue: round2( wonValue: round2(outcomeMetrics.wonValue),
scopedQuotations lostValue: round2(outcomeMetrics.lostValue)
.filter((row) => (statusMaps.quotationStatusById.get(row.status) ?? '') === 'accepted')
.reduce((sum, row) => sum + row.totalAmount, 0)
),
lostValue: round2(
scopedQuotations
.filter((row) =>
['lost', 'rejected'].includes(statusMaps.quotationStatusById.get(row.status) ?? '')
)
.reduce((sum, row) => sum + row.totalAmount, 0)
)
} }
}; };
} }
@@ -387,9 +384,7 @@ function buildFunnel(
key: 'closed_won', key: 'closed_won',
label: getPipelineStageThaiLabel('closed_won'), label: getPipelineStageThaiLabel('closed_won'),
count: summary.enquiry.wonCount, count: summary.enquiry.wonCount,
value: scopedQuotations value: summary.revenue.wonValue
.filter((row) => (statusMaps.quotationStatusById.get(row.status) ?? '') === 'accepted')
.reduce((sum, row) => sum + row.totalAmount, 0)
} }
]; ];
@@ -463,7 +458,8 @@ async function buildRevenueAnalytics(
async function buildSalesRanking( async function buildSalesRanking(
scopedEnquiries: Array<typeof crmEnquiries.$inferSelect>, scopedEnquiries: Array<typeof crmEnquiries.$inferSelect>,
scopedQuotations: Array<typeof crmQuotations.$inferSelect>, scopedQuotations: Array<typeof crmQuotations.$inferSelect>,
statusMaps: StatusMaps statusMaps: StatusMaps,
wonRevenueRows: Array<{ assignedToUserId: string | null; revenue: number }>
): Promise<CrmDashboardSalesRankingRow[]> { ): Promise<CrmDashboardSalesRankingRow[]> {
const userIds = [ const userIds = [
...new Set( ...new Set(
@@ -529,11 +525,29 @@ async function buildSalesRanking(
current.approvedQuotations += 1; current.approvedQuotations += 1;
} }
if (statusCode === 'accepted') { rankingMap.set(row.salesmanId, current);
current.wonRevenue += row.totalAmount;
} }
rankingMap.set(row.salesmanId, current); for (const row of wonRevenueRows) {
if (!row.assignedToUserId) {
continue;
}
const current =
rankingMap.get(row.assignedToUserId) ??
{
salesPersonId: row.assignedToUserId,
salesPersonName: userMap.get(row.assignedToUserId) ?? 'Unknown',
leadCount: 0,
enquiryCount: 0,
quotationCount: 0,
approvedQuotations: 0,
wonRevenue: 0,
conversionRate: 0
};
current.wonRevenue += row.revenue;
rankingMap.set(row.assignedToUserId, current);
} }
return [...rankingMap.values()] return [...rankingMap.values()]
@@ -776,12 +790,33 @@ export async function getCrmDashboardData(
const filters = normalizeDashboardFilters(rawFilters); const filters = normalizeDashboardFilters(rawFilters);
const allowCommercialData = canViewCommercialData(context); const allowCommercialData = canViewCommercialData(context);
const allowApprovalData = canViewApprovalData(context); const allowApprovalData = canViewApprovalData(context);
const outcomeFilters = {
dateFrom: filters.dateFrom,
dateTo: filters.dateTo,
branch: filters.branch,
salesman: filters.salesman,
productType: filters.productType
};
const [referenceData, statusMaps, scoped] = await Promise.all([ const [referenceData, statusMaps, scoped] = await Promise.all([
getReferenceData(context.organizationId), getReferenceData(context.organizationId),
getStatusMaps(context.organizationId), getStatusMaps(context.organizationId),
loadScopedRows(context, filters) loadScopedRows(context, filters)
]); ]);
const summary = buildSummary(scoped.enquiries, scoped.quotations, statusMaps); const [wonRevenueRows, lostRevenueRows, winRate, lostByReasonRows, lostByCompetitorRows] =
allowCommercialData
? await Promise.all([
getWonRevenue(context.organizationId, outcomeFilters),
getLostRevenue(context.organizationId, outcomeFilters),
getWinRate(context.organizationId, outcomeFilters),
getLostByReason(context.organizationId, outcomeFilters),
getLostByCompetitor(context.organizationId, outcomeFilters)
])
: [[], [], 0, [], []];
const summary = buildSummary(scoped.enquiries, scoped.quotations, statusMaps, {
wonValue: wonRevenueRows.reduce((sum, row) => sum + row.revenue, 0),
lostValue: lostRevenueRows.reduce((sum, row) => sum + row.revenue, 0),
winRate
});
const [revenueAnalytics, salesRanking, followups, approvals, hotProjects] = await Promise.all([ const [revenueAnalytics, salesRanking, followups, approvals, hotProjects] = await Promise.all([
allowCommercialData allowCommercialData
? buildRevenueAnalytics(context.organizationId, filters) ? buildRevenueAnalytics(context.organizationId, filters)
@@ -792,7 +827,7 @@ export async function getCrmDashboardData(
topConsultants: [] topConsultants: []
}), }),
allowCommercialData allowCommercialData
? buildSalesRanking(scoped.enquiries, scoped.quotations, statusMaps) ? buildSalesRanking(scoped.enquiries, scoped.quotations, statusMaps, wonRevenueRows)
: Promise.resolve([]), : Promise.resolve([]),
buildFollowups(context, filters), buildFollowups(context, filters),
allowApprovalData allowApprovalData
@@ -825,6 +860,11 @@ export async function getCrmDashboardData(
followups, followups,
approvals, approvals,
hotProjects, hotProjects,
outcomeAnalytics: {
winRate,
lostByReason: lostByReasonRows,
lostByCompetitor: lostByCompetitorRows
},
visibility: { visibility: {
canViewCommercialData: allowCommercialData, canViewCommercialData: allowCommercialData,
canViewApprovalData: allowApprovalData canViewApprovalData: allowApprovalData

View File

@@ -6,7 +6,10 @@ import {
createEnquiryFollowup, createEnquiryFollowup,
deleteEnquiry, deleteEnquiry,
deleteEnquiryFollowup, deleteEnquiryFollowup,
markEnquiryLost,
markEnquiryWon,
reassignEnquiry, reassignEnquiry,
reopenEnquiry,
updateEnquiry, updateEnquiry,
updateEnquiryFollowup updateEnquiryFollowup
} from './service'; } from './service';
@@ -14,6 +17,8 @@ import { enquiryKeys } from './queries';
import type { import type {
EnquiryAssignmentMutationPayload, EnquiryAssignmentMutationPayload,
EnquiryFollowupMutationPayload, EnquiryFollowupMutationPayload,
EnquiryMarkLostPayload,
EnquiryMarkWonPayload,
EnquiryMutationPayload EnquiryMutationPayload
} from './types'; } from './types';
import { crmDashboardKeys } from '@/features/crm/dashboard/api/queries'; import { crmDashboardKeys } from '@/features/crm/dashboard/api/queries';
@@ -38,11 +43,16 @@ async function invalidateEnquiryFollowups(id: string) {
await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.followups(id) }); await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.followups(id) });
} }
async function invalidateEnquiryAttachments(id: string) {
await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.attachments(id) });
}
export async function invalidateEnquiryMutationQueries(id: string) { export async function invalidateEnquiryMutationQueries(id: string) {
await Promise.all([ await Promise.all([
invalidateEnquiryLists(), invalidateEnquiryLists(),
invalidateEnquiryDetail(id), invalidateEnquiryDetail(id),
invalidateEnquiryProjectParties(id), invalidateEnquiryProjectParties(id),
invalidateEnquiryAttachments(id),
invalidateDashboardQueries() invalidateDashboardQueries()
]); ]);
} }
@@ -148,3 +158,33 @@ export const deleteEnquiryFollowupMutation = mutationOptions({
} }
} }
}); });
export const markEnquiryWonMutation = mutationOptions({
mutationFn: ({ id, values }: { id: string; values: EnquiryMarkWonPayload }) =>
markEnquiryWon(id, values),
onSettled: async (_data, error, variables) => {
if (!error) {
await invalidateEnquiryMutationQueries(variables.id);
}
}
});
export const markEnquiryLostMutation = mutationOptions({
mutationFn: ({ id, values }: { id: string; values: EnquiryMarkLostPayload }) =>
markEnquiryLost(id, values),
onSettled: async (_data, error, variables) => {
if (!error) {
await invalidateEnquiryMutationQueries(variables.id);
}
}
});
export const reopenEnquiryMutation = mutationOptions({
mutationFn: ({ id, reason }: { id: string; reason: string }) =>
reopenEnquiry(id, { reopenReason: reason }),
onSettled: async (_data, error, variables) => {
if (!error) {
await invalidateEnquiryMutationQueries(variables.id);
}
}
});

View File

@@ -1,5 +1,6 @@
import { queryOptions } from '@tanstack/react-query'; import { queryOptions } from '@tanstack/react-query';
import { import {
getEnquiryAttachments,
getEnquiries, getEnquiries,
getEnquiryById, getEnquiryById,
getEnquiryFollowups, getEnquiryFollowups,
@@ -16,7 +17,9 @@ export const enquiryKeys = {
projectPartiesRoot: () => [...enquiryKeys.all, 'project-parties'] as const, projectPartiesRoot: () => [...enquiryKeys.all, 'project-parties'] as const,
projectParties: (id: string) => [...enquiryKeys.projectPartiesRoot(), id] as const, projectParties: (id: string) => [...enquiryKeys.projectPartiesRoot(), id] as const,
followupsRoot: () => [...enquiryKeys.all, 'followups'] as const, followupsRoot: () => [...enquiryKeys.all, 'followups'] as const,
followups: (id: string) => [...enquiryKeys.followupsRoot(), id] as const followups: (id: string) => [...enquiryKeys.followupsRoot(), id] as const,
attachmentsRoot: () => [...enquiryKeys.all, 'attachments'] as const,
attachments: (id: string) => [...enquiryKeys.attachmentsRoot(), id] as const
}; };
export const enquiriesQueryOptions = (filters: EnquiryFilters) => export const enquiriesQueryOptions = (filters: EnquiryFilters) =>
@@ -42,3 +45,9 @@ export const enquiryFollowupsOptions = (id: string) =>
queryKey: enquiryKeys.followups(id), queryKey: enquiryKeys.followups(id),
queryFn: () => getEnquiryFollowups(id) queryFn: () => getEnquiryFollowups(id)
}); });
export const enquiryAttachmentsOptions = (id: string) =>
queryOptions({
queryKey: enquiryKeys.attachments(id),
queryFn: () => getEnquiryAttachments(id)
});

View File

@@ -1,13 +1,17 @@
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import type { import type {
EnquiryAttachmentsResponse,
EnquiryAssignmentMutationPayload, EnquiryAssignmentMutationPayload,
EnquiryDetailResponse, EnquiryDetailResponse,
EnquiryFilters, EnquiryFilters,
EnquiryFollowupMutationPayload, EnquiryFollowupMutationPayload,
EnquiryFollowupsResponse, EnquiryFollowupsResponse,
EnquiryListResponse, EnquiryListResponse,
EnquiryMarkLostPayload,
EnquiryMarkWonPayload,
EnquiryMutationPayload, EnquiryMutationPayload,
EnquiryProjectPartiesResponse, EnquiryProjectPartiesResponse,
EnquiryReopenPayload,
MutationSuccessResponse MutationSuccessResponse
} from './types'; } from './types';
@@ -76,6 +80,10 @@ export async function getEnquiryFollowups(id: string): Promise<EnquiryFollowupsR
return apiClient<EnquiryFollowupsResponse>(`/crm/enquiries/${id}/followups`); return apiClient<EnquiryFollowupsResponse>(`/crm/enquiries/${id}/followups`);
} }
export async function getEnquiryAttachments(id: string): Promise<EnquiryAttachmentsResponse> {
return apiClient<EnquiryAttachmentsResponse>(`/crm/enquiries/${id}/po-attachments`);
}
export async function createEnquiryFollowup( export async function createEnquiryFollowup(
enquiryId: string, enquiryId: string,
data: EnquiryFollowupMutationPayload data: EnquiryFollowupMutationPayload
@@ -102,3 +110,24 @@ export async function deleteEnquiryFollowup(enquiryId: string, followupId: strin
method: 'DELETE' method: 'DELETE'
}); });
} }
export async function markEnquiryWon(id: string, data: EnquiryMarkWonPayload) {
return apiClient<MutationSuccessResponse>(`/crm/enquiries/${id}/mark-won`, {
method: 'POST',
body: JSON.stringify(data)
});
}
export async function markEnquiryLost(id: string, data: EnquiryMarkLostPayload) {
return apiClient<MutationSuccessResponse>(`/crm/enquiries/${id}/mark-lost`, {
method: 'POST',
body: JSON.stringify(data)
});
}
export async function reopenEnquiry(id: string, data: EnquiryReopenPayload) {
return apiClient<MutationSuccessResponse>(`/crm/enquiries/${id}/reopen`, {
method: 'POST',
body: JSON.stringify(data)
});
}

View File

@@ -56,6 +56,25 @@ export interface EnquiryAssignableUserLookup {
businessRole: string; businessRole: string;
} }
export interface EnquiryAttachmentRecord {
id: string;
organizationId: string;
enquiryId: string;
category: string;
fileName: string;
originalFileName: string;
storageProvider: string;
storageKey: string;
fileSize: number | null;
fileType: string | null;
description: string | null;
uploadedAt: string;
uploadedBy: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
export type EnquiryPipelineStage = 'lead' | 'enquiry' | 'closed_won' | 'closed_lost'; export type EnquiryPipelineStage = 'lead' | 'enquiry' | 'closed_won' | 'closed_lost';
export interface EnquiryRecord { export interface EnquiryRecord {
@@ -83,6 +102,19 @@ export interface EnquiryRecord {
isHotProject: boolean; isHotProject: boolean;
isActive: boolean; isActive: boolean;
pipelineStage: EnquiryPipelineStage; pipelineStage: EnquiryPipelineStage;
closedWonAt: string | null;
closedLostAt: string | null;
closedByUserId: string | null;
closedByName: string | null;
poNumber: string | null;
poDate: string | null;
poAmount: number | null;
poCurrency: string | null;
lostReason: string | null;
lostReasonCode: string | null;
lostReasonLabel: string | null;
lostCompetitor: string | null;
lostRemark: string | null;
assignedToUserId: string | null; assignedToUserId: string | null;
assignedToName: string | null; assignedToName: string | null;
assignedAt: string | null; assignedAt: string | null;
@@ -140,6 +172,7 @@ export interface EnquiryReferenceData {
priorities: EnquiryOption[]; priorities: EnquiryOption[];
leadChannels: EnquiryOption[]; leadChannels: EnquiryOption[];
followupTypes: EnquiryOption[]; followupTypes: EnquiryOption[];
lostReasons: EnquiryOption[];
projectPartyRoles: EnquiryOption[]; projectPartyRoles: EnquiryOption[];
customers: EnquiryCustomerLookup[]; customers: EnquiryCustomerLookup[];
contacts: EnquiryContactLookup[]; contacts: EnquiryContactLookup[];
@@ -191,6 +224,13 @@ export interface EnquiryFollowupsResponse {
items: EnquiryFollowupRecord[]; items: EnquiryFollowupRecord[];
} }
export interface EnquiryAttachmentsResponse {
success: boolean;
time: string;
message: string;
items: EnquiryAttachmentRecord[];
}
export interface EnquiryProjectPartyMutationPayload { export interface EnquiryProjectPartyMutationPayload {
customerId: string; customerId: string;
role: string; role: string;
@@ -237,6 +277,24 @@ export interface EnquiryAssignmentMutationPayload {
remark?: string; remark?: string;
} }
export interface EnquiryMarkWonPayload {
poNumber: string;
poDate: string;
poAmount?: number | null;
poCurrency?: string | null;
remark?: string | null;
}
export interface EnquiryMarkLostPayload {
lostReason: string;
lostCompetitor?: string | null;
lostRemark?: string | null;
}
export interface EnquiryReopenPayload {
reopenReason: string;
}
export interface EnquiryCustomerRelationItem { export interface EnquiryCustomerRelationItem {
id: string; id: string;
code: string; code: string;

View File

@@ -16,6 +16,7 @@ import type { EnquiryRecord, EnquiryReferenceData } from '../api/types';
import { EnquiryAssignmentDialog } from './enquiry-assignment-dialog'; import { EnquiryAssignmentDialog } from './enquiry-assignment-dialog';
import { EnquiryFollowupsTab } from './enquiry-followups-tab'; import { EnquiryFollowupsTab } from './enquiry-followups-tab';
import { EnquiryFormSheet } from './enquiry-form-sheet'; import { EnquiryFormSheet } from './enquiry-form-sheet';
import { EnquiryOutcomeCard } from './enquiry-outcome-card';
import { EnquiryStatusBadge } from './enquiry-status-badge'; import { EnquiryStatusBadge } from './enquiry-status-badge';
function FieldItem({ label, value }: { label: string; value: string | null | undefined }) { function FieldItem({ label, value }: { label: string; value: string | null | undefined }) {
@@ -40,6 +41,10 @@ interface EnquiryDetailProps {
canAssign: boolean; canAssign: boolean;
canReassign: boolean; canReassign: boolean;
canViewRelatedQuotations: boolean; canViewRelatedQuotations: boolean;
canViewOutcomeCommercialData: boolean;
canMarkWon: boolean;
canMarkLost: boolean;
canReopen: boolean;
canManageFollowups: { canManageFollowups: {
create: boolean; create: boolean;
update: boolean; update: boolean;
@@ -56,6 +61,10 @@ export function EnquiryDetail({
canAssign, canAssign,
canReassign, canReassign,
canViewRelatedQuotations, canViewRelatedQuotations,
canViewOutcomeCommercialData,
canMarkWon,
canMarkLost,
canReopen,
canManageFollowups canManageFollowups
}: EnquiryDetailProps) { }: EnquiryDetailProps) {
const { data } = useSuspenseQuery(enquiryByIdOptions(enquiryId)); const { data } = useSuspenseQuery(enquiryByIdOptions(enquiryId));
@@ -374,6 +383,14 @@ export function EnquiryDetail({
</div> </div>
<div className='space-y-6'> <div className='space-y-6'>
<EnquiryOutcomeCard
enquiryId={enquiryId}
referenceData={referenceData}
canViewCommercialData={canViewOutcomeCommercialData}
canMarkWon={canMarkWon}
canMarkLost={canMarkLost}
canReopen={canReopen}
/>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle></CardTitle> <CardTitle></CardTitle>

View File

@@ -0,0 +1,465 @@
'use client';
import Link from 'next/link';
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Icons } from '@/components/icons';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import {
invalidateEnquiryMutationQueries,
markEnquiryLostMutation,
markEnquiryWonMutation,
reopenEnquiryMutation
} from '../api/mutations';
import { enquiryAttachmentsOptions, enquiryByIdOptions } from '../api/queries';
import type { EnquiryReferenceData } from '../api/types';
function FieldItem({ label, value }: { label: string; value: string | null | undefined }) {
return (
<div className='space-y-1'>
<div className='text-muted-foreground text-xs'>{label}</div>
<div className='text-sm'>{value || '-'}</div>
</div>
);
}
function getOutcomeLabel(stage: string) {
if (stage === 'closed_won') return 'Won';
if (stage === 'closed_lost') return 'Lost';
return 'Open';
}
type EnquiryOutcomeCardProps = {
enquiryId: string;
referenceData: EnquiryReferenceData;
canViewCommercialData: boolean;
canMarkWon: boolean;
canMarkLost: boolean;
canReopen: boolean;
};
export function EnquiryOutcomeCard({
enquiryId,
referenceData,
canViewCommercialData,
canMarkWon,
canMarkLost,
canReopen
}: EnquiryOutcomeCardProps) {
const { data } = useSuspenseQuery(enquiryByIdOptions(enquiryId));
const attachmentsQuery = useQuery({
...enquiryAttachmentsOptions(enquiryId),
enabled: canViewCommercialData
});
const enquiry = data.enquiry;
const [wonOpen, setWonOpen] = useState(false);
const [lostOpen, setLostOpen] = useState(false);
const [reopenOpen, setReopenOpen] = useState(false);
const [poNumber, setPoNumber] = useState('');
const [poDate, setPoDate] = useState('');
const [poAmount, setPoAmount] = useState('');
const [poCurrency, setPoCurrency] = useState('THB');
const [wonRemark, setWonRemark] = useState('');
const [lostReason, setLostReason] = useState('');
const [lostCompetitor, setLostCompetitor] = useState('');
const [lostRemark, setLostRemark] = useState('');
const [reopenReason, setReopenReason] = useState('');
const [poFile, setPoFile] = useState<File | null>(null);
const [poFileDescription, setPoFileDescription] = useState('');
const wonMutation = useMutation({
...markEnquiryWonMutation,
onSuccess: async () => {
toast.success('Marked enquiry as won');
setWonOpen(false);
await invalidateEnquiryMutationQueries(enquiryId);
},
onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to mark won')
});
const lostMutation = useMutation({
...markEnquiryLostMutation,
onSuccess: async () => {
toast.success('Marked enquiry as lost');
setLostOpen(false);
await invalidateEnquiryMutationQueries(enquiryId);
},
onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to mark lost')
});
const reopenMutation = useMutation({
...reopenEnquiryMutation,
onSuccess: async () => {
toast.success('Reopened enquiry');
setReopenOpen(false);
await invalidateEnquiryMutationQueries(enquiryId);
},
onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to reopen enquiry')
});
const uploadMutation = useMutation({
mutationFn: async () => {
if (!poFile) {
throw new Error('Please choose a PO attachment file');
}
const formData = new FormData();
formData.append('file', poFile);
formData.append('description', poFileDescription);
const response = await fetch(`/api/crm/enquiries/${enquiryId}/po-attachments`, {
method: 'POST',
body: formData
});
if (!response.ok) {
const payload = (await response.json().catch(() => null)) as { message?: string } | null;
throw new Error(payload?.message ?? 'Failed to upload PO attachment');
}
return response.json();
},
onSuccess: async () => {
toast.success('PO attachment uploaded');
setPoFile(null);
setPoFileDescription('');
await invalidateEnquiryMutationQueries(enquiryId);
},
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to upload PO attachment')
});
const closedDate = enquiry.closedWonAt ?? enquiry.closedLostAt;
const lostReasonLabel = useMemo(() => {
if (!enquiry.lostReason) {
return null;
}
return (
enquiry.lostReasonLabel ??
referenceData.lostReasons.find(
(item) => item.id === enquiry.lostReason || item.code === enquiry.lostReason
)?.label ??
enquiry.lostReason
);
}, [enquiry.lostReason, enquiry.lostReasonLabel, referenceData.lostReasons]);
const attachmentItems = attachmentsQuery.data?.items ?? [];
return (
<>
<Card>
<CardHeader>
<CardTitle>Outcome</CardTitle>
<CardDescription>Official won/lost lifecycle and purchase order tracking.</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<div className='flex items-center justify-between gap-3'>
<Badge variant={enquiry.pipelineStage === 'closed_won' ? 'default' : enquiry.pipelineStage === 'closed_lost' ? 'destructive' : 'secondary'}>
{getOutcomeLabel(enquiry.pipelineStage)}
</Badge>
<div className='flex flex-wrap gap-2'>
{enquiry.pipelineStage === 'enquiry' && canMarkWon ? (
<Button size='sm' onClick={() => {
setPoNumber(enquiry.poNumber ?? '');
setPoDate(enquiry.poDate ? enquiry.poDate.slice(0, 10) : '');
setPoAmount(enquiry.poAmount !== null && enquiry.poAmount !== undefined ? String(enquiry.poAmount) : '');
setPoCurrency(enquiry.poCurrency ?? 'THB');
setWonRemark('');
setWonOpen(true);
}}>
<Icons.check className='mr-2 h-4 w-4' />
Mark Won
</Button>
) : null}
{enquiry.pipelineStage === 'enquiry' && canMarkLost ? (
<Button variant='destructive' size='sm' onClick={() => {
setLostReason(enquiry.lostReason ?? '');
setLostCompetitor(enquiry.lostCompetitor ?? '');
setLostRemark(enquiry.lostRemark ?? '');
setLostOpen(true);
}}>
<Icons.xCircle className='mr-2 h-4 w-4' />
Mark Lost
</Button>
) : null}
{enquiry.pipelineStage === 'closed_lost' && canReopen ? (
<Button variant='outline' size='sm' onClick={() => setReopenOpen(true)}>
Reopen
</Button>
) : null}
</div>
</div>
<div className='grid gap-4 md:grid-cols-2'>
<FieldItem label='Closed By' value={enquiry.closedByName} />
<FieldItem
label='Closed Date'
value={closedDate ? new Date(closedDate).toLocaleString() : null}
/>
<FieldItem label='PO Number' value={enquiry.poNumber} />
<FieldItem
label='PO Date'
value={enquiry.poDate ? new Date(enquiry.poDate).toLocaleDateString() : null}
/>
<FieldItem
label='PO Amount'
value={
canViewCommercialData && enquiry.poAmount !== null
? `${enquiry.poAmount.toLocaleString()} ${enquiry.poCurrency ?? ''}`.trim()
: canViewCommercialData
? null
: 'Restricted'
}
/>
<FieldItem label='Lost Reason' value={lostReasonLabel} />
<FieldItem label='Lost Competitor' value={enquiry.lostCompetitor} />
<FieldItem label='Remark' value={enquiry.lostRemark} />
</div>
{canViewCommercialData && enquiry.pipelineStage === 'closed_won' ? (
<div className='space-y-4 rounded-lg border p-4'>
<div className='space-y-1'>
<div className='font-medium'>Purchase Order Attachment</div>
<div className='text-muted-foreground text-sm'>
Upload PDF, image, or Excel files for the received PO.
</div>
</div>
<div className='grid gap-3 md:grid-cols-2'>
<div className='space-y-2'>
<Label htmlFor='po-file'>PO file</Label>
<Input
id='po-file'
type='file'
accept='.pdf,.png,.jpg,.jpeg,.webp,.xls,.xlsx'
onChange={(event) => setPoFile(event.target.files?.[0] ?? null)}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='po-file-description'>Description</Label>
<Input
id='po-file-description'
value={poFileDescription}
onChange={(event) => setPoFileDescription(event.target.value)}
placeholder='Optional note for this file'
/>
</div>
</div>
<Button
type='button'
variant='outline'
onClick={() => uploadMutation.mutate()}
isLoading={uploadMutation.isPending}
disabled={!poFile}
>
<Icons.upload className='mr-2 h-4 w-4' />
Upload PO
</Button>
{!attachmentItems.length ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-4 text-sm'>
No PO attachments uploaded yet.
</div>
) : (
<div className='space-y-2'>
{attachmentItems.map((attachment) => (
<div
key={attachment.id}
className='flex flex-col gap-2 rounded-lg border p-3 md:flex-row md:items-center md:justify-between'
>
<div className='space-y-1'>
<div className='font-medium'>{attachment.originalFileName}</div>
<div className='text-muted-foreground text-xs'>
{attachment.fileType ?? 'Unknown type'}
{attachment.fileSize ? `${attachment.fileSize.toLocaleString()} bytes` : ''}
</div>
</div>
<div className='flex gap-2'>
<Button asChild variant='outline' size='sm'>
<Link href={`/api/crm/enquiries/${enquiryId}/po-attachments/${attachment.id}`}>
View
</Link>
</Button>
<Button asChild variant='outline' size='sm'>
<Link href={`/api/crm/enquiries/${enquiryId}/po-attachments/${attachment.id}?download=1`}>
Download
</Link>
</Button>
</div>
</div>
))}
</div>
)}
</div>
) : null}
</CardContent>
</Card>
<Dialog open={wonOpen} onOpenChange={setWonOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Mark Won</DialogTitle>
<DialogDescription>PO received is the official trigger for closed won.</DialogDescription>
</DialogHeader>
<div className='grid gap-4 py-2'>
<div className='space-y-2'>
<Label htmlFor='po-number'>PO Number</Label>
<Input id='po-number' value={poNumber} onChange={(e) => setPoNumber(e.target.value)} />
</div>
<div className='space-y-2'>
<Label htmlFor='po-date'>PO Date</Label>
<Input id='po-date' type='date' value={poDate} onChange={(e) => setPoDate(e.target.value)} />
</div>
<div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-2'>
<Label htmlFor='po-amount'>PO Amount</Label>
<Input id='po-amount' value={poAmount} onChange={(e) => setPoAmount(e.target.value)} />
</div>
<div className='space-y-2'>
<Label>PO Currency</Label>
<Select value={poCurrency} onValueChange={setPoCurrency}>
<SelectTrigger>
<SelectValue placeholder='Select currency' />
</SelectTrigger>
<SelectContent>
<SelectItem value='THB'>THB</SelectItem>
<SelectItem value='USD'>USD</SelectItem>
<SelectItem value='EUR'>EUR</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className='space-y-2'>
<Label htmlFor='won-remark'>Remark</Label>
<Textarea id='won-remark' value={wonRemark} onChange={(e) => setWonRemark(e.target.value)} />
</div>
</div>
<DialogFooter>
<Button variant='outline' onClick={() => setWonOpen(false)}>
Cancel
</Button>
<Button
isLoading={wonMutation.isPending}
onClick={() =>
wonMutation.mutate({
id: enquiryId,
values: {
poNumber,
poDate,
poAmount: poAmount.trim() ? Number(poAmount) : null,
poCurrency,
remark: wonRemark
}
})
}
>
Confirm Won
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={lostOpen} onOpenChange={setLostOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Mark Lost</DialogTitle>
<DialogDescription>Lost reason is required for every closed lost outcome.</DialogDescription>
</DialogHeader>
<div className='grid gap-4 py-2'>
<div className='space-y-2'>
<Label>Lost Reason</Label>
<Select value={lostReason} onValueChange={setLostReason}>
<SelectTrigger>
<SelectValue placeholder='Select lost reason' />
</SelectTrigger>
<SelectContent>
{referenceData.lostReasons.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label htmlFor='lost-competitor'>Lost Competitor</Label>
<Input
id='lost-competitor'
value={lostCompetitor}
onChange={(e) => setLostCompetitor(e.target.value)}
/>
</div>
<div className='space-y-2'>
<Label htmlFor='lost-remark'>Lost Remark</Label>
<Textarea id='lost-remark' value={lostRemark} onChange={(e) => setLostRemark(e.target.value)} />
</div>
</div>
<DialogFooter>
<Button variant='outline' onClick={() => setLostOpen(false)}>
Cancel
</Button>
<Button
variant='destructive'
isLoading={lostMutation.isPending}
onClick={() =>
lostMutation.mutate({
id: enquiryId,
values: {
lostReason,
lostCompetitor,
lostRemark
}
})
}
>
Confirm Lost
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={reopenOpen} onOpenChange={setReopenOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Reopen Opportunity</DialogTitle>
<DialogDescription>Only lost opportunities can be reopened.</DialogDescription>
</DialogHeader>
<div className='space-y-2 py-2'>
<Label htmlFor='reopen-reason'>Reopen Reason</Label>
<Textarea
id='reopen-reason'
value={reopenReason}
onChange={(e) => setReopenReason(e.target.value)}
/>
</div>
<DialogFooter>
<Button variant='outline' onClick={() => setReopenOpen(false)}>
Cancel
</Button>
<Button
isLoading={reopenMutation.isPending}
onClick={() => reopenMutation.mutate({ id: enquiryId, reason: reopenReason })}
>
Reopen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -73,5 +73,29 @@ export const enquiryFollowupSchema = z.object({
nextAction: z.string().optional() nextAction: z.string().optional()
}); });
export const enquiryMarkWonSchema = 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 enquiryMarkLostSchema = z.object({
lostReason: z.string().min(1, 'Lost reason is required'),
lostCompetitor: z.string().optional().nullable(),
lostRemark: z.string().optional().nullable()
});
export const enquiryReopenSchema = z.object({
reopenReason: z.string().min(3, 'Reopen reason is required')
});
export type EnquiryFormValues = z.input<typeof enquirySchema>; export type EnquiryFormValues = z.input<typeof enquirySchema>;
export type EnquiryFollowupFormValues = z.input<typeof enquiryFollowupSchema>; export type EnquiryFollowupFormValues = z.input<typeof enquiryFollowupSchema>;
export type EnquiryMarkWonFormValues = z.input<typeof enquiryMarkWonSchema>;
export type EnquiryMarkLostFormValues = z.input<typeof enquiryMarkLostSchema>;
export type EnquiryReopenFormValues = z.input<typeof enquiryReopenSchema>;

View File

@@ -3,6 +3,7 @@ import {
crmCustomerContacts, crmCustomerContacts,
crmCustomers, crmCustomers,
crmEnquiries, crmEnquiries,
crmEnquiryAttachments,
crmEnquiryCustomers, crmEnquiryCustomers,
crmEnquiryFollowups, crmEnquiryFollowups,
crmQuotations, crmQuotations,
@@ -12,13 +13,15 @@ import {
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { AuthError } from '@/lib/auth/session'; import { AuthError } from '@/lib/auth/session';
import { hasBranchScopeAccess, hasProductScopeAccess } from '@/lib/auth/crm-access'; import { hasBranchScopeAccess, hasProductScopeAccess } from '@/lib/auth/crm-access';
import { listAuditLogs } from '@/features/foundation/audit-log/service'; import { auditAction, listAuditLogs } from '@/features/foundation/audit-log/service';
import { getUserBranches, validateBranchAccess } from '@/features/foundation/branch-scope/service'; import { getUserBranches, validateBranchAccess } from '@/features/foundation/branch-scope/service';
import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service'; import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service';
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service'; import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
import { buildOrganizationStorageKey, getStorageProvider } from '@/features/foundation/storage/service';
import type { import type {
EnquiryActivityRecord, EnquiryActivityRecord,
EnquiryAssignableUserLookup, EnquiryAssignableUserLookup,
EnquiryAttachmentRecord,
EnquiryBranchOption, EnquiryBranchOption,
EnquiryAssignmentMutationPayload, EnquiryAssignmentMutationPayload,
EnquiryCustomerRelationItem, EnquiryCustomerRelationItem,
@@ -26,12 +29,15 @@ import type {
EnquiryFollowupMutationPayload, EnquiryFollowupMutationPayload,
EnquiryFollowupRecord, EnquiryFollowupRecord,
EnquiryListItem, EnquiryListItem,
EnquiryMarkLostPayload,
EnquiryMarkWonPayload,
EnquiryMutationPayload, EnquiryMutationPayload,
EnquiryOption, EnquiryOption,
EnquiryProjectPartyListItem, EnquiryProjectPartyListItem,
EnquiryProjectPartyMutationPayload, EnquiryProjectPartyMutationPayload,
EnquiryProjectPartyRecord, EnquiryProjectPartyRecord,
EnquiryPipelineStage, EnquiryPipelineStage,
EnquiryReopenPayload,
EnquiryRecord, EnquiryRecord,
EnquiryReferenceData EnquiryReferenceData
} from '../api/types'; } from '../api/types';
@@ -40,6 +46,7 @@ import {
PROJECT_PARTY_OPTION_CATEGORY, PROJECT_PARTY_OPTION_CATEGORY,
resolveProjectPartyRole resolveProjectPartyRole
} from '@/features/crm/shared/project-party'; } from '@/features/crm/shared/project-party';
import { PERMISSIONS } from '@/lib/auth/rbac';
const ENQUIRY_OPTION_CATEGORIES = { const ENQUIRY_OPTION_CATEGORIES = {
status: 'crm_enquiry_status', status: 'crm_enquiry_status',
@@ -47,6 +54,7 @@ const ENQUIRY_OPTION_CATEGORIES = {
priority: 'crm_priority', priority: 'crm_priority',
leadChannel: 'crm_lead_channel', leadChannel: 'crm_lead_channel',
followupType: 'crm_followup_type', followupType: 'crm_followup_type',
lostReason: 'crm_lost_reason',
projectPartyRole: PROJECT_PARTY_OPTION_CATEGORY projectPartyRole: PROJECT_PARTY_OPTION_CATEGORY
} as const; } as const;
@@ -58,15 +66,22 @@ const SALES_CREATED_ENQUIRY_ROLES = new Set([
'department_manager', 'department_manager',
'top_manager' 'top_manager'
]); ]);
const CLOSED_LOST_STATUS_CODES = new Set(['closed_lost', 'cancelled']); const PURCHASE_ORDER_ATTACHMENT_CATEGORY = 'purchase_order';
const CLOSED_WON_QUOTATION_STATUS_CODES = new Set(['accepted']); const ALLOWED_PO_CONTENT_TYPES = new Set([
const CLOSED_LOST_QUOTATION_STATUS_CODES = new Set(['lost', 'rejected']); 'application/pdf',
'image/jpeg',
'image/png',
'image/webp',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
]);
export interface EnquiryAccessContext { export interface EnquiryAccessContext {
organizationId: string; organizationId: string;
userId: string; userId: string;
membershipRole: string; membershipRole: string;
businessRole: string; businessRole: string;
permissions?: string[];
branchScopeIds: string[]; branchScopeIds: string[];
productTypeScopeIds: string[]; productTypeScopeIds: string[];
ownershipScope: string; ownershipScope: string;
@@ -98,7 +113,13 @@ function mapBranch(
function mapEnquiryRecord( function mapEnquiryRecord(
row: typeof crmEnquiries.$inferSelect, row: typeof crmEnquiries.$inferSelect,
options?: { assignedToName?: string | null; assignedByName?: string | null } options?: {
assignedToName?: string | null;
assignedByName?: string | null;
closedByName?: string | null;
lostReason?: { code: string; label: string } | null;
canViewCommercialData?: boolean;
}
): EnquiryRecord { ): EnquiryRecord {
return { return {
id: row.id, id: row.id,
@@ -125,6 +146,19 @@ function mapEnquiryRecord(
isHotProject: row.isHotProject, isHotProject: row.isHotProject,
isActive: row.isActive, isActive: row.isActive,
pipelineStage: row.pipelineStage as EnquiryPipelineStage, pipelineStage: row.pipelineStage as EnquiryPipelineStage,
closedWonAt: row.closedWonAt?.toISOString() ?? null,
closedLostAt: row.closedLostAt?.toISOString() ?? null,
closedByUserId: row.closedByUserId,
closedByName: options?.closedByName ?? null,
poNumber: row.poNumber,
poDate: row.poDate?.toISOString() ?? null,
poAmount: options?.canViewCommercialData === false ? null : row.poAmount,
poCurrency: row.poCurrency,
lostReason: row.lostReason,
lostReasonCode: options?.lostReason?.code ?? null,
lostReasonLabel: options?.lostReason?.label ?? null,
lostCompetitor: row.lostCompetitor,
lostRemark: row.lostRemark,
assignedToUserId: row.assignedToUserId, assignedToUserId: row.assignedToUserId,
assignedToName: options?.assignedToName ?? null, assignedToName: options?.assignedToName ?? null,
assignedAt: row.assignedAt?.toISOString() ?? null, assignedAt: row.assignedAt?.toISOString() ?? null,
@@ -139,6 +173,38 @@ function mapEnquiryRecord(
}; };
} }
function mapAttachmentRecord(row: typeof crmEnquiryAttachments.$inferSelect): EnquiryAttachmentRecord {
return {
id: row.id,
organizationId: row.organizationId,
enquiryId: row.enquiryId,
category: row.category,
fileName: row.fileName,
originalFileName: row.originalFileName,
storageProvider: row.storageProvider,
storageKey: row.storageKey,
fileSize: row.fileSize,
fileType: row.fileType,
description: row.description,
uploadedAt: row.uploadedAt.toISOString(),
uploadedBy: row.uploadedBy,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
deletedAt: row.deletedAt?.toISOString() ?? null
};
}
function canViewOutcomeCommercialData(context?: EnquiryAccessContext) {
if (!context) {
return true;
}
return (
context.membershipRole === 'admin' ||
(context.permissions ?? []).includes(PERMISSIONS.crmQuotationPricingRead)
);
}
function hasEnquiryFullAccess(context: EnquiryAccessContext) { function hasEnquiryFullAccess(context: EnquiryAccessContext) {
return context.membershipRole === 'admin' || context.ownershipScope !== 'own'; return context.membershipRole === 'admin' || context.ownershipScope !== 'own';
} }
@@ -561,40 +627,16 @@ function derivePipelineStageFromRole(businessRole: string): EnquiryPipelineStage
} }
async function resolvePipelineStageForCreate( async function resolvePipelineStageForCreate(
organizationId: string,
businessRole: string, businessRole: string,
statusId: string
): Promise<EnquiryPipelineStage> { ): Promise<EnquiryPipelineStage> {
const statusCode = await resolveOptionCodeById(
organizationId,
ENQUIRY_OPTION_CATEGORIES.status,
statusId
);
if (statusCode && CLOSED_LOST_STATUS_CODES.has(statusCode)) {
return 'closed_lost';
}
return derivePipelineStageFromRole(businessRole); return derivePipelineStageFromRole(businessRole);
} }
async function resolvePipelineStageForUpdate( async function resolvePipelineStageForUpdate(
organizationId: string,
current: typeof crmEnquiries.$inferSelect, current: typeof crmEnquiries.$inferSelect,
payload: EnquiryMutationPayload
): Promise<EnquiryPipelineStage> { ): Promise<EnquiryPipelineStage> {
const statusCode = await resolveOptionCodeById( if (current.pipelineStage === 'closed_won' || current.pipelineStage === 'closed_lost') {
organizationId, return current.pipelineStage as EnquiryPipelineStage;
ENQUIRY_OPTION_CATEGORIES.status,
payload.status
);
if (statusCode && CLOSED_LOST_STATUS_CODES.has(statusCode)) {
return 'closed_lost';
}
if (current.pipelineStage === 'closed_won') {
return 'closed_won';
} }
if (current.pipelineStage === 'enquiry' || current.assignedToUserId) { if (current.pipelineStage === 'enquiry' || current.assignedToUserId) {
@@ -614,6 +656,7 @@ export async function getEnquiryReferenceData(
priorities, priorities,
leadChannels, leadChannels,
followupTypes, followupTypes,
lostReasons,
projectPartyRoles, projectPartyRoles,
customers, customers,
contacts, contacts,
@@ -625,6 +668,7 @@ export async function getEnquiryReferenceData(
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.priority, { organizationId }), getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.priority, { organizationId }),
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.leadChannel, { organizationId }), getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.leadChannel, { organizationId }),
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.followupType, { organizationId }), getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.followupType, { organizationId }),
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.lostReason, { organizationId }),
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.projectPartyRole, { organizationId }), getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.projectPartyRole, { organizationId }),
db db
.select({ .select({
@@ -665,6 +709,7 @@ export async function getEnquiryReferenceData(
priorities: priorities.map(mapOption), priorities: priorities.map(mapOption),
leadChannels: leadChannels.map(mapOption), leadChannels: leadChannels.map(mapOption),
followupTypes: followupTypes.map(mapOption), followupTypes: followupTypes.map(mapOption),
lostReasons: lostReasons.map(mapOption),
projectPartyRoles: projectPartyRoles.map(mapOption), projectPartyRoles: projectPartyRoles.map(mapOption),
customers: customers.map((customer) => ({ customers: customers.map((customer) => ({
id: customer.id, id: customer.id,
@@ -844,20 +889,32 @@ export async function getEnquiryDetail(
accessContext?: EnquiryAccessContext accessContext?: EnquiryAccessContext
): Promise<EnquiryRecord> { ): Promise<EnquiryRecord> {
const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext); const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
const relatedUserIds = [enquiry.assignedToUserId, enquiry.assignedBy].filter(Boolean) as string[]; const relatedUserIds = [
const userRows = relatedUserIds.length enquiry.assignedToUserId,
? await db enquiry.assignedBy,
.select({ id: users.id, name: users.name }) enquiry.closedByUserId
.from(users) ].filter(Boolean) as string[];
.where(inArray(users.id, relatedUserIds)) const [userRows, lostReasonOptions] = await Promise.all([
: []; relatedUserIds.length
? db.select({ id: users.id, name: users.name }).from(users).where(inArray(users.id, relatedUserIds))
: [],
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.lostReason, { organizationId })
]);
const userMap = new Map(userRows.map((row) => [row.id, row.name])); const userMap = new Map(userRows.map((row) => [row.id, row.name]));
const lostReasonOption =
lostReasonOptions.find((option) => option.id === enquiry.lostReason || option.code === enquiry.lostReason) ??
null;
return mapEnquiryRecord(enquiry, { return mapEnquiryRecord(enquiry, {
assignedToName: enquiry.assignedToUserId assignedToName: enquiry.assignedToUserId
? (userMap.get(enquiry.assignedToUserId) ?? null) ? (userMap.get(enquiry.assignedToUserId) ?? null)
: null, : null,
assignedByName: enquiry.assignedBy ? (userMap.get(enquiry.assignedBy) ?? null) : null assignedByName: enquiry.assignedBy ? (userMap.get(enquiry.assignedBy) ?? null) : null,
closedByName: enquiry.closedByUserId ? (userMap.get(enquiry.closedByUserId) ?? null) : null,
lostReason: lostReasonOption
? { code: lostReasonOption.code, label: lostReasonOption.label }
: null,
canViewCommercialData: canViewOutcomeCommercialData(accessContext)
}); });
} }
@@ -1043,11 +1100,7 @@ export async function createEnquiry(
payload: EnquiryMutationPayload payload: EnquiryMutationPayload
) { ) {
await validateEnquiryPayload(organizationId, payload, accessContext); await validateEnquiryPayload(organizationId, payload, accessContext);
const pipelineStage = await resolvePipelineStageForCreate( const pipelineStage = await resolvePipelineStageForCreate(accessContext.businessRole);
organizationId,
accessContext.businessRole,
payload.status
);
const documentCode = await generateNextDocumentCode({ const documentCode = await generateNextDocumentCode({
organizationId, organizationId,
@@ -1113,7 +1166,7 @@ export async function updateEnquiry(
) { ) {
await validateEnquiryPayload(organizationId, payload, accessContext); await validateEnquiryPayload(organizationId, payload, accessContext);
const current = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext); const current = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
const pipelineStage = await resolvePipelineStageForUpdate(organizationId, current, payload); const pipelineStage = await resolvePipelineStageForUpdate(current);
return db.transaction(async (tx) => { return db.transaction(async (tx) => {
const [updated] = await tx const [updated] = await tx
@@ -1370,6 +1423,296 @@ export async function softDeleteEnquiryFollowup(
return updated; return updated;
} }
function ensureEnquiryStageForOutcome(
enquiry: typeof crmEnquiries.$inferSelect,
expectedStage: EnquiryPipelineStage
) {
if (enquiry.pipelineStage !== expectedStage) {
throw new AuthError(`Enquiry must be in ${expectedStage} stage`, 400);
}
}
export async function markEnquiryAsWon(
id: string,
organizationId: string,
userId: string,
payload: EnquiryMarkWonPayload,
accessContext?: EnquiryAccessContext
) {
const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
ensureEnquiryStageForOutcome(enquiry, 'enquiry');
if (!payload.poNumber.trim()) {
throw new AuthError('PO number is required', 400);
}
const now = new Date();
const [updated] = await db
.update(crmEnquiries)
.set({
pipelineStage: 'closed_won',
closedWonAt: now,
closedLostAt: null,
closedByUserId: userId,
poNumber: payload.poNumber.trim(),
poDate: new Date(payload.poDate),
poAmount: payload.poAmount ?? null,
poCurrency: payload.poCurrency?.trim() || null,
lostReason: null,
lostCompetitor: null,
lostRemark: null,
updatedAt: now,
updatedBy: userId
})
.where(eq(crmEnquiries.id, id))
.returning();
await auditAction({
organizationId,
branchId: updated.branchId,
userId,
entityType: 'crm_enquiry',
entityId: id,
action: 'mark_won',
afterData: {
poNumber: updated.poNumber,
poAmount: updated.poAmount,
poCurrency: updated.poCurrency,
remark: payload.remark?.trim() || null,
closedBy: userId,
closedAt: updated.closedWonAt?.toISOString() ?? null
}
});
return updated;
}
export async function markEnquiryAsLost(
id: string,
organizationId: string,
userId: string,
payload: EnquiryMarkLostPayload,
accessContext?: EnquiryAccessContext
) {
const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
ensureEnquiryStageForOutcome(enquiry, 'enquiry');
await assertMasterOptionValue(organizationId, ENQUIRY_OPTION_CATEGORIES.lostReason, payload.lostReason);
const lostReasonCode =
(await resolveOptionCodeById(organizationId, ENQUIRY_OPTION_CATEGORIES.lostReason, payload.lostReason)) ??
payload.lostReason;
const now = new Date();
const [updated] = await db
.update(crmEnquiries)
.set({
pipelineStage: 'closed_lost',
closedWonAt: null,
closedLostAt: now,
closedByUserId: userId,
poNumber: null,
poDate: null,
poAmount: null,
poCurrency: null,
lostReason: payload.lostReason,
lostCompetitor: payload.lostCompetitor?.trim() || null,
lostRemark: payload.lostRemark?.trim() || null,
updatedAt: now,
updatedBy: userId
})
.where(eq(crmEnquiries.id, id))
.returning();
await auditAction({
organizationId,
branchId: updated.branchId,
userId,
entityType: 'crm_enquiry',
entityId: id,
action: 'mark_lost',
afterData: {
lostReason: lostReasonCode,
lostCompetitor: updated.lostCompetitor,
closedBy: userId,
closedAt: updated.closedLostAt?.toISOString() ?? null
}
});
return updated;
}
export async function reopenLostEnquiry(
id: string,
organizationId: string,
userId: string,
payload: EnquiryReopenPayload,
accessContext?: EnquiryAccessContext
) {
const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
if (enquiry.pipelineStage === 'closed_won') {
throw new AuthError('Won enquiries cannot be reopened', 400);
}
ensureEnquiryStageForOutcome(enquiry, 'closed_lost');
const now = new Date();
const [updated] = await db
.update(crmEnquiries)
.set({
pipelineStage: 'enquiry',
closedLostAt: null,
closedByUserId: null,
lostReason: null,
lostCompetitor: null,
lostRemark: null,
updatedAt: now,
updatedBy: userId
})
.where(eq(crmEnquiries.id, id))
.returning();
await auditAction({
organizationId,
branchId: updated.branchId,
userId,
entityType: 'crm_enquiry',
entityId: id,
action: 'reopen',
afterData: {
reopenReason: payload.reopenReason.trim(),
previousStage: enquiry.pipelineStage,
nextStage: updated.pipelineStage
}
});
return updated;
}
export async function listEnquiryAttachments(
enquiryId: string,
organizationId: string,
accessContext?: EnquiryAccessContext
) {
await assertEnquiryBelongsToOrganization(enquiryId, organizationId, accessContext);
const rows = await db
.select()
.from(crmEnquiryAttachments)
.where(
and(
eq(crmEnquiryAttachments.organizationId, organizationId),
eq(crmEnquiryAttachments.enquiryId, enquiryId),
eq(crmEnquiryAttachments.category, PURCHASE_ORDER_ATTACHMENT_CATEGORY),
isNull(crmEnquiryAttachments.deletedAt)
)
)
.orderBy(desc(crmEnquiryAttachments.uploadedAt));
return rows.map(mapAttachmentRecord);
}
export async function getEnquiryAttachment(
enquiryId: string,
attachmentId: string,
organizationId: string,
accessContext?: EnquiryAccessContext
) {
await assertEnquiryBelongsToOrganization(enquiryId, organizationId, accessContext);
const [attachment] = await db
.select()
.from(crmEnquiryAttachments)
.where(
and(
eq(crmEnquiryAttachments.id, attachmentId),
eq(crmEnquiryAttachments.organizationId, organizationId),
eq(crmEnquiryAttachments.enquiryId, enquiryId),
isNull(crmEnquiryAttachments.deletedAt)
)
)
.limit(1);
if (!attachment) {
throw new AuthError('Attachment not found', 404);
}
return attachment;
}
export async function uploadPurchaseOrderAttachment(input: {
enquiryId: string;
organizationId: string;
userId: string;
file: {
name: string;
type: string;
size: number;
buffer: Buffer;
};
description?: string | null;
accessContext?: EnquiryAccessContext;
}) {
const enquiry = await assertEnquiryBelongsToOrganization(
input.enquiryId,
input.organizationId,
input.accessContext
);
if (enquiry.pipelineStage !== 'closed_won') {
throw new AuthError('PO attachment is available only for won enquiries', 400);
}
if (!ALLOWED_PO_CONTENT_TYPES.has(input.file.type)) {
throw new AuthError('Unsupported PO attachment file type', 400);
}
const storageProvider = getStorageProvider();
const safeFileName = input.file.name.replace(/[^a-zA-Z0-9._-]/g, '-');
const storageKey = buildOrganizationStorageKey(input.organizationId, [
'enquiries',
input.enquiryId,
'purchase-orders',
`${Date.now()}-${safeFileName}`
]);
const stored = await storageProvider.putObject({
key: storageKey,
body: input.file.buffer,
contentType: input.file.type,
fileName: safeFileName
});
const [created] = await db
.insert(crmEnquiryAttachments)
.values({
id: crypto.randomUUID(),
organizationId: input.organizationId,
enquiryId: input.enquiryId,
category: PURCHASE_ORDER_ATTACHMENT_CATEGORY,
fileName: safeFileName,
originalFileName: input.file.name,
storageProvider: stored.provider,
storageKey: stored.key,
fileSize: stored.size,
fileType: input.file.type,
description: input.description?.trim() || null,
uploadedBy: input.userId
})
.returning();
await auditAction({
organizationId: input.organizationId,
branchId: enquiry.branchId,
userId: input.userId,
entityType: 'crm_enquiry',
entityId: input.enquiryId,
action: 'upload_po',
afterData: {
attachmentId: created.id,
poNumber: enquiry.poNumber,
fileName: created.fileName,
fileType: created.fileType
}
});
return created;
}
export async function listCustomerEnquiryRelations( export async function listCustomerEnquiryRelations(
customerId: string, customerId: string,
organizationId: string, organizationId: string,
@@ -1405,33 +1748,7 @@ export async function syncEnquiryPipelineStageFromQuotationStatus(
organizationId: string, organizationId: string,
quotationStatusCode: string | null quotationStatusCode: string | null
) { ) {
if (!quotationStatusCode) { void enquiryId;
return; void organizationId;
} void quotationStatusCode;
const nextStage: EnquiryPipelineStage | null = CLOSED_WON_QUOTATION_STATUS_CODES.has(
quotationStatusCode
)
? 'closed_won'
: CLOSED_LOST_QUOTATION_STATUS_CODES.has(quotationStatusCode)
? 'closed_lost'
: null;
if (!nextStage) {
return;
}
await db
.update(crmEnquiries)
.set({
pipelineStage: nextStage,
updatedAt: new Date()
})
.where(
and(
eq(crmEnquiries.id, enquiryId),
eq(crmEnquiries.organizationId, organizationId),
isNull(crmEnquiries.deletedAt)
)
);
} }

View File

@@ -14,6 +14,7 @@ import {
} from 'drizzle-orm'; } from 'drizzle-orm';
import { import {
crmCustomers, crmCustomers,
crmEnquiries,
crmEnquiryCustomers, crmEnquiryCustomers,
crmQuotationCustomers, crmQuotationCustomers,
crmQuotations crmQuotations
@@ -24,7 +25,13 @@ import {
PROJECT_PARTY_OPTION_CATEGORY, PROJECT_PARTY_OPTION_CATEGORY,
resolveProjectPartyRole resolveProjectPartyRole
} from '@/features/crm/shared/project-party'; } from '@/features/crm/shared/project-party';
import type { RevenueAttributionFilters, RevenueAttributionSummary } from './types'; import type {
OutcomeBreakdownRow,
OutcomeRevenueFilters,
OutcomeRevenueRecord,
RevenueAttributionFilters,
RevenueAttributionSummary
} from './types';
type SupportedRevenueRole = 'end_customer' | 'billing_customer' | 'contractor' | 'consultant'; type SupportedRevenueRole = 'end_customer' | 'billing_customer' | 'contractor' | 'consultant';
@@ -378,3 +385,174 @@ export async function getRevenueByConsultant(
) { ) {
return getRevenueByRole(organizationId, 'consultant', filters); return getRevenueByRole(organizationId, 'consultant', filters);
} }
function buildOutcomeRevenueFilters(
organizationId: string,
stage: 'closed_won' | 'closed_lost',
filters: OutcomeRevenueFilters
): SQL[] {
const dateColumn = stage === 'closed_won' ? crmEnquiries.closedWonAt : crmEnquiries.closedLostAt;
return [
eq(crmEnquiries.organizationId, organizationId),
isNull(crmEnquiries.deletedAt),
eq(crmEnquiries.pipelineStage, stage),
...(filters.branch ? [eq(crmEnquiries.branchId, filters.branch)] : []),
...(filters.salesman ? [eq(crmEnquiries.assignedToUserId, filters.salesman)] : []),
...(filters.productType ? [eq(crmEnquiries.productType, filters.productType)] : []),
...(filters.dateFrom ? [gte(dateColumn, new Date(filters.dateFrom))] : []),
...(filters.dateTo ? [lte(dateColumn, new Date(filters.dateTo))] : [])
];
}
async function getOutcomeRevenueRecords(
organizationId: string,
stage: 'closed_won' | 'closed_lost',
filters: OutcomeRevenueFilters = {}
): Promise<OutcomeRevenueRecord[]> {
const whereFilters = buildOutcomeRevenueFilters(organizationId, stage, filters);
const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters);
const enquiries = await db.select().from(crmEnquiries).where(where);
if (!enquiries.length) {
return [];
}
const enquiryIds = enquiries.map((row) => row.id);
const quotationRows = await db
.select({
enquiryId: crmQuotations.enquiryId,
totalAmount: crmQuotations.totalAmount
})
.from(crmQuotations)
.where(
and(
eq(crmQuotations.organizationId, organizationId),
inArray(crmQuotations.enquiryId, enquiryIds),
isNull(crmQuotations.deletedAt)
)
);
const quotationTotals = new Map<string, number>();
for (const row of quotationRows) {
if (!row.enquiryId) {
continue;
}
quotationTotals.set(row.enquiryId, (quotationTotals.get(row.enquiryId) ?? 0) + row.totalAmount);
}
return enquiries.map((row) => ({
enquiryId: row.id,
customerId: row.customerId,
branchId: row.branchId,
productType: row.productType,
assignedToUserId: row.assignedToUserId,
revenue: stage === 'closed_won' ? row.poAmount ?? quotationTotals.get(row.id) ?? 0 : quotationTotals.get(row.id) ?? 0,
closedAt:
(stage === 'closed_won' ? row.closedWonAt : row.closedLostAt)?.toISOString() ?? null
}));
}
export async function getWonRevenue(
organizationId: string,
filters: OutcomeRevenueFilters = {}
) {
return getOutcomeRevenueRecords(organizationId, 'closed_won', filters);
}
export async function getLostRevenue(
organizationId: string,
filters: OutcomeRevenueFilters = {}
) {
return getOutcomeRevenueRecords(organizationId, 'closed_lost', filters);
}
export async function getLostByReason(
organizationId: string,
filters: OutcomeRevenueFilters = {}
): Promise<OutcomeBreakdownRow[]> {
const records = await getLostRevenue(organizationId, filters);
const enquiryIds = records.map((row) => row.enquiryId);
if (!enquiryIds.length) {
return [];
}
const [enquiries, lostReasonOptions] = await Promise.all([
db
.select({ id: crmEnquiries.id, lostReason: crmEnquiries.lostReason })
.from(crmEnquiries)
.where(inArray(crmEnquiries.id, enquiryIds)),
getActiveOptionsByCategory('crm_lost_reason', { organizationId })
]);
const lostReasonMap = new Map(
lostReasonOptions.flatMap((item) => [
[item.id, item.label] as const,
[item.code, item.label] as const
])
);
const grouped = new Map<string, OutcomeBreakdownRow>();
for (const row of records) {
const enquiry = enquiries.find((item) => item.id === row.enquiryId);
const key = enquiry?.lostReason ?? 'unknown';
const current = grouped.get(key) ?? {
key,
label: lostReasonMap.get(key) ?? key,
count: 0,
revenue: 0
};
current.count += 1;
current.revenue += row.revenue;
grouped.set(key, current);
}
return [...grouped.values()].sort((a, b) => b.count - a.count || b.revenue - a.revenue);
}
export async function getLostByCompetitor(
organizationId: string,
filters: OutcomeRevenueFilters = {}
): Promise<OutcomeBreakdownRow[]> {
const records = await getLostRevenue(organizationId, filters);
const enquiryIds = records.map((row) => row.enquiryId);
if (!enquiryIds.length) {
return [];
}
const enquiries = await db
.select({ id: crmEnquiries.id, lostCompetitor: crmEnquiries.lostCompetitor })
.from(crmEnquiries)
.where(inArray(crmEnquiries.id, enquiryIds));
const grouped = new Map<string, OutcomeBreakdownRow>();
for (const row of records) {
const enquiry = enquiries.find((item) => item.id === row.enquiryId);
const key = enquiry?.lostCompetitor?.trim() || 'unknown';
const current = grouped.get(key) ?? { key, label: key, count: 0, revenue: 0 };
current.count += 1;
current.revenue += row.revenue;
grouped.set(key, current);
}
return [...grouped.values()].sort((a, b) => b.count - a.count || b.revenue - a.revenue);
}
export async function getWinRate(
organizationId: string,
filters: OutcomeRevenueFilters = {}
) {
const [wonRows, lostRows] = await Promise.all([
getWonRevenue(organizationId, filters),
getLostRevenue(organizationId, filters)
]);
const denominator = wonRows.length + lostRows.length;
if (denominator === 0) {
return 0;
}
return Math.round((wonRows.length / denominator) * 10000) / 100;
}

View File

@@ -20,3 +20,28 @@ export interface RevenueAttributionSummary {
quotationCount: number; quotationCount: number;
quotationIds: string[]; quotationIds: string[];
} }
export interface OutcomeRevenueFilters {
dateFrom?: string | null;
dateTo?: string | null;
branch?: string | null;
salesman?: string | null;
productType?: string | null;
}
export interface OutcomeRevenueRecord {
enquiryId: string;
customerId: string;
branchId: string | null;
productType: string;
assignedToUserId: string | null;
revenue: number;
closedAt: string | null;
}
export interface OutcomeBreakdownRow {
key: string;
label: string;
count: number;
revenue: number;
}

View File

@@ -54,6 +54,9 @@ export const PERMISSIONS = {
crmEnquiryDelete: 'crm.enquiry.delete', crmEnquiryDelete: 'crm.enquiry.delete',
crmEnquiryAssign: 'crm.enquiry.assign', crmEnquiryAssign: 'crm.enquiry.assign',
crmEnquiryReassign: 'crm.enquiry.reassign', crmEnquiryReassign: 'crm.enquiry.reassign',
crmEnquiryMarkWon: 'crm.enquiry.mark_won',
crmEnquiryMarkLost: 'crm.enquiry.mark_lost',
crmEnquiryReopen: 'crm.enquiry.reopen',
crmEnquiryFollowupRead: 'crm.enquiry.followup.read', crmEnquiryFollowupRead: 'crm.enquiry.followup.read',
crmEnquiryFollowupCreate: 'crm.enquiry.followup.create', crmEnquiryFollowupCreate: 'crm.enquiry.followup.create',
crmEnquiryFollowupUpdate: 'crm.enquiry.followup.update', crmEnquiryFollowupUpdate: 'crm.enquiry.followup.update',
@@ -167,6 +170,8 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
PERMISSIONS.crmEnquiryRead, PERMISSIONS.crmEnquiryRead,
PERMISSIONS.crmEnquiryCreate, PERMISSIONS.crmEnquiryCreate,
PERMISSIONS.crmEnquiryUpdate, PERMISSIONS.crmEnquiryUpdate,
PERMISSIONS.crmEnquiryMarkWon,
PERMISSIONS.crmEnquiryMarkLost,
PERMISSIONS.crmEnquiryFollowupRead, PERMISSIONS.crmEnquiryFollowupRead,
PERMISSIONS.crmEnquiryFollowupCreate, PERMISSIONS.crmEnquiryFollowupCreate,
PERMISSIONS.crmEnquiryFollowupUpdate, PERMISSIONS.crmEnquiryFollowupUpdate,
@@ -204,6 +209,8 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
PERMISSIONS.crmContactRead, PERMISSIONS.crmContactRead,
PERMISSIONS.crmEnquiryRead, PERMISSIONS.crmEnquiryRead,
PERMISSIONS.crmEnquiryUpdate, PERMISSIONS.crmEnquiryUpdate,
PERMISSIONS.crmEnquiryMarkWon,
PERMISSIONS.crmEnquiryMarkLost,
PERMISSIONS.crmEnquiryFollowupRead, PERMISSIONS.crmEnquiryFollowupRead,
PERMISSIONS.crmEnquiryFollowupCreate, PERMISSIONS.crmEnquiryFollowupCreate,
PERMISSIONS.crmEnquiryFollowupUpdate, PERMISSIONS.crmEnquiryFollowupUpdate,
@@ -256,6 +263,9 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
PERMISSIONS.crmEnquiryUpdate, PERMISSIONS.crmEnquiryUpdate,
PERMISSIONS.crmEnquiryAssign, PERMISSIONS.crmEnquiryAssign,
PERMISSIONS.crmEnquiryReassign, PERMISSIONS.crmEnquiryReassign,
PERMISSIONS.crmEnquiryMarkWon,
PERMISSIONS.crmEnquiryMarkLost,
PERMISSIONS.crmEnquiryReopen,
PERMISSIONS.crmEnquiryFollowupRead, PERMISSIONS.crmEnquiryFollowupRead,
PERMISSIONS.crmEnquiryFollowupCreate, PERMISSIONS.crmEnquiryFollowupCreate,
PERMISSIONS.crmEnquiryFollowupUpdate, PERMISSIONS.crmEnquiryFollowupUpdate,
@@ -302,6 +312,9 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
PERMISSIONS.crmEnquiryRead, PERMISSIONS.crmEnquiryRead,
PERMISSIONS.crmEnquiryAssign, PERMISSIONS.crmEnquiryAssign,
PERMISSIONS.crmEnquiryReassign, PERMISSIONS.crmEnquiryReassign,
PERMISSIONS.crmEnquiryMarkWon,
PERMISSIONS.crmEnquiryMarkLost,
PERMISSIONS.crmEnquiryReopen,
PERMISSIONS.crmEnquiryFollowupRead, PERMISSIONS.crmEnquiryFollowupRead,
PERMISSIONS.crmQuotationRead, PERMISSIONS.crmQuotationRead,
PERMISSIONS.crmQuotationPricingRead, PERMISSIONS.crmQuotationPricingRead,
@@ -365,6 +378,9 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
description: 'CRM configuration authority for templates, workflows, sequences, and permissions.', description: 'CRM configuration authority for templates, workflows, sequences, and permissions.',
permissions: [ permissions: [
PERMISSIONS.crmDashboardRead, PERMISSIONS.crmDashboardRead,
PERMISSIONS.crmEnquiryMarkWon,
PERMISSIONS.crmEnquiryMarkLost,
PERMISSIONS.crmEnquiryReopen,
PERMISSIONS.crmCustomerOwnerRead, PERMISSIONS.crmCustomerOwnerRead,
PERMISSIONS.crmCustomerOwnerManage, PERMISSIONS.crmCustomerOwnerManage,
PERMISSIONS.crmContactShareRead, PERMISSIONS.crmContactShareRead,
@@ -466,6 +482,9 @@ export const CRM_PERMISSION_GROUPS: PermissionGroup[] = [
{ key: PERMISSIONS.crmEnquiryDelete, label: 'Delete enquiries' }, { key: PERMISSIONS.crmEnquiryDelete, label: 'Delete enquiries' },
{ key: PERMISSIONS.crmEnquiryAssign, label: 'Assign enquiries' }, { key: PERMISSIONS.crmEnquiryAssign, label: 'Assign enquiries' },
{ key: PERMISSIONS.crmEnquiryReassign, label: 'Reassign enquiries' }, { key: PERMISSIONS.crmEnquiryReassign, label: 'Reassign enquiries' },
{ key: PERMISSIONS.crmEnquiryMarkWon, label: 'Mark enquiries as won' },
{ key: PERMISSIONS.crmEnquiryMarkLost, label: 'Mark enquiries as lost' },
{ key: PERMISSIONS.crmEnquiryReopen, label: 'Reopen lost enquiries' },
{ key: PERMISSIONS.crmEnquiryFollowupRead, label: 'Read enquiry follow-ups' }, { key: PERMISSIONS.crmEnquiryFollowupRead, label: 'Read enquiry follow-ups' },
{ key: PERMISSIONS.crmEnquiryFollowupCreate, label: 'Create enquiry follow-ups' }, { key: PERMISSIONS.crmEnquiryFollowupCreate, label: 'Create enquiry follow-ups' },
{ key: PERMISSIONS.crmEnquiryFollowupUpdate, label: 'Update enquiry follow-ups' }, { key: PERMISSIONS.crmEnquiryFollowupUpdate, label: 'Update enquiry follow-ups' },