taks-d.2.1
This commit is contained in:
36
docs/adr/0010-revenue-attribution-governance.md
Normal file
36
docs/adr/0010-revenue-attribution-governance.md
Normal 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
|
||||||
@@ -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`
|
||||||
@@ -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
|
||||||
139
docs/implementation/task-j0-kpi-definition-freeze.md
Normal file
139
docs/implementation/task-j0-kpi-definition-freeze.md
Normal 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
|
||||||
@@ -242,3 +242,58 @@ Future:
|
|||||||
- decide when direct signed URLs are acceptable versus mandatory server streaming
|
- decide when direct signed URLs are acceptable versus mandatory server streaming
|
||||||
- add response caching strategy for large artifact downloads
|
- add response caching strategy for large artifact downloads
|
||||||
- evaluate optional download-audit sampling if volume grows
|
- 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
|
||||||
|
|||||||
13
drizzle/0010_calm_wendell_rand.sql
Normal file
13
drizzle/0010_calm_wendell_rand.sql
Normal 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;
|
||||||
3267
drizzle/meta/0010_snapshot.json
Normal file
3267
drizzle/meta/0010_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -71,6 +71,13 @@
|
|||||||
"when": 1781603421415,
|
"when": 1781603421415,
|
||||||
"tag": "0009_lazy_shard",
|
"tag": "0009_lazy_shard",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 10,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781675409847,
|
||||||
|
"tag": "0010_calm_wendell_rand",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
370
plans/task-d.2.1.md
Normal file
370
plans/task-d.2.1.md
Normal 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
219
plans/task-d.2.md
Normal 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
421
plans/task-j.0.md
Normal 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
0
plans/task-j.md
Normal file
38
src/app/api/crm/enquiries/[id]/customers/route.ts
Normal file
38
src/app/api/crm/enquiries/[id]/customers/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import { auth } from '@/auth';
|
import { auth } from '@/auth';
|
||||||
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
||||||
import PageContainer from '@/components/layout/page-container';
|
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 { EnquiryDetail } from '@/features/crm/enquiries/components/enquiry-detail';
|
||||||
import { getEnquiryReferenceData } from '@/features/crm/enquiries/server/service';
|
import { getEnquiryReferenceData } from '@/features/crm/enquiries/server/service';
|
||||||
import { listEnquiryQuotationRelations } from '@/features/crm/quotations/server/service';
|
import { listEnquiryQuotationRelations } from '@/features/crm/quotations/server/service';
|
||||||
@@ -54,6 +58,7 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
|
|||||||
|
|
||||||
if (canRead) {
|
if (canRead) {
|
||||||
void queryClient.prefetchQuery(enquiryByIdOptions(id));
|
void queryClient.prefetchQuery(enquiryByIdOptions(id));
|
||||||
|
void queryClient.prefetchQuery(enquiryProjectPartiesOptions(id));
|
||||||
void queryClient.prefetchQuery(enquiryFollowupsOptions(id));
|
void queryClient.prefetchQuery(enquiryFollowupsOptions(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { auth } from '@/auth';
|
import { auth } from "@/auth";
|
||||||
import PageContainer from '@/components/layout/page-container';
|
import PageContainer from "@/components/layout/page-container";
|
||||||
import EnquiryListing from '@/features/crm/enquiries/components/enquiry-listing';
|
import EnquiryListing from "@/features/crm/enquiries/components/enquiry-listing";
|
||||||
import { EnquiryFormSheetTrigger } from '@/features/crm/enquiries/components/enquiry-form-sheet';
|
import { EnquiryFormSheetTrigger } from "@/features/crm/enquiries/components/enquiry-form-sheet";
|
||||||
import { getEnquiryReferenceData } from '@/features/crm/enquiries/server/service';
|
import { getEnquiryReferenceData } from "@/features/crm/enquiries/server/service";
|
||||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
import { PERMISSIONS } from "@/lib/auth/rbac";
|
||||||
import { searchParamsCache } from '@/lib/searchparams';
|
import { searchParamsCache } from "@/lib/searchparams";
|
||||||
import type { SearchParams } from 'nuqs/server';
|
import type { SearchParams } from "nuqs/server";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Dashboard: CRM Enquiries'
|
title: "Dashboard: CRM Enquiries",
|
||||||
};
|
};
|
||||||
|
|
||||||
type PageProps = {
|
type PageProps = {
|
||||||
@@ -19,35 +19,37 @@ export default async function EnquiriesRoute(props: PageProps) {
|
|||||||
const searchParams = await props.searchParams;
|
const searchParams = await props.searchParams;
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const canRead =
|
const canRead =
|
||||||
session?.user?.systemRole === 'super_admin' ||
|
session?.user?.systemRole === "super_admin" ||
|
||||||
(!!session?.user?.activeOrganizationId &&
|
(!!session?.user?.activeOrganizationId &&
|
||||||
(session.user.activeMembershipRole === 'admin' ||
|
(session.user.activeMembershipRole === "admin" ||
|
||||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryRead)));
|
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryRead)));
|
||||||
const canCreate =
|
const canCreate =
|
||||||
session?.user?.systemRole === 'super_admin' ||
|
session?.user?.systemRole === "super_admin" ||
|
||||||
(!!session?.user?.activeOrganizationId &&
|
(!!session?.user?.activeOrganizationId &&
|
||||||
(session.user.activeMembershipRole === 'admin' ||
|
(session.user.activeMembershipRole === "admin" ||
|
||||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryCreate)));
|
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryCreate)));
|
||||||
const canUpdate =
|
const canUpdate =
|
||||||
session?.user?.systemRole === 'super_admin' ||
|
session?.user?.systemRole === "super_admin" ||
|
||||||
(!!session?.user?.activeOrganizationId &&
|
(!!session?.user?.activeOrganizationId &&
|
||||||
(session.user.activeMembershipRole === 'admin' ||
|
(session.user.activeMembershipRole === "admin" ||
|
||||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryUpdate)));
|
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryUpdate)));
|
||||||
const canDelete =
|
const canDelete =
|
||||||
session?.user?.systemRole === 'super_admin' ||
|
session?.user?.systemRole === "super_admin" ||
|
||||||
(!!session?.user?.activeOrganizationId &&
|
(!!session?.user?.activeOrganizationId &&
|
||||||
(session.user.activeMembershipRole === 'admin' ||
|
(session.user.activeMembershipRole === "admin" ||
|
||||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryDelete)));
|
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryDelete)));
|
||||||
const canAssign =
|
const canAssign =
|
||||||
session?.user?.systemRole === 'super_admin' ||
|
session?.user?.systemRole === "super_admin" ||
|
||||||
(!!session?.user?.activeOrganizationId &&
|
(!!session?.user?.activeOrganizationId &&
|
||||||
(session.user.activeMembershipRole === 'admin' ||
|
(session.user.activeMembershipRole === "admin" ||
|
||||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryAssign)));
|
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryAssign)));
|
||||||
const canReassign =
|
const canReassign =
|
||||||
session?.user?.systemRole === 'super_admin' ||
|
session?.user?.systemRole === "super_admin" ||
|
||||||
(!!session?.user?.activeOrganizationId &&
|
(!!session?.user?.activeOrganizationId &&
|
||||||
(session.user.activeMembershipRole === 'admin' ||
|
(session.user.activeMembershipRole === "admin" ||
|
||||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryReassign)));
|
session.user.activePermissions.includes(
|
||||||
|
PERMISSIONS.crmEnquiryReassign,
|
||||||
|
)));
|
||||||
|
|
||||||
searchParamsCache.parse(searchParams);
|
searchParamsCache.parse(searchParams);
|
||||||
|
|
||||||
@@ -58,11 +60,11 @@ export default async function EnquiriesRoute(props: PageProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer
|
<PageContainer
|
||||||
pageTitle='Enquiries'
|
pageTitle="Enquiries (Lead)"
|
||||||
pageDescription='Production opportunity registry with customer linkage, follow-up workflow, and audit-backed activity.'
|
pageDescription="Production opportunity registry with customer linkage, follow-up workflow, and audit-backed activity."
|
||||||
access={canRead}
|
access={canRead}
|
||||||
accessFallback={
|
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.
|
You do not have access to CRM enquiries.
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,110 +35,113 @@ import { NavGroup } from "@/types";
|
|||||||
*/
|
*/
|
||||||
export const navGroups: NavGroup[] = [
|
export const navGroups: NavGroup[] = [
|
||||||
{
|
{
|
||||||
label: 'Overview',
|
label: "Overview",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: 'Dashboard',
|
title: "Dashboard",
|
||||||
url: '/dashboard',
|
url: "/dashboard",
|
||||||
icon: 'dashboard',
|
icon: "dashboard",
|
||||||
isActive: false,
|
isActive: false,
|
||||||
shortcut: ['d', 'd'],
|
shortcut: ["d", "d"],
|
||||||
items: []
|
items: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Workspaces',
|
title: "Workspaces",
|
||||||
url: '/dashboard/workspaces',
|
url: "/dashboard/workspaces",
|
||||||
icon: 'workspace',
|
icon: "workspace",
|
||||||
isActive: false,
|
isActive: false,
|
||||||
items: [],
|
items: [],
|
||||||
access: { systemRole: 'super_admin' }
|
access: { systemRole: "super_admin" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Teams',
|
title: "Teams",
|
||||||
url: '/dashboard/workspaces/team',
|
url: "/dashboard/workspaces/team",
|
||||||
icon: 'teams',
|
icon: "teams",
|
||||||
isActive: false,
|
isActive: false,
|
||||||
items: [],
|
items: [],
|
||||||
access: { requireOrg: true, role: 'admin' }
|
access: { requireOrg: true, role: "admin" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Users',
|
title: "Users",
|
||||||
url: '/dashboard/users',
|
url: "/dashboard/users",
|
||||||
icon: 'teams',
|
icon: "teams",
|
||||||
shortcut: ['u', 'u'],
|
shortcut: ["u", "u"],
|
||||||
isActive: false,
|
isActive: false,
|
||||||
items: [],
|
items: [],
|
||||||
access: { requireOrg: true, permission: 'users:manage' }
|
access: { requireOrg: true, permission: "users:manage" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Kanban',
|
title: "Kanban",
|
||||||
url: '/dashboard/kanban',
|
url: "/dashboard/kanban",
|
||||||
icon: 'kanban',
|
icon: "kanban",
|
||||||
shortcut: ['k', 'k'],
|
shortcut: ["k", "k"],
|
||||||
isActive: false,
|
isActive: false,
|
||||||
items: []
|
items: [],
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'CRM',
|
label: "CRM",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: 'Customers',
|
title: "Customers",
|
||||||
url: '/dashboard/crm/customers',
|
url: "/dashboard/crm/customers",
|
||||||
icon: 'teams',
|
icon: "teams",
|
||||||
isActive: false,
|
isActive: false,
|
||||||
items: [],
|
items: [],
|
||||||
access: { requireOrg: true, permission: 'crm.customer.read' }
|
access: { requireOrg: true, permission: "crm.customer.read" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Enquiries',
|
title: "Enquiries (Lead)",
|
||||||
url: '/dashboard/crm/enquiries',
|
url: "/dashboard/crm/enquiries",
|
||||||
icon: 'forms',
|
icon: "forms",
|
||||||
isActive: false,
|
isActive: false,
|
||||||
items: [],
|
items: [],
|
||||||
access: { requireOrg: true, permission: 'crm.enquiry.read' }
|
access: { requireOrg: true, permission: "crm.enquiry.read" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Quotations',
|
title: "Quotations",
|
||||||
url: '/dashboard/crm/quotations',
|
url: "/dashboard/crm/quotations",
|
||||||
icon: 'post',
|
icon: "post",
|
||||||
isActive: false,
|
isActive: false,
|
||||||
items: [],
|
items: [],
|
||||||
access: { requireOrg: true, role: 'admin' }
|
access: { requireOrg: true, role: "admin" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Approvals',
|
title: "Approvals",
|
||||||
url: '/dashboard/crm/approvals',
|
url: "/dashboard/crm/approvals",
|
||||||
icon: 'checks',
|
icon: "checks",
|
||||||
isActive: false,
|
isActive: false,
|
||||||
items: [],
|
items: [],
|
||||||
access: { requireOrg: true, permission: 'crm.approval.read' }
|
access: { requireOrg: true, permission: "crm.approval.read" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'CRM Settings',
|
title: "CRM Settings",
|
||||||
url: '/dashboard/crm/settings/master-options',
|
url: "/dashboard/crm/settings/master-options",
|
||||||
icon: 'settings',
|
icon: "settings",
|
||||||
isActive: false,
|
isActive: false,
|
||||||
access: { requireOrg: true, permission: 'crm.document_template.read' },
|
access: { requireOrg: true, permission: "crm.document_template.read" },
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: 'Master Options',
|
title: "Master Options",
|
||||||
url: '/dashboard/crm/settings/master-options',
|
url: "/dashboard/crm/settings/master-options",
|
||||||
access: { requireOrg: true, permission: 'organization:manage' }
|
access: { requireOrg: true, permission: "organization:manage" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Document Sequences',
|
title: "Document Sequences",
|
||||||
url: '/dashboard/crm/settings/document-sequences',
|
url: "/dashboard/crm/settings/document-sequences",
|
||||||
access: { requireOrg: true, permission: 'organization:manage' }
|
access: { requireOrg: true, permission: "organization:manage" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Templates',
|
title: "Templates",
|
||||||
url: '/dashboard/crm/settings/templates',
|
url: "/dashboard/crm/settings/templates",
|
||||||
access: { requireOrg: true, permission: 'crm.document_template.read' }
|
access: {
|
||||||
}
|
requireOrg: true,
|
||||||
]
|
permission: "crm.document_template.read",
|
||||||
}
|
},
|
||||||
]
|
},
|
||||||
}
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -251,6 +251,18 @@ export const crmEnquiryFollowups = pgTable('crm_enquiry_followups', {
|
|||||||
updatedBy: text('updated_by').notNull()
|
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(
|
export const crmQuotations = pgTable(
|
||||||
'crm_quotations',
|
'crm_quotations',
|
||||||
{
|
{
|
||||||
@@ -339,6 +351,7 @@ export const crmQuotationCustomers = pgTable('crm_quotation_customers', {
|
|||||||
customerId: text('customer_id').notNull(),
|
customerId: text('customer_id').notNull(),
|
||||||
role: text('role').notNull(),
|
role: text('role').notNull(),
|
||||||
isPrimary: boolean('is_primary').default(false).notNull(),
|
isPrimary: boolean('is_primary').default(false).notNull(),
|
||||||
|
remark: text('remark'),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
deletedAt: timestamp('deleted_at', { withTimezone: true })
|
deletedAt: timestamp('deleted_at', { withTimezone: true })
|
||||||
|
|||||||
@@ -171,11 +171,18 @@ const FOUNDATION_OPTIONS = {
|
|||||||
{ code: 'manual', label: 'Manual', value: 'manual', sortOrder: 2 },
|
{ code: 'manual', label: 'Manual', value: 'manual', sortOrder: 2 },
|
||||||
{ code: 'system', label: 'System', value: 'system', sortOrder: 3 }
|
{ code: 'system', label: 'System', value: 'system', sortOrder: 3 }
|
||||||
],
|
],
|
||||||
crm_quotation_customer_role: [
|
crm_project_party_role: [
|
||||||
{ code: 'owner', label: 'Owner', value: 'owner', sortOrder: 1 },
|
{
|
||||||
{ code: 'consultant', label: 'Consultant', value: 'consultant', sortOrder: 2 },
|
code: 'billing_customer',
|
||||||
{ code: 'contractor', label: 'Contractor', value: 'contractor', sortOrder: 3 },
|
label: 'Billing Customer',
|
||||||
{ code: 'billing', label: 'Billing', value: 'billing', sortOrder: 4 }
|
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: [
|
crm_quotation_topic_type: [
|
||||||
{ code: 'scope', label: 'Scope', value: 'scope', sortOrder: 1 },
|
{ code: 'scope', label: 'Scope', value: 'scope', sortOrder: 1 },
|
||||||
|
|||||||
153
src/features/crm/components/project-parties-editor.tsx
Normal file
153
src/features/crm/components/project-parties-editor.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,12 +25,20 @@ async function invalidateEnquiryDetail(id: string) {
|
|||||||
await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.detail(id) });
|
await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.detail(id) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function invalidateEnquiryProjectParties(id: string) {
|
||||||
|
await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.projectParties(id) });
|
||||||
|
}
|
||||||
|
|
||||||
async function invalidateEnquiryFollowups(id: string) {
|
async function invalidateEnquiryFollowups(id: string) {
|
||||||
await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.followups(id) });
|
await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.followups(id) });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function invalidateEnquiryMutationQueries(id: string) {
|
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) {
|
export async function invalidateEnquiryFollowupMutationQueries(enquiryId: string) {
|
||||||
@@ -65,6 +73,7 @@ export const deleteEnquiryMutation = mutationOptions({
|
|||||||
onSettled: async (_data, error, id) => {
|
onSettled: async (_data, error, id) => {
|
||||||
if (!error) {
|
if (!error) {
|
||||||
getQueryClient().removeQueries({ queryKey: enquiryKeys.detail(id) });
|
getQueryClient().removeQueries({ queryKey: enquiryKeys.detail(id) });
|
||||||
|
getQueryClient().removeQueries({ queryKey: enquiryKeys.projectParties(id) });
|
||||||
getQueryClient().removeQueries({ queryKey: enquiryKeys.followups(id) });
|
getQueryClient().removeQueries({ queryKey: enquiryKeys.followups(id) });
|
||||||
await invalidateEnquiryLists();
|
await invalidateEnquiryLists();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { queryOptions } from '@tanstack/react-query';
|
import { queryOptions } from '@tanstack/react-query';
|
||||||
import { getEnquiries, getEnquiryById, getEnquiryFollowups } from './service';
|
import {
|
||||||
|
getEnquiries,
|
||||||
|
getEnquiryById,
|
||||||
|
getEnquiryFollowups,
|
||||||
|
getEnquiryProjectParties
|
||||||
|
} from './service';
|
||||||
import type { EnquiryFilters } from './types';
|
import type { EnquiryFilters } from './types';
|
||||||
|
|
||||||
export const enquiryKeys = {
|
export const enquiryKeys = {
|
||||||
@@ -8,6 +13,8 @@ export const enquiryKeys = {
|
|||||||
list: (filters: EnquiryFilters) => [...enquiryKeys.lists(), filters] as const,
|
list: (filters: EnquiryFilters) => [...enquiryKeys.lists(), filters] as const,
|
||||||
details: () => [...enquiryKeys.all, 'detail'] as const,
|
details: () => [...enquiryKeys.all, 'detail'] as const,
|
||||||
detail: (id: string) => [...enquiryKeys.details(), id] 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,
|
followupsRoot: () => [...enquiryKeys.all, 'followups'] as const,
|
||||||
followups: (id: string) => [...enquiryKeys.followupsRoot(), id] as const
|
followups: (id: string) => [...enquiryKeys.followupsRoot(), id] as const
|
||||||
};
|
};
|
||||||
@@ -24,6 +31,12 @@ export const enquiryByIdOptions = (id: string) =>
|
|||||||
queryFn: () => getEnquiryById(id)
|
queryFn: () => getEnquiryById(id)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const enquiryProjectPartiesOptions = (id: string) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: enquiryKeys.projectParties(id),
|
||||||
|
queryFn: () => getEnquiryProjectParties(id)
|
||||||
|
});
|
||||||
|
|
||||||
export const enquiryFollowupsOptions = (id: string) =>
|
export const enquiryFollowupsOptions = (id: string) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: enquiryKeys.followups(id),
|
queryKey: enquiryKeys.followups(id),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
EnquiryFollowupsResponse,
|
EnquiryFollowupsResponse,
|
||||||
EnquiryListResponse,
|
EnquiryListResponse,
|
||||||
EnquiryMutationPayload,
|
EnquiryMutationPayload,
|
||||||
|
EnquiryProjectPartiesResponse,
|
||||||
MutationSuccessResponse
|
MutationSuccessResponse
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
@@ -32,6 +33,10 @@ export async function getEnquiryById(id: string): Promise<EnquiryDetailResponse>
|
|||||||
return apiClient<EnquiryDetailResponse>(`/crm/enquiries/${id}`);
|
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) {
|
export async function createEnquiry(data: EnquiryMutationPayload) {
|
||||||
return apiClient<MutationSuccessResponse>('/crm/enquiries', {
|
return apiClient<MutationSuccessResponse>('/crm/enquiries', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -5,6 +5,25 @@ export interface EnquiryOption {
|
|||||||
value: string | null;
|
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 {
|
export interface EnquiryBranchOption {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
@@ -113,6 +132,7 @@ export interface EnquiryReferenceData {
|
|||||||
priorities: EnquiryOption[];
|
priorities: EnquiryOption[];
|
||||||
leadChannels: EnquiryOption[];
|
leadChannels: EnquiryOption[];
|
||||||
followupTypes: EnquiryOption[];
|
followupTypes: EnquiryOption[];
|
||||||
|
projectPartyRoles: EnquiryOption[];
|
||||||
customers: EnquiryCustomerLookup[];
|
customers: EnquiryCustomerLookup[];
|
||||||
contacts: EnquiryContactLookup[];
|
contacts: EnquiryContactLookup[];
|
||||||
assignableUsers: EnquiryAssignableUserLookup[];
|
assignableUsers: EnquiryAssignableUserLookup[];
|
||||||
@@ -148,6 +168,13 @@ export interface EnquiryDetailResponse {
|
|||||||
activity: EnquiryActivityRecord[];
|
activity: EnquiryActivityRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EnquiryProjectPartiesResponse {
|
||||||
|
success: boolean;
|
||||||
|
time: string;
|
||||||
|
message: string;
|
||||||
|
items: EnquiryProjectPartyListItem[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface EnquiryFollowupsResponse {
|
export interface EnquiryFollowupsResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
time: string;
|
time: string;
|
||||||
@@ -155,6 +182,12 @@ export interface EnquiryFollowupsResponse {
|
|||||||
items: EnquiryFollowupRecord[];
|
items: EnquiryFollowupRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EnquiryProjectPartyMutationPayload {
|
||||||
|
customerId: string;
|
||||||
|
role: string;
|
||||||
|
remark?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface EnquiryMutationPayload {
|
export interface EnquiryMutationPayload {
|
||||||
customerId: string;
|
customerId: string;
|
||||||
contactId?: string | null;
|
contactId?: string | null;
|
||||||
@@ -176,6 +209,7 @@ export interface EnquiryMutationPayload {
|
|||||||
isHotProject?: boolean;
|
isHotProject?: boolean;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
|
projectParties?: EnquiryProjectPartyMutationPayload[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnquiryFollowupMutationPayload {
|
export interface EnquiryFollowupMutationPayload {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
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 { EnquiryReferenceData } from '../api/types';
|
||||||
import type { QuotationRelationItem } from '@/features/crm/quotations/api/types';
|
import type { QuotationRelationItem } from '@/features/crm/quotations/api/types';
|
||||||
import { EnquiryAssignmentDialog } from './enquiry-assignment-dialog';
|
import { EnquiryAssignmentDialog } from './enquiry-assignment-dialog';
|
||||||
@@ -48,6 +48,7 @@ export function EnquiryDetail({
|
|||||||
};
|
};
|
||||||
}) {
|
}) {
|
||||||
const { data } = useSuspenseQuery(enquiryByIdOptions(enquiryId));
|
const { data } = useSuspenseQuery(enquiryByIdOptions(enquiryId));
|
||||||
|
const { data: projectPartiesData } = useSuspenseQuery(enquiryProjectPartiesOptions(enquiryId));
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [assignmentOpen, setAssignmentOpen] = useState(false);
|
const [assignmentOpen, setAssignmentOpen] = useState(false);
|
||||||
const enquiry = data.enquiry;
|
const enquiry = data.enquiry;
|
||||||
@@ -142,7 +143,7 @@ export function EnquiryDetail({
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
<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='Contact' value={contact?.name} />
|
||||||
<FieldItem
|
<FieldItem
|
||||||
label='Branch'
|
label='Branch'
|
||||||
@@ -192,6 +193,28 @@ export function EnquiryDetail({
|
|||||||
/>
|
/>
|
||||||
<FieldItem label='Assigned By' value={enquiry.assignedByName} />
|
<FieldItem label='Assigned By' value={enquiry.assignedByName} />
|
||||||
<FieldItem label='Assignment Remark' value={enquiry.assignmentRemark} />
|
<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'>
|
<div className='md:col-span-2 xl:col-span-4'>
|
||||||
<FieldItem
|
<FieldItem
|
||||||
label='Project'
|
label='Project'
|
||||||
|
|||||||
@@ -2,10 +2,14 @@
|
|||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useEffect, useMemo, useState } 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 { toast } from 'sonner';
|
||||||
import { Icons } from '@/components/icons';
|
import { Icons } from '@/components/icons';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
ProjectPartiesEditor,
|
||||||
|
type ProjectPartyEditorItem
|
||||||
|
} from '@/features/crm/components/project-parties-editor';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -22,6 +26,7 @@ import {
|
|||||||
SheetTitle
|
SheetTitle
|
||||||
} from '@/components/ui/sheet';
|
} from '@/components/ui/sheet';
|
||||||
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
|
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
|
||||||
|
import { enquiryProjectPartiesOptions } from '../api/queries';
|
||||||
import { createEnquiryMutation, updateEnquiryMutation } from '../api/mutations';
|
import { createEnquiryMutation, updateEnquiryMutation } from '../api/mutations';
|
||||||
import type { EnquiryMutationPayload, EnquiryRecord, EnquiryReferenceData } from '../api/types';
|
import type { EnquiryMutationPayload, EnquiryRecord, EnquiryReferenceData } from '../api/types';
|
||||||
import { enquirySchema, type EnquiryFormValues } from '../schemas/enquiry.schema';
|
import { enquirySchema, type EnquiryFormValues } from '../schemas/enquiry.schema';
|
||||||
@@ -72,8 +77,13 @@ export function EnquiryFormSheet({
|
|||||||
[enquiry, referenceData]
|
[enquiry, referenceData]
|
||||||
);
|
);
|
||||||
const [selectedCustomerId, setSelectedCustomerId] = useState(defaultValues.customerId);
|
const [selectedCustomerId, setSelectedCustomerId] = useState(defaultValues.customerId);
|
||||||
|
const [projectParties, setProjectParties] = useState<ProjectPartyEditorItem[]>([]);
|
||||||
const { FormTextField, FormTextareaField, FormSelectField, FormSwitchField } =
|
const { FormTextField, FormTextareaField, FormSelectField, FormSwitchField } =
|
||||||
useFormFields<EnquiryFormValues>();
|
useFormFields<EnquiryFormValues>();
|
||||||
|
const projectPartiesQuery = useQuery({
|
||||||
|
...enquiryProjectPartiesOptions(enquiry?.id ?? ''),
|
||||||
|
enabled: open && !!enquiry?.id
|
||||||
|
});
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
...createEnquiryMutation,
|
...createEnquiryMutation,
|
||||||
@@ -127,7 +137,14 @@ export function EnquiryFormSheet({
|
|||||||
source: value.source,
|
source: value.source,
|
||||||
isHotProject: value.isHotProject ?? false,
|
isHotProject: value.isHotProject ?? false,
|
||||||
notes: value.notes,
|
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) {
|
if (isEdit && enquiry) {
|
||||||
@@ -146,7 +163,17 @@ export function EnquiryFormSheet({
|
|||||||
|
|
||||||
setSelectedCustomerId(defaultValues.customerId);
|
setSelectedCustomerId(defaultValues.customerId);
|
||||||
form.reset(defaultValues);
|
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;
|
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
@@ -168,7 +195,7 @@ export function EnquiryFormSheet({
|
|||||||
children={(field) => (
|
children={(field) => (
|
||||||
<field.FieldSet>
|
<field.FieldSet>
|
||||||
<field.Field>
|
<field.Field>
|
||||||
<field.FieldLabel>Customer *</field.FieldLabel>
|
<field.FieldLabel>Billing Customer *</field.FieldLabel>
|
||||||
<Select
|
<Select
|
||||||
value={field.state.value ?? ''}
|
value={field.state.value ?? ''}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
@@ -340,6 +367,14 @@ export function EnquiryFormSheet({
|
|||||||
description='Inactive enquiries stay in history but should not progress further.'
|
description='Inactive enquiries stay in history but should not progress further.'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className='md:col-span-2'>
|
||||||
|
<ProjectPartiesEditor
|
||||||
|
customers={referenceData.customers}
|
||||||
|
roles={referenceData.projectPartyRoles}
|
||||||
|
items={projectParties}
|
||||||
|
onChange={setProjectParties}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</form.Form>
|
</form.Form>
|
||||||
</form.AppForm>
|
</form.AppForm>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -50,7 +50,16 @@ export const enquirySchema = z.object({
|
|||||||
source: z.string().optional(),
|
source: z.string().optional(),
|
||||||
isHotProject: z.boolean().default(false),
|
isHotProject: z.boolean().default(false),
|
||||||
notes: z.string().optional(),
|
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({
|
export const enquiryFollowupSchema = z.object({
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
crmCustomerContacts,
|
crmCustomerContacts,
|
||||||
crmCustomers,
|
crmCustomers,
|
||||||
crmEnquiries,
|
crmEnquiries,
|
||||||
|
crmEnquiryCustomers,
|
||||||
crmEnquiryFollowups,
|
crmEnquiryFollowups,
|
||||||
memberships,
|
memberships,
|
||||||
users
|
users
|
||||||
@@ -25,16 +26,25 @@ import type {
|
|||||||
EnquiryListItem,
|
EnquiryListItem,
|
||||||
EnquiryMutationPayload,
|
EnquiryMutationPayload,
|
||||||
EnquiryOption,
|
EnquiryOption,
|
||||||
|
EnquiryProjectPartyListItem,
|
||||||
|
EnquiryProjectPartyMutationPayload,
|
||||||
|
EnquiryProjectPartyRecord,
|
||||||
EnquiryRecord,
|
EnquiryRecord,
|
||||||
EnquiryReferenceData
|
EnquiryReferenceData
|
||||||
} from '../api/types';
|
} from '../api/types';
|
||||||
|
import {
|
||||||
|
buildProjectPartyKey,
|
||||||
|
PROJECT_PARTY_OPTION_CATEGORY,
|
||||||
|
resolveProjectPartyRole
|
||||||
|
} from '@/features/crm/shared/project-party';
|
||||||
|
|
||||||
const ENQUIRY_OPTION_CATEGORIES = {
|
const ENQUIRY_OPTION_CATEGORIES = {
|
||||||
status: 'crm_enquiry_status',
|
status: 'crm_enquiry_status',
|
||||||
productType: 'crm_product_type',
|
productType: 'crm_product_type',
|
||||||
priority: 'crm_priority',
|
priority: 'crm_priority',
|
||||||
leadChannel: 'crm_lead_channel',
|
leadChannel: 'crm_lead_channel',
|
||||||
followupType: 'crm_followup_type'
|
followupType: 'crm_followup_type',
|
||||||
|
projectPartyRole: PROJECT_PARTY_OPTION_CATEGORY
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const ASSIGNABLE_BUSINESS_ROLES = new Set(['sales_manager', 'sales', 'sales_support']);
|
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) {
|
function parseSort(sort?: string) {
|
||||||
if (!sort) {
|
if (!sort) {
|
||||||
return desc(crmEnquiries.updatedAt);
|
return desc(crmEnquiries.updatedAt);
|
||||||
@@ -352,6 +381,15 @@ async function validateEnquiryPayload(organizationId: string, payload: EnquiryMu
|
|||||||
if (payload.branchId) {
|
if (payload.branchId) {
|
||||||
await validateBranchAccess(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(
|
async function validateFollowupPayload(
|
||||||
@@ -410,6 +448,7 @@ export async function getEnquiryReferenceData(
|
|||||||
priorities,
|
priorities,
|
||||||
leadChannels,
|
leadChannels,
|
||||||
followupTypes,
|
followupTypes,
|
||||||
|
projectPartyRoles,
|
||||||
customers,
|
customers,
|
||||||
contacts,
|
contacts,
|
||||||
assignableUsers
|
assignableUsers
|
||||||
@@ -420,6 +459,7 @@ export async function getEnquiryReferenceData(
|
|||||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.priority, { organizationId }),
|
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.priority, { organizationId }),
|
||||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.leadChannel, { organizationId }),
|
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.leadChannel, { organizationId }),
|
||||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.followupType, { organizationId }),
|
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.followupType, { organizationId }),
|
||||||
|
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.projectPartyRole, { organizationId }),
|
||||||
db
|
db
|
||||||
.select()
|
.select()
|
||||||
.from(crmCustomers)
|
.from(crmCustomers)
|
||||||
@@ -445,6 +485,7 @@ export async function getEnquiryReferenceData(
|
|||||||
priorities: priorities.map(mapOption),
|
priorities: priorities.map(mapOption),
|
||||||
leadChannels: leadChannels.map(mapOption),
|
leadChannels: leadChannels.map(mapOption),
|
||||||
followupTypes: followupTypes.map(mapOption),
|
followupTypes: followupTypes.map(mapOption),
|
||||||
|
projectPartyRoles: projectPartyRoles.map(mapOption),
|
||||||
customers: customers.map((customer) => ({
|
customers: customers.map((customer) => ({
|
||||||
id: customer.id,
|
id: customer.id,
|
||||||
code: customer.code,
|
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(
|
export async function getEnquiryActivity(
|
||||||
id: string,
|
id: string,
|
||||||
organizationId: string
|
organizationId: string
|
||||||
@@ -624,38 +801,48 @@ export async function createEnquiry(
|
|||||||
branchId: payload.branchId ?? ''
|
branchId: payload.branchId ?? ''
|
||||||
});
|
});
|
||||||
|
|
||||||
const [created] = await db
|
return db.transaction(async (tx) => {
|
||||||
.insert(crmEnquiries)
|
const [created] = await tx
|
||||||
.values({
|
.insert(crmEnquiries)
|
||||||
id: crypto.randomUUID(),
|
.values({
|
||||||
organizationId,
|
id: crypto.randomUUID(),
|
||||||
branchId: payload.branchId ?? null,
|
organizationId,
|
||||||
code: documentCode.code,
|
branchId: payload.branchId ?? null,
|
||||||
customerId: payload.customerId,
|
code: documentCode.code,
|
||||||
contactId: payload.contactId ?? null,
|
customerId: payload.customerId,
|
||||||
title: payload.title.trim(),
|
contactId: payload.contactId ?? null,
|
||||||
description: payload.description?.trim() || null,
|
title: payload.title.trim(),
|
||||||
requirement: payload.requirement?.trim() || null,
|
description: payload.description?.trim() || null,
|
||||||
projectName: payload.projectName?.trim() || null,
|
requirement: payload.requirement?.trim() || null,
|
||||||
projectLocation: payload.projectLocation?.trim() || null,
|
projectName: payload.projectName?.trim() || null,
|
||||||
productType: payload.productType,
|
projectLocation: payload.projectLocation?.trim() || null,
|
||||||
status: payload.status,
|
productType: payload.productType,
|
||||||
priority: payload.priority,
|
status: payload.status,
|
||||||
leadChannel: payload.leadChannel ?? null,
|
priority: payload.priority,
|
||||||
estimatedValue: payload.estimatedValue ?? null,
|
leadChannel: payload.leadChannel ?? null,
|
||||||
chancePercent: payload.chancePercent ?? null,
|
estimatedValue: payload.estimatedValue ?? null,
|
||||||
expectedCloseDate: payload.expectedCloseDate ? new Date(payload.expectedCloseDate) : null,
|
chancePercent: payload.chancePercent ?? null,
|
||||||
competitor: payload.competitor?.trim() || null,
|
expectedCloseDate: payload.expectedCloseDate ? new Date(payload.expectedCloseDate) : null,
|
||||||
source: payload.source?.trim() || null,
|
competitor: payload.competitor?.trim() || null,
|
||||||
notes: payload.notes?.trim() || null,
|
source: payload.source?.trim() || null,
|
||||||
isHotProject: payload.isHotProject ?? false,
|
notes: payload.notes?.trim() || null,
|
||||||
isActive: payload.isActive ?? true,
|
isHotProject: payload.isHotProject ?? false,
|
||||||
createdBy: userId,
|
isActive: payload.isActive ?? true,
|
||||||
updatedBy: userId
|
createdBy: userId,
|
||||||
})
|
updatedBy: userId
|
||||||
.returning();
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
return created;
|
await syncEnquiryProjectParties(
|
||||||
|
tx,
|
||||||
|
organizationId,
|
||||||
|
created.id,
|
||||||
|
payload.customerId,
|
||||||
|
payload.projectParties ?? []
|
||||||
|
);
|
||||||
|
|
||||||
|
return created;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateEnquiry(
|
export async function updateEnquiry(
|
||||||
@@ -667,36 +854,46 @@ export async function updateEnquiry(
|
|||||||
await validateEnquiryPayload(organizationId, payload);
|
await validateEnquiryPayload(organizationId, payload);
|
||||||
await assertEnquiryBelongsToOrganization(id, organizationId);
|
await assertEnquiryBelongsToOrganization(id, organizationId);
|
||||||
|
|
||||||
const [updated] = await db
|
return db.transaction(async (tx) => {
|
||||||
.update(crmEnquiries)
|
const [updated] = await tx
|
||||||
.set({
|
.update(crmEnquiries)
|
||||||
branchId: payload.branchId ?? null,
|
.set({
|
||||||
customerId: payload.customerId,
|
branchId: payload.branchId ?? null,
|
||||||
contactId: payload.contactId ?? null,
|
customerId: payload.customerId,
|
||||||
title: payload.title.trim(),
|
contactId: payload.contactId ?? null,
|
||||||
description: payload.description?.trim() || null,
|
title: payload.title.trim(),
|
||||||
requirement: payload.requirement?.trim() || null,
|
description: payload.description?.trim() || null,
|
||||||
projectName: payload.projectName?.trim() || null,
|
requirement: payload.requirement?.trim() || null,
|
||||||
projectLocation: payload.projectLocation?.trim() || null,
|
projectName: payload.projectName?.trim() || null,
|
||||||
productType: payload.productType,
|
projectLocation: payload.projectLocation?.trim() || null,
|
||||||
status: payload.status,
|
productType: payload.productType,
|
||||||
priority: payload.priority,
|
status: payload.status,
|
||||||
leadChannel: payload.leadChannel ?? null,
|
priority: payload.priority,
|
||||||
estimatedValue: payload.estimatedValue ?? null,
|
leadChannel: payload.leadChannel ?? null,
|
||||||
chancePercent: payload.chancePercent ?? null,
|
estimatedValue: payload.estimatedValue ?? null,
|
||||||
expectedCloseDate: payload.expectedCloseDate ? new Date(payload.expectedCloseDate) : null,
|
chancePercent: payload.chancePercent ?? null,
|
||||||
competitor: payload.competitor?.trim() || null,
|
expectedCloseDate: payload.expectedCloseDate ? new Date(payload.expectedCloseDate) : null,
|
||||||
source: payload.source?.trim() || null,
|
competitor: payload.competitor?.trim() || null,
|
||||||
notes: payload.notes?.trim() || null,
|
source: payload.source?.trim() || null,
|
||||||
isHotProject: payload.isHotProject ?? false,
|
notes: payload.notes?.trim() || null,
|
||||||
isActive: payload.isActive ?? true,
|
isHotProject: payload.isHotProject ?? false,
|
||||||
updatedBy: userId,
|
isActive: payload.isActive ?? true,
|
||||||
updatedAt: new Date()
|
updatedBy: userId,
|
||||||
})
|
updatedAt: new Date()
|
||||||
.where(eq(crmEnquiries.id, id))
|
})
|
||||||
.returning();
|
.where(eq(crmEnquiries.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
return updated;
|
await syncEnquiryProjectParties(
|
||||||
|
tx,
|
||||||
|
organizationId,
|
||||||
|
id,
|
||||||
|
payload.customerId,
|
||||||
|
payload.projectParties ?? []
|
||||||
|
);
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function softDeleteEnquiry(id: string, organizationId: string, userId: string) {
|
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;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -147,7 +147,10 @@ export interface QuotationCustomerRecord {
|
|||||||
quotationId: string;
|
quotationId: string;
|
||||||
customerId: string;
|
customerId: string;
|
||||||
role: string;
|
role: string;
|
||||||
|
roleCode: string;
|
||||||
|
roleLabel: string | null;
|
||||||
isPrimary: boolean;
|
isPrimary: boolean;
|
||||||
|
remark: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
deletedAt: string | null;
|
deletedAt: string | null;
|
||||||
@@ -244,7 +247,7 @@ export interface QuotationReferenceData {
|
|||||||
discountTypes: QuotationOption[];
|
discountTypes: QuotationOption[];
|
||||||
units: QuotationOption[];
|
units: QuotationOption[];
|
||||||
sentVias: QuotationOption[];
|
sentVias: QuotationOption[];
|
||||||
customerRoles: QuotationOption[];
|
projectPartyRoles: QuotationOption[];
|
||||||
topicTypes: QuotationOption[];
|
topicTypes: QuotationOption[];
|
||||||
followupTypes: QuotationOption[];
|
followupTypes: QuotationOption[];
|
||||||
productTypes: QuotationOption[];
|
productTypes: QuotationOption[];
|
||||||
@@ -353,6 +356,7 @@ export interface QuotationMutationPayload {
|
|||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
sentVia?: string | null;
|
sentVia?: string | null;
|
||||||
revisionRemark?: string | null;
|
revisionRemark?: string | null;
|
||||||
|
projectParties?: QuotationCustomerMutationPayload[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QuotationItemMutationPayload {
|
export interface QuotationItemMutationPayload {
|
||||||
@@ -372,6 +376,7 @@ export interface QuotationCustomerMutationPayload {
|
|||||||
customerId: string;
|
customerId: string;
|
||||||
role: string;
|
role: string;
|
||||||
isPrimary?: boolean;
|
isPrimary?: boolean;
|
||||||
|
remark?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QuotationTopicMutationPayload {
|
export interface QuotationTopicMutationPayload {
|
||||||
|
|||||||
@@ -268,16 +268,18 @@ function CustomerDialog({
|
|||||||
const [customerId, setCustomerId] = useState(
|
const [customerId, setCustomerId] = useState(
|
||||||
relation?.customerId ?? referenceData.customers[0]?.id ?? ''
|
relation?.customerId ?? referenceData.customers[0]?.id ?? ''
|
||||||
);
|
);
|
||||||
const [role, setRole] = useState(relation?.role ?? 'owner');
|
const [role, setRole] = useState(
|
||||||
const [isPrimary, setIsPrimary] = useState(relation?.isPrimary ?? false);
|
relation?.role ?? referenceData.projectPartyRoles[0]?.id ?? ''
|
||||||
|
);
|
||||||
|
const [remark, setRemark] = useState(relation?.remark ?? '');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{relation ? 'Edit Related Customer' : 'Add Related Customer'}</DialogTitle>
|
<DialogTitle>{relation ? 'Edit Project Party' : 'Add Project Party'}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Keep owner, consultant, contractor, and billing parties visible on the quote.
|
Keep related companies visible with their role in this quotation.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className='grid gap-4'>
|
<div className='grid gap-4'>
|
||||||
@@ -298,22 +300,14 @@ function CustomerDialog({
|
|||||||
<SelectValue placeholder='Role' />
|
<SelectValue placeholder='Role' />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{referenceData.customerRoles.map((item) => (
|
{referenceData.projectPartyRoles.map((item) => (
|
||||||
<SelectItem key={item.id} value={item.id}>
|
<SelectItem key={item.id} value={item.id}>
|
||||||
{item.label}
|
{item.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<label className='flex items-center justify-between rounded-lg border px-4 py-3 text-sm'>
|
<Input value={remark} onChange={(event) => setRemark(event.target.value)} placeholder='Remark' />
|
||||||
<span>Primary relation</span>
|
|
||||||
<input
|
|
||||||
aria-label='Primary relation'
|
|
||||||
type='checkbox'
|
|
||||||
checked={isPrimary}
|
|
||||||
onChange={(e) => setIsPrimary(e.target.checked)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant='outline' onClick={() => onOpenChange(false)}>
|
<Button variant='outline' onClick={() => onOpenChange(false)}>
|
||||||
@@ -322,7 +316,7 @@ function CustomerDialog({
|
|||||||
<Button
|
<Button
|
||||||
isLoading={pending}
|
isLoading={pending}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await onSubmit({ customerId, role, isPrimary });
|
await onSubmit({ customerId, role, remark: remark || null });
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -720,24 +714,24 @@ export function QuotationDetail({
|
|||||||
const [deleteCustomerId, setDeleteCustomerId] = useState<string | null>(null);
|
const [deleteCustomerId, setDeleteCustomerId] = useState<string | null>(null);
|
||||||
const createCustomer = useMutation({
|
const createCustomer = useMutation({
|
||||||
...createQuotationCustomerMutation,
|
...createQuotationCustomerMutation,
|
||||||
onSuccess: () => toast.success('Related customer saved'),
|
onSuccess: () => toast.success('Project party saved'),
|
||||||
onError: (error) =>
|
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({
|
const updateCustomer = useMutation({
|
||||||
...updateQuotationCustomerMutation,
|
...updateQuotationCustomerMutation,
|
||||||
onSuccess: () => toast.success('Related customer updated'),
|
onSuccess: () => toast.success('Project party updated'),
|
||||||
onError: (error) =>
|
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({
|
const deleteCustomer = useMutation({
|
||||||
...deleteQuotationCustomerMutation,
|
...deleteQuotationCustomerMutation,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Related customer deleted');
|
toast.success('Project party deleted');
|
||||||
setDeleteCustomerId(null);
|
setDeleteCustomerId(null);
|
||||||
},
|
},
|
||||||
onError: (error) =>
|
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);
|
const [topicOpen, setTopicOpen] = useState(false);
|
||||||
@@ -1137,7 +1131,7 @@ export function QuotationDetail({
|
|||||||
<TabsList className='flex flex-wrap'>
|
<TabsList className='flex flex-wrap'>
|
||||||
<TabsTrigger value='overview'>Overview</TabsTrigger>
|
<TabsTrigger value='overview'>Overview</TabsTrigger>
|
||||||
<TabsTrigger value='items'>Items</TabsTrigger>
|
<TabsTrigger value='items'>Items</TabsTrigger>
|
||||||
<TabsTrigger value='customers'>Customers</TabsTrigger>
|
<TabsTrigger value='customers'>Project Parties</TabsTrigger>
|
||||||
<TabsTrigger value='topics'>Topics</TabsTrigger>
|
<TabsTrigger value='topics'>Topics</TabsTrigger>
|
||||||
<TabsTrigger value='followups'>Follow-ups</TabsTrigger>
|
<TabsTrigger value='followups'>Follow-ups</TabsTrigger>
|
||||||
<TabsTrigger value='attachments'>Attachments</TabsTrigger>
|
<TabsTrigger value='attachments'>Attachments</TabsTrigger>
|
||||||
@@ -1155,7 +1149,7 @@ export function QuotationDetail({
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
<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='Contact' value={contact?.name} />
|
||||||
<FieldItem
|
<FieldItem
|
||||||
label='Branch'
|
label='Branch'
|
||||||
@@ -1188,6 +1182,28 @@ export function QuotationDetail({
|
|||||||
quotation.chancePercent !== null ? `${quotation.chancePercent}%` : null
|
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'>
|
<div className='md:col-span-2 xl:col-span-4'>
|
||||||
<FieldItem
|
<FieldItem
|
||||||
label='Project'
|
label='Project'
|
||||||
@@ -1285,8 +1301,10 @@ export function QuotationDetail({
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader className='flex flex-row items-center justify-between'>
|
<CardHeader className='flex flex-row items-center justify-between'>
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Customers</CardTitle>
|
<CardTitle>Project Parties</CardTitle>
|
||||||
<CardDescription>All parties involved in this quotation.</CardDescription>
|
<CardDescription>
|
||||||
|
Related companies for this quotation, each with a visible role.
|
||||||
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
{canManageCustomers ? (
|
{canManageCustomers ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -1295,13 +1313,13 @@ export function QuotationDetail({
|
|||||||
setCustomerOpen(true);
|
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>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='space-y-3'>
|
<CardContent className='space-y-3'>
|
||||||
{!customersData.items.length ? (
|
{!customersData.items.length ? (
|
||||||
<EmptyState message='No additional customer relations yet.' />
|
<EmptyState message='No project parties have been added yet.' />
|
||||||
) : (
|
) : (
|
||||||
customersData.items.map((item) => (
|
customersData.items.map((item) => (
|
||||||
<div
|
<div
|
||||||
@@ -1311,9 +1329,11 @@ export function QuotationDetail({
|
|||||||
<div>
|
<div>
|
||||||
<div className='font-medium'>{item.customerName}</div>
|
<div className='font-medium'>{item.customerName}</div>
|
||||||
<div className='text-muted-foreground text-sm'>
|
<div className='text-muted-foreground text-sm'>
|
||||||
{item.role}
|
Role: {item.roleLabel ?? item.roleCode}
|
||||||
{item.isPrimary ? ' - Primary' : ''}
|
|
||||||
</div>
|
</div>
|
||||||
|
{item.remark ? (
|
||||||
|
<div className='text-muted-foreground mt-1 text-sm'>{item.remark}</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{canManageCustomers ? (
|
{canManageCustomers ? (
|
||||||
<div className='flex gap-2'>
|
<div className='flex gap-2'>
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string
|
|||||||
<CardDescription>Bill-to and attention context for the document.</CardDescription>
|
<CardDescription>Bill-to and attention context for the document.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='grid gap-4 md:grid-cols-2'>
|
<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='Contact' value={documentData.contact?.name} />
|
||||||
<Field label='Phone' value={documentData.customer.phone} />
|
<Field label='Phone' value={documentData.customer.phone} />
|
||||||
<Field label='Email' value={documentData.customer.email} />
|
<Field label='Email' value={documentData.customer.email} />
|
||||||
|
|||||||
@@ -2,10 +2,14 @@
|
|||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useEffect, useMemo, useState } 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 { toast } from 'sonner';
|
||||||
import { Icons } from '@/components/icons';
|
import { Icons } from '@/components/icons';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
ProjectPartiesEditor,
|
||||||
|
type ProjectPartyEditorItem
|
||||||
|
} from '@/features/crm/components/project-parties-editor';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -24,6 +28,7 @@ import {
|
|||||||
} from '@/components/ui/sheet';
|
} from '@/components/ui/sheet';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { quotationCustomersOptions } from '../api/queries';
|
||||||
import { createQuotationMutation, updateQuotationMutation } from '../api/mutations';
|
import { createQuotationMutation, updateQuotationMutation } from '../api/mutations';
|
||||||
import type { QuotationMutationPayload, QuotationRecord, QuotationReferenceData } from '../api/types';
|
import type { QuotationMutationPayload, QuotationRecord, QuotationReferenceData } from '../api/types';
|
||||||
|
|
||||||
@@ -123,6 +128,11 @@ export function QuotationFormSheet({
|
|||||||
const isEdit = !!quotation;
|
const isEdit = !!quotation;
|
||||||
const defaultState = useMemo(() => toFormState(quotation, referenceData), [quotation, referenceData]);
|
const defaultState = useMemo(() => toFormState(quotation, referenceData), [quotation, referenceData]);
|
||||||
const [state, setState] = useState<FormState>(defaultState);
|
const [state, setState] = useState<FormState>(defaultState);
|
||||||
|
const [projectParties, setProjectParties] = useState<ProjectPartyEditorItem[]>([]);
|
||||||
|
const projectPartiesQuery = useQuery({
|
||||||
|
...quotationCustomersOptions(quotation?.id ?? ''),
|
||||||
|
enabled: open && !!quotation?.id
|
||||||
|
});
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
...createQuotationMutation,
|
...createQuotationMutation,
|
||||||
@@ -147,8 +157,18 @@ export function QuotationFormSheet({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setState(defaultState);
|
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(
|
const contacts = useMemo(
|
||||||
() => referenceData.contacts.filter((item) => item.customerId === state.customerId),
|
() => referenceData.contacts.filter((item) => item.customerId === state.customerId),
|
||||||
@@ -200,7 +220,14 @@ export function QuotationFormSheet({
|
|||||||
taxRate: state.taxRate ? Number(state.taxRate) : 0,
|
taxRate: state.taxRate ? Number(state.taxRate) : 0,
|
||||||
sentVia: state.sentVia || null,
|
sentVia: state.sentVia || null,
|
||||||
revisionRemark: state.revisionRemark || 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) {
|
if (isEdit && quotation) {
|
||||||
@@ -219,7 +246,7 @@ export function QuotationFormSheet({
|
|||||||
<SheetHeader>
|
<SheetHeader>
|
||||||
<SheetTitle>{isEdit ? 'Edit Quotation' : 'New Quotation'}</SheetTitle>
|
<SheetTitle>{isEdit ? 'Edit Quotation' : 'New Quotation'}</SheetTitle>
|
||||||
<SheetDescription>
|
<SheetDescription>
|
||||||
Capture quotation header data, customer linkage, and commercial context.
|
Capture quotation header data, billing customer, project parties, and commercial context.
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
@@ -241,7 +268,7 @@ export function QuotationFormSheet({
|
|||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label='Customer'>
|
<Field label='Billing Customer'>
|
||||||
<Select
|
<Select
|
||||||
value={state.customerId}
|
value={state.customerId}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
@@ -434,6 +461,15 @@ export function QuotationFormSheet({
|
|||||||
<Input value={state.competitor} onChange={(e) => setField('competitor', e.target.value)} placeholder='Competitor' />
|
<Input value={state.competitor} onChange={(e) => setField('competitor', e.target.value)} placeholder='Competitor' />
|
||||||
</Field>
|
</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'>
|
<div className='md:col-span-2'>
|
||||||
<Field label='Notes'>
|
<Field label='Notes'>
|
||||||
<Textarea value={state.notes} onChange={(e) => setField('notes', e.target.value)} placeholder='Internal notes' />
|
<Textarea value={state.notes} onChange={(e) => setField('notes', e.target.value)} placeholder='Internal notes' />
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ const DOCUMENT_OPTION_CATEGORIES = {
|
|||||||
currency: 'crm_currency',
|
currency: 'crm_currency',
|
||||||
discountType: 'crm_discount_type',
|
discountType: 'crm_discount_type',
|
||||||
unit: 'crm_unit',
|
unit: 'crm_unit',
|
||||||
customerRole: 'crm_quotation_customer_role',
|
customerRole: 'crm_project_party_role',
|
||||||
productType: 'crm_product_type',
|
productType: 'crm_product_type',
|
||||||
topicType: 'crm_quotation_topic_type'
|
topicType: 'crm_quotation_topic_type'
|
||||||
} as const;
|
} as const;
|
||||||
@@ -348,8 +348,8 @@ export async function buildQuotationDocumentData(
|
|||||||
customerId: item.customerId,
|
customerId: item.customerId,
|
||||||
customerName: relatedCustomerMap.get(item.customerId)?.name ?? item.customerName,
|
customerName: relatedCustomerMap.get(item.customerId)?.name ?? item.customerName,
|
||||||
customerCode: relatedCustomerMap.get(item.customerId)?.code ?? item.customerCode,
|
customerCode: relatedCustomerMap.get(item.customerId)?.code ?? item.customerCode,
|
||||||
roleCode: item.role,
|
roleCode: item.roleCode,
|
||||||
roleLabel: findOptionLabel(customerRoleOptions, item.role),
|
roleLabel: item.roleLabel ?? findOptionLabel(customerRoleOptions, item.role),
|
||||||
isPrimary: item.isPrimary
|
isPrimary: item.isPrimary
|
||||||
})),
|
})),
|
||||||
topics: {
|
topics: {
|
||||||
|
|||||||
@@ -67,7 +67,17 @@ export const quotationSchema = z.object({
|
|||||||
taxRate: coerceOptionalNumber('Tax rate must be a number'),
|
taxRate: coerceOptionalNumber('Tax rate must be a number'),
|
||||||
isActive: z.boolean().default(true),
|
isActive: z.boolean().default(true),
|
||||||
sentVia: z.string().optional().nullable(),
|
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({
|
export const quotationItemSchema = z.object({
|
||||||
@@ -98,7 +108,8 @@ export const quotationItemSchema = z.object({
|
|||||||
export const quotationCustomerSchema = z.object({
|
export const quotationCustomerSchema = z.object({
|
||||||
customerId: z.string().min(1, 'Please select a customer'),
|
customerId: z.string().min(1, 'Please select a customer'),
|
||||||
role: z.string().min(1, 'Please select a role'),
|
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({
|
export const quotationTopicSchema = z.object({
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ import type {
|
|||||||
QuotationTopicMutationPayload,
|
QuotationTopicMutationPayload,
|
||||||
QuotationTopicRecord
|
QuotationTopicRecord
|
||||||
} from '../api/types';
|
} from '../api/types';
|
||||||
|
import {
|
||||||
|
buildProjectPartyKey,
|
||||||
|
PROJECT_PARTY_OPTION_CATEGORY,
|
||||||
|
resolveProjectPartyRole
|
||||||
|
} from '@/features/crm/shared/project-party';
|
||||||
|
|
||||||
const QUOTATION_OPTION_CATEGORIES = {
|
const QUOTATION_OPTION_CATEGORIES = {
|
||||||
status: 'crm_quotation_status',
|
status: 'crm_quotation_status',
|
||||||
@@ -56,7 +61,7 @@ const QUOTATION_OPTION_CATEGORIES = {
|
|||||||
discountType: 'crm_discount_type',
|
discountType: 'crm_discount_type',
|
||||||
unit: 'crm_unit',
|
unit: 'crm_unit',
|
||||||
sentVia: 'crm_sent_via',
|
sentVia: 'crm_sent_via',
|
||||||
customerRole: 'crm_quotation_customer_role',
|
projectPartyRole: PROJECT_PARTY_OPTION_CATEGORY,
|
||||||
topicType: 'crm_quotation_topic_type',
|
topicType: 'crm_quotation_topic_type',
|
||||||
followupType: 'crm_followup_type',
|
followupType: 'crm_followup_type',
|
||||||
productType: 'crm_product_type'
|
productType: 'crm_product_type'
|
||||||
@@ -191,15 +196,19 @@ function mapQuotationItemRecord(row: typeof crmQuotationItems.$inferSelect): Quo
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mapQuotationCustomerRecord(
|
function mapQuotationCustomerRecord(
|
||||||
row: typeof crmQuotationCustomers.$inferSelect
|
row: typeof crmQuotationCustomers.$inferSelect,
|
||||||
|
options: { role: { id: string; code: string; label: string } }
|
||||||
): QuotationCustomerRecord {
|
): QuotationCustomerRecord {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
organizationId: row.organizationId,
|
organizationId: row.organizationId,
|
||||||
quotationId: row.quotationId,
|
quotationId: row.quotationId,
|
||||||
customerId: row.customerId,
|
customerId: row.customerId,
|
||||||
role: row.role,
|
role: options.role.id,
|
||||||
|
roleCode: options.role.code,
|
||||||
|
roleLabel: options.role.label,
|
||||||
isPrimary: row.isPrimary,
|
isPrimary: row.isPrimary,
|
||||||
|
remark: row.remark,
|
||||||
createdAt: row.createdAt.toISOString(),
|
createdAt: row.createdAt.toISOString(),
|
||||||
updatedAt: row.updatedAt.toISOString(),
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
deletedAt: row.deletedAt?.toISOString() ?? null
|
deletedAt: row.deletedAt?.toISOString() ?? null
|
||||||
@@ -641,6 +650,15 @@ async function validateQuotationPayload(organizationId: string, payload: Quotati
|
|||||||
QUOTATION_OPTION_CATEGORIES.sentVia,
|
QUOTATION_OPTION_CATEGORIES.sentVia,
|
||||||
payload.sentVia ?? null
|
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(
|
async function validateQuotationItemPayload(
|
||||||
@@ -671,11 +689,156 @@ async function validateQuotationCustomerPayload(
|
|||||||
await assertCustomerBelongsToOrganization(payload.customerId, organizationId);
|
await assertCustomerBelongsToOrganization(payload.customerId, organizationId);
|
||||||
await assertMasterOptionValue(
|
await assertMasterOptionValue(
|
||||||
organizationId,
|
organizationId,
|
||||||
QUOTATION_OPTION_CATEGORIES.customerRole,
|
QUOTATION_OPTION_CATEGORIES.projectPartyRole,
|
||||||
payload.role
|
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(
|
async function validateQuotationTopicPayload(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
payload: QuotationTopicMutationPayload
|
payload: QuotationTopicMutationPayload
|
||||||
@@ -801,7 +964,7 @@ export async function getQuotationReferenceData(
|
|||||||
discountTypes,
|
discountTypes,
|
||||||
units,
|
units,
|
||||||
sentVias,
|
sentVias,
|
||||||
customerRoles,
|
projectPartyRoles,
|
||||||
topicTypes,
|
topicTypes,
|
||||||
followupTypes,
|
followupTypes,
|
||||||
productTypes,
|
productTypes,
|
||||||
@@ -817,7 +980,7 @@ export async function getQuotationReferenceData(
|
|||||||
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.discountType, { organizationId }),
|
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.discountType, { organizationId }),
|
||||||
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.unit, { organizationId }),
|
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.unit, { organizationId }),
|
||||||
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.sentVia, { 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.topicType, { organizationId }),
|
||||||
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.followupType, { organizationId }),
|
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.followupType, { organizationId }),
|
||||||
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.productType, { organizationId }),
|
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.productType, { organizationId }),
|
||||||
@@ -857,7 +1020,7 @@ export async function getQuotationReferenceData(
|
|||||||
discountTypes: discountTypes.map(mapOption),
|
discountTypes: discountTypes.map(mapOption),
|
||||||
units: units.map(mapOption),
|
units: units.map(mapOption),
|
||||||
sentVias: sentVias.map(mapOption),
|
sentVias: sentVias.map(mapOption),
|
||||||
customerRoles: customerRoles.map(mapOption),
|
projectPartyRoles: projectPartyRoles.map(mapOption),
|
||||||
topicTypes: topicTypes.map(mapOption),
|
topicTypes: topicTypes.map(mapOption),
|
||||||
followupTypes: followupTypes.map(mapOption),
|
followupTypes: followupTypes.map(mapOption),
|
||||||
productTypes: productTypes.map(mapOption),
|
productTypes: productTypes.map(mapOption),
|
||||||
@@ -1062,59 +1225,60 @@ export async function createQuotation(
|
|||||||
branchId: payload.branchId ?? enquiry?.branchId ?? ''
|
branchId: payload.branchId ?? enquiry?.branchId ?? ''
|
||||||
});
|
});
|
||||||
|
|
||||||
const [created] = await db
|
return db.transaction(async (tx) => {
|
||||||
.insert(crmQuotations)
|
const [created] = await tx
|
||||||
.values({
|
.insert(crmQuotations)
|
||||||
id: crypto.randomUUID(),
|
.values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
organizationId,
|
||||||
|
branchId: payload.branchId ?? enquiry?.branchId ?? null,
|
||||||
|
code: documentCode.code,
|
||||||
|
enquiryId: payload.enquiryId ?? null,
|
||||||
|
customerId: payload.customerId,
|
||||||
|
contactId: payload.contactId ?? enquiry?.contactId ?? null,
|
||||||
|
quotationDate: new Date(payload.quotationDate),
|
||||||
|
validUntil: payload.validUntil ? new Date(payload.validUntil) : null,
|
||||||
|
quotationType: payload.quotationType,
|
||||||
|
projectName: payload.projectName?.trim() || enquiry?.projectName || null,
|
||||||
|
projectLocation: payload.projectLocation?.trim() || enquiry?.projectLocation || null,
|
||||||
|
attention: payload.attention?.trim() || null,
|
||||||
|
reference: payload.reference?.trim() || null,
|
||||||
|
notes: payload.notes?.trim() || null,
|
||||||
|
status: payload.status,
|
||||||
|
revision: 0,
|
||||||
|
revisionRemark: payload.revisionRemark?.trim() || null,
|
||||||
|
currency: payload.currency,
|
||||||
|
exchangeRate: payload.exchangeRate ?? 1,
|
||||||
|
discount: payload.discount ?? 0,
|
||||||
|
discountType: payload.discountType ?? null,
|
||||||
|
taxRate: payload.taxRate ?? 0,
|
||||||
|
chancePercent: payload.chancePercent ?? enquiry?.chancePercent ?? null,
|
||||||
|
isHotProject: payload.isHotProject ?? enquiry?.isHotProject ?? false,
|
||||||
|
competitor: payload.competitor?.trim() || enquiry?.competitor || null,
|
||||||
|
salesmanId: payload.salesmanId ?? null,
|
||||||
|
isSent: false,
|
||||||
|
sentVia: payload.sentVia ?? null,
|
||||||
|
approvedAt: null,
|
||||||
|
approvedArtifactId: null,
|
||||||
|
approvedPdfUrl: null,
|
||||||
|
approvedSnapshot: null,
|
||||||
|
approvedTemplateVersionId: null,
|
||||||
|
isActive: payload.isActive ?? true,
|
||||||
|
createdBy: userId,
|
||||||
|
updatedBy: userId
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await syncQuotationProjectParties(
|
||||||
|
tx,
|
||||||
organizationId,
|
organizationId,
|
||||||
branchId: payload.branchId ?? enquiry?.branchId ?? null,
|
created.id,
|
||||||
code: documentCode.code,
|
payload.customerId,
|
||||||
enquiryId: payload.enquiryId ?? null,
|
payload.projectParties ?? []
|
||||||
customerId: payload.customerId,
|
);
|
||||||
contactId: payload.contactId ?? enquiry?.contactId ?? null,
|
|
||||||
quotationDate: new Date(payload.quotationDate),
|
|
||||||
validUntil: payload.validUntil ? new Date(payload.validUntil) : null,
|
|
||||||
quotationType: payload.quotationType,
|
|
||||||
projectName: payload.projectName?.trim() || enquiry?.projectName || null,
|
|
||||||
projectLocation: payload.projectLocation?.trim() || enquiry?.projectLocation || null,
|
|
||||||
attention: payload.attention?.trim() || null,
|
|
||||||
reference: payload.reference?.trim() || null,
|
|
||||||
notes: payload.notes?.trim() || null,
|
|
||||||
status: payload.status,
|
|
||||||
revision: 0,
|
|
||||||
revisionRemark: payload.revisionRemark?.trim() || null,
|
|
||||||
currency: payload.currency,
|
|
||||||
exchangeRate: payload.exchangeRate ?? 1,
|
|
||||||
discount: payload.discount ?? 0,
|
|
||||||
discountType: payload.discountType ?? null,
|
|
||||||
taxRate: payload.taxRate ?? 0,
|
|
||||||
chancePercent: payload.chancePercent ?? enquiry?.chancePercent ?? null,
|
|
||||||
isHotProject: payload.isHotProject ?? enquiry?.isHotProject ?? false,
|
|
||||||
competitor: payload.competitor?.trim() || enquiry?.competitor || null,
|
|
||||||
salesmanId: payload.salesmanId ?? null,
|
|
||||||
isSent: false,
|
|
||||||
sentVia: payload.sentVia ?? null,
|
|
||||||
approvedAt: null,
|
|
||||||
approvedArtifactId: null,
|
|
||||||
approvedPdfUrl: null,
|
|
||||||
approvedSnapshot: null,
|
|
||||||
approvedTemplateVersionId: null,
|
|
||||||
isActive: payload.isActive ?? true,
|
|
||||||
createdBy: userId,
|
|
||||||
updatedBy: userId
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
await db.insert(crmQuotationCustomers).values({
|
return created;
|
||||||
id: crypto.randomUUID(),
|
|
||||||
organizationId,
|
|
||||||
quotationId: created.id,
|
|
||||||
customerId: created.customerId,
|
|
||||||
role: 'owner',
|
|
||||||
isPrimary: true
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return created;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateQuotation(
|
export async function updateQuotation(
|
||||||
@@ -1129,39 +1293,51 @@ export async function updateQuotation(
|
|||||||
? await assertEnquiryBelongsToOrganization(payload.enquiryId, organizationId)
|
? await assertEnquiryBelongsToOrganization(payload.enquiryId, organizationId)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const [updated] = await db
|
const updated = await db.transaction(async (tx) => {
|
||||||
.update(crmQuotations)
|
const [row] = await tx
|
||||||
.set({
|
.update(crmQuotations)
|
||||||
branchId: payload.branchId ?? enquiry?.branchId ?? null,
|
.set({
|
||||||
enquiryId: payload.enquiryId ?? null,
|
branchId: payload.branchId ?? enquiry?.branchId ?? null,
|
||||||
customerId: payload.customerId,
|
enquiryId: payload.enquiryId ?? null,
|
||||||
contactId: payload.contactId ?? enquiry?.contactId ?? null,
|
customerId: payload.customerId,
|
||||||
quotationDate: new Date(payload.quotationDate),
|
contactId: payload.contactId ?? enquiry?.contactId ?? null,
|
||||||
validUntil: payload.validUntil ? new Date(payload.validUntil) : null,
|
quotationDate: new Date(payload.quotationDate),
|
||||||
quotationType: payload.quotationType,
|
validUntil: payload.validUntil ? new Date(payload.validUntil) : null,
|
||||||
projectName: payload.projectName?.trim() || enquiry?.projectName || null,
|
quotationType: payload.quotationType,
|
||||||
projectLocation: payload.projectLocation?.trim() || enquiry?.projectLocation || null,
|
projectName: payload.projectName?.trim() || enquiry?.projectName || null,
|
||||||
attention: payload.attention?.trim() || null,
|
projectLocation: payload.projectLocation?.trim() || enquiry?.projectLocation || null,
|
||||||
reference: payload.reference?.trim() || null,
|
attention: payload.attention?.trim() || null,
|
||||||
notes: payload.notes?.trim() || null,
|
reference: payload.reference?.trim() || null,
|
||||||
status: payload.status,
|
notes: payload.notes?.trim() || null,
|
||||||
revisionRemark: payload.revisionRemark?.trim() || existing.revisionRemark || null,
|
status: payload.status,
|
||||||
currency: payload.currency,
|
revisionRemark: payload.revisionRemark?.trim() || existing.revisionRemark || null,
|
||||||
exchangeRate: payload.exchangeRate ?? 1,
|
currency: payload.currency,
|
||||||
discount: payload.discount ?? 0,
|
exchangeRate: payload.exchangeRate ?? 1,
|
||||||
discountType: payload.discountType ?? null,
|
discount: payload.discount ?? 0,
|
||||||
taxRate: payload.taxRate ?? 0,
|
discountType: payload.discountType ?? null,
|
||||||
chancePercent: payload.chancePercent ?? enquiry?.chancePercent ?? null,
|
taxRate: payload.taxRate ?? 0,
|
||||||
isHotProject: payload.isHotProject ?? false,
|
chancePercent: payload.chancePercent ?? enquiry?.chancePercent ?? null,
|
||||||
competitor: payload.competitor?.trim() || enquiry?.competitor || null,
|
isHotProject: payload.isHotProject ?? false,
|
||||||
salesmanId: payload.salesmanId ?? null,
|
competitor: payload.competitor?.trim() || enquiry?.competitor || null,
|
||||||
sentVia: payload.sentVia ?? null,
|
salesmanId: payload.salesmanId ?? null,
|
||||||
isActive: payload.isActive ?? true,
|
sentVia: payload.sentVia ?? null,
|
||||||
updatedAt: new Date(),
|
isActive: payload.isActive ?? true,
|
||||||
updatedBy: userId
|
updatedAt: new Date(),
|
||||||
})
|
updatedBy: userId
|
||||||
.where(eq(crmQuotations.id, id))
|
})
|
||||||
.returning();
|
.where(eq(crmQuotations.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await syncQuotationProjectParties(
|
||||||
|
tx,
|
||||||
|
organizationId,
|
||||||
|
id,
|
||||||
|
payload.customerId,
|
||||||
|
payload.projectParties ?? []
|
||||||
|
);
|
||||||
|
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
|
||||||
await refreshQuotationTotals(id, organizationId, userId);
|
await refreshQuotationTotals(id, organizationId, userId);
|
||||||
return updated;
|
return updated;
|
||||||
@@ -1341,28 +1517,41 @@ export async function softDeleteQuotationItem(
|
|||||||
|
|
||||||
export async function listQuotationCustomers(id: string, organizationId: string) {
|
export async function listQuotationCustomers(id: string, organizationId: string) {
|
||||||
await assertQuotationBelongsToOrganization(id, organizationId);
|
await assertQuotationBelongsToOrganization(id, organizationId);
|
||||||
const relations = await db
|
const [relations, roleOptions] = await Promise.all([
|
||||||
.select()
|
db
|
||||||
.from(crmQuotationCustomers)
|
.select()
|
||||||
.where(
|
.from(crmQuotationCustomers)
|
||||||
and(
|
.where(
|
||||||
eq(crmQuotationCustomers.quotationId, id),
|
and(
|
||||||
eq(crmQuotationCustomers.organizationId, organizationId),
|
eq(crmQuotationCustomers.quotationId, id),
|
||||||
isNull(crmQuotationCustomers.deletedAt)
|
eq(crmQuotationCustomers.organizationId, organizationId),
|
||||||
|
isNull(crmQuotationCustomers.deletedAt)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
.orderBy(desc(crmQuotationCustomers.isPrimary), asc(crmQuotationCustomers.createdAt)),
|
||||||
.orderBy(desc(crmQuotationCustomers.isPrimary), asc(crmQuotationCustomers.role));
|
getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.projectPartyRole, { organizationId })
|
||||||
|
]);
|
||||||
const customerIds = [...new Set(relations.map((row) => row.customerId))];
|
const customerIds = [...new Set(relations.map((row) => row.customerId))];
|
||||||
const customers = customerIds.length
|
const customers = customerIds.length
|
||||||
? await db.select().from(crmCustomers).where(inArray(crmCustomers.id, customerIds))
|
? await db.select().from(crmCustomers).where(inArray(crmCustomers.id, customerIds))
|
||||||
: [];
|
: [];
|
||||||
const customerMap = new Map(customers.map((item) => [item.id, item]));
|
const customerMap = new Map(customers.map((item) => [item.id, item]));
|
||||||
|
|
||||||
return relations.map<QuotationCustomerListItem>((row) => ({
|
return relations
|
||||||
...mapQuotationCustomerRecord(row),
|
.map((row) => {
|
||||||
customerName: customerMap.get(row.customerId)?.name ?? 'Unknown customer',
|
const resolvedRole = resolveProjectPartyRole(roleOptions, row.role);
|
||||||
customerCode: customerMap.get(row.customerId)?.code ?? '-'
|
|
||||||
}));
|
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(
|
export async function createQuotationCustomer(
|
||||||
@@ -1372,9 +1561,22 @@ export async function createQuotationCustomer(
|
|||||||
) {
|
) {
|
||||||
await assertQuotationBelongsToOrganization(quotationId, organizationId);
|
await assertQuotationBelongsToOrganization(quotationId, organizationId);
|
||||||
await validateQuotationCustomerPayload(organizationId, payload);
|
await validateQuotationCustomerPayload(organizationId, payload);
|
||||||
|
await assertQuotationProjectPartyNotDuplicated(quotationId, organizationId, payload);
|
||||||
|
|
||||||
return db.transaction(async (tx) => {
|
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
|
await tx
|
||||||
.update(crmQuotationCustomers)
|
.update(crmQuotationCustomers)
|
||||||
.set({ isPrimary: false, updatedAt: new Date() })
|
.set({ isPrimary: false, updatedAt: new Date() })
|
||||||
@@ -1394,8 +1596,9 @@ export async function createQuotationCustomer(
|
|||||||
organizationId,
|
organizationId,
|
||||||
quotationId,
|
quotationId,
|
||||||
customerId: payload.customerId,
|
customerId: payload.customerId,
|
||||||
role: payload.role,
|
role: resolvedRole.id,
|
||||||
isPrimary: payload.isPrimary ?? false
|
isPrimary,
|
||||||
|
remark: payload.remark?.trim() || null
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@@ -1411,9 +1614,27 @@ export async function updateQuotationCustomer(
|
|||||||
) {
|
) {
|
||||||
await validateQuotationCustomerPayload(organizationId, payload);
|
await validateQuotationCustomerPayload(organizationId, payload);
|
||||||
await assertQuotationCustomerBelongsToQuotation(quotationId, relationId, organizationId);
|
await assertQuotationCustomerBelongsToQuotation(quotationId, relationId, organizationId);
|
||||||
|
await assertQuotationProjectPartyNotDuplicated(
|
||||||
|
quotationId,
|
||||||
|
organizationId,
|
||||||
|
payload,
|
||||||
|
relationId
|
||||||
|
);
|
||||||
|
|
||||||
return db.transaction(async (tx) => {
|
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
|
await tx
|
||||||
.update(crmQuotationCustomers)
|
.update(crmQuotationCustomers)
|
||||||
.set({ isPrimary: false, updatedAt: new Date() })
|
.set({ isPrimary: false, updatedAt: new Date() })
|
||||||
@@ -1430,8 +1651,9 @@ export async function updateQuotationCustomer(
|
|||||||
.update(crmQuotationCustomers)
|
.update(crmQuotationCustomers)
|
||||||
.set({
|
.set({
|
||||||
customerId: payload.customerId,
|
customerId: payload.customerId,
|
||||||
role: payload.role,
|
role: resolvedRole.id,
|
||||||
isPrimary: payload.isPrimary ?? false,
|
isPrimary,
|
||||||
|
remark: payload.remark?.trim() || null,
|
||||||
updatedAt: new Date()
|
updatedAt: new Date()
|
||||||
})
|
})
|
||||||
.where(eq(crmQuotationCustomers.id, relationId))
|
.where(eq(crmQuotationCustomers.id, relationId))
|
||||||
@@ -1908,7 +2130,8 @@ export async function createQuotationRevision(
|
|||||||
quotationId: created.id,
|
quotationId: created.id,
|
||||||
customerId: item.customerId,
|
customerId: item.customerId,
|
||||||
role: item.role,
|
role: item.role,
|
||||||
isPrimary: item.isPrimary
|
isPrimary: item.isPrimary,
|
||||||
|
remark: item.remark
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
341
src/features/crm/reporting/server/service.ts
Normal file
341
src/features/crm/reporting/server/service.ts
Normal 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);
|
||||||
|
}
|
||||||
22
src/features/crm/reporting/server/types.ts
Normal file
22
src/features/crm/reporting/server/types.ts
Normal 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[];
|
||||||
|
}
|
||||||
78
src/features/crm/shared/project-party.ts
Normal file
78
src/features/crm/shared/project-party.ts
Normal 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}`;
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ export const CRM_MASTER_OPTION_CATEGORIES = [
|
|||||||
'crm_discount_type',
|
'crm_discount_type',
|
||||||
'crm_unit',
|
'crm_unit',
|
||||||
'crm_sent_via',
|
'crm_sent_via',
|
||||||
'crm_quotation_customer_role',
|
'crm_project_party_role',
|
||||||
'crm_quotation_topic_type',
|
'crm_quotation_topic_type',
|
||||||
'crm_product_type',
|
'crm_product_type',
|
||||||
'crm_currency',
|
'crm_currency',
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user