taks-d.2.1

This commit is contained in:
phaichayon
2026-06-17 14:46:52 +07:00
parent 0a484e0b45
commit 5be6c54272
39 changed files with 6254 additions and 316 deletions

View File

@@ -0,0 +1,36 @@
Status:
accepted
Context:
Task D.2 introduced separate `Billing Customer` and `Project Parties` data entry in CRM enquiries and quotations.
Task J.0 froze KPI definitions and confirmed that dashboard/reporting behavior must not drift without an explicit governance decision.
Task D.2.1 freezes how revenue attribution and relationship reporting work across dashboard, report center, exports, and future analytics features.
Decision:
- Freeze `Revenue Owner = End Customer`.
- Determine `Revenue Owner` from project-party role code `end_customer`.
- If a record has no `end_customer`, fallback to `billing_customer` for revenue-owner attribution only.
- Freeze `Billing Revenue` as quotation revenue grouped by project-party role `billing_customer`.
- Freeze `Contractor Revenue` as quotation revenue grouped by project-party role `contractor`.
- Freeze `Consultant Revenue` as quotation revenue grouped by project-party role `consultant`.
- Use project-party relationships as the reporting authority, not bare `crm_customers` references alone.
- Prefer quotation project parties for reporting. If quotation project parties are absent, fallback to enquiry project parties for the linked enquiry.
- When multiple parties share the same reporting role on one quotation, each party receives full quotation attribution.
- Do not prorate relationship analytics across multiple parties because this model is for CRM relationship reporting, not accounting allocation.
- Exclude cancelled quotations from revenue attribution by default unless an explicit reporting filter intentionally includes them.
Consequences:
- Dashboard KPI, report center, exports, and ad hoc analytics can share one attribution rule set.
- `Top End Customers`, `Top Contractors`, `Top Consultants`, and `Top Billing Customers` can be built from the same reusable service layer.
- A single quotation may contribute full value to more than one customer in relationship analytics when multiple parties share a role.
- Revenue attribution and accounting allocation remain intentionally separate concerns.
- Legacy or incomplete quotations without project-party rows may not attribute revenue until project-party data is synchronized or backfilled.
Future:
- add a unified reporting view or materialized projection if revenue analytics queries become heavy
- add explicit lifecycle timestamps for won/lost stage reporting precision
- decide whether approved snapshots should become the long-term reporting source for historically locked documents
- add backfill tooling if legacy quotations are found without authoritative project-party rows

View File

@@ -0,0 +1,39 @@
# Task D.2 Billing Customer + Project Parties
## Scope delivered
- Kept the main CRM field named `Billing Customer` on enquiry and quotation forms/detail pages.
- Added a separate `Project Parties` model for enquiries and aligned quotation related customers to the same business concept.
- Added `remark` support on quotation project parties and introduced enquiry project-party persistence.
- Switched role sourcing to the shared master-option category `crm_project_party_role`.
- Added compatibility mapping so legacy quotation roles such as `owner` and `billing` still render correctly.
## Data model
- Added `crm_enquiry_customers` for enquiry project parties.
- Added `remark` column to `crm_quotation_customers`.
- Generated migration `drizzle/0010_calm_wendell_rand.sql`.
## Server behavior
- Enquiry create/update now sync `Project Parties` transactionally.
- Quotation create/update now sync `Project Parties` transactionally.
- Billing customer is auto-added as a `billing_customer` project party during sync.
- Duplicate `customerId + roleCode` combinations are de-duplicated during main form sync and rejected in quotation detail CRUD.
- Quotation project-party reads normalize legacy role codes to current shared role options.
## UI changes
- Enquiry form now has `Billing Customer` plus a `Project Parties` editor.
- Enquiry detail now shows `Billing Customer` and `Project Parties` separately.
- Quotation form now has `Billing Customer` plus a `Project Parties` editor.
- Quotation detail overview separates `Billing Customer` and `Project Parties`.
- Quotation customer tab is relabeled to `Project Parties`, shows role clearly, and supports remark editing.
## Verification
- `npx tsc --noEmit` passes.
- `npm run lint` still fails because of pre-existing repo issues outside this task, including:
- `src/components/ui/input-group.tsx`
- `src/features/chat/components/message-composer.tsx`
- `src/components/forms/demo-form.tsx`

View File

@@ -0,0 +1,76 @@
# Task D.2.1: Revenue Attribution and Party Governance
## Scope delivered
- Froze the revenue attribution rules for CRM reporting.
- Froze project-party reporting authority so dashboard and reports use the same source of truth.
- Added reusable server-side helpers for grouped revenue analytics by party role.
- Added ADR governance so future reporting changes require an explicit decision trail.
## Frozen rules
### Revenue owner
- `Revenue Owner = End Customer`
- Authority: project-party role code `end_customer`
- Fallback: if no `end_customer` exists, use `billing_customer`
### Relationship revenue dimensions
- `Billing Revenue` = quotation revenue grouped by project-party role `billing_customer`
- `Contractor Revenue` = quotation revenue grouped by project-party role `contractor`
- `Consultant Revenue` = quotation revenue grouped by project-party role `consultant`
### Multiple party behavior
- When multiple parties share the same role on a quotation, each party receives full quotation attribution.
- This is intentional CRM relationship analytics behavior.
- No proration is applied.
### Reporting authority
- Reporting must use project-party relationships, not raw customer references by themselves.
- Service logic prefers `crm_quotation_customers`.
- If quotation parties are missing, service logic falls back to `crm_enquiry_customers` on the linked enquiry.
- Cancelled quotations are excluded by default from revenue attribution.
## Service layer added
Added server-only reporting helpers under `src/features/crm/reporting/server/`:
- `getRevenueByEndCustomer()`
- `getRevenueByBillingCustomer()`
- `getRevenueByContractor()`
- `getRevenueByConsultant()`
Shared behavior:
- organization scoped
- supports active quotation/report filters
- groups revenue by customer
- counts quotation coverage per attributed customer
- respects end-customer fallback to billing-customer for revenue-owner attribution
## Governance added
- Added ADR [0010-revenue-attribution-governance.md](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/adr/0010-revenue-attribution-governance.md)
- This ADR freezes:
- revenue owner rule
- project-party reporting rule
- fallback rule
- multiple-party rule
## Remaining risks
- quotations and enquiries still use separate party tables, so analytics currently depends on a precedence rule rather than a unified reporting projection
- legacy records without synchronized project-party rows may not attribute revenue until backfill or edit/save normalization happens
- historical analytics still reflects current project-party state, not an effective-dated party snapshot
## Task J readiness
- revenue attribution rules frozen
- revenue owner frozen
- project-party reporting rules frozen
- KPI dimensions frozen for reporting by end customer, billing customer, contractor, and consultant
- reusable service layer added for dashboard/report reuse
- ADR added

View File

@@ -0,0 +1,139 @@
# Task J.0: KPI Definition Freeze
## Scope delivered
- Froze the CRM sales pipeline definitions that Dashboard KPI must use.
- Froze customer party terminology so enquiry, quotation, and dashboard reporting use the same business language.
- Froze KPI and revenue formulas before Task J dashboard work begins.
- Declared that KPI definition changes after this point require a new ADR or equivalent decision record.
## Governance rule
- After Task J.0, KPI definitions, pipeline meanings, and dashboard counting rules must not change silently.
- Any future change to these definitions requires a new ADR so reporting behavior stays auditable.
## Frozen sales pipeline
### Lead
- Definition: enquiry is not assigned to sales yet.
- Rule: `assignedToUserId = null`
- Rule: `pipelineStage = lead`
- KPI impact: `Lead Count`, `Lead Aging`, `Lead Source`, `New Leads`, `Unassigned Leads`
### Opportunity
- Definition: enquiry has already been assigned to sales.
- Rule: `assignedToUserId != null`
- Rule: `pipelineStage = opportunity`
- Auto transition: assigning sales moves the record from `Lead` to `Opportunity`
- KPI impact: `Opportunity Count`, `Opportunity Value`, `Hot Opportunities`, `Opportunity Aging`, `Open Opportunities`
### Closed Won
- Definition: customer confirms the award, or the project is officially awarded.
- Rule: `pipelineStage = closed_won`
### Closed Lost
- Definition: competitor wins, budget is not approved, customer cancels, or project does not proceed.
- Rule: `pipelineStage = closed_lost`
## Frozen opportunity lifecycle
```txt
Lead
-> Assign Sales
Opportunity
-> Create Quotation
Opportunity
-> Approved Quotation
Opportunity
-> Award
Closed Won
```
## Frozen customer party definitions
- `Billing Customer`: the company used for quotation issue and billing.
- `End Customer`: the real project owner.
- `Contractor`: the construction contractor or main contractor.
- `Consultant`: the project consultant.
- `Customer`: a general commercial counterparty or related party when a more specific role is not applicable.
These terms are frozen as the shared language for CRM forms, detail views, and future dashboard/reporting logic.
## Frozen KPI definitions
### Lead metrics
- `Lead Count` = enquiries where `pipelineStage = lead`
- `New Leads` = lead-stage enquiries created within the selected reporting period
- `Unassigned Leads` = lead-stage enquiries where `assignedToUserId = null`
- `Lead Aging` = age of lead-stage enquiries based on creation date until current/report date
### Opportunity metrics
- `Opportunity Count` = enquiries where `pipelineStage = opportunity`
- `Open Opportunities` = opportunities still active and not moved to `closed_won` or `closed_lost`
- `Hot Opportunities` = opportunity-stage enquiries flagged by the future dashboard/business rule layer
- `Opportunity Aging` = age of opportunity-stage enquiries based on entry into active opportunity lifecycle until current/report date
### Conversion metrics
- `Lead -> Opportunity` = `Opportunity Created / Total Leads`
- `Opportunity -> Quotation` = `Opportunity With Quotation / Total Opportunities`
- `Quotation -> Won` = `Closed Won / Total Opportunities With Quotation`
## Frozen revenue rules
### Opportunity value
- Source: `crm_enquiries.estimatedValue`
- Condition: only count rows where `pipelineStage = opportunity`
### Quotation value
- Source: `crm_quotations.totalAmount`
- Condition: count quotations where `status != cancelled`
### Won value
- Source: `crm_quotations.totalAmount`
- Condition: count quotation-linked business only when parent enquiry `pipelineStage = closed_won`
### Lost value
- Source: `crm_quotations.totalAmount`
- Condition: count quotation-linked business only when parent enquiry `pipelineStage = closed_lost`
## Frozen ownership rules for reporting
- Dashboard ownership for `Lead` and `Opportunity` must use `assignedToUserId`.
- Dashboard ownership for `Quotation` must use `salesmanId`.
This prevents mixing enquiry ownership with quotation ownership in the same KPI bucket.
## Explicit non-scope items
Task J.0 intentionally does not add lifecycle timestamp fields or new master-data defaults.
Deferred fields:
- `convertedToOpportunityAt`
- `closedWonAt`
- `closedLostAt`
- `lostReason`
- `defaultPartyRole`
These remain technical debt and should be added only when downstream reporting or workflow requirements justify schema changes.
## Dashboard readiness
Task J can now build CRM dashboard KPIs on top of these frozen definitions:
- KPI definitions frozen
- sales pipeline frozen
- customer party definitions frozen
- revenue rules frozen
- dashboard semantics ready for implementation

View File

@@ -242,3 +242,58 @@ Future:
- decide when direct signed URLs are acceptable versus mandatory server streaming
- add response caching strategy for large artifact downloads
- evaluate optional download-audit sampling if volume grows
## After Task J.0
### Missing lifecycle timestamps for KPI precision
Task J.0 freezes KPI rules around `pipelineStage`, but the schema still lacks explicit lifecycle timestamps for stage conversion and closure events.
Future:
- add `convertedToOpportunityAt` for precise lead-to-opportunity timing
- add `closedWonAt` for won-date reporting
- add `closedLostAt` for lost-date reporting
- backfill reporting logic to prefer explicit lifecycle timestamps over inference when available
### Lost reason analytics
Closed-lost semantics are frozen, but there is still no structured `lostReason` field for dashboard grouping and sales analysis.
Future:
- add `lostReason` to the CRM enquiry lifecycle model
- define a controlled vocabulary or master-option category for loss analysis
- expose lost-reason reporting only after the field is reliably captured
### Default customer party role behavior
Customer party definitions are frozen, but there is still no `defaultPartyRole` behavior to help normalize customer selection and reduce operator ambiguity.
Future:
- decide whether customers should carry a suggested default project-party role
- add `defaultPartyRole` only if it improves data-entry quality without creating hidden automation
- keep dashboard logic independent from this field until the model is formally introduced
## After Task D.2.1
### Unified project-party reporting projection
Revenue attribution is now frozen, but the schema still stores enquiry and quotation parties in separate tables rather than a dedicated reporting projection.
Future:
- add a unified reporting view or materialized projection if dashboard/report queries become heavy
- keep quotation-party precedence and enquiry fallback centralized in one service until that projection exists
- avoid duplicating attribution logic in dashboard-specific code
### Historical party snapshot fidelity
Current revenue attribution uses live project-party relationships with quotation-first fallback behavior, but it does not yet provide effective-dated historical party snapshots for reporting.
Future:
- decide whether approved quotation snapshots or another immutable reporting snapshot should become the historical source of truth
- define how reporting should behave if project parties change after quotation approval or after a won/lost outcome
- add backfill or reconciliation tooling if historical party drift becomes a business issue

View File

@@ -0,0 +1,13 @@
CREATE TABLE "crm_enquiry_customers" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"enquiry_id" text NOT NULL,
"customer_id" text NOT NULL,
"role" text 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
ALTER TABLE "crm_quotation_customers" ADD COLUMN "remark" text;

File diff suppressed because it is too large Load Diff

View File

@@ -71,6 +71,13 @@
"when": 1781603421415,
"tag": "0009_lazy_shard",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1781675409847,
"tag": "0010_calm_wendell_rand",
"breakpoints": true
}
]
}

370
plans/task-d.2.1.md Normal file
View File

@@ -0,0 +1,370 @@
# Task D.2.1: Revenue Attribution & Party Governance
## Goal
Freeze Revenue Attribution Rules และ Project Party Reporting Rules สำหรับ ALLA OS CRM
หลัง Task D.2.1 ผ่านแล้ว:
* Dashboard KPI
* Report Center
* Export
* Sales Analytics
ต้องใช้กติกาชุดเดียวกัน
ห้ามแต่ละ module ตีความ Revenue Owner เอง
---
# Business Problem
ตัวอย่าง:
Billing Customer:
```txt
Italian Thai
```
Project Parties:
```txt
PTT -> End Customer
Italian Thai -> Contractor
AEC -> Consultant
ALLA -> Billing Customer
```
Quotation:
```txt
5,000,000 THB
```
คำถาม:
```txt
Revenue นี้เป็นของใคร?
```
ถ้าไม่ Freeze ตอนนี้
Dashboard และ Report จะให้คำตอบไม่ตรงกัน
---
# Scope D2.1.1 Revenue Attribution Rule
## Revenue Owner
Freeze:
```txt
Revenue Owner = End Customer
```
Rules:
* ใช้ Project Party role = `end_customer`
* ถ้าไม่มี end_customer ให้ใช้ billing_customer fallback
* Dashboard ต้องใช้ Revenue Owner ในการจัดอันดับลูกค้า
Example:
```txt
Quotation = 5,000,000
Billing Customer:
Italian Thai
End Customer:
PTT
```
Revenue Attribution:
```txt
PTT = 5,000,000
```
---
# Scope D2.1.2 Billing Customer Revenue
Freeze:
```txt
Billing Revenue
```
Definition:
```txt
Sum quotation value
where party role = billing_customer
```
Purpose:
```txt
Commercial relationship reporting
```
Example:
```txt
Italian Thai
```
ได้รับ Billing Revenue
---
# Scope D2.1.3 Contractor Revenue
Freeze:
```txt
Contractor Revenue
```
Definition:
```txt
Sum quotation value
where party role = contractor
```
Purpose:
```txt
Contractor relationship reporting
```
---
# Scope D2.1.4 Consultant Revenue
Freeze:
```txt
Consultant Revenue
```
Definition:
```txt
Sum quotation value
where party role = consultant
```
Purpose:
```txt
Consultant relationship reporting
```
---
# Scope D2.1.5 Multiple Party Rule
Example:
```txt
Contractor A
Contractor B
```
Rules:
```txt
Both receive full attribution
```
Meaning:
```txt
Revenue = 5,000,000
Contractor A = 5,000,000
Contractor B = 5,000,000
```
Reason:
```txt
CRM relationship analytics
Not accounting allocation
```
Do NOT prorate.
---
# Scope D2.1.6 Dashboard Reporting Rule
Dashboard must use:
```txt
crm_project_parties
crm_enquiry_customers
crm_quotation_customers
```
Dashboard must NOT use:
```txt
crm_customers only
```
as revenue source.
Project Party role is the reporting authority.
---
# Scope D2.1.7 KPI Extension
Freeze new KPI dimensions:
## End Customer
```txt
Top End Customers
Revenue by End Customer
Won Revenue by End Customer
Lost Revenue by End Customer
```
---
## Contractor
```txt
Top Contractors
Revenue by Contractor
Won Revenue by Contractor
```
---
## Consultant
```txt
Top Consultants
Revenue by Consultant
Won Revenue by Consultant
```
---
## Billing Customer
```txt
Top Billing Customers
Revenue by Billing Customer
```
---
# Scope D2.1.8 Service Layer
Create reporting helpers:
```ts
getRevenueByEndCustomer()
getRevenueByBillingCustomer()
getRevenueByContractor()
getRevenueByConsultant()
```
Rules:
* organization scoped
* respect active filters
* reusable by Dashboard and Reports
No UI required.
---
# Scope D2.1.9 Governance Documentation
Create ADR:
```txt
Revenue Attribution Governance
```
Document:
```txt
Revenue Owner Rule
Project Party Reporting Rule
Fallback Rule
Multiple Party Rule
```
Update:
```txt
docs/implementation/technical-debt.md
```
if any unresolved reporting edge cases are discovered.
---
# Explicit Non-Scope
Do NOT implement:
```txt
Dashboard UI
Charts
Export
Forecasting
Commission calculation
Accounting allocation
Profitability reporting
```
This task is governance only.
---
# Output
1. Revenue Attribution Rules Frozen
2. Revenue Owner Frozen
3. Project Party Reporting Rules Frozen
4. KPI Dimensions Frozen
5. Service Layer Added
6. ADR Added
7. Remaining Risks
8. Task J Readiness
---
# Definition of Done
✔ Revenue Owner frozen
✔ End Customer attribution frozen
✔ Billing attribution frozen
✔ Contractor attribution frozen
✔ Consultant attribution frozen
✔ Multiple party rule frozen
✔ Dashboard reporting rule frozen
✔ KPI dimensions frozen
✔ ADR created
✔ Ready for Task J Dashboard KPI

219
plans/task-d.2.md Normal file
View File

@@ -0,0 +1,219 @@
# Addendum for Task D.2: Customer Role Selection UI + Separate Project Party Field
## Context
ตอนนี้การเลือก Customer ใน Lead / Opportunity / Quotation ใช้งานได้แล้ว แต่ยังไม่ชัดว่า Customer ที่เลือกมีบทบาทอะไรในโครงการ
Business requirement ใหม่:
* Customer หลักในฟอร์มยังใช้เป็น Billing Customer ได้
* แต่ต้องมี Project Party แยกอีกชุดหนึ่ง
* Project Party ใช้บอกว่าในโครงการนี้มีบริษัทไหนทำ role อะไร
* บริษัทเดียวกันอาจมี role ต่างกันในแต่ละโครงการ
---
# UI Requirement 1: Billing Customer Field
ใน Lead / Opportunity / Quotation form ให้คง field หลัก:
```txt
Billing Customer
```
Data source:
```txt
crm_customers
```
Meaning:
```txt
ลูกค้าที่ใช้เป็นผู้ติดต่อ/ผู้ออกเอกสาร/ผู้วางบิลหลัก
```
Do not rename this field to generic Customer.
---
# UI Requirement 2: Project Party Section
เพิ่ม section แยกชื่อ:
```txt
Project Parties
```
หรือภาษาไทย:
```txt
บริษัทที่เกี่ยวข้องในโครงการ
```
ใน form/detail ของ:
```txt
Lead / Opportunity
Quotation
```
Project Party row ต้องมี field:
```txt
Customer
Role
Remark
```
Role ดึงจาก master option:
```txt
crm_project_party_role
```
Role values:
```txt
billing_customer
customer
consultant
contractor
end_customer
other
```
---
# UI Requirement 3: Add Party Flow
ใน Project Parties section ต้องมีปุ่ม:
```txt
Add Party
```
เมื่อกดให้เลือก:
```txt
Customer
Role
Remark optional
```
ถ้า customer ยังไม่มีใน master ให้ยังไม่ต้องสร้าง inline customer ใน Task นี้
ให้ใช้ customer master ที่มีอยู่ก่อน
---
# UI Requirement 4: Display Role Clearly
ทุกที่ที่แสดง Project Party ต้องแสดง role ชัดเจน เช่น:
```txt
PTT Public Company Limited
Role: End Customer
Italian Thai
Role: Contractor
AEC Consultant
Role: Consultant
```
ห้ามแสดงแค่ชื่อบริษัทโดยไม่มี role
---
# UI Requirement 5: Billing Customer Auto Add Option
เมื่อสร้าง Lead / Opportunity / Quotation แล้วเลือก Billing Customer
ให้ระบบสามารถ auto-add ลง Project Parties เป็น role:
```txt
billing_customer
```
ถ้าทำได้โดยไม่ซับซ้อน
Rules:
* ห้าม duplicate customerId + roleCode ใน entity เดียวกัน
* ถ้า billing customer มีอยู่แล้วใน Project Parties ไม่ต้องเพิ่มซ้ำ
* ถ้า user เปลี่ยน Billing Customer ให้ถาม/หรือ update ตาม business-safe behavior
ถ้า logic นี้ใหญ่เกินไป ให้ทำเป็น TODO และไม่ block Task D.2
---
# UI Requirement 6: Detail Page
ใน detail page ให้แสดงแยกชัดเจน:
```txt
Billing Customer
```
และ
```txt
Project Parties
```
ตัวอย่าง:
```txt
Billing Customer:
ABC Engineering Co., Ltd.
Project Parties:
- ABC Engineering Co., Ltd. / Billing Customer
- PTT / End Customer
- Italian Thai / Contractor
- AEC / Consultant
```
---
# UI Requirement 7: Quotation Compatibility
Quotation เดิมมี:
```txt
crm_quotation_customers
```
ให้ refactor/align กับ Project Parties
แต่ UI ต้องแสดงแบบเดียวกัน:
```txt
Billing Customer
Project Parties
```
อย่าใช้คำว่า Owner ถ้า business ไม่ใช้แล้ว
Mapping เก่า:
```txt
owner -> customer
billing -> billing_customer
consultant -> consultant
contractor -> contractor
```
---
# Definition of Done Addendum
Task D.2 จะถือว่าครบเมื่อ:
* Billing Customer เป็น field หลักแยกจาก Project Parties
* Project Parties เป็น section/table แยก
* แต่ละ Project Party มี Customer + Role + Remark
* Role ดึงจาก master option
* Detail page แสดง Billing Customer และ Project Parties แยกกัน
* Quotation compatibility mapping ไม่ทำให้ข้อมูลเก่าหาย
* ไม่มี UI ที่แสดงบริษัทเกี่ยวข้องโดยไม่บอก role

421
plans/task-j.0.md Normal file
View File

@@ -0,0 +1,421 @@
# Task J.0: KPI Definition Freeze
## Goal
Freeze KPI Definitions และ Sales Pipeline Definitions ก่อนเริ่มสร้าง CRM Dashboard KPI
หลัง Task J.0 ผ่านแล้ว ห้ามเปลี่ยนนิยาม KPI โดยไม่มี ADR ใหม่
---
# Sales Pipeline Definition
## Lead
Definition:
```txt
ยังไม่ได้ Assign Sales
```
Rules:
```txt
assignedToUserId = null
pipelineStage = lead
```
KPI Impact:
```txt
Lead Count
Lead Aging
Lead Source
New Leads
```
---
## Opportunity
Definition:
```txt
Assign Sales แล้ว
```
Rules:
```txt
assignedToUserId != null
pipelineStage = opportunity
```
Auto Transition:
```txt
Lead
↓ Assign Sales
Opportunity
```
KPI Impact:
```txt
Opportunity Count
Opportunity Value
Hot Opportunities
Opportunity Aging
```
---
## Closed Won
Definition:
```txt
ลูกค้ายืนยันรับงาน
หรือโครงการได้รับ Award
```
Rules:
```txt
pipelineStage = closed_won
```
Recommendation:
Add future field:
```txt
closedWonAt
```
---
## Closed Lost
Definition:
```txt
แพ้คู่แข่ง
งบประมาณไม่ผ่าน
ลูกค้ายกเลิก
โครงการไม่เดินต่อ
```
Rules:
```txt
pipelineStage = closed_lost
```
Recommendation:
Add future field:
```txt
closedLostAt
lostReason
```
---
# Opportunity Lifecycle
```txt
Lead
↓ Assign Sales
Opportunity
↓ Create Quotation
Opportunity
↓ Approved Quotation
Opportunity
↓ Award
Closed Won
```
---
# Customer Party Definition
## Billing Customer
Definition:
```txt
บริษัทที่ใช้ในการออกใบเสนอราคา
หรือวางบิล
```
Examples:
```txt
ALLA
Italian Thai
SCG
```
---
## End Customer
Definition:
```txt
เจ้าของโครงการตัวจริง
```
Examples:
```txt
PTT
CP
SCG Chemicals
```
---
## Contractor
Definition:
```txt
ผู้รับเหมาก่อสร้าง
```
---
## Consultant
Definition:
```txt
ที่ปรึกษาโครงการ
```
---
## Customer
Definition:
```txt
คู่ค้าหรือผู้เกี่ยวข้องทั่วไป
```
---
# KPI Definitions
## Lead Metrics
```txt
Lead Count
New Leads
Unassigned Leads
Lead Aging
```
Lead Count:
```txt
pipelineStage = lead
```
---
## Opportunity Metrics
```txt
Opportunity Count
Open Opportunities
Hot Opportunities
```
Opportunity Count:
```txt
pipelineStage = opportunity
```
---
## Conversion Metrics
### Lead → Opportunity
Formula:
```txt
Opportunity Created
/
Total Leads
```
---
### Opportunity → Quotation
Formula:
```txt
Opportunity With Quotation
/
Total Opportunities
```
---
### Quotation → Won
Formula:
```txt
Closed Won
/
Total Opportunities With Quotation
```
---
# Revenue Metrics
## Opportunity Value
Source:
```txt
crm_enquiries.estimatedValue
```
Condition:
```txt
pipelineStage = opportunity
```
---
## Quotation Value
Source:
```txt
crm_quotations.totalAmount
```
Condition:
```txt
quotation status != cancelled
```
---
## Won Value
Source:
```txt
crm_quotations.totalAmount
```
Condition:
```txt
pipelineStage = closed_won
```
---
## Lost Value
Source:
```txt
crm_quotations.totalAmount
```
Condition:
```txt
pipelineStage = closed_lost
```
---
# Sales Ownership
Dashboard must use:
```txt
assignedToUserId
```
for:
```txt
Lead
Opportunity
```
and
```txt
salesmanId
```
for:
```txt
Quotation
```
---
# Future Fields (Not In Scope)
Do NOT implement now.
Track as technical debt:
```txt
convertedToOpportunityAt
closedWonAt
closedLostAt
lostReason
defaultPartyRole
```
---
# Output
1. KPI Definitions Frozen
2. Sales Pipeline Frozen
3. Customer Party Definitions Frozen
4. Revenue Rules Frozen
5. Dashboard Ready
---
# Definition of Done
✔ Lead definition frozen
✔ Opportunity definition frozen
✔ Won/Lost definition frozen
✔ Billing Customer definition frozen
✔ Project Party role definition frozen
✔ KPI formulas frozen
✔ Revenue formulas frozen
✔ Ready for Task J

0
plans/task-j.md Normal file
View File

View File

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server';
import { listEnquiryProjectParties } 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 GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryRead
});
const items = await listEnquiryProjectParties(id, organization.id);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Enquiry project parties 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 enquiry project parties' },
{ status: 500 }
);
}
}

View File

@@ -1,7 +1,11 @@
import { auth } from '@/auth';
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import PageContainer from '@/components/layout/page-container';
import { enquiryByIdOptions, enquiryFollowupsOptions } from '@/features/crm/enquiries/api/queries';
import {
enquiryByIdOptions,
enquiryFollowupsOptions,
enquiryProjectPartiesOptions
} from '@/features/crm/enquiries/api/queries';
import { EnquiryDetail } from '@/features/crm/enquiries/components/enquiry-detail';
import { getEnquiryReferenceData } from '@/features/crm/enquiries/server/service';
import { listEnquiryQuotationRelations } from '@/features/crm/quotations/server/service';
@@ -54,6 +58,7 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
if (canRead) {
void queryClient.prefetchQuery(enquiryByIdOptions(id));
void queryClient.prefetchQuery(enquiryProjectPartiesOptions(id));
void queryClient.prefetchQuery(enquiryFollowupsOptions(id));
}

View File

@@ -1,14 +1,14 @@
import { auth } from '@/auth';
import PageContainer from '@/components/layout/page-container';
import EnquiryListing from '@/features/crm/enquiries/components/enquiry-listing';
import { EnquiryFormSheetTrigger } from '@/features/crm/enquiries/components/enquiry-form-sheet';
import { getEnquiryReferenceData } from '@/features/crm/enquiries/server/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { searchParamsCache } from '@/lib/searchparams';
import type { SearchParams } from 'nuqs/server';
import { auth } from "@/auth";
import PageContainer from "@/components/layout/page-container";
import EnquiryListing from "@/features/crm/enquiries/components/enquiry-listing";
import { EnquiryFormSheetTrigger } from "@/features/crm/enquiries/components/enquiry-form-sheet";
import { getEnquiryReferenceData } from "@/features/crm/enquiries/server/service";
import { PERMISSIONS } from "@/lib/auth/rbac";
import { searchParamsCache } from "@/lib/searchparams";
import type { SearchParams } from "nuqs/server";
export const metadata = {
title: 'Dashboard: CRM Enquiries'
title: "Dashboard: CRM Enquiries",
};
type PageProps = {
@@ -19,35 +19,37 @@ export default async function EnquiriesRoute(props: PageProps) {
const searchParams = await props.searchParams;
const session = await auth();
const canRead =
session?.user?.systemRole === 'super_admin' ||
session?.user?.systemRole === "super_admin" ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
(session.user.activeMembershipRole === "admin" ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryRead)));
const canCreate =
session?.user?.systemRole === 'super_admin' ||
session?.user?.systemRole === "super_admin" ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
(session.user.activeMembershipRole === "admin" ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryCreate)));
const canUpdate =
session?.user?.systemRole === 'super_admin' ||
session?.user?.systemRole === "super_admin" ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
(session.user.activeMembershipRole === "admin" ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryUpdate)));
const canDelete =
session?.user?.systemRole === 'super_admin' ||
session?.user?.systemRole === "super_admin" ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
(session.user.activeMembershipRole === "admin" ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryDelete)));
const canAssign =
session?.user?.systemRole === 'super_admin' ||
session?.user?.systemRole === "super_admin" ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
(session.user.activeMembershipRole === "admin" ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryAssign)));
const canReassign =
session?.user?.systemRole === 'super_admin' ||
session?.user?.systemRole === "super_admin" ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryReassign)));
(session.user.activeMembershipRole === "admin" ||
session.user.activePermissions.includes(
PERMISSIONS.crmEnquiryReassign,
)));
searchParamsCache.parse(searchParams);
@@ -58,11 +60,11 @@ export default async function EnquiriesRoute(props: PageProps) {
return (
<PageContainer
pageTitle='Enquiries'
pageDescription='Production opportunity registry with customer linkage, follow-up workflow, and audit-backed activity.'
pageTitle="Enquiries (Lead)"
pageDescription="Production opportunity registry with customer linkage, follow-up workflow, and audit-backed activity."
access={canRead}
accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
<div className="text-muted-foreground rounded-lg border border-dashed p-8 text-center">
You do not have access to CRM enquiries.
</div>
}

View File

@@ -35,110 +35,113 @@ import { NavGroup } from "@/types";
*/
export const navGroups: NavGroup[] = [
{
label: 'Overview',
label: "Overview",
items: [
{
title: 'Dashboard',
url: '/dashboard',
icon: 'dashboard',
title: "Dashboard",
url: "/dashboard",
icon: "dashboard",
isActive: false,
shortcut: ['d', 'd'],
items: []
shortcut: ["d", "d"],
items: [],
},
{
title: 'Workspaces',
url: '/dashboard/workspaces',
icon: 'workspace',
title: "Workspaces",
url: "/dashboard/workspaces",
icon: "workspace",
isActive: false,
items: [],
access: { systemRole: 'super_admin' }
access: { systemRole: "super_admin" },
},
{
title: 'Teams',
url: '/dashboard/workspaces/team',
icon: 'teams',
title: "Teams",
url: "/dashboard/workspaces/team",
icon: "teams",
isActive: false,
items: [],
access: { requireOrg: true, role: 'admin' }
access: { requireOrg: true, role: "admin" },
},
{
title: 'Users',
url: '/dashboard/users',
icon: 'teams',
shortcut: ['u', 'u'],
title: "Users",
url: "/dashboard/users",
icon: "teams",
shortcut: ["u", "u"],
isActive: false,
items: [],
access: { requireOrg: true, permission: 'users:manage' }
access: { requireOrg: true, permission: "users:manage" },
},
{
title: 'Kanban',
url: '/dashboard/kanban',
icon: 'kanban',
shortcut: ['k', 'k'],
title: "Kanban",
url: "/dashboard/kanban",
icon: "kanban",
shortcut: ["k", "k"],
isActive: false,
items: []
}
]
items: [],
},
],
},
{
label: 'CRM',
label: "CRM",
items: [
{
title: 'Customers',
url: '/dashboard/crm/customers',
icon: 'teams',
title: "Customers",
url: "/dashboard/crm/customers",
icon: "teams",
isActive: false,
items: [],
access: { requireOrg: true, permission: 'crm.customer.read' }
access: { requireOrg: true, permission: "crm.customer.read" },
},
{
title: 'Enquiries',
url: '/dashboard/crm/enquiries',
icon: 'forms',
title: "Enquiries (Lead)",
url: "/dashboard/crm/enquiries",
icon: "forms",
isActive: false,
items: [],
access: { requireOrg: true, permission: 'crm.enquiry.read' }
access: { requireOrg: true, permission: "crm.enquiry.read" },
},
{
title: 'Quotations',
url: '/dashboard/crm/quotations',
icon: 'post',
title: "Quotations",
url: "/dashboard/crm/quotations",
icon: "post",
isActive: false,
items: [],
access: { requireOrg: true, role: 'admin' }
access: { requireOrg: true, role: "admin" },
},
{
title: 'Approvals',
url: '/dashboard/crm/approvals',
icon: 'checks',
title: "Approvals",
url: "/dashboard/crm/approvals",
icon: "checks",
isActive: false,
items: [],
access: { requireOrg: true, permission: 'crm.approval.read' }
access: { requireOrg: true, permission: "crm.approval.read" },
},
{
title: 'CRM Settings',
url: '/dashboard/crm/settings/master-options',
icon: 'settings',
title: "CRM Settings",
url: "/dashboard/crm/settings/master-options",
icon: "settings",
isActive: false,
access: { requireOrg: true, permission: 'crm.document_template.read' },
access: { requireOrg: true, permission: "crm.document_template.read" },
items: [
{
title: 'Master Options',
url: '/dashboard/crm/settings/master-options',
access: { requireOrg: true, permission: 'organization:manage' }
title: "Master Options",
url: "/dashboard/crm/settings/master-options",
access: { requireOrg: true, permission: "organization:manage" },
},
{
title: 'Document Sequences',
url: '/dashboard/crm/settings/document-sequences',
access: { requireOrg: true, permission: 'organization:manage' }
title: "Document Sequences",
url: "/dashboard/crm/settings/document-sequences",
access: { requireOrg: true, permission: "organization:manage" },
},
{
title: 'Templates',
url: '/dashboard/crm/settings/templates',
access: { requireOrg: true, permission: 'crm.document_template.read' }
}
]
}
]
}
title: "Templates",
url: "/dashboard/crm/settings/templates",
access: {
requireOrg: true,
permission: "crm.document_template.read",
},
},
],
},
],
},
];

View File

@@ -251,6 +251,18 @@ export const crmEnquiryFollowups = pgTable('crm_enquiry_followups', {
updatedBy: text('updated_by').notNull()
});
export const crmEnquiryCustomers = pgTable('crm_enquiry_customers', {
id: text('id').primaryKey(),
organizationId: text('organization_id').notNull(),
enquiryId: text('enquiry_id').notNull(),
customerId: text('customer_id').notNull(),
role: text('role').notNull(),
remark: text('remark'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
deletedAt: timestamp('deleted_at', { withTimezone: true })
});
export const crmQuotations = pgTable(
'crm_quotations',
{
@@ -339,6 +351,7 @@ export const crmQuotationCustomers = pgTable('crm_quotation_customers', {
customerId: text('customer_id').notNull(),
role: text('role').notNull(),
isPrimary: boolean('is_primary').default(false).notNull(),
remark: text('remark'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
deletedAt: timestamp('deleted_at', { withTimezone: true })

View File

@@ -171,11 +171,18 @@ const FOUNDATION_OPTIONS = {
{ code: 'manual', label: 'Manual', value: 'manual', sortOrder: 2 },
{ code: 'system', label: 'System', value: 'system', sortOrder: 3 }
],
crm_quotation_customer_role: [
{ code: 'owner', label: 'Owner', value: 'owner', sortOrder: 1 },
{ code: 'consultant', label: 'Consultant', value: 'consultant', sortOrder: 2 },
{ code: 'contractor', label: 'Contractor', value: 'contractor', sortOrder: 3 },
{ code: 'billing', label: 'Billing', value: 'billing', sortOrder: 4 }
crm_project_party_role: [
{
code: 'billing_customer',
label: 'Billing Customer',
value: 'billing_customer',
sortOrder: 1
},
{ code: 'customer', label: 'Customer', value: 'customer', sortOrder: 2 },
{ code: 'consultant', label: 'Consultant', value: 'consultant', sortOrder: 3 },
{ code: 'contractor', label: 'Contractor', value: 'contractor', sortOrder: 4 },
{ code: 'end_customer', label: 'End Customer', value: 'end_customer', sortOrder: 5 },
{ code: 'other', label: 'Other', value: 'other', sortOrder: 6 }
],
crm_quotation_topic_type: [
{ code: 'scope', label: 'Scope', value: 'scope', sortOrder: 1 },

View File

@@ -0,0 +1,153 @@
'use client';
import { Icons } from '@/components/icons';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select';
export interface ProjectPartyEditorCustomer {
id: string;
code: string;
name: string;
}
export interface ProjectPartyEditorRole {
id: string;
code: string;
label: string;
}
export interface ProjectPartyEditorItem {
key: string;
customerId: string;
role: string;
remark: string;
}
export function createEmptyProjectPartyItem(
roles: ProjectPartyEditorRole[],
initial?: Partial<Omit<ProjectPartyEditorItem, 'key'>>
): ProjectPartyEditorItem {
return {
key: crypto.randomUUID(),
customerId: initial?.customerId ?? '',
role: initial?.role ?? roles[0]?.id ?? '',
remark: initial?.remark ?? ''
};
}
export function ProjectPartiesEditor({
customers,
roles,
items,
onChange
}: {
customers: ProjectPartyEditorCustomer[];
roles: ProjectPartyEditorRole[];
items: ProjectPartyEditorItem[];
onChange: (items: ProjectPartyEditorItem[]) => void;
}) {
function updateItem(
key: string,
field: keyof Omit<ProjectPartyEditorItem, 'key'>,
value: string
) {
onChange(
items.map((item) => (item.key === key ? { ...item, [field]: value } : item))
);
}
function removeItem(key: string) {
onChange(items.filter((item) => item.key !== key));
}
function addItem() {
onChange([...items, createEmptyProjectPartyItem(roles)]);
}
return (
<div className='space-y-3'>
<div className='flex items-center justify-between gap-3'>
<div>
<div className='text-sm font-medium'>Project Parties</div>
<p className='text-muted-foreground text-sm'>
Add related companies with their role and optional remark.
</p>
</div>
<Button type='button' variant='outline' size='sm' onClick={addItem}>
<Icons.add className='mr-2 h-4 w-4' /> Add Party
</Button>
</div>
{!items.length ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-4 text-sm'>
No project parties yet. Billing customer will still stay separate from this list.
</div>
) : null}
<div className='space-y-3'>
{items.map((item, index) => (
<div key={item.key} className='rounded-lg border p-4'>
<div className='mb-3 flex items-center justify-between gap-3'>
<div className='text-sm font-medium'>Party {index + 1}</div>
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => removeItem(item.key)}
>
<Icons.trash className='mr-2 h-4 w-4' /> Remove
</Button>
</div>
<div className='grid gap-3 md:grid-cols-3'>
<Select
value={item.customerId || '__none__'}
onValueChange={(value) =>
updateItem(item.key, 'customerId', value === '__none__' ? '' : value)
}
>
<SelectTrigger>
<SelectValue placeholder='Customer' />
</SelectTrigger>
<SelectContent>
<SelectItem value='__none__'>Select customer</SelectItem>
{customers.map((customer) => (
<SelectItem key={customer.id} value={customer.id}>
{customer.name} ({customer.code})
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={item.role || '__none__'} onValueChange={(value) => updateItem(item.key, 'role', value === '__none__' ? '' : value)}>
<SelectTrigger>
<SelectValue placeholder='Role' />
</SelectTrigger>
<SelectContent>
<SelectItem value='__none__'>Select role</SelectItem>
{roles.map((role) => (
<SelectItem key={role.id} value={role.id}>
{role.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={item.remark}
onChange={(event) => updateItem(item.key, 'remark', event.target.value)}
placeholder='Remark'
/>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -25,12 +25,20 @@ async function invalidateEnquiryDetail(id: string) {
await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.detail(id) });
}
async function invalidateEnquiryProjectParties(id: string) {
await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.projectParties(id) });
}
async function invalidateEnquiryFollowups(id: string) {
await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.followups(id) });
}
export async function invalidateEnquiryMutationQueries(id: string) {
await Promise.all([invalidateEnquiryLists(), invalidateEnquiryDetail(id)]);
await Promise.all([
invalidateEnquiryLists(),
invalidateEnquiryDetail(id),
invalidateEnquiryProjectParties(id)
]);
}
export async function invalidateEnquiryFollowupMutationQueries(enquiryId: string) {
@@ -65,6 +73,7 @@ export const deleteEnquiryMutation = mutationOptions({
onSettled: async (_data, error, id) => {
if (!error) {
getQueryClient().removeQueries({ queryKey: enquiryKeys.detail(id) });
getQueryClient().removeQueries({ queryKey: enquiryKeys.projectParties(id) });
getQueryClient().removeQueries({ queryKey: enquiryKeys.followups(id) });
await invalidateEnquiryLists();
}

View File

@@ -1,5 +1,10 @@
import { queryOptions } from '@tanstack/react-query';
import { getEnquiries, getEnquiryById, getEnquiryFollowups } from './service';
import {
getEnquiries,
getEnquiryById,
getEnquiryFollowups,
getEnquiryProjectParties
} from './service';
import type { EnquiryFilters } from './types';
export const enquiryKeys = {
@@ -8,6 +13,8 @@ export const enquiryKeys = {
list: (filters: EnquiryFilters) => [...enquiryKeys.lists(), filters] as const,
details: () => [...enquiryKeys.all, 'detail'] as const,
detail: (id: string) => [...enquiryKeys.details(), id] as const,
projectPartiesRoot: () => [...enquiryKeys.all, 'project-parties'] as const,
projectParties: (id: string) => [...enquiryKeys.projectPartiesRoot(), id] as const,
followupsRoot: () => [...enquiryKeys.all, 'followups'] as const,
followups: (id: string) => [...enquiryKeys.followupsRoot(), id] as const
};
@@ -24,6 +31,12 @@ export const enquiryByIdOptions = (id: string) =>
queryFn: () => getEnquiryById(id)
});
export const enquiryProjectPartiesOptions = (id: string) =>
queryOptions({
queryKey: enquiryKeys.projectParties(id),
queryFn: () => getEnquiryProjectParties(id)
});
export const enquiryFollowupsOptions = (id: string) =>
queryOptions({
queryKey: enquiryKeys.followups(id),

View File

@@ -7,6 +7,7 @@ import type {
EnquiryFollowupsResponse,
EnquiryListResponse,
EnquiryMutationPayload,
EnquiryProjectPartiesResponse,
MutationSuccessResponse
} from './types';
@@ -32,6 +33,10 @@ export async function getEnquiryById(id: string): Promise<EnquiryDetailResponse>
return apiClient<EnquiryDetailResponse>(`/crm/enquiries/${id}`);
}
export async function getEnquiryProjectParties(id: string): Promise<EnquiryProjectPartiesResponse> {
return apiClient<EnquiryProjectPartiesResponse>(`/crm/enquiries/${id}/customers`);
}
export async function createEnquiry(data: EnquiryMutationPayload) {
return apiClient<MutationSuccessResponse>('/crm/enquiries', {
method: 'POST',

View File

@@ -5,6 +5,25 @@ export interface EnquiryOption {
value: string | null;
}
export interface EnquiryProjectPartyRecord {
id: string;
organizationId: string;
enquiryId: string;
customerId: string;
role: string;
roleCode: string;
roleLabel: string | null;
remark: string | null;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
export interface EnquiryProjectPartyListItem extends EnquiryProjectPartyRecord {
customerName: string;
customerCode: string;
}
export interface EnquiryBranchOption {
id: string;
code: string;
@@ -113,6 +132,7 @@ export interface EnquiryReferenceData {
priorities: EnquiryOption[];
leadChannels: EnquiryOption[];
followupTypes: EnquiryOption[];
projectPartyRoles: EnquiryOption[];
customers: EnquiryCustomerLookup[];
contacts: EnquiryContactLookup[];
assignableUsers: EnquiryAssignableUserLookup[];
@@ -148,6 +168,13 @@ export interface EnquiryDetailResponse {
activity: EnquiryActivityRecord[];
}
export interface EnquiryProjectPartiesResponse {
success: boolean;
time: string;
message: string;
items: EnquiryProjectPartyListItem[];
}
export interface EnquiryFollowupsResponse {
success: boolean;
time: string;
@@ -155,6 +182,12 @@ export interface EnquiryFollowupsResponse {
items: EnquiryFollowupRecord[];
}
export interface EnquiryProjectPartyMutationPayload {
customerId: string;
role: string;
remark?: string | null;
}
export interface EnquiryMutationPayload {
customerId: string;
contactId?: string | null;
@@ -176,6 +209,7 @@ export interface EnquiryMutationPayload {
isHotProject?: boolean;
notes?: string;
isActive?: boolean;
projectParties?: EnquiryProjectPartyMutationPayload[];
}
export interface EnquiryFollowupMutationPayload {

View File

@@ -9,7 +9,7 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { enquiryByIdOptions } from '../api/queries';
import { enquiryByIdOptions, enquiryProjectPartiesOptions } from '../api/queries';
import type { EnquiryReferenceData } from '../api/types';
import type { QuotationRelationItem } from '@/features/crm/quotations/api/types';
import { EnquiryAssignmentDialog } from './enquiry-assignment-dialog';
@@ -48,6 +48,7 @@ export function EnquiryDetail({
};
}) {
const { data } = useSuspenseQuery(enquiryByIdOptions(enquiryId));
const { data: projectPartiesData } = useSuspenseQuery(enquiryProjectPartiesOptions(enquiryId));
const [editOpen, setEditOpen] = useState(false);
const [assignmentOpen, setAssignmentOpen] = useState(false);
const enquiry = data.enquiry;
@@ -142,7 +143,7 @@ export function EnquiryDetail({
</CardDescription>
</CardHeader>
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
<FieldItem label='Customer' value={customer?.name} />
<FieldItem label='Billing Customer' value={customer?.name} />
<FieldItem label='Contact' value={contact?.name} />
<FieldItem
label='Branch'
@@ -192,6 +193,28 @@ export function EnquiryDetail({
/>
<FieldItem label='Assigned By' value={enquiry.assignedByName} />
<FieldItem label='Assignment Remark' value={enquiry.assignmentRemark} />
<div className='md:col-span-2 xl:col-span-4'>
<div className='space-y-3'>
<div className='text-muted-foreground text-xs'>Project Parties</div>
{!projectPartiesData.items.length ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-4 text-sm'>
No project parties have been added yet.
</div>
) : (
<div className='space-y-3'>
{projectPartiesData.items.map((item) => (
<div key={item.id} className='rounded-lg border p-4 text-sm'>
<div className='font-medium'>{item.customerName}</div>
<div className='text-muted-foreground'>
Role: {item.roleLabel ?? item.roleCode}
</div>
{item.remark ? <div className='mt-1'>{item.remark}</div> : null}
</div>
))}
</div>
)}
</div>
</div>
<div className='md:col-span-2 xl:col-span-4'>
<FieldItem
label='Project'

View File

@@ -2,10 +2,14 @@
import * as React from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { useMutation, useQuery } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Icons } from '@/components/icons';
import { Button } from '@/components/ui/button';
import {
ProjectPartiesEditor,
type ProjectPartyEditorItem
} from '@/features/crm/components/project-parties-editor';
import {
Select,
SelectContent,
@@ -22,6 +26,7 @@ import {
SheetTitle
} from '@/components/ui/sheet';
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
import { enquiryProjectPartiesOptions } from '../api/queries';
import { createEnquiryMutation, updateEnquiryMutation } from '../api/mutations';
import type { EnquiryMutationPayload, EnquiryRecord, EnquiryReferenceData } from '../api/types';
import { enquirySchema, type EnquiryFormValues } from '../schemas/enquiry.schema';
@@ -72,8 +77,13 @@ export function EnquiryFormSheet({
[enquiry, referenceData]
);
const [selectedCustomerId, setSelectedCustomerId] = useState(defaultValues.customerId);
const [projectParties, setProjectParties] = useState<ProjectPartyEditorItem[]>([]);
const { FormTextField, FormTextareaField, FormSelectField, FormSwitchField } =
useFormFields<EnquiryFormValues>();
const projectPartiesQuery = useQuery({
...enquiryProjectPartiesOptions(enquiry?.id ?? ''),
enabled: open && !!enquiry?.id
});
const createMutation = useMutation({
...createEnquiryMutation,
@@ -127,7 +137,14 @@ export function EnquiryFormSheet({
source: value.source,
isHotProject: value.isHotProject ?? false,
notes: value.notes,
isActive: value.isActive ?? true
isActive: value.isActive ?? true,
projectParties: projectParties
.filter((item) => item.customerId && item.role)
.map((item) => ({
customerId: item.customerId,
role: item.role,
remark: item.remark || null
}))
};
if (isEdit && enquiry) {
@@ -146,7 +163,17 @@ export function EnquiryFormSheet({
setSelectedCustomerId(defaultValues.customerId);
form.reset(defaultValues);
}, [defaultValues, form, open]);
setProjectParties(
enquiry
? (projectPartiesQuery.data?.items ?? []).map((item) => ({
key: item.id,
customerId: item.customerId,
role: item.role,
remark: item.remark ?? ''
}))
: []
);
}, [defaultValues, enquiry, form, open, projectPartiesQuery.data?.items]);
const isPending = createMutation.isPending || updateMutation.isPending;
@@ -168,7 +195,7 @@ export function EnquiryFormSheet({
children={(field) => (
<field.FieldSet>
<field.Field>
<field.FieldLabel>Customer *</field.FieldLabel>
<field.FieldLabel>Billing Customer *</field.FieldLabel>
<Select
value={field.state.value ?? ''}
onValueChange={(value) => {
@@ -340,6 +367,14 @@ export function EnquiryFormSheet({
description='Inactive enquiries stay in history but should not progress further.'
/>
</div>
<div className='md:col-span-2'>
<ProjectPartiesEditor
customers={referenceData.customers}
roles={referenceData.projectPartyRoles}
items={projectParties}
onChange={setProjectParties}
/>
</div>
</form.Form>
</form.AppForm>
</div>

View File

@@ -50,7 +50,16 @@ export const enquirySchema = z.object({
source: z.string().optional(),
isHotProject: z.boolean().default(false),
notes: z.string().optional(),
isActive: z.boolean().default(true)
isActive: z.boolean().default(true),
projectParties: z
.array(
z.object({
customerId: z.string().min(1, 'Please select a customer'),
role: z.string().min(1, 'Please select a role'),
remark: z.string().nullable().optional()
})
)
.optional()
});
export const enquiryFollowupSchema = z.object({

View File

@@ -3,6 +3,7 @@ import {
crmCustomerContacts,
crmCustomers,
crmEnquiries,
crmEnquiryCustomers,
crmEnquiryFollowups,
memberships,
users
@@ -25,16 +26,25 @@ import type {
EnquiryListItem,
EnquiryMutationPayload,
EnquiryOption,
EnquiryProjectPartyListItem,
EnquiryProjectPartyMutationPayload,
EnquiryProjectPartyRecord,
EnquiryRecord,
EnquiryReferenceData
} from '../api/types';
import {
buildProjectPartyKey,
PROJECT_PARTY_OPTION_CATEGORY,
resolveProjectPartyRole
} from '@/features/crm/shared/project-party';
const ENQUIRY_OPTION_CATEGORIES = {
status: 'crm_enquiry_status',
productType: 'crm_product_type',
priority: 'crm_priority',
leadChannel: 'crm_lead_channel',
followupType: 'crm_followup_type'
followupType: 'crm_followup_type',
projectPartyRole: PROJECT_PARTY_OPTION_CATEGORY
} as const;
const ASSIGNABLE_BUSINESS_ROLES = new Set(['sales_manager', 'sales', 'sales_support']);
@@ -123,6 +133,25 @@ function mapFollowupRecord(row: typeof crmEnquiryFollowups.$inferSelect): Enquir
};
}
function mapProjectPartyRecord(
row: typeof crmEnquiryCustomers.$inferSelect,
options: { role: { id: string; code: string; label: string } }
): EnquiryProjectPartyRecord {
return {
id: row.id,
organizationId: row.organizationId,
enquiryId: row.enquiryId,
customerId: row.customerId,
role: options.role.id,
roleCode: options.role.code,
roleLabel: options.role.label,
remark: row.remark,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
deletedAt: row.deletedAt?.toISOString() ?? null
};
}
function parseSort(sort?: string) {
if (!sort) {
return desc(crmEnquiries.updatedAt);
@@ -352,6 +381,15 @@ async function validateEnquiryPayload(organizationId: string, payload: EnquiryMu
if (payload.branchId) {
await validateBranchAccess(payload.branchId);
}
for (const projectParty of payload.projectParties ?? []) {
await assertCustomerBelongsToOrganization(projectParty.customerId, organizationId);
await assertMasterOptionValue(
organizationId,
ENQUIRY_OPTION_CATEGORIES.projectPartyRole,
projectParty.role
);
}
}
async function validateFollowupPayload(
@@ -410,6 +448,7 @@ export async function getEnquiryReferenceData(
priorities,
leadChannels,
followupTypes,
projectPartyRoles,
customers,
contacts,
assignableUsers
@@ -420,6 +459,7 @@ export async function getEnquiryReferenceData(
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.priority, { organizationId }),
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.leadChannel, { organizationId }),
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.followupType, { organizationId }),
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.projectPartyRole, { organizationId }),
db
.select()
.from(crmCustomers)
@@ -445,6 +485,7 @@ export async function getEnquiryReferenceData(
priorities: priorities.map(mapOption),
leadChannels: leadChannels.map(mapOption),
followupTypes: followupTypes.map(mapOption),
projectPartyRoles: projectPartyRoles.map(mapOption),
customers: customers.map((customer) => ({
id: customer.id,
code: customer.code,
@@ -575,6 +616,142 @@ export async function getEnquiryDetail(id: string, organizationId: string): Prom
});
}
async function buildPreparedProjectParties(
organizationId: string,
billingCustomerId: string,
projectParties: EnquiryProjectPartyMutationPayload[] = []
) {
const roleOptions = await getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.projectPartyRole, {
organizationId
});
const billingRole = roleOptions.find((option) => option.code === 'billing_customer');
if (!billingRole) {
throw new AuthError('Billing customer project-party role is not configured', 409);
}
const prepared = new Map<
string,
{ customerId: string; roleId: string; roleCode: string; remark: string | null }
>();
for (const item of projectParties) {
const resolvedRole = resolveProjectPartyRole(roleOptions, item.role);
if (!resolvedRole) {
throw new AuthError('Invalid project party role', 400);
}
const key = buildProjectPartyKey(item.customerId, resolvedRole.code);
if (!prepared.has(key)) {
prepared.set(key, {
customerId: item.customerId,
roleId: resolvedRole.id,
roleCode: resolvedRole.code,
remark: item.remark?.trim() || null
});
}
}
const billingKey = buildProjectPartyKey(billingCustomerId, billingRole.code);
if (!prepared.has(billingKey)) {
prepared.set(billingKey, {
customerId: billingCustomerId,
roleId: billingRole.id,
roleCode: billingRole.code,
remark: null
});
}
return [...prepared.values()];
}
async function syncEnquiryProjectParties(
tx: Pick<typeof db, 'insert' | 'update'>,
organizationId: string,
enquiryId: string,
billingCustomerId: string,
payload: EnquiryProjectPartyMutationPayload[]
) {
const now = new Date();
const preparedParties = await buildPreparedProjectParties(
organizationId,
billingCustomerId,
payload
);
await tx
.update(crmEnquiryCustomers)
.set({ deletedAt: now, updatedAt: now })
.where(
and(
eq(crmEnquiryCustomers.organizationId, organizationId),
eq(crmEnquiryCustomers.enquiryId, enquiryId),
isNull(crmEnquiryCustomers.deletedAt)
)
);
if (!preparedParties.length) {
return;
}
await tx.insert(crmEnquiryCustomers).values(
preparedParties.map((item) => ({
id: crypto.randomUUID(),
organizationId,
enquiryId,
customerId: item.customerId,
role: item.roleId,
remark: item.remark
}))
);
}
export async function listEnquiryProjectParties(
id: string,
organizationId: string
): Promise<EnquiryProjectPartyListItem[]> {
await assertEnquiryBelongsToOrganization(id, organizationId);
const [relations, customers, roleOptions] = await Promise.all([
db
.select()
.from(crmEnquiryCustomers)
.where(
and(
eq(crmEnquiryCustomers.enquiryId, id),
eq(crmEnquiryCustomers.organizationId, organizationId),
isNull(crmEnquiryCustomers.deletedAt)
)
)
.orderBy(asc(crmEnquiryCustomers.createdAt)),
db
.select()
.from(crmCustomers)
.where(and(eq(crmCustomers.organizationId, organizationId), isNull(crmCustomers.deletedAt))),
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.projectPartyRole, { organizationId })
]);
const customerMap = new Map(customers.map((item) => [item.id, item]));
return relations
.map((row) => {
const resolvedRole = resolveProjectPartyRole(roleOptions, row.role);
if (!resolvedRole) {
return null;
}
return {
...mapProjectPartyRecord(row, { role: resolvedRole }),
customerName: customerMap.get(row.customerId)?.name ?? 'Unknown customer',
customerCode: customerMap.get(row.customerId)?.code ?? '-'
} satisfies EnquiryProjectPartyListItem;
})
.filter((item): item is EnquiryProjectPartyListItem => item !== null);
}
export async function getEnquiryActivity(
id: string,
organizationId: string
@@ -624,7 +801,8 @@ export async function createEnquiry(
branchId: payload.branchId ?? ''
});
const [created] = await db
return db.transaction(async (tx) => {
const [created] = await tx
.insert(crmEnquiries)
.values({
id: crypto.randomUUID(),
@@ -655,7 +833,16 @@ export async function createEnquiry(
})
.returning();
await syncEnquiryProjectParties(
tx,
organizationId,
created.id,
payload.customerId,
payload.projectParties ?? []
);
return created;
});
}
export async function updateEnquiry(
@@ -667,7 +854,8 @@ export async function updateEnquiry(
await validateEnquiryPayload(organizationId, payload);
await assertEnquiryBelongsToOrganization(id, organizationId);
const [updated] = await db
return db.transaction(async (tx) => {
const [updated] = await tx
.update(crmEnquiries)
.set({
branchId: payload.branchId ?? null,
@@ -696,7 +884,16 @@ export async function updateEnquiry(
.where(eq(crmEnquiries.id, id))
.returning();
await syncEnquiryProjectParties(
tx,
organizationId,
id,
payload.customerId,
payload.projectParties ?? []
);
return updated;
});
}
export async function softDeleteEnquiry(id: string, organizationId: string, userId: string) {
@@ -728,6 +925,20 @@ export async function softDeleteEnquiry(id: string, organizationId: string, user
)
);
await db
.update(crmEnquiryCustomers)
.set({
deletedAt: now,
updatedAt: now
})
.where(
and(
eq(crmEnquiryCustomers.enquiryId, id),
eq(crmEnquiryCustomers.organizationId, organizationId),
isNull(crmEnquiryCustomers.deletedAt)
)
);
return updated;
}

View File

@@ -147,7 +147,10 @@ export interface QuotationCustomerRecord {
quotationId: string;
customerId: string;
role: string;
roleCode: string;
roleLabel: string | null;
isPrimary: boolean;
remark: string | null;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
@@ -244,7 +247,7 @@ export interface QuotationReferenceData {
discountTypes: QuotationOption[];
units: QuotationOption[];
sentVias: QuotationOption[];
customerRoles: QuotationOption[];
projectPartyRoles: QuotationOption[];
topicTypes: QuotationOption[];
followupTypes: QuotationOption[];
productTypes: QuotationOption[];
@@ -353,6 +356,7 @@ export interface QuotationMutationPayload {
isActive?: boolean;
sentVia?: string | null;
revisionRemark?: string | null;
projectParties?: QuotationCustomerMutationPayload[];
}
export interface QuotationItemMutationPayload {
@@ -372,6 +376,7 @@ export interface QuotationCustomerMutationPayload {
customerId: string;
role: string;
isPrimary?: boolean;
remark?: string | null;
}
export interface QuotationTopicMutationPayload {

View File

@@ -268,16 +268,18 @@ function CustomerDialog({
const [customerId, setCustomerId] = useState(
relation?.customerId ?? referenceData.customers[0]?.id ?? ''
);
const [role, setRole] = useState(relation?.role ?? 'owner');
const [isPrimary, setIsPrimary] = useState(relation?.isPrimary ?? false);
const [role, setRole] = useState(
relation?.role ?? referenceData.projectPartyRoles[0]?.id ?? ''
);
const [remark, setRemark] = useState(relation?.remark ?? '');
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{relation ? 'Edit Related Customer' : 'Add Related Customer'}</DialogTitle>
<DialogTitle>{relation ? 'Edit Project Party' : 'Add Project Party'}</DialogTitle>
<DialogDescription>
Keep owner, consultant, contractor, and billing parties visible on the quote.
Keep related companies visible with their role in this quotation.
</DialogDescription>
</DialogHeader>
<div className='grid gap-4'>
@@ -298,22 +300,14 @@ function CustomerDialog({
<SelectValue placeholder='Role' />
</SelectTrigger>
<SelectContent>
{referenceData.customerRoles.map((item) => (
{referenceData.projectPartyRoles.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<label className='flex items-center justify-between rounded-lg border px-4 py-3 text-sm'>
<span>Primary relation</span>
<input
aria-label='Primary relation'
type='checkbox'
checked={isPrimary}
onChange={(e) => setIsPrimary(e.target.checked)}
/>
</label>
<Input value={remark} onChange={(event) => setRemark(event.target.value)} placeholder='Remark' />
</div>
<DialogFooter>
<Button variant='outline' onClick={() => onOpenChange(false)}>
@@ -322,7 +316,7 @@ function CustomerDialog({
<Button
isLoading={pending}
onClick={async () => {
await onSubmit({ customerId, role, isPrimary });
await onSubmit({ customerId, role, remark: remark || null });
onOpenChange(false);
}}
>
@@ -720,24 +714,24 @@ export function QuotationDetail({
const [deleteCustomerId, setDeleteCustomerId] = useState<string | null>(null);
const createCustomer = useMutation({
...createQuotationCustomerMutation,
onSuccess: () => toast.success('Related customer saved'),
onSuccess: () => toast.success('Project party saved'),
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to save customer')
toast.error(error instanceof Error ? error.message : 'Failed to save project party')
});
const updateCustomer = useMutation({
...updateQuotationCustomerMutation,
onSuccess: () => toast.success('Related customer updated'),
onSuccess: () => toast.success('Project party updated'),
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to update customer')
toast.error(error instanceof Error ? error.message : 'Failed to update project party')
});
const deleteCustomer = useMutation({
...deleteQuotationCustomerMutation,
onSuccess: () => {
toast.success('Related customer deleted');
toast.success('Project party deleted');
setDeleteCustomerId(null);
},
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to delete customer')
toast.error(error instanceof Error ? error.message : 'Failed to delete project party')
});
const [topicOpen, setTopicOpen] = useState(false);
@@ -1137,7 +1131,7 @@ export function QuotationDetail({
<TabsList className='flex flex-wrap'>
<TabsTrigger value='overview'>Overview</TabsTrigger>
<TabsTrigger value='items'>Items</TabsTrigger>
<TabsTrigger value='customers'>Customers</TabsTrigger>
<TabsTrigger value='customers'>Project Parties</TabsTrigger>
<TabsTrigger value='topics'>Topics</TabsTrigger>
<TabsTrigger value='followups'>Follow-ups</TabsTrigger>
<TabsTrigger value='attachments'>Attachments</TabsTrigger>
@@ -1155,7 +1149,7 @@ export function QuotationDetail({
</CardDescription>
</CardHeader>
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
<FieldItem label='Customer' value={customer?.name} />
<FieldItem label='Billing Customer' value={customer?.name} />
<FieldItem label='Contact' value={contact?.name} />
<FieldItem
label='Branch'
@@ -1188,6 +1182,28 @@ export function QuotationDetail({
quotation.chancePercent !== null ? `${quotation.chancePercent}%` : null
}
/>
<div className='md:col-span-2 xl:col-span-4'>
<div className='space-y-3'>
<div className='text-muted-foreground text-xs'>Project Parties</div>
{!customersData.items.length ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-4 text-sm'>
No project parties have been added yet.
</div>
) : (
<div className='space-y-3'>
{customersData.items.map((item) => (
<div key={item.id} className='rounded-lg border p-4 text-sm'>
<div className='font-medium'>{item.customerName}</div>
<div className='text-muted-foreground'>
Role: {item.roleLabel ?? item.roleCode}
</div>
{item.remark ? <div className='mt-1'>{item.remark}</div> : null}
</div>
))}
</div>
)}
</div>
</div>
<div className='md:col-span-2 xl:col-span-4'>
<FieldItem
label='Project'
@@ -1285,8 +1301,10 @@ export function QuotationDetail({
<Card>
<CardHeader className='flex flex-row items-center justify-between'>
<div>
<CardTitle>Customers</CardTitle>
<CardDescription>All parties involved in this quotation.</CardDescription>
<CardTitle>Project Parties</CardTitle>
<CardDescription>
Related companies for this quotation, each with a visible role.
</CardDescription>
</div>
{canManageCustomers ? (
<Button
@@ -1295,13 +1313,13 @@ export function QuotationDetail({
setCustomerOpen(true);
}}
>
<Icons.add className='mr-2 h-4 w-4' /> Add Customer
<Icons.add className='mr-2 h-4 w-4' /> Add Party
</Button>
) : null}
</CardHeader>
<CardContent className='space-y-3'>
{!customersData.items.length ? (
<EmptyState message='No additional customer relations yet.' />
<EmptyState message='No project parties have been added yet.' />
) : (
customersData.items.map((item) => (
<div
@@ -1311,9 +1329,11 @@ export function QuotationDetail({
<div>
<div className='font-medium'>{item.customerName}</div>
<div className='text-muted-foreground text-sm'>
{item.role}
{item.isPrimary ? ' - Primary' : ''}
Role: {item.roleLabel ?? item.roleCode}
</div>
{item.remark ? (
<div className='text-muted-foreground mt-1 text-sm'>{item.remark}</div>
) : null}
</div>
{canManageCustomers ? (
<div className='flex gap-2'>

View File

@@ -73,7 +73,7 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
<CardDescription>Bill-to and attention context for the document.</CardDescription>
</CardHeader>
<CardContent className='grid gap-4 md:grid-cols-2'>
<Field label='Customer' value={documentData.customer.name} />
<Field label='Billing Customer' value={documentData.customer.name} />
<Field label='Contact' value={documentData.contact?.name} />
<Field label='Phone' value={documentData.customer.phone} />
<Field label='Email' value={documentData.customer.email} />

View File

@@ -2,10 +2,14 @@
import * as React from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { useMutation, useQuery } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Icons } from '@/components/icons';
import { Button } from '@/components/ui/button';
import {
ProjectPartiesEditor,
type ProjectPartyEditorItem
} from '@/features/crm/components/project-parties-editor';
import { Input } from '@/components/ui/input';
import {
Select,
@@ -24,6 +28,7 @@ import {
} from '@/components/ui/sheet';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { quotationCustomersOptions } from '../api/queries';
import { createQuotationMutation, updateQuotationMutation } from '../api/mutations';
import type { QuotationMutationPayload, QuotationRecord, QuotationReferenceData } from '../api/types';
@@ -123,6 +128,11 @@ export function QuotationFormSheet({
const isEdit = !!quotation;
const defaultState = useMemo(() => toFormState(quotation, referenceData), [quotation, referenceData]);
const [state, setState] = useState<FormState>(defaultState);
const [projectParties, setProjectParties] = useState<ProjectPartyEditorItem[]>([]);
const projectPartiesQuery = useQuery({
...quotationCustomersOptions(quotation?.id ?? ''),
enabled: open && !!quotation?.id
});
const createMutation = useMutation({
...createQuotationMutation,
@@ -147,8 +157,18 @@ export function QuotationFormSheet({
useEffect(() => {
if (open) {
setState(defaultState);
setProjectParties(
quotation
? (projectPartiesQuery.data?.items ?? []).map((item) => ({
key: item.id,
customerId: item.customerId,
role: item.role,
remark: item.remark ?? ''
}))
: []
);
}
}, [defaultState, open]);
}, [defaultState, open, projectPartiesQuery.data?.items, quotation]);
const contacts = useMemo(
() => referenceData.contacts.filter((item) => item.customerId === state.customerId),
@@ -200,7 +220,14 @@ export function QuotationFormSheet({
taxRate: state.taxRate ? Number(state.taxRate) : 0,
sentVia: state.sentVia || null,
revisionRemark: state.revisionRemark || null,
isActive: state.isActive
isActive: state.isActive,
projectParties: projectParties
.filter((item) => item.customerId && item.role)
.map((item) => ({
customerId: item.customerId,
role: item.role,
remark: item.remark || null
}))
};
if (isEdit && quotation) {
@@ -219,7 +246,7 @@ export function QuotationFormSheet({
<SheetHeader>
<SheetTitle>{isEdit ? 'Edit Quotation' : 'New Quotation'}</SheetTitle>
<SheetDescription>
Capture quotation header data, customer linkage, and commercial context.
Capture quotation header data, billing customer, project parties, and commercial context.
</SheetDescription>
</SheetHeader>
@@ -241,7 +268,7 @@ export function QuotationFormSheet({
</Select>
</Field>
<Field label='Customer'>
<Field label='Billing Customer'>
<Select
value={state.customerId}
onValueChange={(value) => {
@@ -434,6 +461,15 @@ export function QuotationFormSheet({
<Input value={state.competitor} onChange={(e) => setField('competitor', e.target.value)} placeholder='Competitor' />
</Field>
<div className='md:col-span-2'>
<ProjectPartiesEditor
customers={referenceData.customers}
roles={referenceData.projectPartyRoles}
items={projectParties}
onChange={setProjectParties}
/>
</div>
<div className='md:col-span-2'>
<Field label='Notes'>
<Textarea value={state.notes} onChange={(e) => setField('notes', e.target.value)} placeholder='Internal notes' />

View File

@@ -34,7 +34,7 @@ const DOCUMENT_OPTION_CATEGORIES = {
currency: 'crm_currency',
discountType: 'crm_discount_type',
unit: 'crm_unit',
customerRole: 'crm_quotation_customer_role',
customerRole: 'crm_project_party_role',
productType: 'crm_product_type',
topicType: 'crm_quotation_topic_type'
} as const;
@@ -348,8 +348,8 @@ export async function buildQuotationDocumentData(
customerId: item.customerId,
customerName: relatedCustomerMap.get(item.customerId)?.name ?? item.customerName,
customerCode: relatedCustomerMap.get(item.customerId)?.code ?? item.customerCode,
roleCode: item.role,
roleLabel: findOptionLabel(customerRoleOptions, item.role),
roleCode: item.roleCode,
roleLabel: item.roleLabel ?? findOptionLabel(customerRoleOptions, item.role),
isPrimary: item.isPrimary
})),
topics: {

View File

@@ -67,7 +67,17 @@ export const quotationSchema = z.object({
taxRate: coerceOptionalNumber('Tax rate must be a number'),
isActive: z.boolean().default(true),
sentVia: z.string().optional().nullable(),
revisionRemark: z.string().optional().nullable()
revisionRemark: z.string().optional().nullable(),
projectParties: z
.array(
z.object({
customerId: z.string().min(1, 'Please select a customer'),
role: z.string().min(1, 'Please select a role'),
isPrimary: z.boolean().default(false).optional(),
remark: z.string().nullable().optional()
})
)
.optional()
});
export const quotationItemSchema = z.object({
@@ -98,7 +108,8 @@ export const quotationItemSchema = z.object({
export const quotationCustomerSchema = z.object({
customerId: z.string().min(1, 'Please select a customer'),
role: z.string().min(1, 'Please select a role'),
isPrimary: z.boolean().default(false)
isPrimary: z.boolean().default(false),
remark: z.string().nullable().optional()
});
export const quotationTopicSchema = z.object({

View File

@@ -48,6 +48,11 @@ import type {
QuotationTopicMutationPayload,
QuotationTopicRecord
} from '../api/types';
import {
buildProjectPartyKey,
PROJECT_PARTY_OPTION_CATEGORY,
resolveProjectPartyRole
} from '@/features/crm/shared/project-party';
const QUOTATION_OPTION_CATEGORIES = {
status: 'crm_quotation_status',
@@ -56,7 +61,7 @@ const QUOTATION_OPTION_CATEGORIES = {
discountType: 'crm_discount_type',
unit: 'crm_unit',
sentVia: 'crm_sent_via',
customerRole: 'crm_quotation_customer_role',
projectPartyRole: PROJECT_PARTY_OPTION_CATEGORY,
topicType: 'crm_quotation_topic_type',
followupType: 'crm_followup_type',
productType: 'crm_product_type'
@@ -191,15 +196,19 @@ function mapQuotationItemRecord(row: typeof crmQuotationItems.$inferSelect): Quo
}
function mapQuotationCustomerRecord(
row: typeof crmQuotationCustomers.$inferSelect
row: typeof crmQuotationCustomers.$inferSelect,
options: { role: { id: string; code: string; label: string } }
): QuotationCustomerRecord {
return {
id: row.id,
organizationId: row.organizationId,
quotationId: row.quotationId,
customerId: row.customerId,
role: row.role,
role: options.role.id,
roleCode: options.role.code,
roleLabel: options.role.label,
isPrimary: row.isPrimary,
remark: row.remark,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
deletedAt: row.deletedAt?.toISOString() ?? null
@@ -641,6 +650,15 @@ async function validateQuotationPayload(organizationId: string, payload: Quotati
QUOTATION_OPTION_CATEGORIES.sentVia,
payload.sentVia ?? null
);
for (const projectParty of payload.projectParties ?? []) {
await assertCustomerBelongsToOrganization(projectParty.customerId, organizationId);
await assertMasterOptionValue(
organizationId,
QUOTATION_OPTION_CATEGORIES.projectPartyRole,
projectParty.role
);
}
}
async function validateQuotationItemPayload(
@@ -671,11 +689,156 @@ async function validateQuotationCustomerPayload(
await assertCustomerBelongsToOrganization(payload.customerId, organizationId);
await assertMasterOptionValue(
organizationId,
QUOTATION_OPTION_CATEGORIES.customerRole,
QUOTATION_OPTION_CATEGORIES.projectPartyRole,
payload.role
);
}
async function buildPreparedQuotationProjectParties(
organizationId: string,
billingCustomerId: string,
projectParties: QuotationCustomerMutationPayload[] = []
) {
const roleOptions = await getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.projectPartyRole, {
organizationId
});
const billingRole = roleOptions.find((option) => option.code === 'billing_customer');
if (!billingRole) {
throw new AuthError('Billing customer project-party role is not configured', 409);
}
const prepared = new Map<
string,
{
customerId: string;
roleId: string;
roleCode: string;
isPrimary: boolean;
remark: string | null;
}
>();
for (const item of projectParties) {
const resolvedRole = resolveProjectPartyRole(roleOptions, item.role);
if (!resolvedRole) {
throw new AuthError('Invalid project party role', 400);
}
const key = buildProjectPartyKey(item.customerId, resolvedRole.code);
if (!prepared.has(key)) {
prepared.set(key, {
customerId: item.customerId,
roleId: resolvedRole.id,
roleCode: resolvedRole.code,
isPrimary: resolvedRole.code === 'billing_customer' || !!item.isPrimary,
remark: item.remark?.trim() || null
});
}
}
const billingKey = buildProjectPartyKey(billingCustomerId, billingRole.code);
if (!prepared.has(billingKey)) {
prepared.set(billingKey, {
customerId: billingCustomerId,
roleId: billingRole.id,
roleCode: billingRole.code,
isPrimary: true,
remark: null
});
}
return [...prepared.values()].map((item) => ({
...item,
isPrimary: item.roleCode === 'billing_customer'
}));
}
async function syncQuotationProjectParties(
tx: Pick<typeof db, 'insert' | 'update'>,
organizationId: string,
quotationId: string,
billingCustomerId: string,
payload: QuotationCustomerMutationPayload[]
) {
const now = new Date();
const preparedParties = await buildPreparedQuotationProjectParties(
organizationId,
billingCustomerId,
payload
);
await tx
.update(crmQuotationCustomers)
.set({ deletedAt: now, updatedAt: now })
.where(
and(
eq(crmQuotationCustomers.quotationId, quotationId),
eq(crmQuotationCustomers.organizationId, organizationId),
isNull(crmQuotationCustomers.deletedAt)
)
);
if (!preparedParties.length) {
return;
}
await tx.insert(crmQuotationCustomers).values(
preparedParties.map((item) => ({
id: crypto.randomUUID(),
organizationId,
quotationId,
customerId: item.customerId,
role: item.roleId,
isPrimary: item.isPrimary,
remark: item.remark
}))
);
}
async function assertQuotationProjectPartyNotDuplicated(
quotationId: string,
organizationId: string,
payload: QuotationCustomerMutationPayload,
excludeRelationId?: string
) {
const [relations, roleOptions] = await Promise.all([
db
.select()
.from(crmQuotationCustomers)
.where(
and(
eq(crmQuotationCustomers.quotationId, quotationId),
eq(crmQuotationCustomers.organizationId, organizationId),
isNull(crmQuotationCustomers.deletedAt)
)
),
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.projectPartyRole, { organizationId })
]);
const incomingRole = resolveProjectPartyRole(roleOptions, payload.role);
if (!incomingRole) {
throw new AuthError('Invalid project party role', 400);
}
const duplicate = relations.find((relation) => {
if (excludeRelationId && relation.id === excludeRelationId) {
return false;
}
const currentRole = resolveProjectPartyRole(roleOptions, relation.role);
return relation.customerId === payload.customerId && currentRole?.code === incomingRole.code;
});
if (duplicate) {
throw new AuthError('This project party already exists for the quotation', 400);
}
}
async function validateQuotationTopicPayload(
organizationId: string,
payload: QuotationTopicMutationPayload
@@ -801,7 +964,7 @@ export async function getQuotationReferenceData(
discountTypes,
units,
sentVias,
customerRoles,
projectPartyRoles,
topicTypes,
followupTypes,
productTypes,
@@ -817,7 +980,7 @@ export async function getQuotationReferenceData(
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.discountType, { organizationId }),
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.unit, { organizationId }),
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.sentVia, { organizationId }),
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.customerRole, { organizationId }),
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.projectPartyRole, { organizationId }),
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.topicType, { organizationId }),
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.followupType, { organizationId }),
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.productType, { organizationId }),
@@ -857,7 +1020,7 @@ export async function getQuotationReferenceData(
discountTypes: discountTypes.map(mapOption),
units: units.map(mapOption),
sentVias: sentVias.map(mapOption),
customerRoles: customerRoles.map(mapOption),
projectPartyRoles: projectPartyRoles.map(mapOption),
topicTypes: topicTypes.map(mapOption),
followupTypes: followupTypes.map(mapOption),
productTypes: productTypes.map(mapOption),
@@ -1062,7 +1225,8 @@ export async function createQuotation(
branchId: payload.branchId ?? enquiry?.branchId ?? ''
});
const [created] = await db
return db.transaction(async (tx) => {
const [created] = await tx
.insert(crmQuotations)
.values({
id: crypto.randomUUID(),
@@ -1105,16 +1269,16 @@ export async function createQuotation(
})
.returning();
await db.insert(crmQuotationCustomers).values({
id: crypto.randomUUID(),
await syncQuotationProjectParties(
tx,
organizationId,
quotationId: created.id,
customerId: created.customerId,
role: 'owner',
isPrimary: true
});
created.id,
payload.customerId,
payload.projectParties ?? []
);
return created;
});
}
export async function updateQuotation(
@@ -1129,7 +1293,8 @@ export async function updateQuotation(
? await assertEnquiryBelongsToOrganization(payload.enquiryId, organizationId)
: null;
const [updated] = await db
const updated = await db.transaction(async (tx) => {
const [row] = await tx
.update(crmQuotations)
.set({
branchId: payload.branchId ?? enquiry?.branchId ?? null,
@@ -1163,6 +1328,17 @@ export async function updateQuotation(
.where(eq(crmQuotations.id, id))
.returning();
await syncQuotationProjectParties(
tx,
organizationId,
id,
payload.customerId,
payload.projectParties ?? []
);
return row;
});
await refreshQuotationTotals(id, organizationId, userId);
return updated;
}
@@ -1341,7 +1517,8 @@ export async function softDeleteQuotationItem(
export async function listQuotationCustomers(id: string, organizationId: string) {
await assertQuotationBelongsToOrganization(id, organizationId);
const relations = await db
const [relations, roleOptions] = await Promise.all([
db
.select()
.from(crmQuotationCustomers)
.where(
@@ -1351,18 +1528,30 @@ export async function listQuotationCustomers(id: string, organizationId: string)
isNull(crmQuotationCustomers.deletedAt)
)
)
.orderBy(desc(crmQuotationCustomers.isPrimary), asc(crmQuotationCustomers.role));
.orderBy(desc(crmQuotationCustomers.isPrimary), asc(crmQuotationCustomers.createdAt)),
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.projectPartyRole, { organizationId })
]);
const customerIds = [...new Set(relations.map((row) => row.customerId))];
const customers = customerIds.length
? await db.select().from(crmCustomers).where(inArray(crmCustomers.id, customerIds))
: [];
const customerMap = new Map(customers.map((item) => [item.id, item]));
return relations.map<QuotationCustomerListItem>((row) => ({
...mapQuotationCustomerRecord(row),
return relations
.map((row) => {
const resolvedRole = resolveProjectPartyRole(roleOptions, row.role);
if (!resolvedRole) {
return null;
}
return {
...mapQuotationCustomerRecord(row, { role: resolvedRole }),
customerName: customerMap.get(row.customerId)?.name ?? 'Unknown customer',
customerCode: customerMap.get(row.customerId)?.code ?? '-'
}));
} satisfies QuotationCustomerListItem;
})
.filter((item): item is QuotationCustomerListItem => item !== null);
}
export async function createQuotationCustomer(
@@ -1372,9 +1561,22 @@ export async function createQuotationCustomer(
) {
await assertQuotationBelongsToOrganization(quotationId, organizationId);
await validateQuotationCustomerPayload(organizationId, payload);
await assertQuotationProjectPartyNotDuplicated(quotationId, organizationId, payload);
return db.transaction(async (tx) => {
if (payload.isPrimary) {
const roleOptions = await getActiveOptionsByCategory(
QUOTATION_OPTION_CATEGORIES.projectPartyRole,
{ organizationId }
);
const resolvedRole = resolveProjectPartyRole(roleOptions, payload.role);
if (!resolvedRole) {
throw new AuthError('Invalid project party role', 400);
}
const isPrimary = resolvedRole.code === 'billing_customer';
if (isPrimary) {
await tx
.update(crmQuotationCustomers)
.set({ isPrimary: false, updatedAt: new Date() })
@@ -1394,8 +1596,9 @@ export async function createQuotationCustomer(
organizationId,
quotationId,
customerId: payload.customerId,
role: payload.role,
isPrimary: payload.isPrimary ?? false
role: resolvedRole.id,
isPrimary,
remark: payload.remark?.trim() || null
})
.returning();
@@ -1411,9 +1614,27 @@ export async function updateQuotationCustomer(
) {
await validateQuotationCustomerPayload(organizationId, payload);
await assertQuotationCustomerBelongsToQuotation(quotationId, relationId, organizationId);
await assertQuotationProjectPartyNotDuplicated(
quotationId,
organizationId,
payload,
relationId
);
return db.transaction(async (tx) => {
if (payload.isPrimary) {
const roleOptions = await getActiveOptionsByCategory(
QUOTATION_OPTION_CATEGORIES.projectPartyRole,
{ organizationId }
);
const resolvedRole = resolveProjectPartyRole(roleOptions, payload.role);
if (!resolvedRole) {
throw new AuthError('Invalid project party role', 400);
}
const isPrimary = resolvedRole.code === 'billing_customer';
if (isPrimary) {
await tx
.update(crmQuotationCustomers)
.set({ isPrimary: false, updatedAt: new Date() })
@@ -1430,8 +1651,9 @@ export async function updateQuotationCustomer(
.update(crmQuotationCustomers)
.set({
customerId: payload.customerId,
role: payload.role,
isPrimary: payload.isPrimary ?? false,
role: resolvedRole.id,
isPrimary,
remark: payload.remark?.trim() || null,
updatedAt: new Date()
})
.where(eq(crmQuotationCustomers.id, relationId))
@@ -1908,7 +2130,8 @@ export async function createQuotationRevision(
quotationId: created.id,
customerId: item.customerId,
role: item.role,
isPrimary: item.isPrimary
isPrimary: item.isPrimary,
remark: item.remark
}))
);
}

View File

@@ -0,0 +1,341 @@
import {
and,
asc,
eq,
gte,
ilike,
inArray,
isNull,
lte,
ne,
or,
type SQL
} from 'drizzle-orm';
import {
crmCustomers,
crmEnquiryCustomers,
crmQuotationCustomers,
crmQuotations
} from '@/db/schema';
import { db } from '@/lib/db';
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
import {
PROJECT_PARTY_OPTION_CATEGORY,
resolveProjectPartyRole
} from '@/features/crm/shared/project-party';
import type { RevenueAttributionFilters, RevenueAttributionSummary } from './types';
type SupportedRevenueRole = 'end_customer' | 'billing_customer' | 'contractor' | 'consultant';
interface NormalizedProjectParty {
customerId: string;
roleCode: string;
}
function splitFilterValue(value?: string) {
return value
?.split(',')
.map((item) => item.trim())
.filter(Boolean) ?? [];
}
function buildRevenueAttributionFilters(
organizationId: string,
filters: RevenueAttributionFilters
): SQL[] {
const statuses = splitFilterValue(filters.status);
const quotationTypes = splitFilterValue(filters.quotationType);
const branches = splitFilterValue(filters.branch);
const customers = splitFilterValue(filters.customer);
const enquiries = splitFilterValue(filters.enquiry);
return [
eq(crmQuotations.organizationId, organizationId),
isNull(crmQuotations.deletedAt),
...(statuses.length
? [inArray(crmQuotations.status, statuses)]
: filters.includeCancelled
? []
: [ne(crmQuotations.status, 'cancelled')]),
...(quotationTypes.length ? [inArray(crmQuotations.quotationType, quotationTypes)] : []),
...(branches.length ? [inArray(crmQuotations.branchId, branches)] : []),
...(customers.length ? [inArray(crmQuotations.customerId, customers)] : []),
...(enquiries.length ? [inArray(crmQuotations.enquiryId, enquiries)] : []),
...(filters.hot === 'true' ? [eq(crmQuotations.isHotProject, true)] : []),
...(filters.hot === 'false' ? [eq(crmQuotations.isHotProject, false)] : []),
...(filters.quotationDateFrom ? [gte(crmQuotations.quotationDate, new Date(filters.quotationDateFrom))] : []),
...(filters.quotationDateTo ? [lte(crmQuotations.quotationDate, new Date(filters.quotationDateTo))] : []),
...(filters.search
? [
or(
ilike(crmQuotations.code, `%${filters.search}%`),
ilike(crmQuotations.projectName, `%${filters.search}%`),
ilike(crmQuotations.projectLocation, `%${filters.search}%`),
ilike(crmQuotations.reference, `%${filters.search}%`),
ilike(crmQuotations.competitor, `%${filters.search}%`)
)!
]
: [])
];
}
function normalizeParties(
rows: Array<{ customerId: string; role: string }>,
roleOptions: Awaited<ReturnType<typeof getActiveOptionsByCategory>>
) {
const normalized: NormalizedProjectParty[] = [];
const seen = new Set<string>();
for (const row of rows) {
const resolvedRole = resolveProjectPartyRole(roleOptions, row.role);
if (!resolvedRole) {
continue;
}
const key = `${row.customerId}::${resolvedRole.code}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
normalized.push({
customerId: row.customerId,
roleCode: resolvedRole.code
});
}
return normalized;
}
function groupNormalizedParties<TGroupKey extends string>(
rows: Array<{ groupKey: TGroupKey; customerId: string; role: string }>,
roleOptions: Awaited<ReturnType<typeof getActiveOptionsByCategory>>
) {
const grouped = new Map<TGroupKey, Array<{ customerId: string; role: string }>>();
for (const row of rows) {
const existing = grouped.get(row.groupKey) ?? [];
existing.push({
customerId: row.customerId,
role: row.role
});
grouped.set(row.groupKey, existing);
}
return new Map(
[...grouped.entries()].map(([groupKey, groupRows]) => [
groupKey,
normalizeParties(groupRows, roleOptions)
])
);
}
function pickAttributedParties(
roleCode: SupportedRevenueRole,
quotationParties: NormalizedProjectParty[],
enquiryParties: NormalizedProjectParty[]
) {
const authoritativeParties = quotationParties.length > 0 ? quotationParties : enquiryParties;
if (roleCode === 'end_customer') {
const endCustomers = authoritativeParties.filter((party) => party.roleCode === 'end_customer');
if (endCustomers.length > 0) {
return endCustomers;
}
return authoritativeParties.filter((party) => party.roleCode === 'billing_customer');
}
return authoritativeParties.filter((party) => party.roleCode === roleCode);
}
async function getRevenueByRole(
organizationId: string,
roleCode: SupportedRevenueRole,
filters: RevenueAttributionFilters = {}
): Promise<RevenueAttributionSummary[]> {
const whereFilters = buildRevenueAttributionFilters(organizationId, filters);
const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters);
const quotations = await db
.select({
id: crmQuotations.id,
enquiryId: crmQuotations.enquiryId,
totalAmount: crmQuotations.totalAmount
})
.from(crmQuotations)
.where(where)
.orderBy(asc(crmQuotations.quotationDate), asc(crmQuotations.code));
if (quotations.length === 0) {
return [];
}
const quotationIds = quotations.map((quotation) => quotation.id);
const enquiryIds = [...new Set(quotations.map((quotation) => quotation.enquiryId).filter(Boolean))] as string[];
const [roleOptions, quotationParties, enquiryParties] = await Promise.all([
getActiveOptionsByCategory(PROJECT_PARTY_OPTION_CATEGORY, { organizationId }),
db
.select({
quotationId: crmQuotationCustomers.quotationId,
customerId: crmQuotationCustomers.customerId,
role: crmQuotationCustomers.role
})
.from(crmQuotationCustomers)
.where(
and(
eq(crmQuotationCustomers.organizationId, organizationId),
inArray(crmQuotationCustomers.quotationId, quotationIds),
isNull(crmQuotationCustomers.deletedAt)
)
),
enquiryIds.length
? db
.select({
enquiryId: crmEnquiryCustomers.enquiryId,
customerId: crmEnquiryCustomers.customerId,
role: crmEnquiryCustomers.role
})
.from(crmEnquiryCustomers)
.where(
and(
eq(crmEnquiryCustomers.organizationId, organizationId),
inArray(crmEnquiryCustomers.enquiryId, enquiryIds),
isNull(crmEnquiryCustomers.deletedAt)
)
)
: []
]);
const quotationPartyMap = groupNormalizedParties(
quotationParties.map((row) => ({
groupKey: row.quotationId,
customerId: row.customerId,
role: row.role
})),
roleOptions
);
const enquiryPartyMap = groupNormalizedParties(
enquiryParties.map((row) => ({
groupKey: row.enquiryId,
customerId: row.customerId,
role: row.role
})),
roleOptions
);
const attributedCustomerIds = new Set<string>();
const attributionByQuotation = new Map<string, NormalizedProjectParty[]>();
for (const quotation of quotations) {
const attributedParties = pickAttributedParties(
roleCode,
quotationPartyMap.get(quotation.id) ?? [],
quotation.enquiryId ? (enquiryPartyMap.get(quotation.enquiryId) ?? []) : []
);
attributionByQuotation.set(quotation.id, attributedParties);
for (const party of attributedParties) {
attributedCustomerIds.add(party.customerId);
}
}
if (attributedCustomerIds.size === 0) {
return [];
}
const customers = await db
.select({
id: crmCustomers.id,
code: crmCustomers.code,
name: crmCustomers.name
})
.from(crmCustomers)
.where(
and(
eq(crmCustomers.organizationId, organizationId),
inArray(crmCustomers.id, [...attributedCustomerIds]),
isNull(crmCustomers.deletedAt)
)
);
const customerMap = new Map(customers.map((customer) => [customer.id, customer]));
const summaryMap = new Map<string, RevenueAttributionSummary>();
for (const quotation of quotations) {
const attributedParties = attributionByQuotation.get(quotation.id) ?? [];
for (const party of attributedParties) {
const customer = customerMap.get(party.customerId);
if (!customer) {
continue;
}
const existing = summaryMap.get(customer.id);
if (existing) {
existing.revenue += quotation.totalAmount;
if (!existing.quotationIds.includes(quotation.id)) {
existing.quotationIds.push(quotation.id);
existing.quotationCount += 1;
}
continue;
}
summaryMap.set(customer.id, {
customerId: customer.id,
customerCode: customer.code,
customerName: customer.name,
roleCode,
revenue: quotation.totalAmount,
quotationCount: 1,
quotationIds: [quotation.id]
});
}
}
return [...summaryMap.values()].sort((a, b) => {
if (b.revenue !== a.revenue) {
return b.revenue - a.revenue;
}
return a.customerName.localeCompare(b.customerName);
});
}
export async function getRevenueByEndCustomer(
organizationId: string,
filters: RevenueAttributionFilters = {}
) {
return getRevenueByRole(organizationId, 'end_customer', filters);
}
export async function getRevenueByBillingCustomer(
organizationId: string,
filters: RevenueAttributionFilters = {}
) {
return getRevenueByRole(organizationId, 'billing_customer', filters);
}
export async function getRevenueByContractor(
organizationId: string,
filters: RevenueAttributionFilters = {}
) {
return getRevenueByRole(organizationId, 'contractor', filters);
}
export async function getRevenueByConsultant(
organizationId: string,
filters: RevenueAttributionFilters = {}
) {
return getRevenueByRole(organizationId, 'consultant', filters);
}

View File

@@ -0,0 +1,22 @@
export interface RevenueAttributionFilters {
search?: string;
status?: string;
quotationType?: string;
branch?: string;
customer?: string;
enquiry?: string;
hot?: string;
quotationDateFrom?: string | null;
quotationDateTo?: string | null;
includeCancelled?: boolean;
}
export interface RevenueAttributionSummary {
customerId: string;
customerCode: string;
customerName: string;
roleCode: string;
revenue: number;
quotationCount: number;
quotationIds: string[];
}

View File

@@ -0,0 +1,78 @@
export const PROJECT_PARTY_OPTION_CATEGORY = 'crm_project_party_role';
const LEGACY_PROJECT_PARTY_ROLE_CODE_MAP = {
owner: 'customer',
billing: 'billing_customer',
consultant: 'consultant',
contractor: 'contractor'
} as const;
export const PROJECT_PARTY_CODE_LABELS: Record<string, string> = {
billing_customer: 'Billing Customer',
customer: 'Customer',
consultant: 'Consultant',
contractor: 'Contractor',
end_customer: 'End Customer',
other: 'Other'
};
export interface ProjectPartyRoleOptionLike {
id: string;
code: string;
label: string;
}
export interface ProjectPartyResolvedRole {
id: string;
code: string;
label: string;
}
export function normalizeProjectPartyRoleCode(role: string | null | undefined) {
if (!role) {
return null;
}
return LEGACY_PROJECT_PARTY_ROLE_CODE_MAP[role as keyof typeof LEGACY_PROJECT_PARTY_ROLE_CODE_MAP] ?? role;
}
export function resolveProjectPartyRole(
options: ProjectPartyRoleOptionLike[],
role: string | null | undefined
): ProjectPartyResolvedRole | null {
if (!role) {
return null;
}
const direct = options.find((option) => option.id === role);
if (direct) {
return {
id: direct.id,
code: direct.code,
label: direct.label
};
}
const normalizedCode = normalizeProjectPartyRoleCode(role);
if (!normalizedCode) {
return null;
}
const byCode = options.find((option) => option.code === normalizedCode);
if (!byCode) {
return null;
}
return {
id: byCode.id,
code: byCode.code,
label: byCode.label
};
}
export function buildProjectPartyKey(customerId: string, roleCode: string) {
return `${customerId}::${roleCode}`;
}

View File

@@ -8,7 +8,7 @@ export const CRM_MASTER_OPTION_CATEGORIES = [
'crm_discount_type',
'crm_unit',
'crm_sent_via',
'crm_quotation_customer_role',
'crm_project_party_role',
'crm_quotation_topic_type',
'crm_product_type',
'crm_currency',