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,
|
"when": 1782549600000,
|
||||||
"tag": "0015_customer_ownership_contact_shares",
|
"tag": "0015_customer_ownership_contact_shares",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 16,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1782795600000,
|
||||||
|
"tag": "0016_won_lost_lifecycle_governance",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 17,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1782121929212,
|
||||||
|
"tag": "0017_short_swordsman",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
710
plans/task-d.4.md
Normal file
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,
|
userId: session.user.id,
|
||||||
membershipRole: membership.role,
|
membershipRole: membership.role,
|
||||||
businessRole: membership.businessRole,
|
businessRole: membership.businessRole,
|
||||||
|
permissions: membership.permissions ?? [],
|
||||||
branchScopeIds: membership.branchScopeIds ?? [],
|
branchScopeIds: membership.branchScopeIds ?? [],
|
||||||
productTypeScopeIds: membership.productTypeScopeIds ?? [],
|
productTypeScopeIds: membership.productTypeScopeIds ?? [],
|
||||||
ownershipScope: membership.ownershipScope ?? 'organization',
|
ownershipScope: membership.ownershipScope ?? 'organization',
|
||||||
|
|||||||
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,
|
userId: session.user.id,
|
||||||
membershipRole: membership.role,
|
membershipRole: membership.role,
|
||||||
businessRole: membership.businessRole,
|
businessRole: membership.businessRole,
|
||||||
|
permissions: membership.permissions ?? [],
|
||||||
branchScopeIds: membership.branchScopeIds ?? [],
|
branchScopeIds: membership.branchScopeIds ?? [],
|
||||||
productTypeScopeIds: membership.productTypeScopeIds ?? [],
|
productTypeScopeIds: membership.productTypeScopeIds ?? [],
|
||||||
ownershipScope: membership.ownershipScope ?? 'organization',
|
ownershipScope: membership.ownershipScope ?? 'organization',
|
||||||
|
|||||||
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,
|
userId: session.user.id,
|
||||||
membershipRole: membership.role,
|
membershipRole: membership.role,
|
||||||
businessRole: membership.businessRole,
|
businessRole: membership.businessRole,
|
||||||
|
permissions: membership.permissions ?? [],
|
||||||
branchScopeIds: membership.branchScopeIds ?? [],
|
branchScopeIds: membership.branchScopeIds ?? [],
|
||||||
productTypeScopeIds: membership.productTypeScopeIds ?? [],
|
productTypeScopeIds: membership.productTypeScopeIds ?? [],
|
||||||
ownershipScope: membership.ownershipScope ?? 'organization',
|
ownershipScope: membership.ownershipScope ?? 'organization',
|
||||||
@@ -76,6 +77,7 @@ export async function PATCH(request: NextRequest, { params }: Params) {
|
|||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
membershipRole: membership.role,
|
membershipRole: membership.role,
|
||||||
businessRole: membership.businessRole,
|
businessRole: membership.businessRole,
|
||||||
|
permissions: membership.permissions ?? [],
|
||||||
branchScopeIds: membership.branchScopeIds ?? [],
|
branchScopeIds: membership.branchScopeIds ?? [],
|
||||||
productTypeScopeIds: membership.productTypeScopeIds ?? [],
|
productTypeScopeIds: membership.productTypeScopeIds ?? [],
|
||||||
ownershipScope: membership.ownershipScope ?? 'organization',
|
ownershipScope: membership.ownershipScope ?? 'organization',
|
||||||
@@ -131,6 +133,7 @@ export async function DELETE(_request: NextRequest, { params }: Params) {
|
|||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
membershipRole: membership.role,
|
membershipRole: membership.role,
|
||||||
businessRole: membership.businessRole,
|
businessRole: membership.businessRole,
|
||||||
|
permissions: membership.permissions ?? [],
|
||||||
branchScopeIds: membership.branchScopeIds ?? [],
|
branchScopeIds: membership.branchScopeIds ?? [],
|
||||||
productTypeScopeIds: membership.productTypeScopeIds ?? [],
|
productTypeScopeIds: membership.productTypeScopeIds ?? [],
|
||||||
ownershipScope: membership.ownershipScope ?? 'organization',
|
ownershipScope: membership.ownershipScope ?? 'organization',
|
||||||
|
|||||||
@@ -59,6 +59,26 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
|
|||||||
(!!session?.user?.activeOrganizationId &&
|
(!!session?.user?.activeOrganizationId &&
|
||||||
(session.user.activeMembershipRole === 'admin' ||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationRead)));
|
session.user.activePermissions.includes(PERMISSIONS.crmQuotationRead)));
|
||||||
|
const canViewOutcomeCommercialData =
|
||||||
|
session?.user?.systemRole === 'super_admin' ||
|
||||||
|
(!!session?.user?.activeOrganizationId &&
|
||||||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
|
session.user.activePermissions.includes(PERMISSIONS.crmQuotationPricingRead)));
|
||||||
|
const canMarkWon =
|
||||||
|
session?.user?.systemRole === 'super_admin' ||
|
||||||
|
(!!session?.user?.activeOrganizationId &&
|
||||||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
|
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryMarkWon)));
|
||||||
|
const canMarkLost =
|
||||||
|
session?.user?.systemRole === 'super_admin' ||
|
||||||
|
(!!session?.user?.activeOrganizationId &&
|
||||||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
|
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryMarkLost)));
|
||||||
|
const canReopen =
|
||||||
|
session?.user?.systemRole === 'super_admin' ||
|
||||||
|
(!!session?.user?.activeOrganizationId &&
|
||||||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
|
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryReopen)));
|
||||||
const queryClient = getQueryClient();
|
const queryClient = getQueryClient();
|
||||||
|
|
||||||
if (canRead) {
|
if (canRead) {
|
||||||
@@ -98,6 +118,10 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
|
|||||||
canAssign={canAssign}
|
canAssign={canAssign}
|
||||||
canReassign={canReassign}
|
canReassign={canReassign}
|
||||||
canViewRelatedQuotations={canReadQuotation}
|
canViewRelatedQuotations={canReadQuotation}
|
||||||
|
canViewOutcomeCommercialData={canViewOutcomeCommercialData}
|
||||||
|
canMarkWon={canMarkWon}
|
||||||
|
canMarkLost={canMarkLost}
|
||||||
|
canReopen={canReopen}
|
||||||
canManageFollowups={{
|
canManageFollowups={{
|
||||||
create: canFollowupCreate,
|
create: canFollowupCreate,
|
||||||
update: canFollowupUpdate,
|
update: canFollowupUpdate,
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export default async function LeadDetailRoute({ params }: PageProps) {
|
|||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
membershipRole: session.user.activeMembershipRole ?? 'user',
|
membershipRole: session.user.activeMembershipRole ?? 'user',
|
||||||
businessRole: session.user.activeBusinessRole ?? 'sales_support',
|
businessRole: session.user.activeBusinessRole ?? 'sales_support',
|
||||||
|
permissions: session.user.activePermissions ?? [],
|
||||||
branchScopeIds: session.user.activeBranchScopeIds ?? [],
|
branchScopeIds: session.user.activeBranchScopeIds ?? [],
|
||||||
productTypeScopeIds: session.user.activeProductTypeScopeIds ?? [],
|
productTypeScopeIds: session.user.activeProductTypeScopeIds ?? [],
|
||||||
ownershipScope: session.user.activeOwnershipScope ?? 'organization',
|
ownershipScope: session.user.activeOwnershipScope ?? 'organization',
|
||||||
@@ -91,6 +92,10 @@ export default async function LeadDetailRoute({ params }: PageProps) {
|
|||||||
canAssign={canAssign}
|
canAssign={canAssign}
|
||||||
canReassign={false}
|
canReassign={false}
|
||||||
canViewRelatedQuotations={false}
|
canViewRelatedQuotations={false}
|
||||||
|
canViewOutcomeCommercialData={false}
|
||||||
|
canMarkWon={false}
|
||||||
|
canMarkLost={false}
|
||||||
|
canReopen={false}
|
||||||
canManageFollowups={{
|
canManageFollowups={{
|
||||||
create: false,
|
create: false,
|
||||||
update: false,
|
update: false,
|
||||||
|
|||||||
@@ -317,6 +317,16 @@ export const crmEnquiries = pgTable(
|
|||||||
isHotProject: boolean('is_hot_project').default(false).notNull(),
|
isHotProject: boolean('is_hot_project').default(false).notNull(),
|
||||||
isActive: boolean('is_active').default(true).notNull(),
|
isActive: boolean('is_active').default(true).notNull(),
|
||||||
pipelineStage: text('pipeline_stage').default('lead').notNull(),
|
pipelineStage: text('pipeline_stage').default('lead').notNull(),
|
||||||
|
closedWonAt: timestamp('closed_won_at', { withTimezone: true }),
|
||||||
|
closedLostAt: timestamp('closed_lost_at', { withTimezone: true }),
|
||||||
|
closedByUserId: text('closed_by_user_id'),
|
||||||
|
poNumber: text('po_number'),
|
||||||
|
poDate: timestamp('po_date', { withTimezone: true }),
|
||||||
|
poAmount: doublePrecision('po_amount'),
|
||||||
|
poCurrency: text('po_currency'),
|
||||||
|
lostReason: text('lost_reason'),
|
||||||
|
lostCompetitor: text('lost_competitor'),
|
||||||
|
lostRemark: text('lost_remark'),
|
||||||
assignedToUserId: text('assigned_to_user_id'),
|
assignedToUserId: text('assigned_to_user_id'),
|
||||||
assignedAt: timestamp('assigned_at', { withTimezone: true }),
|
assignedAt: timestamp('assigned_at', { withTimezone: true }),
|
||||||
assignedBy: text('assigned_by'),
|
assignedBy: text('assigned_by'),
|
||||||
@@ -365,6 +375,25 @@ export const crmEnquiryCustomers = pgTable('crm_enquiry_customers', {
|
|||||||
deletedAt: timestamp('deleted_at', { withTimezone: true })
|
deletedAt: timestamp('deleted_at', { withTimezone: true })
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const crmEnquiryAttachments = pgTable('crm_enquiry_attachments', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
organizationId: text('organization_id').notNull(),
|
||||||
|
enquiryId: text('enquiry_id').notNull(),
|
||||||
|
category: text('category').notNull(),
|
||||||
|
fileName: text('file_name').notNull(),
|
||||||
|
originalFileName: text('original_file_name').notNull(),
|
||||||
|
storageProvider: text('storage_provider').notNull(),
|
||||||
|
storageKey: text('storage_key').notNull(),
|
||||||
|
fileSize: integer('file_size'),
|
||||||
|
fileType: text('file_type'),
|
||||||
|
description: text('description'),
|
||||||
|
uploadedAt: timestamp('uploaded_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
uploadedBy: text('uploaded_by').notNull(),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
deletedAt: timestamp('deleted_at', { withTimezone: true })
|
||||||
|
});
|
||||||
|
|
||||||
export const crmQuotations = pgTable(
|
export const crmQuotations = pgTable(
|
||||||
'crm_quotations',
|
'crm_quotations',
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -285,6 +285,36 @@ const FOUNDATION_OPTIONS = {
|
|||||||
{ code: 'email', label: 'Email', value: 'email', sortOrder: 4 },
|
{ code: 'email', label: 'Email', value: 'email', sortOrder: 4 },
|
||||||
{ code: 'line', label: 'LINE', value: 'line', sortOrder: 5 }
|
{ code: 'line', label: 'LINE', value: 'line', sortOrder: 5 }
|
||||||
],
|
],
|
||||||
|
crm_lost_reason: [
|
||||||
|
{ code: 'competitor_won', label: 'Competitor Won', value: 'competitor_won', sortOrder: 1 },
|
||||||
|
{
|
||||||
|
code: 'budget_not_approved',
|
||||||
|
label: 'Budget Not Approved',
|
||||||
|
value: 'budget_not_approved',
|
||||||
|
sortOrder: 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'project_cancelled',
|
||||||
|
label: 'Project Cancelled',
|
||||||
|
value: 'project_cancelled',
|
||||||
|
sortOrder: 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'customer_no_response',
|
||||||
|
label: 'Customer No Response',
|
||||||
|
value: 'customer_no_response',
|
||||||
|
sortOrder: 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'technical_requirement_mismatch',
|
||||||
|
label: 'Technical Requirement Mismatch',
|
||||||
|
value: 'technical_requirement_mismatch',
|
||||||
|
sortOrder: 5
|
||||||
|
},
|
||||||
|
{ code: 'price_too_high', label: 'Price Too High', value: 'price_too_high', sortOrder: 6 },
|
||||||
|
{ code: 'timeline_not_fit', label: 'Timeline Not Fit', value: 'timeline_not_fit', sortOrder: 7 },
|
||||||
|
{ code: 'other', label: 'Other', value: 'other', sortOrder: 8 }
|
||||||
|
],
|
||||||
crm_job_title: [
|
crm_job_title: [
|
||||||
{ code: 'sales', label: 'Sales Engineer', value: 'sales', sortOrder: 1 },
|
{ code: 'sales', label: 'Sales Engineer', value: 'sales', sortOrder: 1 },
|
||||||
{ code: 'sales_support', label: 'Sales Support', value: 'sales_support', sortOrder: 2 },
|
{ code: 'sales_support', label: 'Sales Support', value: 'sales_support', sortOrder: 2 },
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export interface CrmDashboardSummary {
|
|||||||
activeEnquiries: number;
|
activeEnquiries: number;
|
||||||
wonCount: number;
|
wonCount: number;
|
||||||
lostCount: number;
|
lostCount: number;
|
||||||
|
winRate: number;
|
||||||
hotEnquiries: number;
|
hotEnquiries: number;
|
||||||
enquiryValue: number;
|
enquiryValue: number;
|
||||||
};
|
};
|
||||||
@@ -150,6 +151,11 @@ export interface CrmDashboardResponse {
|
|||||||
myPendingApprovals: CrmDashboardApprovalRow[];
|
myPendingApprovals: CrmDashboardApprovalRow[];
|
||||||
};
|
};
|
||||||
hotProjects: CrmDashboardHotProjectRow[];
|
hotProjects: CrmDashboardHotProjectRow[];
|
||||||
|
outcomeAnalytics: {
|
||||||
|
winRate: number;
|
||||||
|
lostByReason: Array<{ key: string; label: string; count: number; revenue: number }>;
|
||||||
|
lostByCompetitor: Array<{ key: string; label: string; count: number; revenue: number }>;
|
||||||
|
};
|
||||||
visibility: {
|
visibility: {
|
||||||
canViewCommercialData: boolean;
|
canViewCommercialData: boolean;
|
||||||
canViewApprovalData: boolean;
|
canViewApprovalData: boolean;
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export function DashboardSummaryCards({
|
|||||||
{ label: 'จำนวนโอกาสขาย', value: summary.enquiry.enquiryCount },
|
{ label: 'จำนวนโอกาสขาย', value: summary.enquiry.enquiryCount },
|
||||||
{ label: 'โอกาสขายที่กำลังดำเนินการ', value: summary.enquiry.activeEnquiries },
|
{ label: 'โอกาสขายที่กำลังดำเนินการ', value: summary.enquiry.activeEnquiries },
|
||||||
{ label: 'จำนวนงานที่ชนะ', value: summary.enquiry.wonCount },
|
{ label: 'จำนวนงานที่ชนะ', value: summary.enquiry.wonCount },
|
||||||
{ label: 'จำนวนงานที่แพ้', value: summary.enquiry.lostCount }
|
{ label: 'Win Rate', value: summary.enquiry.winRate, suffix: '%' }
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
{canViewCommercialData ? (
|
{canViewCommercialData ? (
|
||||||
|
|||||||
@@ -30,7 +30,12 @@ import {
|
|||||||
getRevenueByBillingCustomer,
|
getRevenueByBillingCustomer,
|
||||||
getRevenueByConsultant,
|
getRevenueByConsultant,
|
||||||
getRevenueByContractor,
|
getRevenueByContractor,
|
||||||
getRevenueByEndCustomer
|
getRevenueByEndCustomer,
|
||||||
|
getLostByCompetitor,
|
||||||
|
getLostByReason,
|
||||||
|
getLostRevenue,
|
||||||
|
getWinRate,
|
||||||
|
getWonRevenue
|
||||||
} from '@/features/crm/reporting/server/service';
|
} from '@/features/crm/reporting/server/service';
|
||||||
import { getPipelineStageThaiLabel, getProjectPartyRoleThaiLabel } from '@/features/crm/shared/terminology';
|
import { getPipelineStageThaiLabel, getProjectPartyRoleThaiLabel } from '@/features/crm/shared/terminology';
|
||||||
import type {
|
import type {
|
||||||
@@ -281,7 +286,8 @@ async function loadScopedRows(
|
|||||||
function buildSummary(
|
function buildSummary(
|
||||||
scopedEnquiries: Array<typeof crmEnquiries.$inferSelect>,
|
scopedEnquiries: Array<typeof crmEnquiries.$inferSelect>,
|
||||||
scopedQuotations: Array<typeof crmQuotations.$inferSelect>,
|
scopedQuotations: Array<typeof crmQuotations.$inferSelect>,
|
||||||
statusMaps: StatusMaps
|
statusMaps: StatusMaps,
|
||||||
|
outcomeMetrics: { wonValue: number; lostValue: number; winRate: number }
|
||||||
): CrmDashboardSummary {
|
): CrmDashboardSummary {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const leadRows = scopedEnquiries.filter((row) => isPipelineStage(row, 'lead'));
|
const leadRows = scopedEnquiries.filter((row) => isPipelineStage(row, 'lead'));
|
||||||
@@ -304,6 +310,7 @@ function buildSummary(
|
|||||||
activeEnquiries: enquiryRows.length,
|
activeEnquiries: enquiryRows.length,
|
||||||
wonCount: wonRows.length,
|
wonCount: wonRows.length,
|
||||||
lostCount: lostRows.length,
|
lostCount: lostRows.length,
|
||||||
|
winRate: outcomeMetrics.winRate,
|
||||||
hotEnquiries: enquiryRows.filter((row) => row.isHotProject).length,
|
hotEnquiries: enquiryRows.filter((row) => row.isHotProject).length,
|
||||||
enquiryValue: round2(enquiryRows.reduce((sum, row) => sum + (row.estimatedValue ?? 0), 0))
|
enquiryValue: round2(enquiryRows.reduce((sum, row) => sum + (row.estimatedValue ?? 0), 0))
|
||||||
},
|
},
|
||||||
@@ -319,18 +326,8 @@ function buildSummary(
|
|||||||
.filter((row) => (statusMaps.quotationStatusById.get(row.status) ?? '') !== 'cancelled')
|
.filter((row) => (statusMaps.quotationStatusById.get(row.status) ?? '') !== 'cancelled')
|
||||||
.reduce((sum, row) => sum + row.totalAmount, 0)
|
.reduce((sum, row) => sum + row.totalAmount, 0)
|
||||||
),
|
),
|
||||||
wonValue: round2(
|
wonValue: round2(outcomeMetrics.wonValue),
|
||||||
scopedQuotations
|
lostValue: round2(outcomeMetrics.lostValue)
|
||||||
.filter((row) => (statusMaps.quotationStatusById.get(row.status) ?? '') === 'accepted')
|
|
||||||
.reduce((sum, row) => sum + row.totalAmount, 0)
|
|
||||||
),
|
|
||||||
lostValue: round2(
|
|
||||||
scopedQuotations
|
|
||||||
.filter((row) =>
|
|
||||||
['lost', 'rejected'].includes(statusMaps.quotationStatusById.get(row.status) ?? '')
|
|
||||||
)
|
|
||||||
.reduce((sum, row) => sum + row.totalAmount, 0)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -387,9 +384,7 @@ function buildFunnel(
|
|||||||
key: 'closed_won',
|
key: 'closed_won',
|
||||||
label: getPipelineStageThaiLabel('closed_won'),
|
label: getPipelineStageThaiLabel('closed_won'),
|
||||||
count: summary.enquiry.wonCount,
|
count: summary.enquiry.wonCount,
|
||||||
value: scopedQuotations
|
value: summary.revenue.wonValue
|
||||||
.filter((row) => (statusMaps.quotationStatusById.get(row.status) ?? '') === 'accepted')
|
|
||||||
.reduce((sum, row) => sum + row.totalAmount, 0)
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -463,7 +458,8 @@ async function buildRevenueAnalytics(
|
|||||||
async function buildSalesRanking(
|
async function buildSalesRanking(
|
||||||
scopedEnquiries: Array<typeof crmEnquiries.$inferSelect>,
|
scopedEnquiries: Array<typeof crmEnquiries.$inferSelect>,
|
||||||
scopedQuotations: Array<typeof crmQuotations.$inferSelect>,
|
scopedQuotations: Array<typeof crmQuotations.$inferSelect>,
|
||||||
statusMaps: StatusMaps
|
statusMaps: StatusMaps,
|
||||||
|
wonRevenueRows: Array<{ assignedToUserId: string | null; revenue: number }>
|
||||||
): Promise<CrmDashboardSalesRankingRow[]> {
|
): Promise<CrmDashboardSalesRankingRow[]> {
|
||||||
const userIds = [
|
const userIds = [
|
||||||
...new Set(
|
...new Set(
|
||||||
@@ -529,11 +525,29 @@ async function buildSalesRanking(
|
|||||||
current.approvedQuotations += 1;
|
current.approvedQuotations += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (statusCode === 'accepted') {
|
rankingMap.set(row.salesmanId, current);
|
||||||
current.wonRevenue += row.totalAmount;
|
}
|
||||||
|
|
||||||
|
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()]
|
return [...rankingMap.values()]
|
||||||
@@ -776,12 +790,33 @@ export async function getCrmDashboardData(
|
|||||||
const filters = normalizeDashboardFilters(rawFilters);
|
const filters = normalizeDashboardFilters(rawFilters);
|
||||||
const allowCommercialData = canViewCommercialData(context);
|
const allowCommercialData = canViewCommercialData(context);
|
||||||
const allowApprovalData = canViewApprovalData(context);
|
const allowApprovalData = canViewApprovalData(context);
|
||||||
|
const outcomeFilters = {
|
||||||
|
dateFrom: filters.dateFrom,
|
||||||
|
dateTo: filters.dateTo,
|
||||||
|
branch: filters.branch,
|
||||||
|
salesman: filters.salesman,
|
||||||
|
productType: filters.productType
|
||||||
|
};
|
||||||
const [referenceData, statusMaps, scoped] = await Promise.all([
|
const [referenceData, statusMaps, scoped] = await Promise.all([
|
||||||
getReferenceData(context.organizationId),
|
getReferenceData(context.organizationId),
|
||||||
getStatusMaps(context.organizationId),
|
getStatusMaps(context.organizationId),
|
||||||
loadScopedRows(context, filters)
|
loadScopedRows(context, filters)
|
||||||
]);
|
]);
|
||||||
const summary = buildSummary(scoped.enquiries, scoped.quotations, statusMaps);
|
const [wonRevenueRows, lostRevenueRows, winRate, lostByReasonRows, lostByCompetitorRows] =
|
||||||
|
allowCommercialData
|
||||||
|
? await Promise.all([
|
||||||
|
getWonRevenue(context.organizationId, outcomeFilters),
|
||||||
|
getLostRevenue(context.organizationId, outcomeFilters),
|
||||||
|
getWinRate(context.organizationId, outcomeFilters),
|
||||||
|
getLostByReason(context.organizationId, outcomeFilters),
|
||||||
|
getLostByCompetitor(context.organizationId, outcomeFilters)
|
||||||
|
])
|
||||||
|
: [[], [], 0, [], []];
|
||||||
|
const summary = buildSummary(scoped.enquiries, scoped.quotations, statusMaps, {
|
||||||
|
wonValue: wonRevenueRows.reduce((sum, row) => sum + row.revenue, 0),
|
||||||
|
lostValue: lostRevenueRows.reduce((sum, row) => sum + row.revenue, 0),
|
||||||
|
winRate
|
||||||
|
});
|
||||||
const [revenueAnalytics, salesRanking, followups, approvals, hotProjects] = await Promise.all([
|
const [revenueAnalytics, salesRanking, followups, approvals, hotProjects] = await Promise.all([
|
||||||
allowCommercialData
|
allowCommercialData
|
||||||
? buildRevenueAnalytics(context.organizationId, filters)
|
? buildRevenueAnalytics(context.organizationId, filters)
|
||||||
@@ -792,7 +827,7 @@ export async function getCrmDashboardData(
|
|||||||
topConsultants: []
|
topConsultants: []
|
||||||
}),
|
}),
|
||||||
allowCommercialData
|
allowCommercialData
|
||||||
? buildSalesRanking(scoped.enquiries, scoped.quotations, statusMaps)
|
? buildSalesRanking(scoped.enquiries, scoped.quotations, statusMaps, wonRevenueRows)
|
||||||
: Promise.resolve([]),
|
: Promise.resolve([]),
|
||||||
buildFollowups(context, filters),
|
buildFollowups(context, filters),
|
||||||
allowApprovalData
|
allowApprovalData
|
||||||
@@ -825,6 +860,11 @@ export async function getCrmDashboardData(
|
|||||||
followups,
|
followups,
|
||||||
approvals,
|
approvals,
|
||||||
hotProjects,
|
hotProjects,
|
||||||
|
outcomeAnalytics: {
|
||||||
|
winRate,
|
||||||
|
lostByReason: lostByReasonRows,
|
||||||
|
lostByCompetitor: lostByCompetitorRows
|
||||||
|
},
|
||||||
visibility: {
|
visibility: {
|
||||||
canViewCommercialData: allowCommercialData,
|
canViewCommercialData: allowCommercialData,
|
||||||
canViewApprovalData: allowApprovalData
|
canViewApprovalData: allowApprovalData
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import {
|
|||||||
createEnquiryFollowup,
|
createEnquiryFollowup,
|
||||||
deleteEnquiry,
|
deleteEnquiry,
|
||||||
deleteEnquiryFollowup,
|
deleteEnquiryFollowup,
|
||||||
|
markEnquiryLost,
|
||||||
|
markEnquiryWon,
|
||||||
reassignEnquiry,
|
reassignEnquiry,
|
||||||
|
reopenEnquiry,
|
||||||
updateEnquiry,
|
updateEnquiry,
|
||||||
updateEnquiryFollowup
|
updateEnquiryFollowup
|
||||||
} from './service';
|
} from './service';
|
||||||
@@ -14,6 +17,8 @@ import { enquiryKeys } from './queries';
|
|||||||
import type {
|
import type {
|
||||||
EnquiryAssignmentMutationPayload,
|
EnquiryAssignmentMutationPayload,
|
||||||
EnquiryFollowupMutationPayload,
|
EnquiryFollowupMutationPayload,
|
||||||
|
EnquiryMarkLostPayload,
|
||||||
|
EnquiryMarkWonPayload,
|
||||||
EnquiryMutationPayload
|
EnquiryMutationPayload
|
||||||
} from './types';
|
} from './types';
|
||||||
import { crmDashboardKeys } from '@/features/crm/dashboard/api/queries';
|
import { crmDashboardKeys } from '@/features/crm/dashboard/api/queries';
|
||||||
@@ -38,11 +43,16 @@ async function invalidateEnquiryFollowups(id: string) {
|
|||||||
await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.followups(id) });
|
await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.followups(id) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function invalidateEnquiryAttachments(id: string) {
|
||||||
|
await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.attachments(id) });
|
||||||
|
}
|
||||||
|
|
||||||
export async function invalidateEnquiryMutationQueries(id: string) {
|
export async function invalidateEnquiryMutationQueries(id: string) {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
invalidateEnquiryLists(),
|
invalidateEnquiryLists(),
|
||||||
invalidateEnquiryDetail(id),
|
invalidateEnquiryDetail(id),
|
||||||
invalidateEnquiryProjectParties(id),
|
invalidateEnquiryProjectParties(id),
|
||||||
|
invalidateEnquiryAttachments(id),
|
||||||
invalidateDashboardQueries()
|
invalidateDashboardQueries()
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -148,3 +158,33 @@ export const deleteEnquiryFollowupMutation = mutationOptions({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const markEnquiryWonMutation = mutationOptions({
|
||||||
|
mutationFn: ({ id, values }: { id: string; values: EnquiryMarkWonPayload }) =>
|
||||||
|
markEnquiryWon(id, values),
|
||||||
|
onSettled: async (_data, error, variables) => {
|
||||||
|
if (!error) {
|
||||||
|
await invalidateEnquiryMutationQueries(variables.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const markEnquiryLostMutation = mutationOptions({
|
||||||
|
mutationFn: ({ id, values }: { id: string; values: EnquiryMarkLostPayload }) =>
|
||||||
|
markEnquiryLost(id, values),
|
||||||
|
onSettled: async (_data, error, variables) => {
|
||||||
|
if (!error) {
|
||||||
|
await invalidateEnquiryMutationQueries(variables.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const reopenEnquiryMutation = mutationOptions({
|
||||||
|
mutationFn: ({ id, reason }: { id: string; reason: string }) =>
|
||||||
|
reopenEnquiry(id, { reopenReason: reason }),
|
||||||
|
onSettled: async (_data, error, variables) => {
|
||||||
|
if (!error) {
|
||||||
|
await invalidateEnquiryMutationQueries(variables.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { queryOptions } from '@tanstack/react-query';
|
import { queryOptions } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
|
getEnquiryAttachments,
|
||||||
getEnquiries,
|
getEnquiries,
|
||||||
getEnquiryById,
|
getEnquiryById,
|
||||||
getEnquiryFollowups,
|
getEnquiryFollowups,
|
||||||
@@ -16,7 +17,9 @@ export const enquiryKeys = {
|
|||||||
projectPartiesRoot: () => [...enquiryKeys.all, 'project-parties'] as const,
|
projectPartiesRoot: () => [...enquiryKeys.all, 'project-parties'] as const,
|
||||||
projectParties: (id: string) => [...enquiryKeys.projectPartiesRoot(), id] as const,
|
projectParties: (id: string) => [...enquiryKeys.projectPartiesRoot(), id] as const,
|
||||||
followupsRoot: () => [...enquiryKeys.all, 'followups'] as const,
|
followupsRoot: () => [...enquiryKeys.all, 'followups'] as const,
|
||||||
followups: (id: string) => [...enquiryKeys.followupsRoot(), id] as const
|
followups: (id: string) => [...enquiryKeys.followupsRoot(), id] as const,
|
||||||
|
attachmentsRoot: () => [...enquiryKeys.all, 'attachments'] as const,
|
||||||
|
attachments: (id: string) => [...enquiryKeys.attachmentsRoot(), id] as const
|
||||||
};
|
};
|
||||||
|
|
||||||
export const enquiriesQueryOptions = (filters: EnquiryFilters) =>
|
export const enquiriesQueryOptions = (filters: EnquiryFilters) =>
|
||||||
@@ -42,3 +45,9 @@ export const enquiryFollowupsOptions = (id: string) =>
|
|||||||
queryKey: enquiryKeys.followups(id),
|
queryKey: enquiryKeys.followups(id),
|
||||||
queryFn: () => getEnquiryFollowups(id)
|
queryFn: () => getEnquiryFollowups(id)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const enquiryAttachmentsOptions = (id: string) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: enquiryKeys.attachments(id),
|
||||||
|
queryFn: () => getEnquiryAttachments(id)
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import type {
|
import type {
|
||||||
|
EnquiryAttachmentsResponse,
|
||||||
EnquiryAssignmentMutationPayload,
|
EnquiryAssignmentMutationPayload,
|
||||||
EnquiryDetailResponse,
|
EnquiryDetailResponse,
|
||||||
EnquiryFilters,
|
EnquiryFilters,
|
||||||
EnquiryFollowupMutationPayload,
|
EnquiryFollowupMutationPayload,
|
||||||
EnquiryFollowupsResponse,
|
EnquiryFollowupsResponse,
|
||||||
EnquiryListResponse,
|
EnquiryListResponse,
|
||||||
|
EnquiryMarkLostPayload,
|
||||||
|
EnquiryMarkWonPayload,
|
||||||
EnquiryMutationPayload,
|
EnquiryMutationPayload,
|
||||||
EnquiryProjectPartiesResponse,
|
EnquiryProjectPartiesResponse,
|
||||||
|
EnquiryReopenPayload,
|
||||||
MutationSuccessResponse
|
MutationSuccessResponse
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
@@ -76,6 +80,10 @@ export async function getEnquiryFollowups(id: string): Promise<EnquiryFollowupsR
|
|||||||
return apiClient<EnquiryFollowupsResponse>(`/crm/enquiries/${id}/followups`);
|
return apiClient<EnquiryFollowupsResponse>(`/crm/enquiries/${id}/followups`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getEnquiryAttachments(id: string): Promise<EnquiryAttachmentsResponse> {
|
||||||
|
return apiClient<EnquiryAttachmentsResponse>(`/crm/enquiries/${id}/po-attachments`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function createEnquiryFollowup(
|
export async function createEnquiryFollowup(
|
||||||
enquiryId: string,
|
enquiryId: string,
|
||||||
data: EnquiryFollowupMutationPayload
|
data: EnquiryFollowupMutationPayload
|
||||||
@@ -102,3 +110,24 @@ export async function deleteEnquiryFollowup(enquiryId: string, followupId: strin
|
|||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function markEnquiryWon(id: string, data: EnquiryMarkWonPayload) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`/crm/enquiries/${id}/mark-won`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markEnquiryLost(id: string, data: EnquiryMarkLostPayload) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`/crm/enquiries/${id}/mark-lost`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reopenEnquiry(id: string, data: EnquiryReopenPayload) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`/crm/enquiries/${id}/reopen`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -56,6 +56,25 @@ export interface EnquiryAssignableUserLookup {
|
|||||||
businessRole: string;
|
businessRole: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EnquiryAttachmentRecord {
|
||||||
|
id: string;
|
||||||
|
organizationId: string;
|
||||||
|
enquiryId: string;
|
||||||
|
category: string;
|
||||||
|
fileName: string;
|
||||||
|
originalFileName: string;
|
||||||
|
storageProvider: string;
|
||||||
|
storageKey: string;
|
||||||
|
fileSize: number | null;
|
||||||
|
fileType: string | null;
|
||||||
|
description: string | null;
|
||||||
|
uploadedAt: string;
|
||||||
|
uploadedBy: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export type EnquiryPipelineStage = 'lead' | 'enquiry' | 'closed_won' | 'closed_lost';
|
export type EnquiryPipelineStage = 'lead' | 'enquiry' | 'closed_won' | 'closed_lost';
|
||||||
|
|
||||||
export interface EnquiryRecord {
|
export interface EnquiryRecord {
|
||||||
@@ -83,6 +102,19 @@ export interface EnquiryRecord {
|
|||||||
isHotProject: boolean;
|
isHotProject: boolean;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
pipelineStage: EnquiryPipelineStage;
|
pipelineStage: EnquiryPipelineStage;
|
||||||
|
closedWonAt: string | null;
|
||||||
|
closedLostAt: string | null;
|
||||||
|
closedByUserId: string | null;
|
||||||
|
closedByName: string | null;
|
||||||
|
poNumber: string | null;
|
||||||
|
poDate: string | null;
|
||||||
|
poAmount: number | null;
|
||||||
|
poCurrency: string | null;
|
||||||
|
lostReason: string | null;
|
||||||
|
lostReasonCode: string | null;
|
||||||
|
lostReasonLabel: string | null;
|
||||||
|
lostCompetitor: string | null;
|
||||||
|
lostRemark: string | null;
|
||||||
assignedToUserId: string | null;
|
assignedToUserId: string | null;
|
||||||
assignedToName: string | null;
|
assignedToName: string | null;
|
||||||
assignedAt: string | null;
|
assignedAt: string | null;
|
||||||
@@ -140,6 +172,7 @@ export interface EnquiryReferenceData {
|
|||||||
priorities: EnquiryOption[];
|
priorities: EnquiryOption[];
|
||||||
leadChannels: EnquiryOption[];
|
leadChannels: EnquiryOption[];
|
||||||
followupTypes: EnquiryOption[];
|
followupTypes: EnquiryOption[];
|
||||||
|
lostReasons: EnquiryOption[];
|
||||||
projectPartyRoles: EnquiryOption[];
|
projectPartyRoles: EnquiryOption[];
|
||||||
customers: EnquiryCustomerLookup[];
|
customers: EnquiryCustomerLookup[];
|
||||||
contacts: EnquiryContactLookup[];
|
contacts: EnquiryContactLookup[];
|
||||||
@@ -191,6 +224,13 @@ export interface EnquiryFollowupsResponse {
|
|||||||
items: EnquiryFollowupRecord[];
|
items: EnquiryFollowupRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EnquiryAttachmentsResponse {
|
||||||
|
success: boolean;
|
||||||
|
time: string;
|
||||||
|
message: string;
|
||||||
|
items: EnquiryAttachmentRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface EnquiryProjectPartyMutationPayload {
|
export interface EnquiryProjectPartyMutationPayload {
|
||||||
customerId: string;
|
customerId: string;
|
||||||
role: string;
|
role: string;
|
||||||
@@ -237,6 +277,24 @@ export interface EnquiryAssignmentMutationPayload {
|
|||||||
remark?: string;
|
remark?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EnquiryMarkWonPayload {
|
||||||
|
poNumber: string;
|
||||||
|
poDate: string;
|
||||||
|
poAmount?: number | null;
|
||||||
|
poCurrency?: string | null;
|
||||||
|
remark?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnquiryMarkLostPayload {
|
||||||
|
lostReason: string;
|
||||||
|
lostCompetitor?: string | null;
|
||||||
|
lostRemark?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnquiryReopenPayload {
|
||||||
|
reopenReason: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface EnquiryCustomerRelationItem {
|
export interface EnquiryCustomerRelationItem {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type { EnquiryRecord, EnquiryReferenceData } from '../api/types';
|
|||||||
import { EnquiryAssignmentDialog } from './enquiry-assignment-dialog';
|
import { EnquiryAssignmentDialog } from './enquiry-assignment-dialog';
|
||||||
import { EnquiryFollowupsTab } from './enquiry-followups-tab';
|
import { EnquiryFollowupsTab } from './enquiry-followups-tab';
|
||||||
import { EnquiryFormSheet } from './enquiry-form-sheet';
|
import { EnquiryFormSheet } from './enquiry-form-sheet';
|
||||||
|
import { EnquiryOutcomeCard } from './enquiry-outcome-card';
|
||||||
import { EnquiryStatusBadge } from './enquiry-status-badge';
|
import { EnquiryStatusBadge } from './enquiry-status-badge';
|
||||||
|
|
||||||
function FieldItem({ label, value }: { label: string; value: string | null | undefined }) {
|
function FieldItem({ label, value }: { label: string; value: string | null | undefined }) {
|
||||||
@@ -40,6 +41,10 @@ interface EnquiryDetailProps {
|
|||||||
canAssign: boolean;
|
canAssign: boolean;
|
||||||
canReassign: boolean;
|
canReassign: boolean;
|
||||||
canViewRelatedQuotations: boolean;
|
canViewRelatedQuotations: boolean;
|
||||||
|
canViewOutcomeCommercialData: boolean;
|
||||||
|
canMarkWon: boolean;
|
||||||
|
canMarkLost: boolean;
|
||||||
|
canReopen: boolean;
|
||||||
canManageFollowups: {
|
canManageFollowups: {
|
||||||
create: boolean;
|
create: boolean;
|
||||||
update: boolean;
|
update: boolean;
|
||||||
@@ -56,6 +61,10 @@ export function EnquiryDetail({
|
|||||||
canAssign,
|
canAssign,
|
||||||
canReassign,
|
canReassign,
|
||||||
canViewRelatedQuotations,
|
canViewRelatedQuotations,
|
||||||
|
canViewOutcomeCommercialData,
|
||||||
|
canMarkWon,
|
||||||
|
canMarkLost,
|
||||||
|
canReopen,
|
||||||
canManageFollowups
|
canManageFollowups
|
||||||
}: EnquiryDetailProps) {
|
}: EnquiryDetailProps) {
|
||||||
const { data } = useSuspenseQuery(enquiryByIdOptions(enquiryId));
|
const { data } = useSuspenseQuery(enquiryByIdOptions(enquiryId));
|
||||||
@@ -374,6 +383,14 @@ export function EnquiryDetail({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='space-y-6'>
|
<div className='space-y-6'>
|
||||||
|
<EnquiryOutcomeCard
|
||||||
|
enquiryId={enquiryId}
|
||||||
|
referenceData={referenceData}
|
||||||
|
canViewCommercialData={canViewOutcomeCommercialData}
|
||||||
|
canMarkWon={canMarkWon}
|
||||||
|
canMarkLost={canMarkLost}
|
||||||
|
canReopen={canReopen}
|
||||||
|
/>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>ข้อมูลระบบ</CardTitle>
|
<CardTitle>ข้อมูลระบบ</CardTitle>
|
||||||
|
|||||||
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()
|
nextAction: z.string().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const enquiryMarkWonSchema = z.object({
|
||||||
|
poNumber: z.string().min(1, 'PO number is required'),
|
||||||
|
poDate: z.string().min(1, 'PO date is required'),
|
||||||
|
poAmount: coerceOptionalNumber('PO amount must be a number').refine(
|
||||||
|
(value) => value === undefined || value >= 0,
|
||||||
|
'PO amount must be 0 or greater'
|
||||||
|
),
|
||||||
|
poCurrency: z.string().optional().nullable(),
|
||||||
|
remark: z.string().optional().nullable()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const enquiryMarkLostSchema = z.object({
|
||||||
|
lostReason: z.string().min(1, 'Lost reason is required'),
|
||||||
|
lostCompetitor: z.string().optional().nullable(),
|
||||||
|
lostRemark: z.string().optional().nullable()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const enquiryReopenSchema = z.object({
|
||||||
|
reopenReason: z.string().min(3, 'Reopen reason is required')
|
||||||
|
});
|
||||||
|
|
||||||
export type EnquiryFormValues = z.input<typeof enquirySchema>;
|
export type EnquiryFormValues = z.input<typeof enquirySchema>;
|
||||||
export type EnquiryFollowupFormValues = z.input<typeof enquiryFollowupSchema>;
|
export type EnquiryFollowupFormValues = z.input<typeof enquiryFollowupSchema>;
|
||||||
|
export type EnquiryMarkWonFormValues = z.input<typeof enquiryMarkWonSchema>;
|
||||||
|
export type EnquiryMarkLostFormValues = z.input<typeof enquiryMarkLostSchema>;
|
||||||
|
export type EnquiryReopenFormValues = z.input<typeof enquiryReopenSchema>;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
crmCustomerContacts,
|
crmCustomerContacts,
|
||||||
crmCustomers,
|
crmCustomers,
|
||||||
crmEnquiries,
|
crmEnquiries,
|
||||||
|
crmEnquiryAttachments,
|
||||||
crmEnquiryCustomers,
|
crmEnquiryCustomers,
|
||||||
crmEnquiryFollowups,
|
crmEnquiryFollowups,
|
||||||
crmQuotations,
|
crmQuotations,
|
||||||
@@ -12,13 +13,15 @@ import {
|
|||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { AuthError } from '@/lib/auth/session';
|
import { AuthError } from '@/lib/auth/session';
|
||||||
import { hasBranchScopeAccess, hasProductScopeAccess } from '@/lib/auth/crm-access';
|
import { hasBranchScopeAccess, hasProductScopeAccess } from '@/lib/auth/crm-access';
|
||||||
import { listAuditLogs } from '@/features/foundation/audit-log/service';
|
import { auditAction, listAuditLogs } from '@/features/foundation/audit-log/service';
|
||||||
import { getUserBranches, validateBranchAccess } from '@/features/foundation/branch-scope/service';
|
import { getUserBranches, validateBranchAccess } from '@/features/foundation/branch-scope/service';
|
||||||
import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service';
|
import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service';
|
||||||
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||||
|
import { buildOrganizationStorageKey, getStorageProvider } from '@/features/foundation/storage/service';
|
||||||
import type {
|
import type {
|
||||||
EnquiryActivityRecord,
|
EnquiryActivityRecord,
|
||||||
EnquiryAssignableUserLookup,
|
EnquiryAssignableUserLookup,
|
||||||
|
EnquiryAttachmentRecord,
|
||||||
EnquiryBranchOption,
|
EnquiryBranchOption,
|
||||||
EnquiryAssignmentMutationPayload,
|
EnquiryAssignmentMutationPayload,
|
||||||
EnquiryCustomerRelationItem,
|
EnquiryCustomerRelationItem,
|
||||||
@@ -26,12 +29,15 @@ import type {
|
|||||||
EnquiryFollowupMutationPayload,
|
EnquiryFollowupMutationPayload,
|
||||||
EnquiryFollowupRecord,
|
EnquiryFollowupRecord,
|
||||||
EnquiryListItem,
|
EnquiryListItem,
|
||||||
|
EnquiryMarkLostPayload,
|
||||||
|
EnquiryMarkWonPayload,
|
||||||
EnquiryMutationPayload,
|
EnquiryMutationPayload,
|
||||||
EnquiryOption,
|
EnquiryOption,
|
||||||
EnquiryProjectPartyListItem,
|
EnquiryProjectPartyListItem,
|
||||||
EnquiryProjectPartyMutationPayload,
|
EnquiryProjectPartyMutationPayload,
|
||||||
EnquiryProjectPartyRecord,
|
EnquiryProjectPartyRecord,
|
||||||
EnquiryPipelineStage,
|
EnquiryPipelineStage,
|
||||||
|
EnquiryReopenPayload,
|
||||||
EnquiryRecord,
|
EnquiryRecord,
|
||||||
EnquiryReferenceData
|
EnquiryReferenceData
|
||||||
} from '../api/types';
|
} from '../api/types';
|
||||||
@@ -40,6 +46,7 @@ import {
|
|||||||
PROJECT_PARTY_OPTION_CATEGORY,
|
PROJECT_PARTY_OPTION_CATEGORY,
|
||||||
resolveProjectPartyRole
|
resolveProjectPartyRole
|
||||||
} from '@/features/crm/shared/project-party';
|
} from '@/features/crm/shared/project-party';
|
||||||
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
|
|
||||||
const ENQUIRY_OPTION_CATEGORIES = {
|
const ENQUIRY_OPTION_CATEGORIES = {
|
||||||
status: 'crm_enquiry_status',
|
status: 'crm_enquiry_status',
|
||||||
@@ -47,6 +54,7 @@ const ENQUIRY_OPTION_CATEGORIES = {
|
|||||||
priority: 'crm_priority',
|
priority: 'crm_priority',
|
||||||
leadChannel: 'crm_lead_channel',
|
leadChannel: 'crm_lead_channel',
|
||||||
followupType: 'crm_followup_type',
|
followupType: 'crm_followup_type',
|
||||||
|
lostReason: 'crm_lost_reason',
|
||||||
projectPartyRole: PROJECT_PARTY_OPTION_CATEGORY
|
projectPartyRole: PROJECT_PARTY_OPTION_CATEGORY
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -58,15 +66,22 @@ const SALES_CREATED_ENQUIRY_ROLES = new Set([
|
|||||||
'department_manager',
|
'department_manager',
|
||||||
'top_manager'
|
'top_manager'
|
||||||
]);
|
]);
|
||||||
const CLOSED_LOST_STATUS_CODES = new Set(['closed_lost', 'cancelled']);
|
const PURCHASE_ORDER_ATTACHMENT_CATEGORY = 'purchase_order';
|
||||||
const CLOSED_WON_QUOTATION_STATUS_CODES = new Set(['accepted']);
|
const ALLOWED_PO_CONTENT_TYPES = new Set([
|
||||||
const CLOSED_LOST_QUOTATION_STATUS_CODES = new Set(['lost', 'rejected']);
|
'application/pdf',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
'image/webp',
|
||||||
|
'application/vnd.ms-excel',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
]);
|
||||||
|
|
||||||
export interface EnquiryAccessContext {
|
export interface EnquiryAccessContext {
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
membershipRole: string;
|
membershipRole: string;
|
||||||
businessRole: string;
|
businessRole: string;
|
||||||
|
permissions?: string[];
|
||||||
branchScopeIds: string[];
|
branchScopeIds: string[];
|
||||||
productTypeScopeIds: string[];
|
productTypeScopeIds: string[];
|
||||||
ownershipScope: string;
|
ownershipScope: string;
|
||||||
@@ -98,7 +113,13 @@ function mapBranch(
|
|||||||
|
|
||||||
function mapEnquiryRecord(
|
function mapEnquiryRecord(
|
||||||
row: typeof crmEnquiries.$inferSelect,
|
row: typeof crmEnquiries.$inferSelect,
|
||||||
options?: { assignedToName?: string | null; assignedByName?: string | null }
|
options?: {
|
||||||
|
assignedToName?: string | null;
|
||||||
|
assignedByName?: string | null;
|
||||||
|
closedByName?: string | null;
|
||||||
|
lostReason?: { code: string; label: string } | null;
|
||||||
|
canViewCommercialData?: boolean;
|
||||||
|
}
|
||||||
): EnquiryRecord {
|
): EnquiryRecord {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -125,6 +146,19 @@ function mapEnquiryRecord(
|
|||||||
isHotProject: row.isHotProject,
|
isHotProject: row.isHotProject,
|
||||||
isActive: row.isActive,
|
isActive: row.isActive,
|
||||||
pipelineStage: row.pipelineStage as EnquiryPipelineStage,
|
pipelineStage: row.pipelineStage as EnquiryPipelineStage,
|
||||||
|
closedWonAt: row.closedWonAt?.toISOString() ?? null,
|
||||||
|
closedLostAt: row.closedLostAt?.toISOString() ?? null,
|
||||||
|
closedByUserId: row.closedByUserId,
|
||||||
|
closedByName: options?.closedByName ?? null,
|
||||||
|
poNumber: row.poNumber,
|
||||||
|
poDate: row.poDate?.toISOString() ?? null,
|
||||||
|
poAmount: options?.canViewCommercialData === false ? null : row.poAmount,
|
||||||
|
poCurrency: row.poCurrency,
|
||||||
|
lostReason: row.lostReason,
|
||||||
|
lostReasonCode: options?.lostReason?.code ?? null,
|
||||||
|
lostReasonLabel: options?.lostReason?.label ?? null,
|
||||||
|
lostCompetitor: row.lostCompetitor,
|
||||||
|
lostRemark: row.lostRemark,
|
||||||
assignedToUserId: row.assignedToUserId,
|
assignedToUserId: row.assignedToUserId,
|
||||||
assignedToName: options?.assignedToName ?? null,
|
assignedToName: options?.assignedToName ?? null,
|
||||||
assignedAt: row.assignedAt?.toISOString() ?? null,
|
assignedAt: row.assignedAt?.toISOString() ?? null,
|
||||||
@@ -139,6 +173,38 @@ function mapEnquiryRecord(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mapAttachmentRecord(row: typeof crmEnquiryAttachments.$inferSelect): EnquiryAttachmentRecord {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
organizationId: row.organizationId,
|
||||||
|
enquiryId: row.enquiryId,
|
||||||
|
category: row.category,
|
||||||
|
fileName: row.fileName,
|
||||||
|
originalFileName: row.originalFileName,
|
||||||
|
storageProvider: row.storageProvider,
|
||||||
|
storageKey: row.storageKey,
|
||||||
|
fileSize: row.fileSize,
|
||||||
|
fileType: row.fileType,
|
||||||
|
description: row.description,
|
||||||
|
uploadedAt: row.uploadedAt.toISOString(),
|
||||||
|
uploadedBy: row.uploadedBy,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
|
deletedAt: row.deletedAt?.toISOString() ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function canViewOutcomeCommercialData(context?: EnquiryAccessContext) {
|
||||||
|
if (!context) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
context.membershipRole === 'admin' ||
|
||||||
|
(context.permissions ?? []).includes(PERMISSIONS.crmQuotationPricingRead)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function hasEnquiryFullAccess(context: EnquiryAccessContext) {
|
function hasEnquiryFullAccess(context: EnquiryAccessContext) {
|
||||||
return context.membershipRole === 'admin' || context.ownershipScope !== 'own';
|
return context.membershipRole === 'admin' || context.ownershipScope !== 'own';
|
||||||
}
|
}
|
||||||
@@ -561,40 +627,16 @@ function derivePipelineStageFromRole(businessRole: string): EnquiryPipelineStage
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function resolvePipelineStageForCreate(
|
async function resolvePipelineStageForCreate(
|
||||||
organizationId: string,
|
|
||||||
businessRole: string,
|
businessRole: string,
|
||||||
statusId: string
|
|
||||||
): Promise<EnquiryPipelineStage> {
|
): Promise<EnquiryPipelineStage> {
|
||||||
const statusCode = await resolveOptionCodeById(
|
|
||||||
organizationId,
|
|
||||||
ENQUIRY_OPTION_CATEGORIES.status,
|
|
||||||
statusId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (statusCode && CLOSED_LOST_STATUS_CODES.has(statusCode)) {
|
|
||||||
return 'closed_lost';
|
|
||||||
}
|
|
||||||
|
|
||||||
return derivePipelineStageFromRole(businessRole);
|
return derivePipelineStageFromRole(businessRole);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolvePipelineStageForUpdate(
|
async function resolvePipelineStageForUpdate(
|
||||||
organizationId: string,
|
|
||||||
current: typeof crmEnquiries.$inferSelect,
|
current: typeof crmEnquiries.$inferSelect,
|
||||||
payload: EnquiryMutationPayload
|
|
||||||
): Promise<EnquiryPipelineStage> {
|
): Promise<EnquiryPipelineStage> {
|
||||||
const statusCode = await resolveOptionCodeById(
|
if (current.pipelineStage === 'closed_won' || current.pipelineStage === 'closed_lost') {
|
||||||
organizationId,
|
return current.pipelineStage as EnquiryPipelineStage;
|
||||||
ENQUIRY_OPTION_CATEGORIES.status,
|
|
||||||
payload.status
|
|
||||||
);
|
|
||||||
|
|
||||||
if (statusCode && CLOSED_LOST_STATUS_CODES.has(statusCode)) {
|
|
||||||
return 'closed_lost';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (current.pipelineStage === 'closed_won') {
|
|
||||||
return 'closed_won';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (current.pipelineStage === 'enquiry' || current.assignedToUserId) {
|
if (current.pipelineStage === 'enquiry' || current.assignedToUserId) {
|
||||||
@@ -614,6 +656,7 @@ export async function getEnquiryReferenceData(
|
|||||||
priorities,
|
priorities,
|
||||||
leadChannels,
|
leadChannels,
|
||||||
followupTypes,
|
followupTypes,
|
||||||
|
lostReasons,
|
||||||
projectPartyRoles,
|
projectPartyRoles,
|
||||||
customers,
|
customers,
|
||||||
contacts,
|
contacts,
|
||||||
@@ -625,6 +668,7 @@ export async function getEnquiryReferenceData(
|
|||||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.priority, { organizationId }),
|
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.priority, { organizationId }),
|
||||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.leadChannel, { organizationId }),
|
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.leadChannel, { organizationId }),
|
||||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.followupType, { organizationId }),
|
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.followupType, { organizationId }),
|
||||||
|
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.lostReason, { organizationId }),
|
||||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.projectPartyRole, { organizationId }),
|
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.projectPartyRole, { organizationId }),
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
@@ -665,6 +709,7 @@ export async function getEnquiryReferenceData(
|
|||||||
priorities: priorities.map(mapOption),
|
priorities: priorities.map(mapOption),
|
||||||
leadChannels: leadChannels.map(mapOption),
|
leadChannels: leadChannels.map(mapOption),
|
||||||
followupTypes: followupTypes.map(mapOption),
|
followupTypes: followupTypes.map(mapOption),
|
||||||
|
lostReasons: lostReasons.map(mapOption),
|
||||||
projectPartyRoles: projectPartyRoles.map(mapOption),
|
projectPartyRoles: projectPartyRoles.map(mapOption),
|
||||||
customers: customers.map((customer) => ({
|
customers: customers.map((customer) => ({
|
||||||
id: customer.id,
|
id: customer.id,
|
||||||
@@ -844,20 +889,32 @@ export async function getEnquiryDetail(
|
|||||||
accessContext?: EnquiryAccessContext
|
accessContext?: EnquiryAccessContext
|
||||||
): Promise<EnquiryRecord> {
|
): Promise<EnquiryRecord> {
|
||||||
const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
|
const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
|
||||||
const relatedUserIds = [enquiry.assignedToUserId, enquiry.assignedBy].filter(Boolean) as string[];
|
const relatedUserIds = [
|
||||||
const userRows = relatedUserIds.length
|
enquiry.assignedToUserId,
|
||||||
? await db
|
enquiry.assignedBy,
|
||||||
.select({ id: users.id, name: users.name })
|
enquiry.closedByUserId
|
||||||
.from(users)
|
].filter(Boolean) as string[];
|
||||||
.where(inArray(users.id, relatedUserIds))
|
const [userRows, lostReasonOptions] = await Promise.all([
|
||||||
: [];
|
relatedUserIds.length
|
||||||
|
? db.select({ id: users.id, name: users.name }).from(users).where(inArray(users.id, relatedUserIds))
|
||||||
|
: [],
|
||||||
|
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.lostReason, { organizationId })
|
||||||
|
]);
|
||||||
const userMap = new Map(userRows.map((row) => [row.id, row.name]));
|
const userMap = new Map(userRows.map((row) => [row.id, row.name]));
|
||||||
|
const lostReasonOption =
|
||||||
|
lostReasonOptions.find((option) => option.id === enquiry.lostReason || option.code === enquiry.lostReason) ??
|
||||||
|
null;
|
||||||
|
|
||||||
return mapEnquiryRecord(enquiry, {
|
return mapEnquiryRecord(enquiry, {
|
||||||
assignedToName: enquiry.assignedToUserId
|
assignedToName: enquiry.assignedToUserId
|
||||||
? (userMap.get(enquiry.assignedToUserId) ?? null)
|
? (userMap.get(enquiry.assignedToUserId) ?? null)
|
||||||
: null,
|
: null,
|
||||||
assignedByName: enquiry.assignedBy ? (userMap.get(enquiry.assignedBy) ?? null) : null
|
assignedByName: enquiry.assignedBy ? (userMap.get(enquiry.assignedBy) ?? null) : null,
|
||||||
|
closedByName: enquiry.closedByUserId ? (userMap.get(enquiry.closedByUserId) ?? null) : null,
|
||||||
|
lostReason: lostReasonOption
|
||||||
|
? { code: lostReasonOption.code, label: lostReasonOption.label }
|
||||||
|
: null,
|
||||||
|
canViewCommercialData: canViewOutcomeCommercialData(accessContext)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1043,11 +1100,7 @@ export async function createEnquiry(
|
|||||||
payload: EnquiryMutationPayload
|
payload: EnquiryMutationPayload
|
||||||
) {
|
) {
|
||||||
await validateEnquiryPayload(organizationId, payload, accessContext);
|
await validateEnquiryPayload(organizationId, payload, accessContext);
|
||||||
const pipelineStage = await resolvePipelineStageForCreate(
|
const pipelineStage = await resolvePipelineStageForCreate(accessContext.businessRole);
|
||||||
organizationId,
|
|
||||||
accessContext.businessRole,
|
|
||||||
payload.status
|
|
||||||
);
|
|
||||||
|
|
||||||
const documentCode = await generateNextDocumentCode({
|
const documentCode = await generateNextDocumentCode({
|
||||||
organizationId,
|
organizationId,
|
||||||
@@ -1113,7 +1166,7 @@ export async function updateEnquiry(
|
|||||||
) {
|
) {
|
||||||
await validateEnquiryPayload(organizationId, payload, accessContext);
|
await validateEnquiryPayload(organizationId, payload, accessContext);
|
||||||
const current = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
|
const current = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
|
||||||
const pipelineStage = await resolvePipelineStageForUpdate(organizationId, current, payload);
|
const pipelineStage = await resolvePipelineStageForUpdate(current);
|
||||||
|
|
||||||
return db.transaction(async (tx) => {
|
return db.transaction(async (tx) => {
|
||||||
const [updated] = await tx
|
const [updated] = await tx
|
||||||
@@ -1370,6 +1423,296 @@ export async function softDeleteEnquiryFollowup(
|
|||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureEnquiryStageForOutcome(
|
||||||
|
enquiry: typeof crmEnquiries.$inferSelect,
|
||||||
|
expectedStage: EnquiryPipelineStage
|
||||||
|
) {
|
||||||
|
if (enquiry.pipelineStage !== expectedStage) {
|
||||||
|
throw new AuthError(`Enquiry must be in ${expectedStage} stage`, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markEnquiryAsWon(
|
||||||
|
id: string,
|
||||||
|
organizationId: string,
|
||||||
|
userId: string,
|
||||||
|
payload: EnquiryMarkWonPayload,
|
||||||
|
accessContext?: EnquiryAccessContext
|
||||||
|
) {
|
||||||
|
const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
|
||||||
|
ensureEnquiryStageForOutcome(enquiry, 'enquiry');
|
||||||
|
|
||||||
|
if (!payload.poNumber.trim()) {
|
||||||
|
throw new AuthError('PO number is required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const [updated] = await db
|
||||||
|
.update(crmEnquiries)
|
||||||
|
.set({
|
||||||
|
pipelineStage: 'closed_won',
|
||||||
|
closedWonAt: now,
|
||||||
|
closedLostAt: null,
|
||||||
|
closedByUserId: userId,
|
||||||
|
poNumber: payload.poNumber.trim(),
|
||||||
|
poDate: new Date(payload.poDate),
|
||||||
|
poAmount: payload.poAmount ?? null,
|
||||||
|
poCurrency: payload.poCurrency?.trim() || null,
|
||||||
|
lostReason: null,
|
||||||
|
lostCompetitor: null,
|
||||||
|
lostRemark: null,
|
||||||
|
updatedAt: now,
|
||||||
|
updatedBy: userId
|
||||||
|
})
|
||||||
|
.where(eq(crmEnquiries.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await auditAction({
|
||||||
|
organizationId,
|
||||||
|
branchId: updated.branchId,
|
||||||
|
userId,
|
||||||
|
entityType: 'crm_enquiry',
|
||||||
|
entityId: id,
|
||||||
|
action: 'mark_won',
|
||||||
|
afterData: {
|
||||||
|
poNumber: updated.poNumber,
|
||||||
|
poAmount: updated.poAmount,
|
||||||
|
poCurrency: updated.poCurrency,
|
||||||
|
remark: payload.remark?.trim() || null,
|
||||||
|
closedBy: userId,
|
||||||
|
closedAt: updated.closedWonAt?.toISOString() ?? null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markEnquiryAsLost(
|
||||||
|
id: string,
|
||||||
|
organizationId: string,
|
||||||
|
userId: string,
|
||||||
|
payload: EnquiryMarkLostPayload,
|
||||||
|
accessContext?: EnquiryAccessContext
|
||||||
|
) {
|
||||||
|
const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
|
||||||
|
ensureEnquiryStageForOutcome(enquiry, 'enquiry');
|
||||||
|
await assertMasterOptionValue(organizationId, ENQUIRY_OPTION_CATEGORIES.lostReason, payload.lostReason);
|
||||||
|
|
||||||
|
const lostReasonCode =
|
||||||
|
(await resolveOptionCodeById(organizationId, ENQUIRY_OPTION_CATEGORIES.lostReason, payload.lostReason)) ??
|
||||||
|
payload.lostReason;
|
||||||
|
const now = new Date();
|
||||||
|
const [updated] = await db
|
||||||
|
.update(crmEnquiries)
|
||||||
|
.set({
|
||||||
|
pipelineStage: 'closed_lost',
|
||||||
|
closedWonAt: null,
|
||||||
|
closedLostAt: now,
|
||||||
|
closedByUserId: userId,
|
||||||
|
poNumber: null,
|
||||||
|
poDate: null,
|
||||||
|
poAmount: null,
|
||||||
|
poCurrency: null,
|
||||||
|
lostReason: payload.lostReason,
|
||||||
|
lostCompetitor: payload.lostCompetitor?.trim() || null,
|
||||||
|
lostRemark: payload.lostRemark?.trim() || null,
|
||||||
|
updatedAt: now,
|
||||||
|
updatedBy: userId
|
||||||
|
})
|
||||||
|
.where(eq(crmEnquiries.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await auditAction({
|
||||||
|
organizationId,
|
||||||
|
branchId: updated.branchId,
|
||||||
|
userId,
|
||||||
|
entityType: 'crm_enquiry',
|
||||||
|
entityId: id,
|
||||||
|
action: 'mark_lost',
|
||||||
|
afterData: {
|
||||||
|
lostReason: lostReasonCode,
|
||||||
|
lostCompetitor: updated.lostCompetitor,
|
||||||
|
closedBy: userId,
|
||||||
|
closedAt: updated.closedLostAt?.toISOString() ?? null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reopenLostEnquiry(
|
||||||
|
id: string,
|
||||||
|
organizationId: string,
|
||||||
|
userId: string,
|
||||||
|
payload: EnquiryReopenPayload,
|
||||||
|
accessContext?: EnquiryAccessContext
|
||||||
|
) {
|
||||||
|
const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
|
||||||
|
|
||||||
|
if (enquiry.pipelineStage === 'closed_won') {
|
||||||
|
throw new AuthError('Won enquiries cannot be reopened', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureEnquiryStageForOutcome(enquiry, 'closed_lost');
|
||||||
|
const now = new Date();
|
||||||
|
const [updated] = await db
|
||||||
|
.update(crmEnquiries)
|
||||||
|
.set({
|
||||||
|
pipelineStage: 'enquiry',
|
||||||
|
closedLostAt: null,
|
||||||
|
closedByUserId: null,
|
||||||
|
lostReason: null,
|
||||||
|
lostCompetitor: null,
|
||||||
|
lostRemark: null,
|
||||||
|
updatedAt: now,
|
||||||
|
updatedBy: userId
|
||||||
|
})
|
||||||
|
.where(eq(crmEnquiries.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await auditAction({
|
||||||
|
organizationId,
|
||||||
|
branchId: updated.branchId,
|
||||||
|
userId,
|
||||||
|
entityType: 'crm_enquiry',
|
||||||
|
entityId: id,
|
||||||
|
action: 'reopen',
|
||||||
|
afterData: {
|
||||||
|
reopenReason: payload.reopenReason.trim(),
|
||||||
|
previousStage: enquiry.pipelineStage,
|
||||||
|
nextStage: updated.pipelineStage
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listEnquiryAttachments(
|
||||||
|
enquiryId: string,
|
||||||
|
organizationId: string,
|
||||||
|
accessContext?: EnquiryAccessContext
|
||||||
|
) {
|
||||||
|
await assertEnquiryBelongsToOrganization(enquiryId, organizationId, accessContext);
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(crmEnquiryAttachments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmEnquiryAttachments.organizationId, organizationId),
|
||||||
|
eq(crmEnquiryAttachments.enquiryId, enquiryId),
|
||||||
|
eq(crmEnquiryAttachments.category, PURCHASE_ORDER_ATTACHMENT_CATEGORY),
|
||||||
|
isNull(crmEnquiryAttachments.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(desc(crmEnquiryAttachments.uploadedAt));
|
||||||
|
|
||||||
|
return rows.map(mapAttachmentRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEnquiryAttachment(
|
||||||
|
enquiryId: string,
|
||||||
|
attachmentId: string,
|
||||||
|
organizationId: string,
|
||||||
|
accessContext?: EnquiryAccessContext
|
||||||
|
) {
|
||||||
|
await assertEnquiryBelongsToOrganization(enquiryId, organizationId, accessContext);
|
||||||
|
const [attachment] = await db
|
||||||
|
.select()
|
||||||
|
.from(crmEnquiryAttachments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmEnquiryAttachments.id, attachmentId),
|
||||||
|
eq(crmEnquiryAttachments.organizationId, organizationId),
|
||||||
|
eq(crmEnquiryAttachments.enquiryId, enquiryId),
|
||||||
|
isNull(crmEnquiryAttachments.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!attachment) {
|
||||||
|
throw new AuthError('Attachment not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return attachment;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadPurchaseOrderAttachment(input: {
|
||||||
|
enquiryId: string;
|
||||||
|
organizationId: string;
|
||||||
|
userId: string;
|
||||||
|
file: {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
size: number;
|
||||||
|
buffer: Buffer;
|
||||||
|
};
|
||||||
|
description?: string | null;
|
||||||
|
accessContext?: EnquiryAccessContext;
|
||||||
|
}) {
|
||||||
|
const enquiry = await assertEnquiryBelongsToOrganization(
|
||||||
|
input.enquiryId,
|
||||||
|
input.organizationId,
|
||||||
|
input.accessContext
|
||||||
|
);
|
||||||
|
|
||||||
|
if (enquiry.pipelineStage !== 'closed_won') {
|
||||||
|
throw new AuthError('PO attachment is available only for won enquiries', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ALLOWED_PO_CONTENT_TYPES.has(input.file.type)) {
|
||||||
|
throw new AuthError('Unsupported PO attachment file type', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storageProvider = getStorageProvider();
|
||||||
|
const safeFileName = input.file.name.replace(/[^a-zA-Z0-9._-]/g, '-');
|
||||||
|
const storageKey = buildOrganizationStorageKey(input.organizationId, [
|
||||||
|
'enquiries',
|
||||||
|
input.enquiryId,
|
||||||
|
'purchase-orders',
|
||||||
|
`${Date.now()}-${safeFileName}`
|
||||||
|
]);
|
||||||
|
const stored = await storageProvider.putObject({
|
||||||
|
key: storageKey,
|
||||||
|
body: input.file.buffer,
|
||||||
|
contentType: input.file.type,
|
||||||
|
fileName: safeFileName
|
||||||
|
});
|
||||||
|
const [created] = await db
|
||||||
|
.insert(crmEnquiryAttachments)
|
||||||
|
.values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
organizationId: input.organizationId,
|
||||||
|
enquiryId: input.enquiryId,
|
||||||
|
category: PURCHASE_ORDER_ATTACHMENT_CATEGORY,
|
||||||
|
fileName: safeFileName,
|
||||||
|
originalFileName: input.file.name,
|
||||||
|
storageProvider: stored.provider,
|
||||||
|
storageKey: stored.key,
|
||||||
|
fileSize: stored.size,
|
||||||
|
fileType: input.file.type,
|
||||||
|
description: input.description?.trim() || null,
|
||||||
|
uploadedBy: input.userId
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await auditAction({
|
||||||
|
organizationId: input.organizationId,
|
||||||
|
branchId: enquiry.branchId,
|
||||||
|
userId: input.userId,
|
||||||
|
entityType: 'crm_enquiry',
|
||||||
|
entityId: input.enquiryId,
|
||||||
|
action: 'upload_po',
|
||||||
|
afterData: {
|
||||||
|
attachmentId: created.id,
|
||||||
|
poNumber: enquiry.poNumber,
|
||||||
|
fileName: created.fileName,
|
||||||
|
fileType: created.fileType
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
export async function listCustomerEnquiryRelations(
|
export async function listCustomerEnquiryRelations(
|
||||||
customerId: string,
|
customerId: string,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
@@ -1405,33 +1748,7 @@ export async function syncEnquiryPipelineStageFromQuotationStatus(
|
|||||||
organizationId: string,
|
organizationId: string,
|
||||||
quotationStatusCode: string | null
|
quotationStatusCode: string | null
|
||||||
) {
|
) {
|
||||||
if (!quotationStatusCode) {
|
void enquiryId;
|
||||||
return;
|
void organizationId;
|
||||||
}
|
void quotationStatusCode;
|
||||||
|
|
||||||
const nextStage: EnquiryPipelineStage | null = CLOSED_WON_QUOTATION_STATUS_CODES.has(
|
|
||||||
quotationStatusCode
|
|
||||||
)
|
|
||||||
? 'closed_won'
|
|
||||||
: CLOSED_LOST_QUOTATION_STATUS_CODES.has(quotationStatusCode)
|
|
||||||
? 'closed_lost'
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (!nextStage) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(crmEnquiries)
|
|
||||||
.set({
|
|
||||||
pipelineStage: nextStage,
|
|
||||||
updatedAt: new Date()
|
|
||||||
})
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(crmEnquiries.id, enquiryId),
|
|
||||||
eq(crmEnquiries.organizationId, organizationId),
|
|
||||||
isNull(crmEnquiries.deletedAt)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from 'drizzle-orm';
|
} from 'drizzle-orm';
|
||||||
import {
|
import {
|
||||||
crmCustomers,
|
crmCustomers,
|
||||||
|
crmEnquiries,
|
||||||
crmEnquiryCustomers,
|
crmEnquiryCustomers,
|
||||||
crmQuotationCustomers,
|
crmQuotationCustomers,
|
||||||
crmQuotations
|
crmQuotations
|
||||||
@@ -24,7 +25,13 @@ import {
|
|||||||
PROJECT_PARTY_OPTION_CATEGORY,
|
PROJECT_PARTY_OPTION_CATEGORY,
|
||||||
resolveProjectPartyRole
|
resolveProjectPartyRole
|
||||||
} from '@/features/crm/shared/project-party';
|
} from '@/features/crm/shared/project-party';
|
||||||
import type { RevenueAttributionFilters, RevenueAttributionSummary } from './types';
|
import type {
|
||||||
|
OutcomeBreakdownRow,
|
||||||
|
OutcomeRevenueFilters,
|
||||||
|
OutcomeRevenueRecord,
|
||||||
|
RevenueAttributionFilters,
|
||||||
|
RevenueAttributionSummary
|
||||||
|
} from './types';
|
||||||
|
|
||||||
type SupportedRevenueRole = 'end_customer' | 'billing_customer' | 'contractor' | 'consultant';
|
type SupportedRevenueRole = 'end_customer' | 'billing_customer' | 'contractor' | 'consultant';
|
||||||
|
|
||||||
@@ -378,3 +385,174 @@ export async function getRevenueByConsultant(
|
|||||||
) {
|
) {
|
||||||
return getRevenueByRole(organizationId, 'consultant', filters);
|
return getRevenueByRole(organizationId, 'consultant', filters);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildOutcomeRevenueFilters(
|
||||||
|
organizationId: string,
|
||||||
|
stage: 'closed_won' | 'closed_lost',
|
||||||
|
filters: OutcomeRevenueFilters
|
||||||
|
): SQL[] {
|
||||||
|
const dateColumn = stage === 'closed_won' ? crmEnquiries.closedWonAt : crmEnquiries.closedLostAt;
|
||||||
|
|
||||||
|
return [
|
||||||
|
eq(crmEnquiries.organizationId, organizationId),
|
||||||
|
isNull(crmEnquiries.deletedAt),
|
||||||
|
eq(crmEnquiries.pipelineStage, stage),
|
||||||
|
...(filters.branch ? [eq(crmEnquiries.branchId, filters.branch)] : []),
|
||||||
|
...(filters.salesman ? [eq(crmEnquiries.assignedToUserId, filters.salesman)] : []),
|
||||||
|
...(filters.productType ? [eq(crmEnquiries.productType, filters.productType)] : []),
|
||||||
|
...(filters.dateFrom ? [gte(dateColumn, new Date(filters.dateFrom))] : []),
|
||||||
|
...(filters.dateTo ? [lte(dateColumn, new Date(filters.dateTo))] : [])
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOutcomeRevenueRecords(
|
||||||
|
organizationId: string,
|
||||||
|
stage: 'closed_won' | 'closed_lost',
|
||||||
|
filters: OutcomeRevenueFilters = {}
|
||||||
|
): Promise<OutcomeRevenueRecord[]> {
|
||||||
|
const whereFilters = buildOutcomeRevenueFilters(organizationId, stage, filters);
|
||||||
|
const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters);
|
||||||
|
const enquiries = await db.select().from(crmEnquiries).where(where);
|
||||||
|
|
||||||
|
if (!enquiries.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const enquiryIds = enquiries.map((row) => row.id);
|
||||||
|
const quotationRows = await db
|
||||||
|
.select({
|
||||||
|
enquiryId: crmQuotations.enquiryId,
|
||||||
|
totalAmount: crmQuotations.totalAmount
|
||||||
|
})
|
||||||
|
.from(crmQuotations)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmQuotations.organizationId, organizationId),
|
||||||
|
inArray(crmQuotations.enquiryId, enquiryIds),
|
||||||
|
isNull(crmQuotations.deletedAt)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const quotationTotals = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const row of quotationRows) {
|
||||||
|
if (!row.enquiryId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
quotationTotals.set(row.enquiryId, (quotationTotals.get(row.enquiryId) ?? 0) + row.totalAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
return enquiries.map((row) => ({
|
||||||
|
enquiryId: row.id,
|
||||||
|
customerId: row.customerId,
|
||||||
|
branchId: row.branchId,
|
||||||
|
productType: row.productType,
|
||||||
|
assignedToUserId: row.assignedToUserId,
|
||||||
|
revenue: stage === 'closed_won' ? row.poAmount ?? quotationTotals.get(row.id) ?? 0 : quotationTotals.get(row.id) ?? 0,
|
||||||
|
closedAt:
|
||||||
|
(stage === 'closed_won' ? row.closedWonAt : row.closedLostAt)?.toISOString() ?? null
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWonRevenue(
|
||||||
|
organizationId: string,
|
||||||
|
filters: OutcomeRevenueFilters = {}
|
||||||
|
) {
|
||||||
|
return getOutcomeRevenueRecords(organizationId, 'closed_won', filters);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLostRevenue(
|
||||||
|
organizationId: string,
|
||||||
|
filters: OutcomeRevenueFilters = {}
|
||||||
|
) {
|
||||||
|
return getOutcomeRevenueRecords(organizationId, 'closed_lost', filters);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLostByReason(
|
||||||
|
organizationId: string,
|
||||||
|
filters: OutcomeRevenueFilters = {}
|
||||||
|
): Promise<OutcomeBreakdownRow[]> {
|
||||||
|
const records = await getLostRevenue(organizationId, filters);
|
||||||
|
const enquiryIds = records.map((row) => row.enquiryId);
|
||||||
|
|
||||||
|
if (!enquiryIds.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [enquiries, lostReasonOptions] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({ id: crmEnquiries.id, lostReason: crmEnquiries.lostReason })
|
||||||
|
.from(crmEnquiries)
|
||||||
|
.where(inArray(crmEnquiries.id, enquiryIds)),
|
||||||
|
getActiveOptionsByCategory('crm_lost_reason', { organizationId })
|
||||||
|
]);
|
||||||
|
const lostReasonMap = new Map(
|
||||||
|
lostReasonOptions.flatMap((item) => [
|
||||||
|
[item.id, item.label] as const,
|
||||||
|
[item.code, item.label] as const
|
||||||
|
])
|
||||||
|
);
|
||||||
|
const grouped = new Map<string, OutcomeBreakdownRow>();
|
||||||
|
|
||||||
|
for (const row of records) {
|
||||||
|
const enquiry = enquiries.find((item) => item.id === row.enquiryId);
|
||||||
|
const key = enquiry?.lostReason ?? 'unknown';
|
||||||
|
const current = grouped.get(key) ?? {
|
||||||
|
key,
|
||||||
|
label: lostReasonMap.get(key) ?? key,
|
||||||
|
count: 0,
|
||||||
|
revenue: 0
|
||||||
|
};
|
||||||
|
current.count += 1;
|
||||||
|
current.revenue += row.revenue;
|
||||||
|
grouped.set(key, current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...grouped.values()].sort((a, b) => b.count - a.count || b.revenue - a.revenue);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLostByCompetitor(
|
||||||
|
organizationId: string,
|
||||||
|
filters: OutcomeRevenueFilters = {}
|
||||||
|
): Promise<OutcomeBreakdownRow[]> {
|
||||||
|
const records = await getLostRevenue(organizationId, filters);
|
||||||
|
const enquiryIds = records.map((row) => row.enquiryId);
|
||||||
|
|
||||||
|
if (!enquiryIds.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const enquiries = await db
|
||||||
|
.select({ id: crmEnquiries.id, lostCompetitor: crmEnquiries.lostCompetitor })
|
||||||
|
.from(crmEnquiries)
|
||||||
|
.where(inArray(crmEnquiries.id, enquiryIds));
|
||||||
|
const grouped = new Map<string, OutcomeBreakdownRow>();
|
||||||
|
|
||||||
|
for (const row of records) {
|
||||||
|
const enquiry = enquiries.find((item) => item.id === row.enquiryId);
|
||||||
|
const key = enquiry?.lostCompetitor?.trim() || 'unknown';
|
||||||
|
const current = grouped.get(key) ?? { key, label: key, count: 0, revenue: 0 };
|
||||||
|
current.count += 1;
|
||||||
|
current.revenue += row.revenue;
|
||||||
|
grouped.set(key, current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...grouped.values()].sort((a, b) => b.count - a.count || b.revenue - a.revenue);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWinRate(
|
||||||
|
organizationId: string,
|
||||||
|
filters: OutcomeRevenueFilters = {}
|
||||||
|
) {
|
||||||
|
const [wonRows, lostRows] = await Promise.all([
|
||||||
|
getWonRevenue(organizationId, filters),
|
||||||
|
getLostRevenue(organizationId, filters)
|
||||||
|
]);
|
||||||
|
const denominator = wonRows.length + lostRows.length;
|
||||||
|
|
||||||
|
if (denominator === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.round((wonRows.length / denominator) * 10000) / 100;
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,3 +20,28 @@ export interface RevenueAttributionSummary {
|
|||||||
quotationCount: number;
|
quotationCount: number;
|
||||||
quotationIds: string[];
|
quotationIds: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OutcomeRevenueFilters {
|
||||||
|
dateFrom?: string | null;
|
||||||
|
dateTo?: string | null;
|
||||||
|
branch?: string | null;
|
||||||
|
salesman?: string | null;
|
||||||
|
productType?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OutcomeRevenueRecord {
|
||||||
|
enquiryId: string;
|
||||||
|
customerId: string;
|
||||||
|
branchId: string | null;
|
||||||
|
productType: string;
|
||||||
|
assignedToUserId: string | null;
|
||||||
|
revenue: number;
|
||||||
|
closedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OutcomeBreakdownRow {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
revenue: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ export const PERMISSIONS = {
|
|||||||
crmEnquiryDelete: 'crm.enquiry.delete',
|
crmEnquiryDelete: 'crm.enquiry.delete',
|
||||||
crmEnquiryAssign: 'crm.enquiry.assign',
|
crmEnquiryAssign: 'crm.enquiry.assign',
|
||||||
crmEnquiryReassign: 'crm.enquiry.reassign',
|
crmEnquiryReassign: 'crm.enquiry.reassign',
|
||||||
|
crmEnquiryMarkWon: 'crm.enquiry.mark_won',
|
||||||
|
crmEnquiryMarkLost: 'crm.enquiry.mark_lost',
|
||||||
|
crmEnquiryReopen: 'crm.enquiry.reopen',
|
||||||
crmEnquiryFollowupRead: 'crm.enquiry.followup.read',
|
crmEnquiryFollowupRead: 'crm.enquiry.followup.read',
|
||||||
crmEnquiryFollowupCreate: 'crm.enquiry.followup.create',
|
crmEnquiryFollowupCreate: 'crm.enquiry.followup.create',
|
||||||
crmEnquiryFollowupUpdate: 'crm.enquiry.followup.update',
|
crmEnquiryFollowupUpdate: 'crm.enquiry.followup.update',
|
||||||
@@ -167,6 +170,8 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
|
|||||||
PERMISSIONS.crmEnquiryRead,
|
PERMISSIONS.crmEnquiryRead,
|
||||||
PERMISSIONS.crmEnquiryCreate,
|
PERMISSIONS.crmEnquiryCreate,
|
||||||
PERMISSIONS.crmEnquiryUpdate,
|
PERMISSIONS.crmEnquiryUpdate,
|
||||||
|
PERMISSIONS.crmEnquiryMarkWon,
|
||||||
|
PERMISSIONS.crmEnquiryMarkLost,
|
||||||
PERMISSIONS.crmEnquiryFollowupRead,
|
PERMISSIONS.crmEnquiryFollowupRead,
|
||||||
PERMISSIONS.crmEnquiryFollowupCreate,
|
PERMISSIONS.crmEnquiryFollowupCreate,
|
||||||
PERMISSIONS.crmEnquiryFollowupUpdate,
|
PERMISSIONS.crmEnquiryFollowupUpdate,
|
||||||
@@ -204,6 +209,8 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
|
|||||||
PERMISSIONS.crmContactRead,
|
PERMISSIONS.crmContactRead,
|
||||||
PERMISSIONS.crmEnquiryRead,
|
PERMISSIONS.crmEnquiryRead,
|
||||||
PERMISSIONS.crmEnquiryUpdate,
|
PERMISSIONS.crmEnquiryUpdate,
|
||||||
|
PERMISSIONS.crmEnquiryMarkWon,
|
||||||
|
PERMISSIONS.crmEnquiryMarkLost,
|
||||||
PERMISSIONS.crmEnquiryFollowupRead,
|
PERMISSIONS.crmEnquiryFollowupRead,
|
||||||
PERMISSIONS.crmEnquiryFollowupCreate,
|
PERMISSIONS.crmEnquiryFollowupCreate,
|
||||||
PERMISSIONS.crmEnquiryFollowupUpdate,
|
PERMISSIONS.crmEnquiryFollowupUpdate,
|
||||||
@@ -256,6 +263,9 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
|
|||||||
PERMISSIONS.crmEnquiryUpdate,
|
PERMISSIONS.crmEnquiryUpdate,
|
||||||
PERMISSIONS.crmEnquiryAssign,
|
PERMISSIONS.crmEnquiryAssign,
|
||||||
PERMISSIONS.crmEnquiryReassign,
|
PERMISSIONS.crmEnquiryReassign,
|
||||||
|
PERMISSIONS.crmEnquiryMarkWon,
|
||||||
|
PERMISSIONS.crmEnquiryMarkLost,
|
||||||
|
PERMISSIONS.crmEnquiryReopen,
|
||||||
PERMISSIONS.crmEnquiryFollowupRead,
|
PERMISSIONS.crmEnquiryFollowupRead,
|
||||||
PERMISSIONS.crmEnquiryFollowupCreate,
|
PERMISSIONS.crmEnquiryFollowupCreate,
|
||||||
PERMISSIONS.crmEnquiryFollowupUpdate,
|
PERMISSIONS.crmEnquiryFollowupUpdate,
|
||||||
@@ -302,6 +312,9 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
|
|||||||
PERMISSIONS.crmEnquiryRead,
|
PERMISSIONS.crmEnquiryRead,
|
||||||
PERMISSIONS.crmEnquiryAssign,
|
PERMISSIONS.crmEnquiryAssign,
|
||||||
PERMISSIONS.crmEnquiryReassign,
|
PERMISSIONS.crmEnquiryReassign,
|
||||||
|
PERMISSIONS.crmEnquiryMarkWon,
|
||||||
|
PERMISSIONS.crmEnquiryMarkLost,
|
||||||
|
PERMISSIONS.crmEnquiryReopen,
|
||||||
PERMISSIONS.crmEnquiryFollowupRead,
|
PERMISSIONS.crmEnquiryFollowupRead,
|
||||||
PERMISSIONS.crmQuotationRead,
|
PERMISSIONS.crmQuotationRead,
|
||||||
PERMISSIONS.crmQuotationPricingRead,
|
PERMISSIONS.crmQuotationPricingRead,
|
||||||
@@ -365,6 +378,9 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
|
|||||||
description: 'CRM configuration authority for templates, workflows, sequences, and permissions.',
|
description: 'CRM configuration authority for templates, workflows, sequences, and permissions.',
|
||||||
permissions: [
|
permissions: [
|
||||||
PERMISSIONS.crmDashboardRead,
|
PERMISSIONS.crmDashboardRead,
|
||||||
|
PERMISSIONS.crmEnquiryMarkWon,
|
||||||
|
PERMISSIONS.crmEnquiryMarkLost,
|
||||||
|
PERMISSIONS.crmEnquiryReopen,
|
||||||
PERMISSIONS.crmCustomerOwnerRead,
|
PERMISSIONS.crmCustomerOwnerRead,
|
||||||
PERMISSIONS.crmCustomerOwnerManage,
|
PERMISSIONS.crmCustomerOwnerManage,
|
||||||
PERMISSIONS.crmContactShareRead,
|
PERMISSIONS.crmContactShareRead,
|
||||||
@@ -466,6 +482,9 @@ export const CRM_PERMISSION_GROUPS: PermissionGroup[] = [
|
|||||||
{ key: PERMISSIONS.crmEnquiryDelete, label: 'Delete enquiries' },
|
{ key: PERMISSIONS.crmEnquiryDelete, label: 'Delete enquiries' },
|
||||||
{ key: PERMISSIONS.crmEnquiryAssign, label: 'Assign enquiries' },
|
{ key: PERMISSIONS.crmEnquiryAssign, label: 'Assign enquiries' },
|
||||||
{ key: PERMISSIONS.crmEnquiryReassign, label: 'Reassign enquiries' },
|
{ key: PERMISSIONS.crmEnquiryReassign, label: 'Reassign enquiries' },
|
||||||
|
{ key: PERMISSIONS.crmEnquiryMarkWon, label: 'Mark enquiries as won' },
|
||||||
|
{ key: PERMISSIONS.crmEnquiryMarkLost, label: 'Mark enquiries as lost' },
|
||||||
|
{ key: PERMISSIONS.crmEnquiryReopen, label: 'Reopen lost enquiries' },
|
||||||
{ key: PERMISSIONS.crmEnquiryFollowupRead, label: 'Read enquiry follow-ups' },
|
{ key: PERMISSIONS.crmEnquiryFollowupRead, label: 'Read enquiry follow-ups' },
|
||||||
{ key: PERMISSIONS.crmEnquiryFollowupCreate, label: 'Create enquiry follow-ups' },
|
{ key: PERMISSIONS.crmEnquiryFollowupCreate, label: 'Create enquiry follow-ups' },
|
||||||
{ key: PERMISSIONS.crmEnquiryFollowupUpdate, label: 'Update enquiry follow-ups' },
|
{ key: PERMISSIONS.crmEnquiryFollowupUpdate, label: 'Update enquiry follow-ups' },
|
||||||
|
|||||||
Reference in New Issue
Block a user