task-d.4
This commit is contained in:
58
docs/adr/0016-won-lost-lifecycle-governance.md
Normal file
58
docs/adr/0016-won-lost-lifecycle-governance.md
Normal 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
|
||||
41
docs/implementation/task-d4-won-lost-lifecycle-governance.md
Normal file
41
docs/implementation/task-d4-won-lost-lifecycle-governance.md
Normal 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
|
||||
30
drizzle/0016_won_lost_lifecycle_governance.sql
Normal file
30
drizzle/0016_won_lost_lifecycle_governance.sql
Normal 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
|
||||
);
|
||||
61
drizzle/0017_short_swordsman.sql
Normal file
61
drizzle/0017_short_swordsman.sql
Normal 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");
|
||||
3963
drizzle/meta/0017_snapshot.json
Normal file
3963
drizzle/meta/0017_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -113,6 +113,20 @@
|
||||
"when": 1782549600000,
|
||||
"tag": "0015_customer_ownership_contact_shares",
|
||||
"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
710
plans/task-d.4.md
Normal 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.
|
||||
@@ -22,6 +22,7 @@ export async function POST(request: NextRequest, { params }: Params) {
|
||||
userId: session.user.id,
|
||||
membershipRole: membership.role,
|
||||
businessRole: membership.businessRole,
|
||||
permissions: membership.permissions ?? [],
|
||||
branchScopeIds: membership.branchScopeIds ?? [],
|
||||
productTypeScopeIds: membership.productTypeScopeIds ?? [],
|
||||
ownershipScope: membership.ownershipScope ?? 'organization',
|
||||
|
||||
62
src/app/api/crm/enquiries/[id]/mark-lost/route.ts
Normal file
62
src/app/api/crm/enquiries/[id]/mark-lost/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
62
src/app/api/crm/enquiries/[id]/mark-won/route.ts
Normal file
62
src/app/api/crm/enquiries/[id]/mark-won/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
141
src/app/api/crm/enquiries/[id]/po-attachments/route.ts
Normal file
141
src/app/api/crm/enquiries/[id]/po-attachments/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export async function POST(request: NextRequest, { params }: Params) {
|
||||
userId: session.user.id,
|
||||
membershipRole: membership.role,
|
||||
businessRole: membership.businessRole,
|
||||
permissions: membership.permissions ?? [],
|
||||
branchScopeIds: membership.branchScopeIds ?? [],
|
||||
productTypeScopeIds: membership.productTypeScopeIds ?? [],
|
||||
ownershipScope: membership.ownershipScope ?? 'organization',
|
||||
|
||||
60
src/app/api/crm/enquiries/[id]/reopen/route.ts
Normal file
60
src/app/api/crm/enquiries/[id]/reopen/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
||||
userId: session.user.id,
|
||||
membershipRole: membership.role,
|
||||
businessRole: membership.businessRole,
|
||||
permissions: membership.permissions ?? [],
|
||||
branchScopeIds: membership.branchScopeIds ?? [],
|
||||
productTypeScopeIds: membership.productTypeScopeIds ?? [],
|
||||
ownershipScope: membership.ownershipScope ?? 'organization',
|
||||
@@ -76,6 +77,7 @@ export async function PATCH(request: NextRequest, { params }: Params) {
|
||||
userId: session.user.id,
|
||||
membershipRole: membership.role,
|
||||
businessRole: membership.businessRole,
|
||||
permissions: membership.permissions ?? [],
|
||||
branchScopeIds: membership.branchScopeIds ?? [],
|
||||
productTypeScopeIds: membership.productTypeScopeIds ?? [],
|
||||
ownershipScope: membership.ownershipScope ?? 'organization',
|
||||
@@ -131,6 +133,7 @@ export async function DELETE(_request: NextRequest, { params }: Params) {
|
||||
userId: session.user.id,
|
||||
membershipRole: membership.role,
|
||||
businessRole: membership.businessRole,
|
||||
permissions: membership.permissions ?? [],
|
||||
branchScopeIds: membership.branchScopeIds ?? [],
|
||||
productTypeScopeIds: membership.productTypeScopeIds ?? [],
|
||||
ownershipScope: membership.ownershipScope ?? 'organization',
|
||||
|
||||
@@ -59,6 +59,26 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
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();
|
||||
|
||||
if (canRead) {
|
||||
@@ -98,6 +118,10 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
|
||||
canAssign={canAssign}
|
||||
canReassign={canReassign}
|
||||
canViewRelatedQuotations={canReadQuotation}
|
||||
canViewOutcomeCommercialData={canViewOutcomeCommercialData}
|
||||
canMarkWon={canMarkWon}
|
||||
canMarkLost={canMarkLost}
|
||||
canReopen={canReopen}
|
||||
canManageFollowups={{
|
||||
create: canFollowupCreate,
|
||||
update: canFollowupUpdate,
|
||||
|
||||
@@ -46,6 +46,7 @@ export default async function LeadDetailRoute({ params }: PageProps) {
|
||||
userId: session.user.id,
|
||||
membershipRole: session.user.activeMembershipRole ?? 'user',
|
||||
businessRole: session.user.activeBusinessRole ?? 'sales_support',
|
||||
permissions: session.user.activePermissions ?? [],
|
||||
branchScopeIds: session.user.activeBranchScopeIds ?? [],
|
||||
productTypeScopeIds: session.user.activeProductTypeScopeIds ?? [],
|
||||
ownershipScope: session.user.activeOwnershipScope ?? 'organization',
|
||||
@@ -91,6 +92,10 @@ export default async function LeadDetailRoute({ params }: PageProps) {
|
||||
canAssign={canAssign}
|
||||
canReassign={false}
|
||||
canViewRelatedQuotations={false}
|
||||
canViewOutcomeCommercialData={false}
|
||||
canMarkWon={false}
|
||||
canMarkLost={false}
|
||||
canReopen={false}
|
||||
canManageFollowups={{
|
||||
create: false,
|
||||
update: false,
|
||||
|
||||
@@ -317,6 +317,16 @@ export const crmEnquiries = pgTable(
|
||||
isHotProject: boolean('is_hot_project').default(false).notNull(),
|
||||
isActive: boolean('is_active').default(true).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'),
|
||||
assignedAt: timestamp('assigned_at', { withTimezone: true }),
|
||||
assignedBy: text('assigned_by'),
|
||||
@@ -365,6 +375,25 @@ export const crmEnquiryCustomers = pgTable('crm_enquiry_customers', {
|
||||
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(
|
||||
'crm_quotations',
|
||||
{
|
||||
|
||||
@@ -285,6 +285,36 @@ const FOUNDATION_OPTIONS = {
|
||||
{ code: 'email', label: 'Email', value: 'email', sortOrder: 4 },
|
||||
{ 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: [
|
||||
{ code: 'sales', label: 'Sales Engineer', value: 'sales', sortOrder: 1 },
|
||||
{ code: 'sales_support', label: 'Sales Support', value: 'sales_support', sortOrder: 2 },
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface CrmDashboardSummary {
|
||||
activeEnquiries: number;
|
||||
wonCount: number;
|
||||
lostCount: number;
|
||||
winRate: number;
|
||||
hotEnquiries: number;
|
||||
enquiryValue: number;
|
||||
};
|
||||
@@ -150,6 +151,11 @@ export interface CrmDashboardResponse {
|
||||
myPendingApprovals: CrmDashboardApprovalRow[];
|
||||
};
|
||||
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: {
|
||||
canViewCommercialData: boolean;
|
||||
canViewApprovalData: boolean;
|
||||
|
||||
@@ -70,7 +70,7 @@ export function DashboardSummaryCards({
|
||||
{ label: 'จำนวนโอกาสขาย', value: summary.enquiry.enquiryCount },
|
||||
{ label: 'โอกาสขายที่กำลังดำเนินการ', value: summary.enquiry.activeEnquiries },
|
||||
{ label: 'จำนวนงานที่ชนะ', value: summary.enquiry.wonCount },
|
||||
{ label: 'จำนวนงานที่แพ้', value: summary.enquiry.lostCount }
|
||||
{ label: 'Win Rate', value: summary.enquiry.winRate, suffix: '%' }
|
||||
]}
|
||||
/>
|
||||
{canViewCommercialData ? (
|
||||
|
||||
@@ -30,7 +30,12 @@ import {
|
||||
getRevenueByBillingCustomer,
|
||||
getRevenueByConsultant,
|
||||
getRevenueByContractor,
|
||||
getRevenueByEndCustomer
|
||||
getRevenueByEndCustomer,
|
||||
getLostByCompetitor,
|
||||
getLostByReason,
|
||||
getLostRevenue,
|
||||
getWinRate,
|
||||
getWonRevenue
|
||||
} from '@/features/crm/reporting/server/service';
|
||||
import { getPipelineStageThaiLabel, getProjectPartyRoleThaiLabel } from '@/features/crm/shared/terminology';
|
||||
import type {
|
||||
@@ -281,7 +286,8 @@ async function loadScopedRows(
|
||||
function buildSummary(
|
||||
scopedEnquiries: Array<typeof crmEnquiries.$inferSelect>,
|
||||
scopedQuotations: Array<typeof crmQuotations.$inferSelect>,
|
||||
statusMaps: StatusMaps
|
||||
statusMaps: StatusMaps,
|
||||
outcomeMetrics: { wonValue: number; lostValue: number; winRate: number }
|
||||
): CrmDashboardSummary {
|
||||
const now = new Date();
|
||||
const leadRows = scopedEnquiries.filter((row) => isPipelineStage(row, 'lead'));
|
||||
@@ -304,6 +310,7 @@ function buildSummary(
|
||||
activeEnquiries: enquiryRows.length,
|
||||
wonCount: wonRows.length,
|
||||
lostCount: lostRows.length,
|
||||
winRate: outcomeMetrics.winRate,
|
||||
hotEnquiries: enquiryRows.filter((row) => row.isHotProject).length,
|
||||
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')
|
||||
.reduce((sum, row) => sum + row.totalAmount, 0)
|
||||
),
|
||||
wonValue: round2(
|
||||
scopedQuotations
|
||||
.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)
|
||||
)
|
||||
wonValue: round2(outcomeMetrics.wonValue),
|
||||
lostValue: round2(outcomeMetrics.lostValue)
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -387,9 +384,7 @@ function buildFunnel(
|
||||
key: 'closed_won',
|
||||
label: getPipelineStageThaiLabel('closed_won'),
|
||||
count: summary.enquiry.wonCount,
|
||||
value: scopedQuotations
|
||||
.filter((row) => (statusMaps.quotationStatusById.get(row.status) ?? '') === 'accepted')
|
||||
.reduce((sum, row) => sum + row.totalAmount, 0)
|
||||
value: summary.revenue.wonValue
|
||||
}
|
||||
];
|
||||
|
||||
@@ -463,7 +458,8 @@ async function buildRevenueAnalytics(
|
||||
async function buildSalesRanking(
|
||||
scopedEnquiries: Array<typeof crmEnquiries.$inferSelect>,
|
||||
scopedQuotations: Array<typeof crmQuotations.$inferSelect>,
|
||||
statusMaps: StatusMaps
|
||||
statusMaps: StatusMaps,
|
||||
wonRevenueRows: Array<{ assignedToUserId: string | null; revenue: number }>
|
||||
): Promise<CrmDashboardSalesRankingRow[]> {
|
||||
const userIds = [
|
||||
...new Set(
|
||||
@@ -529,11 +525,29 @@ async function buildSalesRanking(
|
||||
current.approvedQuotations += 1;
|
||||
}
|
||||
|
||||
if (statusCode === 'accepted') {
|
||||
current.wonRevenue += row.totalAmount;
|
||||
rankingMap.set(row.salesmanId, current);
|
||||
}
|
||||
|
||||
for (const row of wonRevenueRows) {
|
||||
if (!row.assignedToUserId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
rankingMap.set(row.salesmanId, current);
|
||||
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()]
|
||||
@@ -776,12 +790,33 @@ export async function getCrmDashboardData(
|
||||
const filters = normalizeDashboardFilters(rawFilters);
|
||||
const allowCommercialData = canViewCommercialData(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([
|
||||
getReferenceData(context.organizationId),
|
||||
getStatusMaps(context.organizationId),
|
||||
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([
|
||||
allowCommercialData
|
||||
? buildRevenueAnalytics(context.organizationId, filters)
|
||||
@@ -792,7 +827,7 @@ export async function getCrmDashboardData(
|
||||
topConsultants: []
|
||||
}),
|
||||
allowCommercialData
|
||||
? buildSalesRanking(scoped.enquiries, scoped.quotations, statusMaps)
|
||||
? buildSalesRanking(scoped.enquiries, scoped.quotations, statusMaps, wonRevenueRows)
|
||||
: Promise.resolve([]),
|
||||
buildFollowups(context, filters),
|
||||
allowApprovalData
|
||||
@@ -825,6 +860,11 @@ export async function getCrmDashboardData(
|
||||
followups,
|
||||
approvals,
|
||||
hotProjects,
|
||||
outcomeAnalytics: {
|
||||
winRate,
|
||||
lostByReason: lostByReasonRows,
|
||||
lostByCompetitor: lostByCompetitorRows
|
||||
},
|
||||
visibility: {
|
||||
canViewCommercialData: allowCommercialData,
|
||||
canViewApprovalData: allowApprovalData
|
||||
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
createEnquiryFollowup,
|
||||
deleteEnquiry,
|
||||
deleteEnquiryFollowup,
|
||||
markEnquiryLost,
|
||||
markEnquiryWon,
|
||||
reassignEnquiry,
|
||||
reopenEnquiry,
|
||||
updateEnquiry,
|
||||
updateEnquiryFollowup
|
||||
} from './service';
|
||||
@@ -14,6 +17,8 @@ import { enquiryKeys } from './queries';
|
||||
import type {
|
||||
EnquiryAssignmentMutationPayload,
|
||||
EnquiryFollowupMutationPayload,
|
||||
EnquiryMarkLostPayload,
|
||||
EnquiryMarkWonPayload,
|
||||
EnquiryMutationPayload
|
||||
} from './types';
|
||||
import { crmDashboardKeys } from '@/features/crm/dashboard/api/queries';
|
||||
@@ -38,11 +43,16 @@ async function invalidateEnquiryFollowups(id: string) {
|
||||
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) {
|
||||
await Promise.all([
|
||||
invalidateEnquiryLists(),
|
||||
invalidateEnquiryDetail(id),
|
||||
invalidateEnquiryProjectParties(id),
|
||||
invalidateEnquiryAttachments(id),
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
import {
|
||||
getEnquiryAttachments,
|
||||
getEnquiries,
|
||||
getEnquiryById,
|
||||
getEnquiryFollowups,
|
||||
@@ -16,7 +17,9 @@ export const enquiryKeys = {
|
||||
projectPartiesRoot: () => [...enquiryKeys.all, 'project-parties'] as const,
|
||||
projectParties: (id: string) => [...enquiryKeys.projectPartiesRoot(), id] 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) =>
|
||||
@@ -42,3 +45,9 @@ export const enquiryFollowupsOptions = (id: string) =>
|
||||
queryKey: enquiryKeys.followups(id),
|
||||
queryFn: () => getEnquiryFollowups(id)
|
||||
});
|
||||
|
||||
export const enquiryAttachmentsOptions = (id: string) =>
|
||||
queryOptions({
|
||||
queryKey: enquiryKeys.attachments(id),
|
||||
queryFn: () => getEnquiryAttachments(id)
|
||||
});
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import type {
|
||||
EnquiryAttachmentsResponse,
|
||||
EnquiryAssignmentMutationPayload,
|
||||
EnquiryDetailResponse,
|
||||
EnquiryFilters,
|
||||
EnquiryFollowupMutationPayload,
|
||||
EnquiryFollowupsResponse,
|
||||
EnquiryListResponse,
|
||||
EnquiryMarkLostPayload,
|
||||
EnquiryMarkWonPayload,
|
||||
EnquiryMutationPayload,
|
||||
EnquiryProjectPartiesResponse,
|
||||
EnquiryReopenPayload,
|
||||
MutationSuccessResponse
|
||||
} from './types';
|
||||
|
||||
@@ -76,6 +80,10 @@ export async function getEnquiryFollowups(id: string): Promise<EnquiryFollowupsR
|
||||
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(
|
||||
enquiryId: string,
|
||||
data: EnquiryFollowupMutationPayload
|
||||
@@ -102,3 +110,24 @@ export async function deleteEnquiryFollowup(enquiryId: string, followupId: strin
|
||||
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)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -56,6 +56,25 @@ export interface EnquiryAssignableUserLookup {
|
||||
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 interface EnquiryRecord {
|
||||
@@ -83,6 +102,19 @@ export interface EnquiryRecord {
|
||||
isHotProject: boolean;
|
||||
isActive: boolean;
|
||||
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;
|
||||
assignedToName: string | null;
|
||||
assignedAt: string | null;
|
||||
@@ -140,6 +172,7 @@ export interface EnquiryReferenceData {
|
||||
priorities: EnquiryOption[];
|
||||
leadChannels: EnquiryOption[];
|
||||
followupTypes: EnquiryOption[];
|
||||
lostReasons: EnquiryOption[];
|
||||
projectPartyRoles: EnquiryOption[];
|
||||
customers: EnquiryCustomerLookup[];
|
||||
contacts: EnquiryContactLookup[];
|
||||
@@ -191,6 +224,13 @@ export interface EnquiryFollowupsResponse {
|
||||
items: EnquiryFollowupRecord[];
|
||||
}
|
||||
|
||||
export interface EnquiryAttachmentsResponse {
|
||||
success: boolean;
|
||||
time: string;
|
||||
message: string;
|
||||
items: EnquiryAttachmentRecord[];
|
||||
}
|
||||
|
||||
export interface EnquiryProjectPartyMutationPayload {
|
||||
customerId: string;
|
||||
role: string;
|
||||
@@ -237,6 +277,24 @@ export interface EnquiryAssignmentMutationPayload {
|
||||
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 {
|
||||
id: string;
|
||||
code: string;
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { EnquiryRecord, EnquiryReferenceData } from '../api/types';
|
||||
import { EnquiryAssignmentDialog } from './enquiry-assignment-dialog';
|
||||
import { EnquiryFollowupsTab } from './enquiry-followups-tab';
|
||||
import { EnquiryFormSheet } from './enquiry-form-sheet';
|
||||
import { EnquiryOutcomeCard } from './enquiry-outcome-card';
|
||||
import { EnquiryStatusBadge } from './enquiry-status-badge';
|
||||
|
||||
function FieldItem({ label, value }: { label: string; value: string | null | undefined }) {
|
||||
@@ -40,6 +41,10 @@ interface EnquiryDetailProps {
|
||||
canAssign: boolean;
|
||||
canReassign: boolean;
|
||||
canViewRelatedQuotations: boolean;
|
||||
canViewOutcomeCommercialData: boolean;
|
||||
canMarkWon: boolean;
|
||||
canMarkLost: boolean;
|
||||
canReopen: boolean;
|
||||
canManageFollowups: {
|
||||
create: boolean;
|
||||
update: boolean;
|
||||
@@ -56,6 +61,10 @@ export function EnquiryDetail({
|
||||
canAssign,
|
||||
canReassign,
|
||||
canViewRelatedQuotations,
|
||||
canViewOutcomeCommercialData,
|
||||
canMarkWon,
|
||||
canMarkLost,
|
||||
canReopen,
|
||||
canManageFollowups
|
||||
}: EnquiryDetailProps) {
|
||||
const { data } = useSuspenseQuery(enquiryByIdOptions(enquiryId));
|
||||
@@ -374,6 +383,14 @@ export function EnquiryDetail({
|
||||
</div>
|
||||
|
||||
<div className='space-y-6'>
|
||||
<EnquiryOutcomeCard
|
||||
enquiryId={enquiryId}
|
||||
referenceData={referenceData}
|
||||
canViewCommercialData={canViewOutcomeCommercialData}
|
||||
canMarkWon={canMarkWon}
|
||||
canMarkLost={canMarkLost}
|
||||
canReopen={canReopen}
|
||||
/>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>ข้อมูลระบบ</CardTitle>
|
||||
|
||||
465
src/features/crm/enquiries/components/enquiry-outcome-card.tsx
Normal file
465
src/features/crm/enquiries/components/enquiry-outcome-card.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -73,5 +73,29 @@ export const enquiryFollowupSchema = z.object({
|
||||
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 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>;
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
crmCustomerContacts,
|
||||
crmCustomers,
|
||||
crmEnquiries,
|
||||
crmEnquiryAttachments,
|
||||
crmEnquiryCustomers,
|
||||
crmEnquiryFollowups,
|
||||
crmQuotations,
|
||||
@@ -12,13 +13,15 @@ import {
|
||||
import { db } from '@/lib/db';
|
||||
import { AuthError } from '@/lib/auth/session';
|
||||
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 { generateNextDocumentCode } from '@/features/foundation/document-sequence/service';
|
||||
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||
import { buildOrganizationStorageKey, getStorageProvider } from '@/features/foundation/storage/service';
|
||||
import type {
|
||||
EnquiryActivityRecord,
|
||||
EnquiryAssignableUserLookup,
|
||||
EnquiryAttachmentRecord,
|
||||
EnquiryBranchOption,
|
||||
EnquiryAssignmentMutationPayload,
|
||||
EnquiryCustomerRelationItem,
|
||||
@@ -26,12 +29,15 @@ import type {
|
||||
EnquiryFollowupMutationPayload,
|
||||
EnquiryFollowupRecord,
|
||||
EnquiryListItem,
|
||||
EnquiryMarkLostPayload,
|
||||
EnquiryMarkWonPayload,
|
||||
EnquiryMutationPayload,
|
||||
EnquiryOption,
|
||||
EnquiryProjectPartyListItem,
|
||||
EnquiryProjectPartyMutationPayload,
|
||||
EnquiryProjectPartyRecord,
|
||||
EnquiryPipelineStage,
|
||||
EnquiryReopenPayload,
|
||||
EnquiryRecord,
|
||||
EnquiryReferenceData
|
||||
} from '../api/types';
|
||||
@@ -40,6 +46,7 @@ import {
|
||||
PROJECT_PARTY_OPTION_CATEGORY,
|
||||
resolveProjectPartyRole
|
||||
} from '@/features/crm/shared/project-party';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
|
||||
const ENQUIRY_OPTION_CATEGORIES = {
|
||||
status: 'crm_enquiry_status',
|
||||
@@ -47,6 +54,7 @@ const ENQUIRY_OPTION_CATEGORIES = {
|
||||
priority: 'crm_priority',
|
||||
leadChannel: 'crm_lead_channel',
|
||||
followupType: 'crm_followup_type',
|
||||
lostReason: 'crm_lost_reason',
|
||||
projectPartyRole: PROJECT_PARTY_OPTION_CATEGORY
|
||||
} as const;
|
||||
|
||||
@@ -58,15 +66,22 @@ const SALES_CREATED_ENQUIRY_ROLES = new Set([
|
||||
'department_manager',
|
||||
'top_manager'
|
||||
]);
|
||||
const CLOSED_LOST_STATUS_CODES = new Set(['closed_lost', 'cancelled']);
|
||||
const CLOSED_WON_QUOTATION_STATUS_CODES = new Set(['accepted']);
|
||||
const CLOSED_LOST_QUOTATION_STATUS_CODES = new Set(['lost', 'rejected']);
|
||||
const PURCHASE_ORDER_ATTACHMENT_CATEGORY = 'purchase_order';
|
||||
const ALLOWED_PO_CONTENT_TYPES = new Set([
|
||||
'application/pdf',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
]);
|
||||
|
||||
export interface EnquiryAccessContext {
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
membershipRole: string;
|
||||
businessRole: string;
|
||||
permissions?: string[];
|
||||
branchScopeIds: string[];
|
||||
productTypeScopeIds: string[];
|
||||
ownershipScope: string;
|
||||
@@ -98,7 +113,13 @@ function mapBranch(
|
||||
|
||||
function mapEnquiryRecord(
|
||||
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 {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -125,6 +146,19 @@ function mapEnquiryRecord(
|
||||
isHotProject: row.isHotProject,
|
||||
isActive: row.isActive,
|
||||
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,
|
||||
assignedToName: options?.assignedToName ?? 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) {
|
||||
return context.membershipRole === 'admin' || context.ownershipScope !== 'own';
|
||||
}
|
||||
@@ -561,40 +627,16 @@ function derivePipelineStageFromRole(businessRole: string): EnquiryPipelineStage
|
||||
}
|
||||
|
||||
async function resolvePipelineStageForCreate(
|
||||
organizationId: string,
|
||||
businessRole: string,
|
||||
statusId: string
|
||||
): 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);
|
||||
}
|
||||
|
||||
async function resolvePipelineStageForUpdate(
|
||||
organizationId: string,
|
||||
current: typeof crmEnquiries.$inferSelect,
|
||||
payload: EnquiryMutationPayload
|
||||
): Promise<EnquiryPipelineStage> {
|
||||
const statusCode = await resolveOptionCodeById(
|
||||
organizationId,
|
||||
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 === 'closed_won' || current.pipelineStage === 'closed_lost') {
|
||||
return current.pipelineStage as EnquiryPipelineStage;
|
||||
}
|
||||
|
||||
if (current.pipelineStage === 'enquiry' || current.assignedToUserId) {
|
||||
@@ -614,6 +656,7 @@ export async function getEnquiryReferenceData(
|
||||
priorities,
|
||||
leadChannels,
|
||||
followupTypes,
|
||||
lostReasons,
|
||||
projectPartyRoles,
|
||||
customers,
|
||||
contacts,
|
||||
@@ -625,6 +668,7 @@ export async function getEnquiryReferenceData(
|
||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.priority, { organizationId }),
|
||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.leadChannel, { organizationId }),
|
||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.followupType, { organizationId }),
|
||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.lostReason, { organizationId }),
|
||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.projectPartyRole, { organizationId }),
|
||||
db
|
||||
.select({
|
||||
@@ -665,6 +709,7 @@ export async function getEnquiryReferenceData(
|
||||
priorities: priorities.map(mapOption),
|
||||
leadChannels: leadChannels.map(mapOption),
|
||||
followupTypes: followupTypes.map(mapOption),
|
||||
lostReasons: lostReasons.map(mapOption),
|
||||
projectPartyRoles: projectPartyRoles.map(mapOption),
|
||||
customers: customers.map((customer) => ({
|
||||
id: customer.id,
|
||||
@@ -844,20 +889,32 @@ export async function getEnquiryDetail(
|
||||
accessContext?: EnquiryAccessContext
|
||||
): Promise<EnquiryRecord> {
|
||||
const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
|
||||
const relatedUserIds = [enquiry.assignedToUserId, enquiry.assignedBy].filter(Boolean) as string[];
|
||||
const userRows = relatedUserIds.length
|
||||
? await db
|
||||
.select({ id: users.id, name: users.name })
|
||||
.from(users)
|
||||
.where(inArray(users.id, relatedUserIds))
|
||||
: [];
|
||||
const relatedUserIds = [
|
||||
enquiry.assignedToUserId,
|
||||
enquiry.assignedBy,
|
||||
enquiry.closedByUserId
|
||||
].filter(Boolean) as string[];
|
||||
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 lostReasonOption =
|
||||
lostReasonOptions.find((option) => option.id === enquiry.lostReason || option.code === enquiry.lostReason) ??
|
||||
null;
|
||||
|
||||
return mapEnquiryRecord(enquiry, {
|
||||
assignedToName: enquiry.assignedToUserId
|
||||
? (userMap.get(enquiry.assignedToUserId) ?? 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
|
||||
) {
|
||||
await validateEnquiryPayload(organizationId, payload, accessContext);
|
||||
const pipelineStage = await resolvePipelineStageForCreate(
|
||||
organizationId,
|
||||
accessContext.businessRole,
|
||||
payload.status
|
||||
);
|
||||
const pipelineStage = await resolvePipelineStageForCreate(accessContext.businessRole);
|
||||
|
||||
const documentCode = await generateNextDocumentCode({
|
||||
organizationId,
|
||||
@@ -1113,7 +1166,7 @@ export async function updateEnquiry(
|
||||
) {
|
||||
await validateEnquiryPayload(organizationId, payload, 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) => {
|
||||
const [updated] = await tx
|
||||
@@ -1370,6 +1423,296 @@ export async function softDeleteEnquiryFollowup(
|
||||
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(
|
||||
customerId: string,
|
||||
organizationId: string,
|
||||
@@ -1405,33 +1748,7 @@ export async function syncEnquiryPipelineStageFromQuotationStatus(
|
||||
organizationId: string,
|
||||
quotationStatusCode: string | null
|
||||
) {
|
||||
if (!quotationStatusCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
);
|
||||
void enquiryId;
|
||||
void organizationId;
|
||||
void quotationStatusCode;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from 'drizzle-orm';
|
||||
import {
|
||||
crmCustomers,
|
||||
crmEnquiries,
|
||||
crmEnquiryCustomers,
|
||||
crmQuotationCustomers,
|
||||
crmQuotations
|
||||
@@ -24,7 +25,13 @@ import {
|
||||
PROJECT_PARTY_OPTION_CATEGORY,
|
||||
resolveProjectPartyRole
|
||||
} 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';
|
||||
|
||||
@@ -378,3 +385,174 @@ export async function getRevenueByConsultant(
|
||||
) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -20,3 +20,28 @@ export interface RevenueAttributionSummary {
|
||||
quotationCount: number;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -54,6 +54,9 @@ export const PERMISSIONS = {
|
||||
crmEnquiryDelete: 'crm.enquiry.delete',
|
||||
crmEnquiryAssign: 'crm.enquiry.assign',
|
||||
crmEnquiryReassign: 'crm.enquiry.reassign',
|
||||
crmEnquiryMarkWon: 'crm.enquiry.mark_won',
|
||||
crmEnquiryMarkLost: 'crm.enquiry.mark_lost',
|
||||
crmEnquiryReopen: 'crm.enquiry.reopen',
|
||||
crmEnquiryFollowupRead: 'crm.enquiry.followup.read',
|
||||
crmEnquiryFollowupCreate: 'crm.enquiry.followup.create',
|
||||
crmEnquiryFollowupUpdate: 'crm.enquiry.followup.update',
|
||||
@@ -167,6 +170,8 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
|
||||
PERMISSIONS.crmEnquiryRead,
|
||||
PERMISSIONS.crmEnquiryCreate,
|
||||
PERMISSIONS.crmEnquiryUpdate,
|
||||
PERMISSIONS.crmEnquiryMarkWon,
|
||||
PERMISSIONS.crmEnquiryMarkLost,
|
||||
PERMISSIONS.crmEnquiryFollowupRead,
|
||||
PERMISSIONS.crmEnquiryFollowupCreate,
|
||||
PERMISSIONS.crmEnquiryFollowupUpdate,
|
||||
@@ -204,6 +209,8 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
|
||||
PERMISSIONS.crmContactRead,
|
||||
PERMISSIONS.crmEnquiryRead,
|
||||
PERMISSIONS.crmEnquiryUpdate,
|
||||
PERMISSIONS.crmEnquiryMarkWon,
|
||||
PERMISSIONS.crmEnquiryMarkLost,
|
||||
PERMISSIONS.crmEnquiryFollowupRead,
|
||||
PERMISSIONS.crmEnquiryFollowupCreate,
|
||||
PERMISSIONS.crmEnquiryFollowupUpdate,
|
||||
@@ -256,6 +263,9 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
|
||||
PERMISSIONS.crmEnquiryUpdate,
|
||||
PERMISSIONS.crmEnquiryAssign,
|
||||
PERMISSIONS.crmEnquiryReassign,
|
||||
PERMISSIONS.crmEnquiryMarkWon,
|
||||
PERMISSIONS.crmEnquiryMarkLost,
|
||||
PERMISSIONS.crmEnquiryReopen,
|
||||
PERMISSIONS.crmEnquiryFollowupRead,
|
||||
PERMISSIONS.crmEnquiryFollowupCreate,
|
||||
PERMISSIONS.crmEnquiryFollowupUpdate,
|
||||
@@ -302,6 +312,9 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
|
||||
PERMISSIONS.crmEnquiryRead,
|
||||
PERMISSIONS.crmEnquiryAssign,
|
||||
PERMISSIONS.crmEnquiryReassign,
|
||||
PERMISSIONS.crmEnquiryMarkWon,
|
||||
PERMISSIONS.crmEnquiryMarkLost,
|
||||
PERMISSIONS.crmEnquiryReopen,
|
||||
PERMISSIONS.crmEnquiryFollowupRead,
|
||||
PERMISSIONS.crmQuotationRead,
|
||||
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.',
|
||||
permissions: [
|
||||
PERMISSIONS.crmDashboardRead,
|
||||
PERMISSIONS.crmEnquiryMarkWon,
|
||||
PERMISSIONS.crmEnquiryMarkLost,
|
||||
PERMISSIONS.crmEnquiryReopen,
|
||||
PERMISSIONS.crmCustomerOwnerRead,
|
||||
PERMISSIONS.crmCustomerOwnerManage,
|
||||
PERMISSIONS.crmContactShareRead,
|
||||
@@ -466,6 +482,9 @@ export const CRM_PERMISSION_GROUPS: PermissionGroup[] = [
|
||||
{ key: PERMISSIONS.crmEnquiryDelete, label: 'Delete enquiries' },
|
||||
{ key: PERMISSIONS.crmEnquiryAssign, label: 'Assign 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.crmEnquiryFollowupCreate, label: 'Create enquiry follow-ups' },
|
||||
{ key: PERMISSIONS.crmEnquiryFollowupUpdate, label: 'Update enquiry follow-ups' },
|
||||
|
||||
Reference in New Issue
Block a user