lead menu
nquiry menu
This commit is contained in:
131
docs/adr/0011-lead-enquiry-ownership-model.md
Normal file
131
docs/adr/0011-lead-enquiry-ownership-model.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# ADR 0011: Lead and Enquiry Ownership Model
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The CRM module originally used a mixed terminology model where:
|
||||
|
||||
- `Lead` meant an unassigned enquiry
|
||||
- `Opportunity` meant an assigned enquiry
|
||||
|
||||
That model no longer matched the business process. The business now distinguishes ownership by function:
|
||||
|
||||
- `Lead` is marketing-owned
|
||||
- `Enquiry` is sales-owned
|
||||
|
||||
The system must preserve the single-source-of-truth table `crm_enquiries` while refining lifecycle, permissions, monitoring visibility, and KPI semantics.
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. Single record model
|
||||
|
||||
- `crm_enquiries` remains the single source of truth
|
||||
- no `crm_leads` table is introduced
|
||||
- no split lead form or split enquiry API is introduced
|
||||
|
||||
### 2. Pipeline stage model
|
||||
|
||||
`crm_enquiries.pipeline_stage` is the lifecycle discriminator with allowed values:
|
||||
|
||||
- `lead`
|
||||
- `enquiry`
|
||||
- `closed_won`
|
||||
- `closed_lost`
|
||||
|
||||
### 3. Ownership model
|
||||
|
||||
- `lead` is marketing-owned
|
||||
- `enquiry` is sales-owned
|
||||
- assignment converts a lead into an enquiry
|
||||
|
||||
### 4. Assignment behavior
|
||||
|
||||
On assign:
|
||||
|
||||
- `assignedToUserId` is set
|
||||
- `assignedAt` is set
|
||||
- `assignedBy` is set
|
||||
- `pipelineStage` becomes `enquiry`
|
||||
|
||||
On reassign:
|
||||
|
||||
- assignee metadata is updated
|
||||
- `pipelineStage` remains `enquiry`
|
||||
|
||||
### 5. Create behavior
|
||||
|
||||
- marketing-created records default to `pipelineStage = lead`
|
||||
- sales-created records default to `pipelineStage = enquiry`
|
||||
|
||||
### 6. Closure behavior
|
||||
|
||||
- lost or cancelled enquiry status resolves to `pipelineStage = closed_lost`
|
||||
- accepted or lost/rejected quotation outcomes may synchronize the linked enquiry to:
|
||||
- `closed_won`
|
||||
- `closed_lost`
|
||||
|
||||
### 7. Visibility policy
|
||||
|
||||
Marketing can monitor:
|
||||
|
||||
- leads
|
||||
- enquiries
|
||||
- follow-ups
|
||||
- status
|
||||
- pipeline stage
|
||||
- enquiry owner
|
||||
- won/lost result
|
||||
|
||||
Marketing cannot access:
|
||||
|
||||
- quotation pricing
|
||||
- quotation items
|
||||
- cost, discount, or margin views
|
||||
- approval analytics or approval workflow data
|
||||
|
||||
Sales scope remains ownership-based:
|
||||
|
||||
- `sales` and `sales_support` are limited server-side to enquiries they created or are assigned to
|
||||
- managers and admins retain wider organization visibility
|
||||
|
||||
### 8. KPI terminology
|
||||
|
||||
User-facing dashboard terminology replaces `Opportunity` with `Enquiry`.
|
||||
|
||||
Count definitions:
|
||||
|
||||
- `Lead Count` = `pipelineStage = lead`
|
||||
- `Enquiry Count` = `pipelineStage = enquiry`
|
||||
- `Won Count` = `pipelineStage = closed_won`
|
||||
- `Lost Count` = `pipelineStage = closed_lost`
|
||||
|
||||
### 9. Navigation Separation
|
||||
|
||||
- `Lead` and `Enquiry` are separate UX workspaces
|
||||
- `Lead` and `Enquiry` are not separate domain entities
|
||||
- both workspaces continue to use the same `crm_enquiries` records
|
||||
- `pipelineStage` determines whether a record appears in the Leads or Enquiries workspace
|
||||
- the navigation split reduces user confusion without duplicating schema, API, or service layers
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- matches the real business handoff from marketing to sales
|
||||
- keeps backward-compatible storage in `crm_enquiries`
|
||||
- removes ambiguous `opportunity` wording from user-facing CRM surfaces
|
||||
- gives marketing monitoring visibility without exposing commercial data
|
||||
- enforces own-or-assigned visibility server-side for sales roles
|
||||
|
||||
### Tradeoffs
|
||||
|
||||
- lifecycle still depends partly on status mapping and quotation outcome synchronization
|
||||
- the model does not yet include dedicated timestamps such as `closedWonAt` or `closedLostAt`
|
||||
- reporting still relies on current-state lifecycle fields rather than effective-dated snapshots
|
||||
|
||||
## Supersedes
|
||||
|
||||
This ADR supersedes the opportunity terminology frozen in `docs/implementation/task-j0-kpi-definition-freeze.md` for user-facing CRM ownership and KPI language.
|
||||
109
docs/implementation/task-d3-lead-enquiry-ownership-refinement.md
Normal file
109
docs/implementation/task-d3-lead-enquiry-ownership-refinement.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# Task D.3: Lead / Enquiry Ownership Refinement
|
||||
|
||||
## 1. Files Added
|
||||
|
||||
- `drizzle/0012_odd_fat_cobra.sql`
|
||||
- `docs/adr/0011-lead-enquiry-ownership-model.md`
|
||||
- `docs/implementation/task-d3-lead-enquiry-ownership-refinement.md`
|
||||
|
||||
## 2. Files Modified
|
||||
|
||||
- `src/db/schema.ts`
|
||||
- `src/lib/auth/rbac.ts`
|
||||
- `src/features/users/components/user-form-sheet.tsx`
|
||||
- `src/features/crm/enquiries/api/types.ts`
|
||||
- `src/features/crm/enquiries/server/service.ts`
|
||||
- `src/features/crm/quotations/server/service.ts`
|
||||
- `src/app/api/crm/enquiries/route.ts`
|
||||
- `src/app/api/crm/enquiries/[id]/route.ts`
|
||||
- `src/app/api/crm/enquiries/[id]/assign/route.ts`
|
||||
- `src/app/api/crm/enquiries/[id]/reassign/route.ts`
|
||||
- `src/app/api/crm/enquiries/[id]/customers/route.ts`
|
||||
- `src/app/api/crm/enquiries/[id]/followups/route.ts`
|
||||
- `src/app/api/crm/enquiries/[id]/followups/[followupId]/route.ts`
|
||||
- `src/app/api/crm/dashboard/route.ts`
|
||||
- `src/app/api/crm/dashboard/export/route.ts`
|
||||
- `src/app/dashboard/crm/enquiries/page.tsx`
|
||||
- `src/app/dashboard/crm/enquiries/[id]/page.tsx`
|
||||
- `src/config/nav-config.ts`
|
||||
- `src/features/crm/enquiries/components/enquiry-form-sheet.tsx`
|
||||
- `src/features/crm/enquiries/components/enquiry-assignment-dialog.tsx`
|
||||
- `src/features/crm/enquiries/components/enquiry-columns.tsx`
|
||||
- `src/features/crm/enquiries/components/enquiry-detail.tsx`
|
||||
- `src/features/crm/enquiries/components/enquiry-cell-action.tsx`
|
||||
- `src/features/crm/dashboard/api/types.ts`
|
||||
- `src/features/crm/dashboard/server/service.ts`
|
||||
- `src/features/crm/dashboard/components/crm-dashboard.tsx`
|
||||
- `src/features/crm/dashboard/components/dashboard-summary-cards.tsx`
|
||||
- `src/features/crm/dashboard/components/dashboard-funnel.tsx`
|
||||
- `src/features/crm/dashboard/components/dashboard-sales-ranking.tsx`
|
||||
- `src/features/crm/dashboard/components/dashboard-followups.tsx`
|
||||
- `src/features/crm/dashboard/components/dashboard-hot-projects.tsx`
|
||||
|
||||
## 3. Pipeline Changes
|
||||
|
||||
- Added `crm_enquiries.pipeline_stage`
|
||||
- Allowed values:
|
||||
- `lead`
|
||||
- `enquiry`
|
||||
- `closed_won`
|
||||
- `closed_lost`
|
||||
- Backfilled legacy enquiry rows:
|
||||
- assigned rows -> `enquiry`
|
||||
- unassigned rows -> `lead`
|
||||
- closed-lost/cancelled status rows -> `closed_lost`
|
||||
- Assign flow now converts `lead -> enquiry`
|
||||
- Quotation accepted/lost/rejected outcomes can synchronize linked enquiry lifecycle to won/lost stages
|
||||
|
||||
## 4. Ownership Changes
|
||||
|
||||
- Added business role `marketing`
|
||||
- Marketing-created records default to `lead`
|
||||
- Sales-created records default to `enquiry`
|
||||
- `sales` and `sales_support` now use server-enforced own-or-assigned enquiry visibility
|
||||
- managers, admins, and marketing retain broader monitoring visibility
|
||||
|
||||
## 5. Visibility Rules Added
|
||||
|
||||
- Marketing can read CRM lead/enquiry monitoring surfaces
|
||||
- Marketing cannot see quotation pricing or approval analytics
|
||||
- Enquiry detail hides related quotation links when the user lacks quotation read access
|
||||
- CRM dashboard hides quotation, revenue, hot-enquiry, sales-ranking, and approval sections when the user lacks those permissions
|
||||
|
||||
## 6. KPI Changes
|
||||
|
||||
- Dashboard summary now uses `pipelineStage` for:
|
||||
- lead count
|
||||
- enquiry count
|
||||
- won count
|
||||
- lost count
|
||||
- User-facing KPI language changed from `Opportunity` to `Enquiry`
|
||||
- Funnel wording now follows lead -> enquiry -> quotation flow
|
||||
- Follow-up and ownership labels now use `Enquiry Owner`
|
||||
|
||||
## 7. ADR Added
|
||||
|
||||
- Added `docs/adr/0011-lead-enquiry-ownership-model.md`
|
||||
- This freezes:
|
||||
- lead ownership
|
||||
- enquiry ownership
|
||||
- assignment conversion behavior
|
||||
- marketing visibility restriction boundaries
|
||||
- won/lost lifecycle naming
|
||||
|
||||
## 8. Migration Notes
|
||||
|
||||
- Apply `drizzle/0012_odd_fat_cobra.sql`
|
||||
- Existing `crm_enquiries` rows are backfilled in-place; no new lead table is introduced
|
||||
- Existing forms and APIs remain shared across lead and enquiry flows
|
||||
- Existing quotation routes remain intact; only linked enquiry lifecycle sync was added
|
||||
|
||||
## 9. Remaining Risks
|
||||
|
||||
- `closed_won` still has no dedicated timestamp field; lifecycle timing remains coarse
|
||||
- dashboard/export visibility now follows permission boundaries, but any future custom report endpoints must preserve the same commercial-data guardrails
|
||||
- quotation-to-enquiry lifecycle sync currently depends on quotation status transitions and does not yet model reopen scenarios explicitly
|
||||
|
||||
## Verification
|
||||
|
||||
- `npx tsc --noEmit`
|
||||
@@ -0,0 +1,72 @@
|
||||
# Task D3.1: Leads / Enquiries Navigation Separation
|
||||
|
||||
## Files Added
|
||||
|
||||
- `src/app/dashboard/crm/leads/page.tsx`
|
||||
- `src/app/dashboard/crm/leads/[id]/page.tsx`
|
||||
- `docs/implementation/task-d31-leads-enquiries-navigation-separation.md`
|
||||
|
||||
## Files Modified
|
||||
|
||||
- `src/config/nav-config.ts`
|
||||
- `src/lib/auth/rbac.ts`
|
||||
- `src/app/dashboard/crm/enquiries/page.tsx`
|
||||
- `src/app/dashboard/crm/enquiries/[id]/page.tsx`
|
||||
- `src/app/api/crm/enquiries/route.ts`
|
||||
- `src/features/crm/dashboard/components/dashboard-summary-cards.tsx`
|
||||
- `src/features/crm/enquiries/api/types.ts`
|
||||
- `src/features/crm/enquiries/api/service.ts`
|
||||
- `src/features/crm/enquiries/api/mutations.ts`
|
||||
- `src/features/crm/enquiries/components/enquiry-listing.tsx`
|
||||
- `src/features/crm/enquiries/components/enquiries-table.tsx`
|
||||
- `src/features/crm/enquiries/components/enquiry-columns.tsx`
|
||||
- `src/features/crm/enquiries/components/enquiry-cell-action.tsx`
|
||||
- `src/features/crm/enquiries/components/enquiry-form-sheet.tsx`
|
||||
- `src/features/crm/enquiries/components/enquiry-detail.tsx`
|
||||
- `src/features/crm/enquiries/server/service.ts`
|
||||
- `docs/adr/0011-lead-enquiry-ownership-model.md`
|
||||
|
||||
## Navigation Changes
|
||||
|
||||
- Split the combined CRM workspace into `Leads` and `Enquiries`.
|
||||
- Kept a single shared feature module by reusing `src/features/crm/enquiries/**`.
|
||||
|
||||
## Route Changes
|
||||
|
||||
- Added `/dashboard/crm/leads`
|
||||
- Added `/dashboard/crm/leads/[id]`
|
||||
- Reused the same list/detail backend and scoped each page with `pipelineStage`
|
||||
- Redirected lead detail to enquiry detail when the record has already moved past the `lead` stage
|
||||
|
||||
## Permission Changes
|
||||
|
||||
- Added:
|
||||
- `crm.lead.read`
|
||||
- `crm.lead.create`
|
||||
- `crm.lead.update`
|
||||
- `crm.lead.assign`
|
||||
- `crm.lead.delete`
|
||||
- Mapped `marketing` to lead-centric permissions
|
||||
- Kept `sales` on enquiry-centric permissions
|
||||
- Gave manager roles both lead and enquiry capabilities
|
||||
|
||||
## Query Invalidation Added
|
||||
|
||||
- CRM enquiry mutations now invalidate lead/enquiry list queries, detail queries, follow-up queries, and CRM dashboard KPI queries.
|
||||
- Assigned leads now disappear from the Leads workspace and appear in the Enquiries workspace without manual refresh.
|
||||
|
||||
## Dashboard Changes
|
||||
|
||||
- Lead KPI card links to `/dashboard/crm/leads`
|
||||
- Enquiry KPI card links to `/dashboard/crm/enquiries`
|
||||
- List filtering now accepts `pipelineStage` for workspace drill-down
|
||||
|
||||
## ADR Updates
|
||||
|
||||
- Added a `Navigation Separation` section to ADR 0011
|
||||
- Clarified that lead/enquiry are UX workspaces, not separate persisted entities
|
||||
|
||||
## Remaining Risks
|
||||
|
||||
- Shared route handlers still live under the enquiry API namespace by design, so the lead workspace depends on the shared `crm_enquiries` backend contract.
|
||||
- Manager permission breadth may need later refinement if `department_manager` and `top_manager` should diverge.
|
||||
31
drizzle/0012_odd_fat_cobra.sql
Normal file
31
drizzle/0012_odd_fat_cobra.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
ALTER TABLE "crm_enquiries"
|
||||
ADD COLUMN "pipeline_stage" text;
|
||||
|
||||
UPDATE "crm_enquiries" AS enquiry
|
||||
SET "pipeline_stage" = CASE
|
||||
WHEN EXISTS (
|
||||
SELECT 1
|
||||
FROM "ms_options" AS option
|
||||
WHERE option."id" = enquiry."status"
|
||||
AND option."category" = 'crm_enquiry_status'
|
||||
AND option."code" IN ('closed_lost', 'cancelled')
|
||||
AND option."deleted_at" IS NULL
|
||||
) THEN 'closed_lost'
|
||||
WHEN enquiry."assigned_to_user_id" IS NOT NULL THEN 'enquiry'
|
||||
ELSE 'lead'
|
||||
END
|
||||
WHERE enquiry."pipeline_stage" IS NULL;
|
||||
|
||||
UPDATE "crm_enquiries"
|
||||
SET "pipeline_stage" = 'lead'
|
||||
WHERE "pipeline_stage" IS NULL;
|
||||
|
||||
ALTER TABLE "crm_enquiries"
|
||||
ALTER COLUMN "pipeline_stage" SET DEFAULT 'lead';
|
||||
|
||||
ALTER TABLE "crm_enquiries"
|
||||
ALTER COLUMN "pipeline_stage" SET NOT NULL;
|
||||
|
||||
ALTER TABLE "crm_enquiries"
|
||||
ADD CONSTRAINT "crm_enquiries_pipeline_stage_check"
|
||||
CHECK ("pipeline_stage" IN ('lead', 'enquiry', 'closed_won', 'closed_lost'));
|
||||
1
drizzle/0012_simple_bucky.sql
Normal file
1
drizzle/0012_simple_bucky.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "crm_enquiries" ADD COLUMN "pipeline_stage" text DEFAULT 'lead' NOT NULL;
|
||||
3280
drizzle/meta/0012_snapshot.json
Normal file
3280
drizzle/meta/0012_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -85,6 +85,13 @@
|
||||
"when": 1781747812297,
|
||||
"tag": "0011_goofy_the_hood",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1781836261952,
|
||||
"tag": "0012_simple_bucky",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
439
plans/task-d.3.1.md
Normal file
439
plans/task-d.3.1.md
Normal file
@@ -0,0 +1,439 @@
|
||||
# Task D.3.1: Leads / Enquiries Navigation Separation
|
||||
|
||||
## Goal
|
||||
|
||||
แยกเมนู Leads และ Enquiries ออกจากกันเพื่อลดความสับสนของผู้ใช้งาน
|
||||
|
||||
Business Process ยังคงเป็น:
|
||||
|
||||
```txt
|
||||
Lead
|
||||
↓ Assign To Sales
|
||||
Enquiry
|
||||
↓
|
||||
Quotation
|
||||
↓
|
||||
Won / Lost
|
||||
```
|
||||
|
||||
แต่ UX จะถูกแยกเป็น 2 Workspace
|
||||
|
||||
```txt
|
||||
CRM
|
||||
├── Dashboard
|
||||
├── Leads
|
||||
├── Enquiries
|
||||
├── Quotations
|
||||
├── Approvals
|
||||
└── Settings
|
||||
```
|
||||
|
||||
Important:
|
||||
|
||||
Task นี้เป็น UX Separation เท่านั้น
|
||||
|
||||
Do NOT create:
|
||||
|
||||
```txt
|
||||
crm_leads
|
||||
```
|
||||
|
||||
Do NOT duplicate:
|
||||
|
||||
```txt
|
||||
API
|
||||
Schema
|
||||
Database
|
||||
Server Service
|
||||
```
|
||||
|
||||
ยังคงใช้
|
||||
|
||||
```txt
|
||||
crm_enquiries
|
||||
```
|
||||
|
||||
เป็น Single Source of Truth
|
||||
|
||||
---
|
||||
|
||||
# Scope D3.1.1 Navigation
|
||||
|
||||
Update:
|
||||
|
||||
```txt
|
||||
src/config/nav-config.ts
|
||||
```
|
||||
|
||||
Add:
|
||||
|
||||
```txt
|
||||
CRM
|
||||
├ Leads
|
||||
└ Enquiries
|
||||
```
|
||||
|
||||
Remove old combined menu if exists.
|
||||
|
||||
Permissions:
|
||||
|
||||
Leads menu:
|
||||
|
||||
```txt
|
||||
crm.lead.read
|
||||
```
|
||||
|
||||
Enquiries menu:
|
||||
|
||||
```txt
|
||||
crm.enquiry.read
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Scope D3.1.2 Routes
|
||||
|
||||
Add:
|
||||
|
||||
```txt
|
||||
/dashboard/crm/leads
|
||||
/dashboard/crm/leads/[id]
|
||||
```
|
||||
|
||||
Keep:
|
||||
|
||||
```txt
|
||||
/dashboard/crm/enquiries
|
||||
/dashboard/crm/enquiries/[id]
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
Leads page:
|
||||
|
||||
```txt
|
||||
pipelineStage = lead
|
||||
```
|
||||
|
||||
Enquiries page:
|
||||
|
||||
```txt
|
||||
pipelineStage = enquiry
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Scope D3.1.3 Reuse Existing Module
|
||||
|
||||
Do NOT create:
|
||||
|
||||
```txt
|
||||
src/features/crm/leads
|
||||
```
|
||||
|
||||
Reuse:
|
||||
|
||||
```txt
|
||||
src/features/crm/enquiries/**
|
||||
```
|
||||
|
||||
Implementation pattern:
|
||||
|
||||
```ts
|
||||
getEnquiries({
|
||||
pipelineStage: "lead"
|
||||
})
|
||||
|
||||
getEnquiries({
|
||||
pipelineStage: "enquiry"
|
||||
})
|
||||
```
|
||||
|
||||
Shared:
|
||||
|
||||
```txt
|
||||
API
|
||||
Types
|
||||
Mutations
|
||||
Queries
|
||||
Schema
|
||||
Server Service
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Scope D3.1.4 Leads UI
|
||||
|
||||
Leads page should focus on Marketing workflow.
|
||||
|
||||
Columns:
|
||||
|
||||
```txt
|
||||
Code
|
||||
Lead Source
|
||||
Company
|
||||
Contact
|
||||
Assigned Sales
|
||||
Created By
|
||||
Created At
|
||||
Age
|
||||
Status
|
||||
```
|
||||
|
||||
Actions:
|
||||
|
||||
```txt
|
||||
View
|
||||
Edit
|
||||
Assign To Sales
|
||||
Mark Lost
|
||||
```
|
||||
|
||||
Hide:
|
||||
|
||||
```txt
|
||||
Quotation Count
|
||||
Revenue
|
||||
Chance %
|
||||
Quotation Value
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Scope D3.1.5 Enquiries UI
|
||||
|
||||
Enquiries page should focus on Sales workflow.
|
||||
|
||||
Columns:
|
||||
|
||||
```txt
|
||||
Code
|
||||
Project
|
||||
Billing Customer
|
||||
Enquiry Owner
|
||||
Chance %
|
||||
Estimated Value
|
||||
Quotation Count
|
||||
Next Follow-up
|
||||
Status
|
||||
```
|
||||
|
||||
Actions:
|
||||
|
||||
```txt
|
||||
View
|
||||
Edit
|
||||
Create Quotation
|
||||
Follow-up
|
||||
Won
|
||||
Lost
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Scope D3.1.6 Detail Page Labels
|
||||
|
||||
Lead Detail:
|
||||
|
||||
```txt
|
||||
Lead Information
|
||||
Lead Source
|
||||
Lead Owner
|
||||
Assigned Sales
|
||||
```
|
||||
|
||||
Enquiry Detail:
|
||||
|
||||
```txt
|
||||
Enquiry Information
|
||||
Project Information
|
||||
Enquiry Owner
|
||||
Follow-ups
|
||||
Quotations
|
||||
```
|
||||
|
||||
Both pages may internally reuse the same component.
|
||||
|
||||
Only labels and visibility differ.
|
||||
|
||||
---
|
||||
|
||||
# Scope D3.1.7 Permissions
|
||||
|
||||
Add:
|
||||
|
||||
```txt
|
||||
crm.lead.read
|
||||
crm.lead.create
|
||||
crm.lead.update
|
||||
crm.lead.assign
|
||||
crm.lead.delete
|
||||
```
|
||||
|
||||
Map existing behavior:
|
||||
|
||||
```txt
|
||||
marketing
|
||||
admin
|
||||
super_admin
|
||||
```
|
||||
|
||||
Default:
|
||||
|
||||
Marketing:
|
||||
|
||||
```txt
|
||||
Lead permissions
|
||||
```
|
||||
|
||||
Sales:
|
||||
|
||||
```txt
|
||||
Enquiry permissions
|
||||
```
|
||||
|
||||
Managers:
|
||||
|
||||
```txt
|
||||
Both
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Scope D3.1.8 Dashboard Alignment
|
||||
|
||||
Dashboard cards should use:
|
||||
|
||||
Lead:
|
||||
|
||||
```txt
|
||||
pipelineStage = lead
|
||||
```
|
||||
|
||||
Enquiry:
|
||||
|
||||
```txt
|
||||
pipelineStage = enquiry
|
||||
```
|
||||
|
||||
Links:
|
||||
|
||||
```txt
|
||||
Lead KPI
|
||||
→ /dashboard/crm/leads
|
||||
|
||||
Enquiry KPI
|
||||
→ /dashboard/crm/enquiries
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Scope D3.1.9 Query Freshness
|
||||
|
||||
After:
|
||||
|
||||
```txt
|
||||
Assign Lead
|
||||
Convert Lead → Enquiry
|
||||
Won
|
||||
Lost
|
||||
```
|
||||
|
||||
Invalidate:
|
||||
|
||||
```txt
|
||||
lead list
|
||||
enquiry list
|
||||
lead detail
|
||||
enquiry detail
|
||||
dashboard KPI
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
Assigned lead should disappear from Leads immediately and appear in Enquiries immediately.
|
||||
|
||||
No manual refresh allowed.
|
||||
|
||||
---
|
||||
|
||||
# Scope D3.1.10 ADR Update
|
||||
|
||||
Update:
|
||||
|
||||
```txt
|
||||
docs/adr/0011-lead-enquiry-ownership-model.md
|
||||
```
|
||||
|
||||
Add section:
|
||||
|
||||
```txt
|
||||
Navigation Separation
|
||||
```
|
||||
|
||||
Document:
|
||||
|
||||
```txt
|
||||
Lead and Enquiry are separate UX workspaces.
|
||||
|
||||
Lead and Enquiry are NOT separate domain entities.
|
||||
|
||||
Both are stored in crm_enquiries.
|
||||
|
||||
pipelineStage determines business state.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Explicit Non-Scope
|
||||
|
||||
Do NOT implement:
|
||||
|
||||
```txt
|
||||
crm_leads table
|
||||
Lead API
|
||||
Lead schema
|
||||
Lead server service
|
||||
Campaign module
|
||||
Lead scoring
|
||||
Marketing automation
|
||||
Import/Export redesign
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Output
|
||||
|
||||
1. Files Added
|
||||
2. Files Modified
|
||||
3. Navigation Changes
|
||||
4. Route Changes
|
||||
5. Permission Changes
|
||||
6. Query Invalidation Added
|
||||
7. Dashboard Changes
|
||||
8. ADR Updates
|
||||
9. Remaining Risks
|
||||
|
||||
---
|
||||
|
||||
# Definition of Done
|
||||
|
||||
✔ Leads menu exists
|
||||
|
||||
✔ Enquiries menu exists
|
||||
|
||||
✔ Leads page shows only lead-stage records
|
||||
|
||||
✔ Enquiries page shows only enquiry-stage records
|
||||
|
||||
✔ Lead assignment moves record between pages immediately
|
||||
|
||||
✔ Dashboard links point correctly
|
||||
|
||||
✔ No duplicate lead module created
|
||||
|
||||
✔ No new database table created
|
||||
|
||||
✔ Existing enquiry APIs reused
|
||||
|
||||
✔ ADR updated
|
||||
464
plans/task-d.3.md
Normal file
464
plans/task-d.3.md
Normal file
@@ -0,0 +1,464 @@
|
||||
# Task D.3: Lead / Enquiry Ownership Refinement
|
||||
|
||||
## Goal
|
||||
|
||||
ปรับ CRM Pipeline ให้สอดคล้องกับ Business Process ล่าสุด
|
||||
|
||||
Current:
|
||||
|
||||
```txt
|
||||
Lead (unassigned enquiry)
|
||||
↓
|
||||
Opportunity (assigned enquiry)
|
||||
↓
|
||||
Quotation
|
||||
↓
|
||||
Won / Lost
|
||||
```
|
||||
|
||||
New Business Flow:
|
||||
|
||||
```txt
|
||||
Lead
|
||||
↓ Assign To Sales
|
||||
Enquiry
|
||||
↓
|
||||
Quotation
|
||||
↓
|
||||
Won / Lost
|
||||
```
|
||||
|
||||
Important:
|
||||
|
||||
Task D.3 focuses on ownership, visibility, permissions, and lifecycle only.
|
||||
|
||||
Lead and Enquiry continue using the same underlying business fields and form structure.
|
||||
|
||||
Do NOT split the Lead form and Enquiry form yet.
|
||||
|
||||
Future form differences will be handled in a separate task if business requirements diverge.
|
||||
|
||||
---
|
||||
|
||||
# Business Rules
|
||||
|
||||
## Lead
|
||||
|
||||
Owner:
|
||||
|
||||
```txt
|
||||
Marketing (MK)
|
||||
```
|
||||
|
||||
Purpose:
|
||||
|
||||
```txt
|
||||
Lead collection
|
||||
Lead qualification
|
||||
Lead nurturing
|
||||
```
|
||||
|
||||
Status examples:
|
||||
|
||||
```txt
|
||||
new
|
||||
contacted
|
||||
qualified
|
||||
disqualified
|
||||
assigned
|
||||
```
|
||||
|
||||
Lead may exist without sales assignment.
|
||||
|
||||
---
|
||||
|
||||
## Enquiry
|
||||
|
||||
Owner:
|
||||
|
||||
```txt
|
||||
Sales
|
||||
```
|
||||
|
||||
Purpose:
|
||||
|
||||
```txt
|
||||
Project follow-up
|
||||
Opportunity development
|
||||
Competitor tracking
|
||||
Quotation preparation
|
||||
```
|
||||
|
||||
Enquiry begins when:
|
||||
|
||||
```txt
|
||||
Lead assigned to Sales
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```txt
|
||||
Sales creates enquiry directly
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Scope D3.1 Pipeline Terminology Refinement
|
||||
|
||||
Replace current KPI and workflow terminology.
|
||||
|
||||
Old:
|
||||
|
||||
```txt
|
||||
Lead = enquiry without assignment
|
||||
Opportunity = enquiry with assignment
|
||||
```
|
||||
|
||||
New:
|
||||
|
||||
```txt
|
||||
Lead = marketing-owned pipeline stage
|
||||
|
||||
Enquiry = sales-owned pipeline stage
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
* Stop using Opportunity terminology in UI where practical
|
||||
* Use Lead and Enquiry terminology consistently
|
||||
* Preserve existing data compatibility
|
||||
|
||||
---
|
||||
|
||||
# Scope D3.2 Enquiry Pipeline Stage
|
||||
|
||||
Add field:
|
||||
|
||||
```txt
|
||||
pipelineStage
|
||||
```
|
||||
|
||||
Allowed values:
|
||||
|
||||
```txt
|
||||
lead
|
||||
enquiry
|
||||
closed_won
|
||||
closed_lost
|
||||
```
|
||||
|
||||
Default:
|
||||
|
||||
```txt
|
||||
lead
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
### Marketing-created lead
|
||||
|
||||
```txt
|
||||
pipelineStage = lead
|
||||
assignedToUserId = null
|
||||
```
|
||||
|
||||
### Assign to sales
|
||||
|
||||
Automatically:
|
||||
|
||||
```txt
|
||||
pipelineStage = enquiry
|
||||
assignedToUserId = salesUserId
|
||||
assignedAt = now()
|
||||
```
|
||||
|
||||
### Reassign
|
||||
|
||||
Keep:
|
||||
|
||||
```txt
|
||||
pipelineStage = enquiry
|
||||
```
|
||||
|
||||
### Won
|
||||
|
||||
```txt
|
||||
pipelineStage = closed_won
|
||||
```
|
||||
|
||||
### Lost
|
||||
|
||||
```txt
|
||||
pipelineStage = closed_lost
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Scope D3.3 Assign Flow Refinement
|
||||
|
||||
Current assign flow already exists.
|
||||
|
||||
Update behavior:
|
||||
|
||||
Assign:
|
||||
|
||||
```txt
|
||||
Lead
|
||||
↓
|
||||
Enquiry
|
||||
```
|
||||
|
||||
When assign executes:
|
||||
|
||||
```txt
|
||||
assignedToUserId
|
||||
assignedAt
|
||||
assignedBy
|
||||
pipelineStage = enquiry
|
||||
```
|
||||
|
||||
Audit:
|
||||
|
||||
```txt
|
||||
assign
|
||||
lead_to_enquiry
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Scope D3.4 MK Visibility Policy
|
||||
|
||||
Marketing users must be able to monitor progress.
|
||||
|
||||
MK can view:
|
||||
|
||||
```txt
|
||||
Lead
|
||||
Enquiry
|
||||
Follow-ups
|
||||
Status
|
||||
Pipeline Stage
|
||||
Assigned Sales
|
||||
Won/Lost Result
|
||||
```
|
||||
|
||||
MK cannot view:
|
||||
|
||||
```txt
|
||||
Quotation Amount
|
||||
Quotation Item
|
||||
Quotation Cost
|
||||
Discount
|
||||
Margin
|
||||
Approval Data
|
||||
```
|
||||
|
||||
MK cannot:
|
||||
|
||||
```txt
|
||||
Edit enquiry owned by sales
|
||||
Create quotation
|
||||
Modify quotation
|
||||
Approve quotation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Scope D3.5 Sales Visibility Policy
|
||||
|
||||
Sales can:
|
||||
|
||||
```txt
|
||||
View assigned enquiries
|
||||
Edit assigned enquiries
|
||||
Create quotations
|
||||
Manage follow-ups
|
||||
View quotation pricing
|
||||
```
|
||||
|
||||
Rules remain compatible with existing assignment ownership.
|
||||
|
||||
---
|
||||
|
||||
# Scope D3.6 Dashboard KPI Alignment
|
||||
|
||||
Update KPI service layer.
|
||||
|
||||
New definitions:
|
||||
|
||||
### Lead Count
|
||||
|
||||
```txt
|
||||
pipelineStage = lead
|
||||
```
|
||||
|
||||
### Enquiry Count
|
||||
|
||||
```txt
|
||||
pipelineStage = enquiry
|
||||
```
|
||||
|
||||
### Won Count
|
||||
|
||||
```txt
|
||||
pipelineStage = closed_won
|
||||
```
|
||||
|
||||
### Lost Count
|
||||
|
||||
```txt
|
||||
pipelineStage = closed_lost
|
||||
```
|
||||
|
||||
Remove dependency on:
|
||||
|
||||
```txt
|
||||
assignedToUserId == null
|
||||
```
|
||||
|
||||
for lead calculation.
|
||||
|
||||
---
|
||||
|
||||
# Scope D3.7 Revenue Rules
|
||||
|
||||
No revenue logic change.
|
||||
|
||||
Still use:
|
||||
|
||||
```txt
|
||||
Quotation Amount
|
||||
Revenue Attribution
|
||||
Project Party Governance
|
||||
```
|
||||
|
||||
from Task D.2.1.
|
||||
|
||||
---
|
||||
|
||||
# Scope D3.8 UI Labels
|
||||
|
||||
Update CRM wording.
|
||||
|
||||
Examples:
|
||||
|
||||
```txt
|
||||
Assign Sales
|
||||
→ Convert To Enquiry
|
||||
|
||||
Assigned Sales
|
||||
→ Enquiry Owner
|
||||
|
||||
Open Opportunities
|
||||
→ Active Enquiries
|
||||
```
|
||||
|
||||
Do not rename database tables.
|
||||
|
||||
Only update business-facing labels.
|
||||
|
||||
---
|
||||
|
||||
# Scope D3.9 Backward Compatibility
|
||||
|
||||
Do NOT:
|
||||
|
||||
```txt
|
||||
Create crm_leads table
|
||||
Split enquiry form
|
||||
Split enquiry schema
|
||||
Split enquiry API
|
||||
```
|
||||
|
||||
Continue using:
|
||||
|
||||
```txt
|
||||
crm_enquiries
|
||||
```
|
||||
|
||||
as the single source of truth.
|
||||
|
||||
Lead vs Enquiry is determined by:
|
||||
|
||||
```txt
|
||||
pipelineStage
|
||||
```
|
||||
|
||||
and ownership.
|
||||
|
||||
---
|
||||
|
||||
# Scope D3.10 ADR
|
||||
|
||||
Create:
|
||||
|
||||
```txt
|
||||
docs/adr/0011-lead-enquiry-ownership-model.md
|
||||
```
|
||||
|
||||
Document:
|
||||
|
||||
```txt
|
||||
Lead ownership
|
||||
Enquiry ownership
|
||||
Assignment flow
|
||||
MK visibility restrictions
|
||||
Won/Lost lifecycle
|
||||
```
|
||||
|
||||
This ADR supersedes the Opportunity terminology frozen in Task J.0.
|
||||
|
||||
---
|
||||
|
||||
# Explicit Non-Scope
|
||||
|
||||
Do NOT implement:
|
||||
|
||||
```txt
|
||||
crm_leads table
|
||||
Lead form
|
||||
Lead API
|
||||
Lead import
|
||||
Campaign module
|
||||
Marketing automation
|
||||
Lead scoring
|
||||
New dashboard widgets
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Output
|
||||
|
||||
1. Files Added
|
||||
2. Files Modified
|
||||
3. Pipeline Changes
|
||||
4. Ownership Changes
|
||||
5. Visibility Rules Added
|
||||
6. KPI Changes
|
||||
7. ADR Added
|
||||
8. Migration Notes
|
||||
9. Remaining Risks
|
||||
|
||||
---
|
||||
|
||||
# Definition of Done
|
||||
|
||||
✔ Lead and Enquiry terminology updated
|
||||
|
||||
✔ Assign converts Lead → Enquiry
|
||||
|
||||
✔ MK can monitor enquiries
|
||||
|
||||
✔ MK cannot see quotation pricing
|
||||
|
||||
✔ Sales owns enquiries
|
||||
|
||||
✔ Dashboard KPI follows pipelineStage
|
||||
|
||||
✔ No duplicate Lead module created
|
||||
|
||||
✔ Existing enquiry form reused
|
||||
|
||||
✔ Existing data remains compatible
|
||||
|
||||
✔ ADR created
|
||||
@@ -39,7 +39,8 @@ export async function GET(request: NextRequest) {
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membershipRole: membership.role,
|
||||
businessRole: membership.businessRole
|
||||
businessRole: membership.businessRole,
|
||||
permissions: membership.permissions
|
||||
},
|
||||
{
|
||||
dateFrom: searchParams.get('dateFrom'),
|
||||
@@ -84,11 +85,11 @@ export async function GET(request: NextRequest) {
|
||||
];
|
||||
} else {
|
||||
rows = [
|
||||
['Sales Person', 'Lead Count', 'Opportunity Count', 'Quotation Count', 'Approved Quotations', 'Won Revenue', 'Conversion Rate'],
|
||||
['Sales Person', 'Lead Count', 'Enquiry Count', 'Quotation Count', 'Approved Quotations', 'Won Revenue', 'Conversion Rate'],
|
||||
...data.salesRanking.map((row) => [
|
||||
row.salesPersonName,
|
||||
String(row.leadCount),
|
||||
String(row.opportunityCount),
|
||||
String(row.enquiryCount),
|
||||
String(row.quotationCount),
|
||||
String(row.approvedQuotations),
|
||||
String(row.wonRevenue),
|
||||
|
||||
@@ -14,7 +14,8 @@ export async function GET(request: NextRequest) {
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membershipRole: membership.role,
|
||||
businessRole: membership.businessRole
|
||||
businessRole: membership.businessRole,
|
||||
permissions: membership.permissions
|
||||
},
|
||||
{
|
||||
dateFrom: searchParams.get('dateFrom'),
|
||||
|
||||
@@ -13,11 +13,16 @@ type Params = {
|
||||
export async function POST(request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization, session } = await requireOrganizationAccess({
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmEnquiryAssign
|
||||
});
|
||||
const payload = enquiryAssignmentRequestSchema.parse(await request.json());
|
||||
const before = await getEnquiryDetail(id, organization.id);
|
||||
const before = await getEnquiryDetail(id, organization.id, {
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membershipRole: membership.role,
|
||||
businessRole: membership.businessRole
|
||||
});
|
||||
const updated = await assignEnquiryToUser(id, organization.id, session.user.id, payload);
|
||||
|
||||
await auditAction({
|
||||
@@ -26,19 +31,21 @@ export async function POST(request: NextRequest, { params }: Params) {
|
||||
userId: session.user.id,
|
||||
entityType: 'crm_enquiry',
|
||||
entityId: id,
|
||||
action: 'assign',
|
||||
action: 'lead_to_enquiry',
|
||||
beforeData: {
|
||||
oldAssignedToUserId: before.assignedToUserId
|
||||
oldAssignedToUserId: before.assignedToUserId,
|
||||
oldPipelineStage: before.pipelineStage
|
||||
},
|
||||
afterData: {
|
||||
newAssignedToUserId: updated.assignedToUserId,
|
||||
newPipelineStage: updated.pipelineStage,
|
||||
remark: updated.assignmentRemark
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Enquiry assigned successfully',
|
||||
message: 'Lead converted to enquiry successfully',
|
||||
enquiry: updated
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -10,10 +10,15 @@ type Params = {
|
||||
export async function GET(_request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization } = await requireOrganizationAccess({
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmEnquiryRead
|
||||
});
|
||||
const items = await listEnquiryProjectParties(id, organization.id);
|
||||
const items = await listEnquiryProjectParties(id, organization.id, {
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membershipRole: membership.role,
|
||||
businessRole: membership.businessRole
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
|
||||
@@ -22,11 +22,18 @@ const followupRequestSchema = enquiryFollowupSchema.extend({
|
||||
export async function PATCH(request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id, followupId } = await params;
|
||||
const { organization, session } = await requireOrganizationAccess({
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmEnquiryFollowupUpdate
|
||||
});
|
||||
const payload = followupRequestSchema.parse(await request.json());
|
||||
const before = (await listEnquiryFollowups(id, organization.id)).find(
|
||||
const before = (
|
||||
await listEnquiryFollowups(id, organization.id, {
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membershipRole: membership.role,
|
||||
businessRole: membership.businessRole
|
||||
})
|
||||
).find(
|
||||
(item) => item.id === followupId
|
||||
);
|
||||
|
||||
@@ -39,7 +46,13 @@ export async function PATCH(request: NextRequest, { params }: Params) {
|
||||
followupId,
|
||||
organization.id,
|
||||
session.user.id,
|
||||
payload
|
||||
payload,
|
||||
{
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membershipRole: membership.role,
|
||||
businessRole: membership.businessRole
|
||||
}
|
||||
);
|
||||
|
||||
await auditUpdate({
|
||||
@@ -79,10 +92,17 @@ export async function PATCH(request: NextRequest, { params }: Params) {
|
||||
export async function DELETE(_request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id, followupId } = await params;
|
||||
const { organization, session } = await requireOrganizationAccess({
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmEnquiryFollowupDelete
|
||||
});
|
||||
const before = (await listEnquiryFollowups(id, organization.id)).find(
|
||||
const before = (
|
||||
await listEnquiryFollowups(id, organization.id, {
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membershipRole: membership.role,
|
||||
businessRole: membership.businessRole
|
||||
})
|
||||
).find(
|
||||
(item) => item.id === followupId
|
||||
);
|
||||
|
||||
@@ -94,7 +114,13 @@ export async function DELETE(_request: NextRequest, { params }: Params) {
|
||||
id,
|
||||
followupId,
|
||||
organization.id,
|
||||
session.user.id
|
||||
session.user.id,
|
||||
{
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membershipRole: membership.role,
|
||||
businessRole: membership.businessRole
|
||||
}
|
||||
);
|
||||
|
||||
await auditDelete({
|
||||
|
||||
@@ -21,10 +21,15 @@ const followupRequestSchema = enquiryFollowupSchema.extend({
|
||||
export async function GET(_request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization } = await requireOrganizationAccess({
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmEnquiryFollowupRead
|
||||
});
|
||||
const items = await listEnquiryFollowups(id, organization.id);
|
||||
const items = await listEnquiryFollowups(id, organization.id, {
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membershipRole: membership.role,
|
||||
businessRole: membership.businessRole
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
@@ -48,11 +53,22 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
||||
export async function POST(request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization, session } = await requireOrganizationAccess({
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmEnquiryFollowupCreate
|
||||
});
|
||||
const payload = followupRequestSchema.parse(await request.json());
|
||||
const created = await createEnquiryFollowup(id, organization.id, session.user.id, payload);
|
||||
const created = await createEnquiryFollowup(
|
||||
id,
|
||||
organization.id,
|
||||
session.user.id,
|
||||
payload,
|
||||
{
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membershipRole: membership.role,
|
||||
businessRole: membership.businessRole
|
||||
}
|
||||
);
|
||||
|
||||
await auditCreate({
|
||||
organizationId: organization.id,
|
||||
|
||||
@@ -13,11 +13,16 @@ type Params = {
|
||||
export async function POST(request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization, session } = await requireOrganizationAccess({
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmEnquiryReassign
|
||||
});
|
||||
const payload = enquiryAssignmentRequestSchema.parse(await request.json());
|
||||
const before = await getEnquiryDetail(id, organization.id);
|
||||
const before = await getEnquiryDetail(id, organization.id, {
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membershipRole: membership.role,
|
||||
businessRole: membership.businessRole
|
||||
});
|
||||
const updated = await reassignEnquiryToUser(id, organization.id, session.user.id, payload);
|
||||
|
||||
await auditAction({
|
||||
@@ -28,10 +33,12 @@ export async function POST(request: NextRequest, { params }: Params) {
|
||||
entityId: id,
|
||||
action: 'reassign',
|
||||
beforeData: {
|
||||
oldAssignedToUserId: before.assignedToUserId
|
||||
oldAssignedToUserId: before.assignedToUserId,
|
||||
oldPipelineStage: before.pipelineStage
|
||||
},
|
||||
afterData: {
|
||||
newAssignedToUserId: updated.assignedToUserId,
|
||||
newPipelineStage: updated.pipelineStage,
|
||||
remark: updated.assignmentRemark
|
||||
}
|
||||
});
|
||||
|
||||
@@ -25,12 +25,18 @@ const enquiryRequestSchema = enquirySchema.extend({
|
||||
export async function GET(_request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization } = await requireOrganizationAccess({
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmEnquiryRead
|
||||
});
|
||||
const accessContext = {
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membershipRole: membership.role,
|
||||
businessRole: membership.businessRole
|
||||
};
|
||||
const [enquiry, activity] = await Promise.all([
|
||||
getEnquiryDetail(id, organization.id),
|
||||
getEnquiryActivity(id, organization.id)
|
||||
getEnquiryDetail(id, organization.id, accessContext),
|
||||
getEnquiryActivity(id, organization.id, accessContext)
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -56,12 +62,22 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
||||
export async function PATCH(request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization, session } = await requireOrganizationAccess({
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmEnquiryUpdate
|
||||
});
|
||||
const payload = enquiryRequestSchema.parse(await request.json());
|
||||
const before = await getEnquiryDetail(id, organization.id);
|
||||
const updated = await updateEnquiry(id, organization.id, session.user.id, payload);
|
||||
const before = await getEnquiryDetail(id, organization.id, {
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membershipRole: membership.role,
|
||||
businessRole: membership.businessRole
|
||||
});
|
||||
const updated = await updateEnquiry(id, organization.id, session.user.id, payload, {
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membershipRole: membership.role,
|
||||
businessRole: membership.businessRole
|
||||
});
|
||||
|
||||
await auditUpdate({
|
||||
organizationId: organization.id,
|
||||
@@ -101,11 +117,21 @@ export async function PATCH(request: NextRequest, { params }: Params) {
|
||||
export async function DELETE(_request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization, session } = await requireOrganizationAccess({
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmEnquiryDelete
|
||||
});
|
||||
const before = await getEnquiryDetail(id, organization.id);
|
||||
const updated = await softDeleteEnquiry(id, organization.id, session.user.id);
|
||||
const before = await getEnquiryDetail(id, organization.id, {
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membershipRole: membership.role,
|
||||
businessRole: membership.businessRole
|
||||
});
|
||||
const updated = await softDeleteEnquiry(id, organization.id, session.user.id, {
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membershipRole: membership.role,
|
||||
businessRole: membership.businessRole
|
||||
});
|
||||
|
||||
await auditDelete({
|
||||
organizationId: organization.id,
|
||||
|
||||
@@ -15,30 +15,40 @@ const enquiryRequestSchema = enquirySchema.extend({
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { organization } = await requireOrganizationAccess({
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmEnquiryRead
|
||||
});
|
||||
const { searchParams } = request.nextUrl;
|
||||
const page = Number(searchParams.get('page') ?? 1);
|
||||
const limit = Number(searchParams.get('limit') ?? 10);
|
||||
const search = searchParams.get('search') ?? undefined;
|
||||
const pipelineStage = searchParams.get('pipelineStage') ?? undefined;
|
||||
const status = searchParams.get('status') ?? undefined;
|
||||
const productType = searchParams.get('productType') ?? undefined;
|
||||
const priority = searchParams.get('priority') ?? undefined;
|
||||
const branch = searchParams.get('branch') ?? undefined;
|
||||
const customer = searchParams.get('customer') ?? undefined;
|
||||
const sort = searchParams.get('sort') ?? undefined;
|
||||
const result = await listEnquiries(organization.id, {
|
||||
const result = await listEnquiries(
|
||||
{
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membershipRole: membership.role,
|
||||
businessRole: membership.businessRole
|
||||
},
|
||||
{
|
||||
page,
|
||||
limit,
|
||||
search,
|
||||
pipelineStage: pipelineStage as 'lead' | 'enquiry' | 'closed_won' | 'closed_lost' | undefined,
|
||||
status,
|
||||
productType,
|
||||
priority,
|
||||
branch,
|
||||
customer,
|
||||
sort
|
||||
});
|
||||
}
|
||||
);
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -65,11 +75,16 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { organization, session } = await requireOrganizationAccess({
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmEnquiryCreate
|
||||
});
|
||||
const payload = enquiryRequestSchema.parse(await request.json());
|
||||
const created = await createEnquiry(organization.id, session.user.id, payload);
|
||||
const created = await createEnquiry(
|
||||
organization.id,
|
||||
session.user.id,
|
||||
membership.businessRole,
|
||||
payload
|
||||
);
|
||||
|
||||
await auditCreate({
|
||||
organizationId: organization.id,
|
||||
|
||||
@@ -54,6 +54,11 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryReassign)));
|
||||
const canReadQuotation =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationRead)));
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
if (canRead) {
|
||||
@@ -67,14 +72,14 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
|
||||
? await getEnquiryReferenceData(session.user.activeOrganizationId)
|
||||
: null;
|
||||
const relatedQuotations =
|
||||
canRead && session?.user?.activeOrganizationId
|
||||
canRead && canReadQuotation && session?.user?.activeOrganizationId
|
||||
? await listEnquiryQuotationRelations(id, session.user.activeOrganizationId)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
pageTitle='Enquiry Detail'
|
||||
pageDescription='Opportunity context, follow-up timeline, and future quotation handoff.'
|
||||
pageDescription='Lead or enquiry context, follow-up timeline, and ownership lifecycle.'
|
||||
access={canRead}
|
||||
accessFallback={
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
|
||||
@@ -88,9 +93,11 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
|
||||
enquiryId={id}
|
||||
referenceData={referenceData}
|
||||
relatedQuotations={relatedQuotations}
|
||||
workspace='enquiry'
|
||||
canUpdate={canUpdate}
|
||||
canAssign={canAssign}
|
||||
canReassign={canReassign}
|
||||
canViewRelatedQuotations={canReadQuotation}
|
||||
canManageFollowups={{
|
||||
create: canFollowupCreate,
|
||||
update: canFollowupUpdate,
|
||||
|
||||
@@ -50,6 +50,7 @@ export default async function EnquiriesRoute(props: PageProps) {
|
||||
session.user.activePermissions.includes(
|
||||
PERMISSIONS.crmEnquiryReassign,
|
||||
)));
|
||||
const businessRole = session?.user?.activeBusinessRole ?? null;
|
||||
|
||||
searchParamsCache.parse(searchParams);
|
||||
|
||||
@@ -60,8 +61,8 @@ export default async function EnquiriesRoute(props: PageProps) {
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
pageTitle="Enquiries (Lead)"
|
||||
pageDescription="Production opportunity registry with customer linkage, follow-up workflow, and audit-backed activity."
|
||||
pageTitle="Enquiries"
|
||||
pageDescription="Sales workspace for active enquiries, follow-up workflow, and quotation preparation."
|
||||
access={canRead}
|
||||
accessFallback={
|
||||
<div className="text-muted-foreground rounded-lg border border-dashed p-8 text-center">
|
||||
@@ -70,13 +71,14 @@ export default async function EnquiriesRoute(props: PageProps) {
|
||||
}
|
||||
pageHeaderAction={
|
||||
canCreate && referenceData ? (
|
||||
<EnquiryFormSheetTrigger referenceData={referenceData} />
|
||||
<EnquiryFormSheetTrigger referenceData={referenceData} workspace='enquiry' />
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{referenceData ? (
|
||||
<EnquiryListing
|
||||
referenceData={referenceData}
|
||||
workspace='enquiry'
|
||||
canUpdate={canUpdate}
|
||||
canDelete={canDelete}
|
||||
canAssign={canAssign}
|
||||
|
||||
99
src/app/dashboard/crm/leads/[id]/page.tsx
Normal file
99
src/app/dashboard/crm/leads/[id]/page.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { auth } from '@/auth';
|
||||
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import PageContainer from '@/components/layout/page-container';
|
||||
import {
|
||||
enquiryByIdOptions,
|
||||
enquiryFollowupsOptions,
|
||||
enquiryProjectPartiesOptions
|
||||
} from '@/features/crm/enquiries/api/queries';
|
||||
import { EnquiryDetail } from '@/features/crm/enquiries/components/enquiry-detail';
|
||||
import { getEnquiryDetail, getEnquiryReferenceData } from '@/features/crm/enquiries/server/service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { getQueryClient } from '@/lib/query-client';
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export default async function LeadDetailRoute({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
const session = await auth();
|
||||
const canRead =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmLeadRead)));
|
||||
const canUpdate =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmLeadUpdate)));
|
||||
const canAssign =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmLeadAssign)));
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
if (!session?.user?.activeOrganizationId) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const enquiry = canRead
|
||||
? await getEnquiryDetail(id, session.user.activeOrganizationId, {
|
||||
organizationId: session.user.activeOrganizationId,
|
||||
userId: session.user.id,
|
||||
membershipRole: session.user.activeMembershipRole ?? 'user',
|
||||
businessRole: session.user.activeBusinessRole ?? 'sales_support'
|
||||
})
|
||||
: null;
|
||||
|
||||
if (enquiry && enquiry.pipelineStage !== 'lead') {
|
||||
redirect(`/dashboard/crm/enquiries/${id}`);
|
||||
}
|
||||
|
||||
if (canRead) {
|
||||
void queryClient.prefetchQuery(enquiryByIdOptions(id));
|
||||
void queryClient.prefetchQuery(enquiryProjectPartiesOptions(id));
|
||||
void queryClient.prefetchQuery(enquiryFollowupsOptions(id));
|
||||
}
|
||||
|
||||
const referenceData =
|
||||
canRead && session.user.activeOrganizationId
|
||||
? await getEnquiryReferenceData(session.user.activeOrganizationId)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
pageTitle='Lead Detail'
|
||||
pageDescription='Marketing lead information and assignment handoff context.'
|
||||
access={canRead}
|
||||
accessFallback={
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
|
||||
You do not have access to this lead.
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{referenceData ? (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<EnquiryDetail
|
||||
enquiryId={id}
|
||||
referenceData={referenceData}
|
||||
relatedQuotations={[]}
|
||||
workspace='lead'
|
||||
canUpdate={canUpdate}
|
||||
canAssign={canAssign}
|
||||
canReassign={false}
|
||||
canViewRelatedQuotations={false}
|
||||
canManageFollowups={{
|
||||
create: false,
|
||||
update: false,
|
||||
delete: false
|
||||
}}
|
||||
/>
|
||||
</HydrationBoundary>
|
||||
) : null}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
82
src/app/dashboard/crm/leads/page.tsx
Normal file
82
src/app/dashboard/crm/leads/page.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { auth } from '@/auth';
|
||||
import PageContainer from '@/components/layout/page-container';
|
||||
import EnquiryListing from '@/features/crm/enquiries/components/enquiry-listing';
|
||||
import { EnquiryFormSheetTrigger } from '@/features/crm/enquiries/components/enquiry-form-sheet';
|
||||
import { getEnquiryReferenceData } from '@/features/crm/enquiries/server/service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { searchParamsCache } from '@/lib/searchparams';
|
||||
import type { SearchParams } from 'nuqs/server';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Dashboard: CRM Leads'
|
||||
};
|
||||
|
||||
type PageProps = {
|
||||
searchParams: Promise<SearchParams>;
|
||||
};
|
||||
|
||||
export default async function LeadsRoute(props: PageProps) {
|
||||
const searchParams = await props.searchParams;
|
||||
const session = await auth();
|
||||
const canRead =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmLeadRead)));
|
||||
const canCreate =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmLeadCreate)));
|
||||
const canUpdate =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmLeadUpdate)));
|
||||
const canDelete =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmLeadDelete)));
|
||||
const canAssign =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmLeadAssign)));
|
||||
|
||||
searchParamsCache.parse(searchParams);
|
||||
|
||||
const referenceData =
|
||||
canRead && session?.user?.activeOrganizationId
|
||||
? await getEnquiryReferenceData(session.user.activeOrganizationId)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
pageTitle='Leads'
|
||||
pageDescription='Marketing workspace for lead collection, qualification, nurturing, and sales assignment.'
|
||||
access={canRead}
|
||||
accessFallback={
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
|
||||
You do not have access to CRM leads.
|
||||
</div>
|
||||
}
|
||||
pageHeaderAction={
|
||||
canCreate && referenceData ? (
|
||||
<EnquiryFormSheetTrigger referenceData={referenceData} workspace='lead' />
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{referenceData ? (
|
||||
<EnquiryListing
|
||||
referenceData={referenceData}
|
||||
workspace='lead'
|
||||
canUpdate={canUpdate}
|
||||
canDelete={canDelete}
|
||||
canAssign={canAssign}
|
||||
canReassign={false}
|
||||
/>
|
||||
) : null}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -100,7 +100,15 @@ export const navGroups: NavGroup[] = [
|
||||
access: { requireOrg: true, permission: "crm.customer.read" },
|
||||
},
|
||||
{
|
||||
title: "Enquiries (Lead)",
|
||||
title: "Leads",
|
||||
url: "/dashboard/crm/leads",
|
||||
icon: "forms",
|
||||
isActive: false,
|
||||
items: [],
|
||||
access: { requireOrg: true, permission: "crm.lead.read" },
|
||||
},
|
||||
{
|
||||
title: "Enquiries",
|
||||
url: "/dashboard/crm/enquiries",
|
||||
icon: "forms",
|
||||
isActive: false,
|
||||
|
||||
@@ -216,6 +216,7 @@ export const crmEnquiries = pgTable(
|
||||
notes: text('notes'),
|
||||
isHotProject: boolean('is_hot_project').default(false).notNull(),
|
||||
isActive: boolean('is_active').default(true).notNull(),
|
||||
pipelineStage: text('pipeline_stage').default('lead').notNull(),
|
||||
assignedToUserId: text('assigned_to_user_id'),
|
||||
assignedAt: timestamp('assigned_at', { withTimezone: true }),
|
||||
assignedBy: text('assigned_by'),
|
||||
|
||||
@@ -32,11 +32,13 @@ export interface CrmDashboardSummary {
|
||||
unassignedLeads: number;
|
||||
averageLeadAgingDays: number;
|
||||
};
|
||||
opportunity: {
|
||||
opportunityCount: number;
|
||||
openOpportunities: number;
|
||||
hotOpportunities: number;
|
||||
opportunityValue: number;
|
||||
enquiry: {
|
||||
enquiryCount: number;
|
||||
activeEnquiries: number;
|
||||
wonCount: number;
|
||||
lostCount: number;
|
||||
hotEnquiries: number;
|
||||
enquiryValue: number;
|
||||
};
|
||||
quotation: {
|
||||
draftQuotations: number;
|
||||
@@ -73,7 +75,7 @@ export interface CrmDashboardSalesRankingRow {
|
||||
salesPersonId: string;
|
||||
salesPersonName: string;
|
||||
leadCount: number;
|
||||
opportunityCount: number;
|
||||
enquiryCount: number;
|
||||
quotationCount: number;
|
||||
approvedQuotations: number;
|
||||
wonRevenue: number;
|
||||
@@ -92,7 +94,7 @@ export interface CrmDashboardFollowupRow {
|
||||
sourceType: 'enquiry' | 'quotation';
|
||||
followupDate: string;
|
||||
customerName: string;
|
||||
opportunityName: string;
|
||||
enquiryName: string;
|
||||
assignedSalesName: string | null;
|
||||
outcome: string | null;
|
||||
}
|
||||
@@ -148,4 +150,8 @@ export interface CrmDashboardResponse {
|
||||
myPendingApprovals: CrmDashboardApprovalRow[];
|
||||
};
|
||||
hotProjects: CrmDashboardHotProjectRow[];
|
||||
visibility: {
|
||||
canViewCommercialData: boolean;
|
||||
canViewApprovalData: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,16 +36,29 @@ export function CrmDashboard({ canExport }: { canExport: boolean }) {
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<DashboardFilters referenceData={data.referenceData} canExport={canExport} />
|
||||
<DashboardSummaryCards summary={data.summary} />
|
||||
<DashboardSummaryCards
|
||||
summary={data.summary}
|
||||
canViewCommercialData={data.visibility.canViewCommercialData}
|
||||
/>
|
||||
{data.visibility.canViewCommercialData ? (
|
||||
<div className='grid gap-6 2xl:grid-cols-[1.35fr_1fr]'>
|
||||
<DashboardFunnel steps={data.funnel} />
|
||||
<DashboardHotProjects rows={data.hotProjects} />
|
||||
</div>
|
||||
) : null}
|
||||
{data.visibility.canViewCommercialData ? (
|
||||
<DashboardRevenueCards revenueAnalytics={data.revenueAnalytics} />
|
||||
) : null}
|
||||
{data.visibility.canViewCommercialData || data.visibility.canViewApprovalData ? (
|
||||
<div className='grid gap-6 2xl:grid-cols-[1.2fr_1fr]'>
|
||||
{data.visibility.canViewCommercialData ? (
|
||||
<DashboardSalesRanking rows={data.salesRanking} />
|
||||
) : null}
|
||||
{data.visibility.canViewApprovalData ? (
|
||||
<DashboardApprovalMetrics approvals={data.approvals} />
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<DashboardFollowups followups={data.followups} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -36,8 +36,8 @@ export function DashboardFollowups({
|
||||
<TableRow>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Customer</TableHead>
|
||||
<TableHead>Opportunity</TableHead>
|
||||
<TableHead>Assigned Sales</TableHead>
|
||||
<TableHead>Enquiry</TableHead>
|
||||
<TableHead>Enquiry Owner</TableHead>
|
||||
<TableHead>Source</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -53,7 +53,7 @@ export function DashboardFollowups({
|
||||
<TableRow key={`${row.sourceType}-${row.id}`}>
|
||||
<TableCell>{new Date(row.followupDate).toLocaleDateString()}</TableCell>
|
||||
<TableCell>{row.customerName}</TableCell>
|
||||
<TableCell>{row.opportunityName}</TableCell>
|
||||
<TableCell>{row.enquiryName}</TableCell>
|
||||
<TableCell>{row.assignedSalesName ?? '-'}</TableCell>
|
||||
<TableCell className='capitalize'>{row.sourceType}</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -14,7 +14,7 @@ export function DashboardFunnel({ steps }: { steps: CrmDashboardFunnelStep[] })
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sales Pipeline Funnel</CardTitle>
|
||||
<CardDescription>Frozen lead, opportunity, quotation, approval, and won stages.</CardDescription>
|
||||
<CardDescription>Lead, enquiry, quotation, approval, and won stages from the current pipeline model.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
{steps.map((step) => (
|
||||
|
||||
@@ -12,8 +12,8 @@ export function DashboardHotProjects({ rows }: { rows: CrmDashboardHotProjectRow
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Top Hot Opportunities</CardTitle>
|
||||
<CardDescription>Production hot projects from enquiries, ranked by commercial value.</CardDescription>
|
||||
<CardTitle>Top Active Enquiries</CardTitle>
|
||||
<CardDescription>Sales-owned hot enquiries ranked by estimated commercial value.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
@@ -22,7 +22,7 @@ export function DashboardHotProjects({ rows }: { rows: CrmDashboardHotProjectRow
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead>Customer</TableHead>
|
||||
<TableHead className='text-right'>Value</TableHead>
|
||||
<TableHead>Assigned Sales</TableHead>
|
||||
<TableHead>Enquiry Owner</TableHead>
|
||||
<TableHead className='text-right'>Probability</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
@@ -13,7 +13,7 @@ export function DashboardSalesRanking({ rows }: { rows: CrmDashboardSalesRanking
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sales Ranking</CardTitle>
|
||||
<CardDescription>Lead and opportunity ownership follow assignment rules. Quotation ownership uses salesman.</CardDescription>
|
||||
<CardDescription>Lead and enquiry ownership follow assignment rules. Quotation ownership uses salesman.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
@@ -21,7 +21,7 @@ export function DashboardSalesRanking({ rows }: { rows: CrmDashboardSalesRanking
|
||||
<TableRow>
|
||||
<TableHead>Sales Person</TableHead>
|
||||
<TableHead className='text-right'>Lead</TableHead>
|
||||
<TableHead className='text-right'>Opportunity</TableHead>
|
||||
<TableHead className='text-right'>Enquiry</TableHead>
|
||||
<TableHead className='text-right'>Quotation</TableHead>
|
||||
<TableHead className='text-right'>Approved</TableHead>
|
||||
<TableHead className='text-right'>Won Revenue</TableHead>
|
||||
@@ -40,7 +40,7 @@ export function DashboardSalesRanking({ rows }: { rows: CrmDashboardSalesRanking
|
||||
<TableRow key={row.salesPersonId}>
|
||||
<TableCell className='font-medium'>{row.salesPersonName}</TableCell>
|
||||
<TableCell className='text-right'>{formatNumber(row.leadCount)}</TableCell>
|
||||
<TableCell className='text-right'>{formatNumber(row.opportunityCount)}</TableCell>
|
||||
<TableCell className='text-right'>{formatNumber(row.enquiryCount)}</TableCell>
|
||||
<TableCell className='text-right'>{formatNumber(row.quotationCount)}</TableCell>
|
||||
<TableCell className='text-right'>{formatNumber(row.approvedQuotations)}</TableCell>
|
||||
<TableCell className='text-right'>{formatNumber(row.wonRevenue)}</TableCell>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import type { CrmDashboardSummary } from '../api/types';
|
||||
|
||||
@@ -10,13 +11,15 @@ function formatNumber(value: number) {
|
||||
function SummaryCard({
|
||||
title,
|
||||
description,
|
||||
items
|
||||
items,
|
||||
href
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
items: Array<{ label: string; value: number; suffix?: string }>;
|
||||
href?: string;
|
||||
}) {
|
||||
return (
|
||||
const content = (
|
||||
<Card className='h-full'>
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
@@ -35,14 +38,23 @@ function SummaryCard({
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return href ? <Link href={href} className='block'>{content}</Link> : content;
|
||||
}
|
||||
|
||||
export function DashboardSummaryCards({ summary }: { summary: CrmDashboardSummary }) {
|
||||
export function DashboardSummaryCards({
|
||||
summary,
|
||||
canViewCommercialData
|
||||
}: {
|
||||
summary: CrmDashboardSummary;
|
||||
canViewCommercialData: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className='grid gap-4 xl:grid-cols-2 2xl:grid-cols-4'>
|
||||
<div className={`grid gap-4 ${canViewCommercialData ? 'xl:grid-cols-2 2xl:grid-cols-4' : 'xl:grid-cols-2'}`}>
|
||||
<SummaryCard
|
||||
title='Lead'
|
||||
description='Lead-stage enquiries based on the frozen KPI rules.'
|
||||
description='Marketing-owned lead-stage records based on pipeline stage.'
|
||||
href='/dashboard/crm/leads'
|
||||
items={[
|
||||
{ label: 'Lead Count', value: summary.lead.leadCount },
|
||||
{ label: 'New Leads', value: summary.lead.newLeads },
|
||||
@@ -51,15 +63,17 @@ export function DashboardSummaryCards({ summary }: { summary: CrmDashboardSummar
|
||||
]}
|
||||
/>
|
||||
<SummaryCard
|
||||
title='Opportunity'
|
||||
description='Assigned enquiries that remain active in the pipeline.'
|
||||
title='Enquiry'
|
||||
description='Sales-owned enquiry-stage records plus closed outcomes.'
|
||||
href='/dashboard/crm/enquiries'
|
||||
items={[
|
||||
{ label: 'Opportunity Count', value: summary.opportunity.opportunityCount },
|
||||
{ label: 'Open Opportunities', value: summary.opportunity.openOpportunities },
|
||||
{ label: 'Hot Opportunities', value: summary.opportunity.hotOpportunities },
|
||||
{ label: 'Opportunity Value', value: summary.opportunity.opportunityValue }
|
||||
{ label: 'Enquiry Count', value: summary.enquiry.enquiryCount },
|
||||
{ label: 'Active Enquiries', value: summary.enquiry.activeEnquiries },
|
||||
{ label: 'Closed Won', value: summary.enquiry.wonCount },
|
||||
{ label: 'Closed Lost', value: summary.enquiry.lostCount }
|
||||
]}
|
||||
/>
|
||||
{canViewCommercialData ? (
|
||||
<SummaryCard
|
||||
title='Quotation'
|
||||
description='Live quotation status counts from production data.'
|
||||
@@ -70,15 +84,18 @@ export function DashboardSummaryCards({ summary }: { summary: CrmDashboardSummar
|
||||
{ label: 'Sent Quotations', value: summary.quotation.sentQuotations }
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
{canViewCommercialData ? (
|
||||
<SummaryCard
|
||||
title='Revenue'
|
||||
description='Quotation, won, and lost values using frozen dashboard rules.'
|
||||
description='Quotation, won, and lost values using governed revenue rules.'
|
||||
items={[
|
||||
{ label: 'Quotation Value', value: summary.revenue.quotationValue },
|
||||
{ label: 'Won Value', value: summary.revenue.wonValue },
|
||||
{ label: 'Lost Value', value: summary.revenue.lostValue }
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
users
|
||||
} from '@/db/schema';
|
||||
import { db } from '@/lib/db';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { getUserBranches } from '@/features/foundation/branch-scope/service';
|
||||
import { listApprovalRequests } from '@/features/foundation/approval/server/service';
|
||||
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||
@@ -54,6 +55,7 @@ type DashboardContext = {
|
||||
userId: string;
|
||||
membershipRole: string;
|
||||
businessRole: string;
|
||||
permissions: string[];
|
||||
};
|
||||
|
||||
type NormalizedFilters = {
|
||||
@@ -70,6 +72,15 @@ type StatusMaps = {
|
||||
quotationStatusById: Map<string, string>;
|
||||
};
|
||||
|
||||
const DASHBOARD_FULL_ACCESS_ROLES = new Set([
|
||||
'marketing',
|
||||
'sales_manager',
|
||||
'department_manager',
|
||||
'top_manager'
|
||||
]);
|
||||
|
||||
const DASHBOARD_OWNED_SCOPE_ROLES = new Set(['sales', 'sales_support']);
|
||||
|
||||
function normalizeDashboardFilters(filters: CrmDashboardFilters): NormalizedFilters {
|
||||
const now = new Date();
|
||||
const monthStart = startOfDay(new Date(now.getFullYear(), now.getMonth(), 1));
|
||||
@@ -103,6 +114,54 @@ function average(values: number[]) {
|
||||
return round2(values.reduce((sum, value) => sum + value, 0) / values.length);
|
||||
}
|
||||
|
||||
function canViewCommercialData(context: DashboardContext) {
|
||||
return (
|
||||
context.membershipRole === 'admin' || context.permissions.includes(PERMISSIONS.crmQuotationRead)
|
||||
);
|
||||
}
|
||||
|
||||
function canViewApprovalData(context: DashboardContext) {
|
||||
return (
|
||||
context.membershipRole === 'admin' || context.permissions.includes(PERMISSIONS.crmApprovalRead)
|
||||
);
|
||||
}
|
||||
|
||||
function hasDashboardFullScope(context: DashboardContext) {
|
||||
return (
|
||||
context.membershipRole === 'admin' || DASHBOARD_FULL_ACCESS_ROLES.has(context.businessRole)
|
||||
);
|
||||
}
|
||||
|
||||
function canAccessDashboardEnquiry(
|
||||
row: typeof crmEnquiries.$inferSelect,
|
||||
context: DashboardContext
|
||||
) {
|
||||
if (hasDashboardFullScope(context)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (DASHBOARD_OWNED_SCOPE_ROLES.has(context.businessRole)) {
|
||||
return row.createdBy === context.userId || row.assignedToUserId === context.userId;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function canAccessDashboardQuotation(
|
||||
row: typeof crmQuotations.$inferSelect,
|
||||
context: DashboardContext
|
||||
) {
|
||||
if (hasDashboardFullScope(context)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (DASHBOARD_OWNED_SCOPE_ROLES.has(context.businessRole)) {
|
||||
return row.salesmanId === context.userId;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function getStatusMaps(organizationId: string): Promise<StatusMaps> {
|
||||
const [enquiryOptions, quotationOptions] = await Promise.all([
|
||||
getActiveOptionsByCategory(ENQUIRY_STATUS_CATEGORY, { organizationId }),
|
||||
@@ -172,35 +231,39 @@ function matchesQuotationFilters(
|
||||
return true;
|
||||
}
|
||||
|
||||
function isLeadStatus(statusCode: string | undefined, enquiry: typeof crmEnquiries.$inferSelect) {
|
||||
return enquiry.assignedToUserId === null && !['closed_lost', 'cancelled'].includes(statusCode ?? '');
|
||||
}
|
||||
|
||||
function isOpportunityStatus(
|
||||
statusCode: string | undefined,
|
||||
enquiry: typeof crmEnquiries.$inferSelect
|
||||
function isPipelineStage(
|
||||
enquiry: typeof crmEnquiries.$inferSelect,
|
||||
stage: 'lead' | 'enquiry' | 'closed_won' | 'closed_lost'
|
||||
) {
|
||||
return enquiry.assignedToUserId !== null && !['closed_lost', 'cancelled'].includes(statusCode ?? '');
|
||||
return enquiry.pipelineStage === stage;
|
||||
}
|
||||
|
||||
async function loadScopedRows(
|
||||
organizationId: string,
|
||||
context: DashboardContext,
|
||||
filters: NormalizedFilters
|
||||
) {
|
||||
const [enquiries, quotations] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(crmEnquiries)
|
||||
.where(and(eq(crmEnquiries.organizationId, organizationId), isNull(crmEnquiries.deletedAt))),
|
||||
.where(
|
||||
and(eq(crmEnquiries.organizationId, context.organizationId), isNull(crmEnquiries.deletedAt))
|
||||
),
|
||||
db
|
||||
.select()
|
||||
.from(crmQuotations)
|
||||
.where(and(eq(crmQuotations.organizationId, organizationId), isNull(crmQuotations.deletedAt)))
|
||||
.where(
|
||||
and(eq(crmQuotations.organizationId, context.organizationId), isNull(crmQuotations.deletedAt))
|
||||
)
|
||||
]);
|
||||
|
||||
return {
|
||||
enquiries: enquiries.filter((row) => matchesEnquiryFilters(row, filters)),
|
||||
quotations: quotations.filter((row) => matchesQuotationFilters(row, filters))
|
||||
enquiries: enquiries
|
||||
.filter((row) => canAccessDashboardEnquiry(row, context))
|
||||
.filter((row) => matchesEnquiryFilters(row, filters)),
|
||||
quotations: quotations
|
||||
.filter((row) => canAccessDashboardQuotation(row, context))
|
||||
.filter((row) => matchesQuotationFilters(row, filters))
|
||||
};
|
||||
}
|
||||
|
||||
@@ -210,12 +273,10 @@ function buildSummary(
|
||||
statusMaps: StatusMaps
|
||||
): CrmDashboardSummary {
|
||||
const now = new Date();
|
||||
const leadRows = scopedEnquiries.filter((row) =>
|
||||
isLeadStatus(statusMaps.enquiryStatusById.get(row.status), row)
|
||||
);
|
||||
const opportunityRows = scopedEnquiries.filter((row) =>
|
||||
isOpportunityStatus(statusMaps.enquiryStatusById.get(row.status), row)
|
||||
);
|
||||
const leadRows = scopedEnquiries.filter((row) => isPipelineStage(row, 'lead'));
|
||||
const enquiryRows = scopedEnquiries.filter((row) => isPipelineStage(row, 'enquiry'));
|
||||
const wonRows = scopedEnquiries.filter((row) => isPipelineStage(row, 'closed_won'));
|
||||
const lostRows = scopedEnquiries.filter((row) => isPipelineStage(row, 'closed_lost'));
|
||||
|
||||
const leadAges = leadRows.map((row) => (now.getTime() - row.createdAt.getTime()) / (1000 * 60 * 60 * 24));
|
||||
const quotationStatusCodes = scopedQuotations.map((row) => statusMaps.quotationStatusById.get(row.status) ?? '');
|
||||
@@ -224,14 +285,16 @@ function buildSummary(
|
||||
lead: {
|
||||
leadCount: leadRows.length,
|
||||
newLeads: leadRows.length,
|
||||
unassignedLeads: leadRows.length,
|
||||
unassignedLeads: leadRows.filter((row) => row.assignedToUserId === null).length,
|
||||
averageLeadAgingDays: average(leadAges)
|
||||
},
|
||||
opportunity: {
|
||||
opportunityCount: opportunityRows.length,
|
||||
openOpportunities: opportunityRows.length,
|
||||
hotOpportunities: opportunityRows.filter((row) => row.isHotProject).length,
|
||||
opportunityValue: round2(opportunityRows.reduce((sum, row) => sum + (row.estimatedValue ?? 0), 0))
|
||||
enquiry: {
|
||||
enquiryCount: enquiryRows.length,
|
||||
activeEnquiries: enquiryRows.length,
|
||||
wonCount: wonRows.length,
|
||||
lostCount: lostRows.length,
|
||||
hotEnquiries: enquiryRows.filter((row) => row.isHotProject).length,
|
||||
enquiryValue: round2(enquiryRows.reduce((sum, row) => sum + (row.estimatedValue ?? 0), 0))
|
||||
},
|
||||
quotation: {
|
||||
draftQuotations: quotationStatusCodes.filter((code) => code === 'draft').length,
|
||||
@@ -274,10 +337,10 @@ function buildFunnel(
|
||||
value: 0
|
||||
},
|
||||
{
|
||||
key: 'opportunity',
|
||||
label: 'Opportunity',
|
||||
count: summary.opportunity.opportunityCount,
|
||||
value: summary.opportunity.opportunityValue
|
||||
key: 'enquiry',
|
||||
label: 'Enquiry',
|
||||
count: summary.enquiry.enquiryCount,
|
||||
value: summary.enquiry.enquiryValue
|
||||
},
|
||||
{
|
||||
key: 'quotation',
|
||||
@@ -312,9 +375,7 @@ function buildFunnel(
|
||||
{
|
||||
key: 'closed_won',
|
||||
label: 'Closed Won',
|
||||
count: scopedQuotations.filter(
|
||||
(row) => (statusMaps.quotationStatusById.get(row.status) ?? '') === 'accepted'
|
||||
).length,
|
||||
count: summary.enquiry.wonCount,
|
||||
value: scopedQuotations
|
||||
.filter((row) => (statusMaps.quotationStatusById.get(row.status) ?? '') === 'accepted')
|
||||
.reduce((sum, row) => sum + row.totalAmount, 0)
|
||||
@@ -415,19 +476,19 @@ async function buildSalesRanking(
|
||||
salesPersonId: row.assignedToUserId,
|
||||
salesPersonName: userMap.get(row.assignedToUserId) ?? 'Unknown',
|
||||
leadCount: 0,
|
||||
opportunityCount: 0,
|
||||
enquiryCount: 0,
|
||||
quotationCount: 0,
|
||||
approvedQuotations: 0,
|
||||
wonRevenue: 0,
|
||||
conversionRate: 0
|
||||
};
|
||||
|
||||
if (isLeadStatus(statusMaps.enquiryStatusById.get(row.status), row)) {
|
||||
if (isPipelineStage(row, 'lead')) {
|
||||
current.leadCount += 1;
|
||||
}
|
||||
|
||||
if (isOpportunityStatus(statusMaps.enquiryStatusById.get(row.status), row)) {
|
||||
current.opportunityCount += 1;
|
||||
if (isPipelineStage(row, 'enquiry')) {
|
||||
current.enquiryCount += 1;
|
||||
}
|
||||
|
||||
rankingMap.set(row.assignedToUserId, current);
|
||||
@@ -441,7 +502,7 @@ async function buildSalesRanking(
|
||||
salesPersonId: row.salesmanId,
|
||||
salesPersonName: userMap.get(row.salesmanId) ?? 'Unknown',
|
||||
leadCount: 0,
|
||||
opportunityCount: 0,
|
||||
enquiryCount: 0,
|
||||
quotationCount: 0,
|
||||
approvedQuotations: 0,
|
||||
wonRevenue: 0,
|
||||
@@ -480,20 +541,33 @@ async function buildSalesRanking(
|
||||
}
|
||||
|
||||
async function buildFollowups(
|
||||
organizationId: string,
|
||||
context: DashboardContext,
|
||||
filters: NormalizedFilters
|
||||
): Promise<{ summary: CrmDashboardFollowupSummary; upcoming: CrmDashboardFollowupRow[] }> {
|
||||
const allowQuotationFollowups = canViewCommercialData(context);
|
||||
const [enquiryFollowups, quotationFollowups] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(crmEnquiryFollowups)
|
||||
.where(and(eq(crmEnquiryFollowups.organizationId, organizationId), isNull(crmEnquiryFollowups.deletedAt)))
|
||||
.where(
|
||||
and(
|
||||
eq(crmEnquiryFollowups.organizationId, context.organizationId),
|
||||
isNull(crmEnquiryFollowups.deletedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(crmEnquiryFollowups.followupDate)),
|
||||
db
|
||||
allowQuotationFollowups
|
||||
? db
|
||||
.select()
|
||||
.from(crmQuotationFollowups)
|
||||
.where(and(eq(crmQuotationFollowups.organizationId, organizationId), isNull(crmQuotationFollowups.deletedAt)))
|
||||
.where(
|
||||
and(
|
||||
eq(crmQuotationFollowups.organizationId, context.organizationId),
|
||||
isNull(crmQuotationFollowups.deletedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(crmQuotationFollowups.followupDate))
|
||||
: []
|
||||
]);
|
||||
|
||||
const { from, to } = toDayBounds(filters);
|
||||
@@ -522,6 +596,10 @@ async function buildFollowups(
|
||||
const salesMap = new Map(salesUsers.map((row) => [row.id, row.name]));
|
||||
|
||||
const enquiryRows = enquiryFollowups
|
||||
.filter((row) => {
|
||||
const enquiry = enquiryMap.get(row.enquiryId);
|
||||
return enquiry ? canAccessDashboardEnquiry(enquiry, context) : false;
|
||||
})
|
||||
.filter((row) => !isBefore(row.followupDate, from) && !isAfter(row.followupDate, to))
|
||||
.flatMap<CrmDashboardFollowupRow>((row) => {
|
||||
const enquiry = enquiryMap.get(row.enquiryId);
|
||||
@@ -536,7 +614,7 @@ async function buildFollowups(
|
||||
sourceType: 'enquiry',
|
||||
followupDate: row.followupDate.toISOString(),
|
||||
customerName: customerMap.get(enquiry.customerId) ?? 'Unknown customer',
|
||||
opportunityName: enquiry.projectName ?? enquiry.title,
|
||||
enquiryName: enquiry.projectName ?? enquiry.title,
|
||||
assignedSalesName: enquiry.assignedToUserId
|
||||
? (salesMap.get(enquiry.assignedToUserId) ?? null)
|
||||
: null,
|
||||
@@ -545,6 +623,10 @@ async function buildFollowups(
|
||||
];
|
||||
});
|
||||
const quotationRows = quotationFollowups
|
||||
.filter((row) => {
|
||||
const quotation = quotationMap.get(row.quotationId);
|
||||
return quotation ? canAccessDashboardQuotation(quotation, context) : false;
|
||||
})
|
||||
.filter((row) => !isBefore(row.followupDate, from) && !isAfter(row.followupDate, to))
|
||||
.flatMap<CrmDashboardFollowupRow>((row) => {
|
||||
const quotation = quotationMap.get(row.quotationId);
|
||||
@@ -559,7 +641,7 @@ async function buildFollowups(
|
||||
sourceType: 'quotation',
|
||||
followupDate: row.followupDate.toISOString(),
|
||||
customerName: customerMap.get(quotation.customerId) ?? 'Unknown customer',
|
||||
opportunityName: quotation.projectName ?? quotation.code,
|
||||
enquiryName: quotation.projectName ?? quotation.code,
|
||||
assignedSalesName: quotation.salesmanId
|
||||
? (salesMap.get(quotation.salesmanId) ?? null)
|
||||
: null,
|
||||
@@ -636,18 +718,25 @@ async function buildApprovalAnalytics(
|
||||
}
|
||||
|
||||
async function buildHotProjects(
|
||||
organizationId: string,
|
||||
context: DashboardContext,
|
||||
filters: NormalizedFilters,
|
||||
statusMaps: StatusMaps
|
||||
): Promise<CrmDashboardHotProjectRow[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(crmEnquiries)
|
||||
.where(and(eq(crmEnquiries.organizationId, organizationId), isNull(crmEnquiries.deletedAt), eq(crmEnquiries.isHotProject, true)))
|
||||
.where(
|
||||
and(
|
||||
eq(crmEnquiries.organizationId, context.organizationId),
|
||||
isNull(crmEnquiries.deletedAt),
|
||||
eq(crmEnquiries.isHotProject, true)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(crmEnquiries.updatedAt));
|
||||
const scoped = rows
|
||||
.filter((row) => canAccessDashboardEnquiry(row, context))
|
||||
.filter((row) => matchesEnquiryFilters(row, filters))
|
||||
.filter((row) => isOpportunityStatus(statusMaps.enquiryStatusById.get(row.status), row))
|
||||
.filter((row) => isPipelineStage(row, 'enquiry'))
|
||||
.slice(0, 10);
|
||||
const customerIds = [...new Set(scoped.map((row) => row.customerId))];
|
||||
const salesIds = [...new Set(scoped.map((row) => row.assignedToUserId).filter(Boolean))] as string[];
|
||||
@@ -674,18 +763,42 @@ export async function getCrmDashboardData(
|
||||
rawFilters: CrmDashboardFilters
|
||||
): Promise<CrmDashboardResponse> {
|
||||
const filters = normalizeDashboardFilters(rawFilters);
|
||||
const allowCommercialData = canViewCommercialData(context);
|
||||
const allowApprovalData = canViewApprovalData(context);
|
||||
const [referenceData, statusMaps, scoped] = await Promise.all([
|
||||
getReferenceData(context.organizationId),
|
||||
getStatusMaps(context.organizationId),
|
||||
loadScopedRows(context.organizationId, filters)
|
||||
loadScopedRows(context, filters)
|
||||
]);
|
||||
const summary = buildSummary(scoped.enquiries, scoped.quotations, statusMaps);
|
||||
const [revenueAnalytics, salesRanking, followups, approvals, hotProjects] = await Promise.all([
|
||||
buildRevenueAnalytics(context.organizationId, filters),
|
||||
buildSalesRanking(scoped.enquiries, scoped.quotations, statusMaps),
|
||||
buildFollowups(context.organizationId, filters),
|
||||
buildApprovalAnalytics(context),
|
||||
buildHotProjects(context.organizationId, filters, statusMaps)
|
||||
allowCommercialData
|
||||
? buildRevenueAnalytics(context.organizationId, filters)
|
||||
: Promise.resolve({
|
||||
topEndCustomers: [],
|
||||
topBillingCustomers: [],
|
||||
topContractors: [],
|
||||
topConsultants: []
|
||||
}),
|
||||
allowCommercialData
|
||||
? buildSalesRanking(scoped.enquiries, scoped.quotations, statusMaps)
|
||||
: Promise.resolve([]),
|
||||
buildFollowups(context, filters),
|
||||
allowApprovalData
|
||||
? buildApprovalAnalytics(context)
|
||||
: Promise.resolve({
|
||||
summary: {
|
||||
pendingApprovals: 0,
|
||||
approvedToday: 0,
|
||||
rejectedToday: 0,
|
||||
returnedToday: 0,
|
||||
averageApprovalTimeHours: 0
|
||||
},
|
||||
myPendingApprovals: []
|
||||
}),
|
||||
allowCommercialData
|
||||
? buildHotProjects(context, filters, statusMaps)
|
||||
: Promise.resolve([])
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -700,6 +813,10 @@ export async function getCrmDashboardData(
|
||||
salesRanking,
|
||||
followups,
|
||||
approvals,
|
||||
hotProjects
|
||||
hotProjects,
|
||||
visibility: {
|
||||
canViewCommercialData: allowCommercialData,
|
||||
canViewApprovalData: allowApprovalData
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,11 +16,16 @@ import type {
|
||||
EnquiryFollowupMutationPayload,
|
||||
EnquiryMutationPayload
|
||||
} from './types';
|
||||
import { crmDashboardKeys } from '@/features/crm/dashboard/api/queries';
|
||||
|
||||
async function invalidateEnquiryLists() {
|
||||
await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.lists() });
|
||||
}
|
||||
|
||||
async function invalidateDashboardQueries() {
|
||||
await getQueryClient().invalidateQueries({ queryKey: crmDashboardKeys.all });
|
||||
}
|
||||
|
||||
async function invalidateEnquiryDetail(id: string) {
|
||||
await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.detail(id) });
|
||||
}
|
||||
@@ -37,7 +42,8 @@ export async function invalidateEnquiryMutationQueries(id: string) {
|
||||
await Promise.all([
|
||||
invalidateEnquiryLists(),
|
||||
invalidateEnquiryDetail(id),
|
||||
invalidateEnquiryProjectParties(id)
|
||||
invalidateEnquiryProjectParties(id),
|
||||
invalidateDashboardQueries()
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -45,7 +51,8 @@ export async function invalidateEnquiryFollowupMutationQueries(enquiryId: string
|
||||
await Promise.all([
|
||||
invalidateEnquiryLists(),
|
||||
invalidateEnquiryDetail(enquiryId),
|
||||
invalidateEnquiryFollowups(enquiryId)
|
||||
invalidateEnquiryFollowups(enquiryId),
|
||||
invalidateDashboardQueries()
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -53,7 +60,7 @@ export const createEnquiryMutation = mutationOptions({
|
||||
mutationFn: (data: EnquiryMutationPayload) => createEnquiry(data),
|
||||
onSettled: async (_data, error) => {
|
||||
if (!error) {
|
||||
await invalidateEnquiryLists();
|
||||
await Promise.all([invalidateEnquiryLists(), invalidateDashboardQueries()]);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -75,7 +82,7 @@ export const deleteEnquiryMutation = mutationOptions({
|
||||
getQueryClient().removeQueries({ queryKey: enquiryKeys.detail(id) });
|
||||
getQueryClient().removeQueries({ queryKey: enquiryKeys.projectParties(id) });
|
||||
getQueryClient().removeQueries({ queryKey: enquiryKeys.followups(id) });
|
||||
await invalidateEnquiryLists();
|
||||
await Promise.all([invalidateEnquiryLists(), invalidateDashboardQueries()]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ export async function getEnquiries(filters: EnquiryFilters): Promise<EnquiryList
|
||||
if (filters.page) searchParams.set('page', String(filters.page));
|
||||
if (filters.limit) searchParams.set('limit', String(filters.limit));
|
||||
if (filters.search) searchParams.set('search', filters.search);
|
||||
if (filters.pipelineStage) searchParams.set('pipelineStage', filters.pipelineStage);
|
||||
if (filters.status) searchParams.set('status', filters.status);
|
||||
if (filters.productType) searchParams.set('productType', filters.productType);
|
||||
if (filters.priority) searchParams.set('priority', filters.priority);
|
||||
|
||||
@@ -54,6 +54,8 @@ export interface EnquiryAssignableUserLookup {
|
||||
businessRole: string;
|
||||
}
|
||||
|
||||
export type EnquiryPipelineStage = 'lead' | 'enquiry' | 'closed_won' | 'closed_lost';
|
||||
|
||||
export interface EnquiryRecord {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
@@ -78,6 +80,7 @@ export interface EnquiryRecord {
|
||||
notes: string | null;
|
||||
isHotProject: boolean;
|
||||
isActive: boolean;
|
||||
pipelineStage: EnquiryPipelineStage;
|
||||
assignedToUserId: string | null;
|
||||
assignedToName: string | null;
|
||||
assignedAt: string | null;
|
||||
@@ -95,6 +98,9 @@ export interface EnquiryListItem extends EnquiryRecord {
|
||||
customerName: string;
|
||||
contactName: string | null;
|
||||
followupCount: number;
|
||||
quotationCount: number;
|
||||
nextFollowupDate: string | null;
|
||||
createdByName: string | null;
|
||||
}
|
||||
|
||||
export interface EnquiryFollowupRecord {
|
||||
@@ -142,6 +148,7 @@ export interface EnquiryFilters {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
pipelineStage?: EnquiryPipelineStage;
|
||||
status?: string;
|
||||
productType?: string;
|
||||
priority?: string;
|
||||
|
||||
@@ -13,20 +13,22 @@ import { getEnquiryColumns } from './enquiry-columns';
|
||||
|
||||
export function EnquiriesTable({
|
||||
referenceData,
|
||||
workspace,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
canAssign,
|
||||
canReassign
|
||||
}: {
|
||||
referenceData: EnquiryReferenceData;
|
||||
workspace: 'lead' | 'enquiry';
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
canAssign: boolean;
|
||||
canReassign: boolean;
|
||||
}) {
|
||||
const columns = useMemo(
|
||||
() => getEnquiryColumns({ referenceData, canUpdate, canDelete, canAssign, canReassign }),
|
||||
[referenceData, canUpdate, canDelete, canAssign, canReassign]
|
||||
() => getEnquiryColumns({ referenceData, workspace, canUpdate, canDelete, canAssign, canReassign }),
|
||||
[referenceData, workspace, canUpdate, canDelete, canAssign, canReassign]
|
||||
);
|
||||
const columnIds = columns.map((column) => column.id).filter(Boolean) as string[];
|
||||
const [params] = useQueryStates({
|
||||
@@ -44,6 +46,7 @@ export function EnquiriesTable({
|
||||
const filters = {
|
||||
page: params.page,
|
||||
limit: params.perPage,
|
||||
pipelineStage: workspace,
|
||||
...(params.name && { search: params.name }),
|
||||
...(params.status && { status: params.status }),
|
||||
...(params.productType && { productType: params.productType }),
|
||||
|
||||
@@ -57,7 +57,7 @@ export function EnquiryAssignmentDialog({
|
||||
const assignMutation = useMutation({
|
||||
...assignEnquiryMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Enquiry assigned successfully');
|
||||
toast.success('Lead converted to enquiry successfully');
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
@@ -109,10 +109,10 @@ export function EnquiryAssignmentDialog({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{mode === 'assign' ? 'Assign Sales' : 'Reassign Sales'}</DialogTitle>
|
||||
<DialogTitle>{mode === 'assign' ? 'Convert To Enquiry' : 'Reassign Enquiry Owner'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{mode === 'assign'
|
||||
? 'Select the sales owner who should take this enquiry forward.'
|
||||
? 'Assign this lead to sales and convert it into an enquiry.'
|
||||
: 'Move this enquiry to a different sales owner and keep the handoff note visible.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -127,7 +127,7 @@ export function EnquiryAssignmentDialog({
|
||||
<>
|
||||
<FormSelectField
|
||||
name='assignedToUserId'
|
||||
label='Sales User'
|
||||
label='Enquiry Owner'
|
||||
required
|
||||
options={referenceData.assignableUsers.map((user) => ({
|
||||
value: user.id,
|
||||
@@ -156,7 +156,7 @@ export function EnquiryAssignmentDialog({
|
||||
disabled={noUsersAvailable}
|
||||
>
|
||||
<Icons.userPen className='mr-2 h-4 w-4' />
|
||||
{mode === 'assign' ? 'Assign Sales' : 'Reassign Sales'}
|
||||
{mode === 'assign' ? 'Convert To Enquiry' : 'Reassign Owner'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { EnquiryFormSheet } from './enquiry-form-sheet';
|
||||
export function EnquiryCellAction({
|
||||
data,
|
||||
referenceData,
|
||||
workspace,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
canAssign: _canAssign,
|
||||
@@ -28,6 +29,7 @@ export function EnquiryCellAction({
|
||||
}: {
|
||||
data: EnquiryListItem;
|
||||
referenceData: EnquiryReferenceData;
|
||||
workspace: 'lead' | 'enquiry';
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
canAssign: boolean;
|
||||
@@ -40,11 +42,15 @@ export function EnquiryCellAction({
|
||||
const deleteMutation = useMutation({
|
||||
...deleteEnquiryMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Enquiry deleted successfully');
|
||||
toast.success(`${workspace === 'lead' ? 'Lead' : 'Enquiry'} deleted successfully`);
|
||||
setDeleteOpen(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to delete enquiry')
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: `Failed to delete ${workspace === 'lead' ? 'lead' : 'enquiry'}`
|
||||
)
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -60,6 +66,7 @@ export function EnquiryCellAction({
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
referenceData={referenceData}
|
||||
workspace={workspace}
|
||||
/>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -70,11 +77,19 @@ export function EnquiryCellAction({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={() => router.push(`/dashboard/crm/enquiries/${data.id}`)}>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
router.push(
|
||||
workspace === 'lead'
|
||||
? `/dashboard/crm/leads/${data.id}`
|
||||
: `/dashboard/crm/enquiries/${data.id}`
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icons.arrowRight className='mr-2 h-4 w-4' /> View
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setEditOpen(true)} disabled={!canUpdate}>
|
||||
<Icons.edit className='mr-2 h-4 w-4' /> Edit
|
||||
<Icons.edit className='mr-2 h-4 w-4' /> {workspace === 'lead' ? 'Edit Lead' : 'Edit Enquiry'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setDeleteOpen(true)} disabled={!canDelete}>
|
||||
<Icons.trash className='mr-2 h-4 w-4' /> Delete
|
||||
|
||||
@@ -9,52 +9,116 @@ import type { EnquiryListItem, EnquiryReferenceData } from '../api/types';
|
||||
import { EnquiryCellAction } from './enquiry-cell-action';
|
||||
import { EnquiryStatusBadge } from './enquiry-status-badge';
|
||||
|
||||
export function getEnquiryColumns({
|
||||
function getLeadColumns({
|
||||
referenceData,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
canAssign,
|
||||
canReassign
|
||||
}: {
|
||||
referenceData: EnquiryReferenceData;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
canAssign: boolean;
|
||||
canReassign: boolean;
|
||||
}): ColumnDef<EnquiryListItem>[] {
|
||||
}: SharedColumnsConfig): ColumnDef<EnquiryListItem>[] {
|
||||
const statusMap = new Map(referenceData.statuses.map((item) => [item.id, item]));
|
||||
const productTypeMap = new Map(referenceData.productTypes.map((item) => [item.id, item]));
|
||||
const priorityMap = new Map(referenceData.priorities.map((item) => [item.id, item]));
|
||||
const branchMap = new Map(referenceData.branches.map((item) => [item.id, item]));
|
||||
const leadChannelMap = new Map(referenceData.leadChannels.map((item) => [item.id, item.label]));
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'title',
|
||||
accessorKey: 'title',
|
||||
id: 'code',
|
||||
accessorKey: 'code',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Enquiry' />
|
||||
<DataTableColumnHeader column={column} title='Code' />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className='flex flex-col'>
|
||||
<Link
|
||||
href={`/dashboard/crm/enquiries/${row.original.id}`}
|
||||
href={`/dashboard/crm/leads/${row.original.id}`}
|
||||
className='font-medium hover:underline'
|
||||
>
|
||||
{row.original.title}
|
||||
{row.original.code}
|
||||
</Link>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{row.original.code} • {row.original.customerName}
|
||||
</span>
|
||||
<span className='text-muted-foreground text-xs'>{row.original.title}</span>
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
label: 'Enquiry',
|
||||
placeholder: 'Search enquiries...',
|
||||
label: 'Code',
|
||||
placeholder: 'Search leads...',
|
||||
variant: 'text' as const,
|
||||
icon: Icons.search
|
||||
},
|
||||
enableColumnFilter: true
|
||||
},
|
||||
{
|
||||
id: 'leadChannel',
|
||||
accessorFn: (row) => row.leadChannel ?? '',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Lead Source' />
|
||||
),
|
||||
cell: ({ row }) => <span>{leadChannelMap.get(row.original.leadChannel ?? '') ?? '-'}</span>
|
||||
},
|
||||
{
|
||||
id: 'customer',
|
||||
accessorFn: (row) => row.customerId,
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Company' />
|
||||
),
|
||||
cell: ({ row }) => <span>{row.original.customerName}</span>,
|
||||
meta: {
|
||||
label: 'Company',
|
||||
variant: 'multiSelect' as const,
|
||||
options: referenceData.customers.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.name
|
||||
}))
|
||||
},
|
||||
enableColumnFilter: true
|
||||
},
|
||||
{
|
||||
id: 'contact',
|
||||
accessorFn: (row) => row.contactId ?? '',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Contact' />
|
||||
),
|
||||
cell: ({ row }) => <span>{row.original.contactName ?? '-'}</span>
|
||||
},
|
||||
{
|
||||
id: 'assignedSales',
|
||||
accessorFn: (row) => row.assignedToName ?? '',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Assigned Sales' />
|
||||
),
|
||||
cell: ({ row }) => <span>{row.original.assignedToName ?? 'Unassigned'}</span>
|
||||
},
|
||||
{
|
||||
id: 'createdBy',
|
||||
accessorFn: (row) => row.createdByName ?? row.createdBy,
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Created By' />
|
||||
),
|
||||
cell: ({ row }) => <span>{row.original.createdByName ?? row.original.createdBy}</span>
|
||||
},
|
||||
{
|
||||
id: 'createdAt',
|
||||
accessorKey: 'createdAt',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Created At' />
|
||||
),
|
||||
cell: ({ row }) => <span>{new Date(row.original.createdAt).toLocaleDateString()}</span>
|
||||
},
|
||||
{
|
||||
id: 'age',
|
||||
accessorFn: (row) => row.createdAt,
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Age' />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const ageInDays = Math.max(
|
||||
0,
|
||||
Math.floor(
|
||||
(Date.now() - new Date(row.original.createdAt).getTime()) / (1000 * 60 * 60 * 24)
|
||||
)
|
||||
);
|
||||
|
||||
return <span>{ageInDays} days</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
accessorKey: 'status',
|
||||
@@ -76,79 +140,74 @@ export function getEnquiryColumns({
|
||||
enableColumnFilter: true
|
||||
},
|
||||
{
|
||||
id: 'productType',
|
||||
accessorKey: 'productType',
|
||||
id: 'actions',
|
||||
cell: ({ row }) => (
|
||||
<EnquiryCellAction
|
||||
data={row.original}
|
||||
referenceData={referenceData}
|
||||
workspace='lead'
|
||||
canUpdate={canUpdate}
|
||||
canDelete={canDelete}
|
||||
canAssign={canAssign}
|
||||
canReassign={canReassign}
|
||||
/>
|
||||
)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function getEnquiryWorkspaceColumns({
|
||||
referenceData,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
canAssign,
|
||||
canReassign
|
||||
}: SharedColumnsConfig): ColumnDef<EnquiryListItem>[] {
|
||||
const statusMap = new Map(referenceData.statuses.map((item) => [item.id, item]));
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'code',
|
||||
accessorKey: 'code',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Product Type' />
|
||||
<DataTableColumnHeader column={column} title='Code' />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Badge variant='outline'>
|
||||
{productTypeMap.get(row.original.productType)?.label ?? 'Unknown'}
|
||||
</Badge>
|
||||
<div className='flex flex-col'>
|
||||
<Link
|
||||
href={`/dashboard/crm/enquiries/${row.original.id}`}
|
||||
className='font-medium hover:underline'
|
||||
>
|
||||
{row.original.code}
|
||||
</Link>
|
||||
<span className='text-muted-foreground text-xs'>{row.original.title}</span>
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
label: 'Product Type',
|
||||
variant: 'multiSelect' as const,
|
||||
options: referenceData.productTypes.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label
|
||||
}))
|
||||
label: 'Code',
|
||||
placeholder: 'Search enquiries...',
|
||||
variant: 'text' as const,
|
||||
icon: Icons.search
|
||||
},
|
||||
enableColumnFilter: true
|
||||
},
|
||||
{
|
||||
id: 'priority',
|
||||
accessorKey: 'priority',
|
||||
id: 'projectName',
|
||||
accessorFn: (row) => row.projectName ?? row.title,
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Priority' />
|
||||
<DataTableColumnHeader column={column} title='Project' />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Badge variant='secondary'>
|
||||
{priorityMap.get(row.original.priority)?.label ?? 'Unknown'}
|
||||
</Badge>
|
||||
),
|
||||
meta: {
|
||||
label: 'Priority',
|
||||
variant: 'multiSelect' as const,
|
||||
options: referenceData.priorities.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label
|
||||
}))
|
||||
},
|
||||
enableColumnFilter: true
|
||||
},
|
||||
{
|
||||
id: 'branch',
|
||||
accessorFn: (row) => row.branchId ?? '',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Branch' />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span>
|
||||
{row.original.branchId
|
||||
? (branchMap.get(row.original.branchId)?.name ?? 'Unknown')
|
||||
: 'Unassigned'}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
label: 'Branch',
|
||||
variant: 'multiSelect' as const,
|
||||
options: referenceData.branches.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.name
|
||||
}))
|
||||
},
|
||||
enableColumnFilter: true
|
||||
cell: ({ row }) => <span>{row.original.projectName ?? row.original.title}</span>
|
||||
},
|
||||
{
|
||||
id: 'customer',
|
||||
accessorFn: (row) => row.customerId,
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Customer' />
|
||||
<DataTableColumnHeader column={column} title='Billing Customer' />
|
||||
),
|
||||
cell: ({ row }) => <span>{row.original.customerName}</span>,
|
||||
meta: {
|
||||
label: 'Customer',
|
||||
label: 'Billing Customer',
|
||||
variant: 'multiSelect' as const,
|
||||
options: referenceData.customers.map((item) => ({
|
||||
value: item.id,
|
||||
@@ -159,25 +218,77 @@ export function getEnquiryColumns({
|
||||
},
|
||||
{
|
||||
id: 'assignedSales',
|
||||
header: 'Assigned Sales',
|
||||
accessorFn: (row) => row.assignedToName ?? '',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Enquiry Owner' />
|
||||
),
|
||||
cell: ({ row }) => <span>{row.original.assignedToName ?? '-'}</span>
|
||||
},
|
||||
{
|
||||
id: 'chancePercent',
|
||||
accessorKey: 'chancePercent',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Chance %' />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className='flex flex-col'>
|
||||
<span>{row.original.assignedToName ?? 'Unassigned'}</span>
|
||||
{row.original.assignedAt ? (
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{new Date(row.original.assignedAt).toLocaleDateString()}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<span>{row.original.chancePercent !== null ? `${row.original.chancePercent}%` : '-'}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'followupCount',
|
||||
accessorKey: 'followupCount',
|
||||
id: 'estimatedValue',
|
||||
accessorKey: 'estimatedValue',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Follow-ups' />
|
||||
<DataTableColumnHeader column={column} title='Estimated Value' />
|
||||
),
|
||||
cell: ({ row }) => <span>{row.original.followupCount}</span>
|
||||
cell: ({ row }) => (
|
||||
<span>
|
||||
{row.original.estimatedValue !== null
|
||||
? row.original.estimatedValue.toLocaleString()
|
||||
: '-'}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'quotationCount',
|
||||
accessorKey: 'quotationCount',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Quotation Count' />
|
||||
),
|
||||
cell: ({ row }) => <span>{row.original.quotationCount}</span>
|
||||
},
|
||||
{
|
||||
id: 'nextFollowupDate',
|
||||
accessorKey: 'nextFollowupDate',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Next Follow-up' />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span>
|
||||
{row.original.nextFollowupDate
|
||||
? new Date(row.original.nextFollowupDate).toLocaleDateString()
|
||||
: '-'}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
accessorKey: 'status',
|
||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||
<DataTableColumnHeader column={column} title='Status' />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const status = statusMap.get(row.original.status);
|
||||
return <EnquiryStatusBadge code={status?.code} label={status?.label ?? 'Unknown'} />;
|
||||
},
|
||||
meta: {
|
||||
label: 'Status',
|
||||
variant: 'multiSelect' as const,
|
||||
options: referenceData.statuses.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label
|
||||
}))
|
||||
},
|
||||
enableColumnFilter: true
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
@@ -185,6 +296,7 @@ export function getEnquiryColumns({
|
||||
<EnquiryCellAction
|
||||
data={row.original}
|
||||
referenceData={referenceData}
|
||||
workspace='enquiry'
|
||||
canUpdate={canUpdate}
|
||||
canDelete={canDelete}
|
||||
canAssign={canAssign}
|
||||
@@ -194,3 +306,34 @@ export function getEnquiryColumns({
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
type SharedColumnsConfig = {
|
||||
referenceData: EnquiryReferenceData;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
canAssign: boolean;
|
||||
canReassign: boolean;
|
||||
};
|
||||
|
||||
export function getEnquiryColumns({
|
||||
referenceData,
|
||||
workspace,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
canAssign,
|
||||
canReassign
|
||||
}: SharedColumnsConfig & {
|
||||
workspace: 'lead' | 'enquiry';
|
||||
}): ColumnDef<EnquiryListItem>[] {
|
||||
const sharedConfig = {
|
||||
referenceData,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
canAssign,
|
||||
canReassign
|
||||
};
|
||||
|
||||
return workspace === 'lead'
|
||||
? getLeadColumns(sharedConfig)
|
||||
: getEnquiryWorkspaceColumns(sharedConfig);
|
||||
}
|
||||
|
||||
@@ -9,12 +9,12 @@ import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { enquiryByIdOptions, enquiryProjectPartiesOptions } from '../api/queries';
|
||||
import type { EnquiryReferenceData } from '../api/types';
|
||||
import type { QuotationRelationItem } from '@/features/crm/quotations/api/types';
|
||||
import { enquiryByIdOptions, enquiryProjectPartiesOptions } from '../api/queries';
|
||||
import type { EnquiryRecord, EnquiryReferenceData } from '../api/types';
|
||||
import { EnquiryAssignmentDialog } from './enquiry-assignment-dialog';
|
||||
import { EnquiryFormSheet } from './enquiry-form-sheet';
|
||||
import { EnquiryFollowupsTab } from './enquiry-followups-tab';
|
||||
import { EnquiryFormSheet } from './enquiry-form-sheet';
|
||||
import { EnquiryStatusBadge } from './enquiry-status-badge';
|
||||
|
||||
function FieldItem({ label, value }: { label: string; value: string | null | undefined }) {
|
||||
@@ -26,27 +26,48 @@ function FieldItem({ label, value }: { label: string; value: string | null | und
|
||||
);
|
||||
}
|
||||
|
||||
export function EnquiryDetail({
|
||||
enquiryId,
|
||||
referenceData,
|
||||
relatedQuotations,
|
||||
canUpdate,
|
||||
canAssign,
|
||||
canReassign,
|
||||
canManageFollowups
|
||||
}: {
|
||||
function getPipelineStageLabel(stage: EnquiryRecord['pipelineStage']) {
|
||||
switch (stage) {
|
||||
case 'lead':
|
||||
return 'Lead';
|
||||
case 'enquiry':
|
||||
return 'Enquiry';
|
||||
case 'closed_won':
|
||||
return 'Closed Won';
|
||||
case 'closed_lost':
|
||||
return 'Closed Lost';
|
||||
default:
|
||||
return stage;
|
||||
}
|
||||
}
|
||||
|
||||
interface EnquiryDetailProps {
|
||||
enquiryId: string;
|
||||
referenceData: EnquiryReferenceData;
|
||||
relatedQuotations: QuotationRelationItem[];
|
||||
workspace: 'lead' | 'enquiry';
|
||||
canUpdate: boolean;
|
||||
canAssign: boolean;
|
||||
canReassign: boolean;
|
||||
canViewRelatedQuotations: boolean;
|
||||
canManageFollowups: {
|
||||
create: boolean;
|
||||
update: boolean;
|
||||
delete: boolean;
|
||||
};
|
||||
}) {
|
||||
}
|
||||
|
||||
export function EnquiryDetail({
|
||||
enquiryId,
|
||||
referenceData,
|
||||
relatedQuotations,
|
||||
workspace,
|
||||
canUpdate,
|
||||
canAssign,
|
||||
canReassign,
|
||||
canViewRelatedQuotations,
|
||||
canManageFollowups
|
||||
}: EnquiryDetailProps) {
|
||||
const { data } = useSuspenseQuery(enquiryByIdOptions(enquiryId));
|
||||
const { data: projectPartiesData } = useSuspenseQuery(enquiryProjectPartiesOptions(enquiryId));
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
@@ -85,6 +106,29 @@ export function EnquiryDetail({
|
||||
const status = statusMap.get(enquiry.status);
|
||||
const canManageAssignment = enquiry.assignedToUserId ? canReassign : canAssign;
|
||||
const assignmentMode = enquiry.assignedToUserId ? 'reassign' : 'assign';
|
||||
const pipelineStageLabel = getPipelineStageLabel(enquiry.pipelineStage);
|
||||
const isLeadWorkspace = workspace === 'lead';
|
||||
const backHref = isLeadWorkspace ? '/dashboard/crm/leads' : '/dashboard/crm/enquiries';
|
||||
const recordLabel = isLeadWorkspace ? 'Lead' : 'Enquiry';
|
||||
const ownerLabel = isLeadWorkspace ? 'Assigned Sales' : 'Enquiry Owner';
|
||||
const infoCardTitle = isLeadWorkspace ? 'Lead Information' : 'Enquiry Information';
|
||||
const infoCardDescription = isLeadWorkspace
|
||||
? 'Lead source, qualification context, and sales handoff readiness.'
|
||||
: 'Project scope, ownership context, and active sales execution details.';
|
||||
const assignmentButtonLabel =
|
||||
assignmentMode === 'assign'
|
||||
? isLeadWorkspace
|
||||
? 'Assign To Sales'
|
||||
: 'Assign Enquiry Owner'
|
||||
: isLeadWorkspace
|
||||
? 'Reassign Sales Owner'
|
||||
: 'Reassign Enquiry Owner';
|
||||
const projectSectionLabel = isLeadWorkspace ? 'Lead Scope' : 'Project Information';
|
||||
const followupTabLabel = isLeadWorkspace ? 'Lead Follow-ups' : 'Follow-ups';
|
||||
const relatedTabLabel = isLeadWorkspace ? 'Related Quotations' : 'Quotations';
|
||||
const snapshotDescription = isLeadWorkspace
|
||||
? 'Operational metadata and lead ownership handoff state for this record.'
|
||||
: 'Operational metadata and assignment state for this enquiry record.';
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
@@ -92,7 +136,7 @@ export function EnquiryDetail({
|
||||
<div className='space-y-3'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button asChild variant='ghost' size='icon'>
|
||||
<Link href='/dashboard/crm/enquiries'>
|
||||
<Link href={backHref}>
|
||||
<Icons.chevronLeft className='h-4 w-4' />
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -103,8 +147,8 @@ export function EnquiryDetail({
|
||||
<div>
|
||||
<h2 className='text-2xl font-semibold'>{enquiry.title}</h2>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{customer?.name ?? 'Unknown customer'}
|
||||
{contact ? ` • ${contact.name}` : ''}
|
||||
{pipelineStageLabel} | {customer?.name ?? 'Unknown customer'}
|
||||
{contact ? ` | ${contact.name}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -112,12 +156,13 @@ export function EnquiryDetail({
|
||||
{canManageAssignment ? (
|
||||
<Button variant='outline' onClick={() => setAssignmentOpen(true)}>
|
||||
<Icons.userPen className='mr-2 h-4 w-4' />
|
||||
{assignmentMode === 'assign' ? 'Assign Sales' : 'Reassign Sales'}
|
||||
{assignmentButtonLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
{canUpdate ? (
|
||||
<Button variant='outline' onClick={() => setEditOpen(true)}>
|
||||
<Icons.edit className='mr-2 h-4 w-4' /> Edit Enquiry
|
||||
<Icons.edit className='mr-2 h-4 w-4' />
|
||||
{`Edit ${recordLabel}`}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -130,20 +175,23 @@ export function EnquiryDetail({
|
||||
<Tabs defaultValue='overview' className='gap-4'>
|
||||
<TabsList>
|
||||
<TabsTrigger value='overview'>Overview</TabsTrigger>
|
||||
<TabsTrigger value='followups'>Follow-ups</TabsTrigger>
|
||||
<TabsTrigger value='followups'>{followupTabLabel}</TabsTrigger>
|
||||
<TabsTrigger value='activity'>Activity</TabsTrigger>
|
||||
<TabsTrigger value='related'>Related Quotations</TabsTrigger>
|
||||
{canViewRelatedQuotations ? (
|
||||
<TabsTrigger value='related'>{relatedTabLabel}</TabsTrigger>
|
||||
) : null}
|
||||
</TabsList>
|
||||
<TabsContent value='overview'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Overview</CardTitle>
|
||||
<CardDescription>
|
||||
Opportunity scope, project context, and commercial direction.
|
||||
</CardDescription>
|
||||
<CardTitle>{infoCardTitle}</CardTitle>
|
||||
<CardDescription>{infoCardDescription}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
||||
<FieldItem label='Billing Customer' value={customer?.name} />
|
||||
<FieldItem
|
||||
label={isLeadWorkspace ? 'Company' : 'Billing Customer'}
|
||||
value={customer?.name}
|
||||
/>
|
||||
<FieldItem label='Contact' value={contact?.name} />
|
||||
<FieldItem
|
||||
label='Branch'
|
||||
@@ -159,7 +207,7 @@ export function EnquiryDetail({
|
||||
value={priorityMap.get(enquiry.priority)?.label}
|
||||
/>
|
||||
<FieldItem
|
||||
label='Lead Channel'
|
||||
label='Lead Source'
|
||||
value={leadChannelMap.get(enquiry.leadChannel ?? '')?.label}
|
||||
/>
|
||||
<FieldItem
|
||||
@@ -184,7 +232,8 @@ export function EnquiryDetail({
|
||||
/>
|
||||
<FieldItem label='Competitor' value={enquiry.competitor} />
|
||||
<FieldItem label='Source' value={enquiry.source} />
|
||||
<FieldItem label='Assigned Sales' value={enquiry.assignedToName} />
|
||||
<FieldItem label='Pipeline Stage' value={pipelineStageLabel} />
|
||||
<FieldItem label={ownerLabel} value={enquiry.assignedToName} />
|
||||
<FieldItem
|
||||
label='Assigned At'
|
||||
value={
|
||||
@@ -195,10 +244,14 @@ export function EnquiryDetail({
|
||||
<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>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{isLeadWorkspace ? 'Lead Parties' : '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.
|
||||
{isLeadWorkspace
|
||||
? 'No lead parties have been added yet.'
|
||||
: 'No project parties have been added yet.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-3'>
|
||||
@@ -217,11 +270,10 @@ export function EnquiryDetail({
|
||||
</div>
|
||||
<div className='md:col-span-2 xl:col-span-4'>
|
||||
<FieldItem
|
||||
label='Project'
|
||||
label={projectSectionLabel}
|
||||
value={
|
||||
[enquiry.projectName, enquiry.projectLocation]
|
||||
.filter(Boolean)
|
||||
.join(' • ') || null
|
||||
[enquiry.projectName, enquiry.projectLocation].filter(Boolean).join(' | ') ||
|
||||
null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -250,9 +302,9 @@ export function EnquiryDetail({
|
||||
<TabsContent value='activity'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Activity</CardTitle>
|
||||
<CardTitle>{isLeadWorkspace ? 'Lead Activity' : 'Activity'}</CardTitle>
|
||||
<CardDescription>
|
||||
Audit events for the enquiry and its follow-up records.
|
||||
Audit events for this record and its follow-up history.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
@@ -289,18 +341,19 @@ export function EnquiryDetail({
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
{canViewRelatedQuotations ? (
|
||||
<TabsContent value='related'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Related Quotations</CardTitle>
|
||||
<CardTitle>{relatedTabLabel}</CardTitle>
|
||||
<CardDescription>
|
||||
Production quotations already linked to this enquiry.
|
||||
Production quotations already linked to this record.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!relatedQuotations.length ? (
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
||||
No quotations have been linked to this enquiry yet.
|
||||
No quotations have been linked to this record yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-3'>
|
||||
@@ -326,6 +379,7 @@ export function EnquiryDetail({
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -335,12 +389,11 @@ export function EnquiryDetail({
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Record Snapshot</CardTitle>
|
||||
<CardDescription>
|
||||
Operational metadata and assignment state for this enquiry row.
|
||||
</CardDescription>
|
||||
<CardDescription>{snapshotDescription}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<FieldItem label='Assigned Sales' value={enquiry.assignedToName} />
|
||||
<FieldItem label='Pipeline Stage' value={pipelineStageLabel} />
|
||||
<FieldItem label={ownerLabel} value={enquiry.assignedToName} />
|
||||
<FieldItem label='Assigned By' value={enquiry.assignedByName} />
|
||||
<FieldItem label='Created By' value={enquiry.createdBy} />
|
||||
<FieldItem label='Updated By' value={enquiry.updatedBy} />
|
||||
@@ -367,6 +420,7 @@ export function EnquiryDetail({
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
referenceData={referenceData}
|
||||
workspace={workspace}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -64,12 +64,14 @@ export function EnquiryFormSheet({
|
||||
enquiry,
|
||||
open,
|
||||
onOpenChange,
|
||||
referenceData
|
||||
referenceData,
|
||||
workspace = 'enquiry'
|
||||
}: {
|
||||
enquiry?: EnquiryRecord;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
referenceData: EnquiryReferenceData;
|
||||
workspace?: 'lead' | 'enquiry';
|
||||
}) {
|
||||
const isEdit = !!enquiry;
|
||||
const defaultValues = useMemo(
|
||||
@@ -88,21 +90,21 @@ export function EnquiryFormSheet({
|
||||
const createMutation = useMutation({
|
||||
...createEnquiryMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Enquiry created successfully');
|
||||
toast.success(`${recordLabel} created successfully`);
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to create enquiry')
|
||||
toast.error(error instanceof Error ? error.message : `Failed to create ${recordLabel.toLowerCase()}`)
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
...updateEnquiryMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Enquiry updated successfully');
|
||||
toast.success(`${recordLabel} updated successfully`);
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to update enquiry')
|
||||
toast.error(error instanceof Error ? error.message : `Failed to update ${recordLabel.toLowerCase()}`)
|
||||
});
|
||||
|
||||
const contactsForCustomer = referenceData.contacts.filter(
|
||||
@@ -177,13 +179,17 @@ export function EnquiryFormSheet({
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
const recordLabel = workspace === 'lead' ? 'Lead' : 'Enquiry';
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className='flex flex-col sm:max-w-4xl'>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{isEdit ? 'Edit Enquiry' : 'New Enquiry'}</SheetTitle>
|
||||
<SheetTitle>{isEdit ? `Edit ${recordLabel}` : `New ${recordLabel}`}</SheetTitle>
|
||||
<SheetDescription>
|
||||
Capture the opportunity details before quotation and approval stages begin.
|
||||
{workspace === 'lead'
|
||||
? 'Capture marketing lead details before assignment to sales.'
|
||||
: 'Capture sales enquiry details before quotation work begins.'}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
@@ -261,7 +267,7 @@ export function EnquiryFormSheet({
|
||||
name='title'
|
||||
label='Title'
|
||||
required
|
||||
placeholder='Warehouse crane upgrade opportunity'
|
||||
placeholder='Warehouse crane upgrade enquiry'
|
||||
/>
|
||||
<FormTextField
|
||||
name='projectName'
|
||||
@@ -338,7 +344,7 @@ export function EnquiryFormSheet({
|
||||
<FormTextareaField
|
||||
name='description'
|
||||
label='Description'
|
||||
placeholder='High-level summary of the opportunity'
|
||||
placeholder='High-level summary of the lead or enquiry'
|
||||
/>
|
||||
</div>
|
||||
<div className='md:col-span-2'>
|
||||
@@ -352,19 +358,19 @@ export function EnquiryFormSheet({
|
||||
<FormTextareaField
|
||||
name='notes'
|
||||
label='Notes'
|
||||
placeholder='Internal notes, blockers, or guidance for the sales team'
|
||||
placeholder='Internal notes, blockers, or handoff guidance'
|
||||
/>
|
||||
</div>
|
||||
<div className='md:col-span-2 grid gap-4 md:grid-cols-2'>
|
||||
<FormSwitchField
|
||||
name='isHotProject'
|
||||
label='Hot Project'
|
||||
description='Use for urgent or highly strategic opportunities.'
|
||||
description='Use for urgent or highly strategic enquiries.'
|
||||
/>
|
||||
<FormSwitchField
|
||||
name='isActive'
|
||||
label='Active Record'
|
||||
description='Inactive enquiries stay in history but should not progress further.'
|
||||
description='Inactive leads and enquiries stay in history but should not progress further.'
|
||||
/>
|
||||
</div>
|
||||
<div className='md:col-span-2'>
|
||||
@@ -385,7 +391,7 @@ export function EnquiryFormSheet({
|
||||
</Button>
|
||||
<Button type='submit' form='enquiry-form-sheet' isLoading={isPending}>
|
||||
<Icons.check className='mr-2 h-4 w-4' />
|
||||
{isEdit ? 'Update Enquiry' : 'Create Enquiry'}
|
||||
{isEdit ? `Update ${recordLabel}` : `Create ${recordLabel}`}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
@@ -394,18 +400,25 @@ export function EnquiryFormSheet({
|
||||
}
|
||||
|
||||
export function EnquiryFormSheetTrigger({
|
||||
referenceData
|
||||
referenceData,
|
||||
workspace = 'enquiry'
|
||||
}: {
|
||||
referenceData: EnquiryReferenceData;
|
||||
workspace?: 'lead' | 'enquiry';
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setOpen(true)}>
|
||||
<Icons.add className='mr-2 h-4 w-4' /> Add Enquiry
|
||||
<Icons.add className='mr-2 h-4 w-4' /> {workspace === 'lead' ? 'Add Lead' : 'Add Enquiry'}
|
||||
</Button>
|
||||
<EnquiryFormSheet open={open} onOpenChange={setOpen} referenceData={referenceData} />
|
||||
<EnquiryFormSheet
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
referenceData={referenceData}
|
||||
workspace={workspace}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,12 +7,14 @@ import { EnquiriesTable } from './enquiries-table';
|
||||
|
||||
export default function EnquiryListing({
|
||||
referenceData,
|
||||
workspace,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
canAssign,
|
||||
canReassign
|
||||
}: {
|
||||
referenceData: EnquiryReferenceData;
|
||||
workspace: 'lead' | 'enquiry';
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
canAssign: boolean;
|
||||
@@ -30,6 +32,7 @@ export default function EnquiryListing({
|
||||
const filters = {
|
||||
page,
|
||||
limit,
|
||||
pipelineStage: workspace,
|
||||
...(search && { search }),
|
||||
...(status && { status }),
|
||||
...(productType && { productType }),
|
||||
@@ -46,6 +49,7 @@ export default function EnquiryListing({
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<EnquiriesTable
|
||||
referenceData={referenceData}
|
||||
workspace={workspace}
|
||||
canUpdate={canUpdate}
|
||||
canDelete={canDelete}
|
||||
canAssign={canAssign}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
crmEnquiries,
|
||||
crmEnquiryCustomers,
|
||||
crmEnquiryFollowups,
|
||||
crmQuotations,
|
||||
memberships,
|
||||
users
|
||||
} from '@/db/schema';
|
||||
@@ -29,6 +30,7 @@ import type {
|
||||
EnquiryProjectPartyListItem,
|
||||
EnquiryProjectPartyMutationPayload,
|
||||
EnquiryProjectPartyRecord,
|
||||
EnquiryPipelineStage,
|
||||
EnquiryRecord,
|
||||
EnquiryReferenceData
|
||||
} from '../api/types';
|
||||
@@ -48,6 +50,30 @@ const ENQUIRY_OPTION_CATEGORIES = {
|
||||
} as const;
|
||||
|
||||
const ASSIGNABLE_BUSINESS_ROLES = new Set(['sales_manager', 'sales', 'sales_support']);
|
||||
const SALES_OWNED_SCOPE_ROLES = new Set(['sales', 'sales_support']);
|
||||
const ENQUIRY_FULL_ACCESS_ROLES = new Set([
|
||||
'marketing',
|
||||
'sales_manager',
|
||||
'department_manager',
|
||||
'top_manager'
|
||||
]);
|
||||
const SALES_CREATED_ENQUIRY_ROLES = new Set([
|
||||
'sales',
|
||||
'sales_support',
|
||||
'sales_manager',
|
||||
'department_manager',
|
||||
'top_manager'
|
||||
]);
|
||||
const CLOSED_LOST_STATUS_CODES = new Set(['closed_lost', 'cancelled']);
|
||||
const CLOSED_WON_QUOTATION_STATUS_CODES = new Set(['accepted']);
|
||||
const CLOSED_LOST_QUOTATION_STATUS_CODES = new Set(['lost', 'rejected']);
|
||||
|
||||
export interface EnquiryAccessContext {
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
membershipRole: string;
|
||||
businessRole: string;
|
||||
}
|
||||
|
||||
function mapOption(
|
||||
option: Awaited<ReturnType<typeof getActiveOptionsByCategory>>[number]
|
||||
@@ -99,6 +125,7 @@ function mapEnquiryRecord(
|
||||
notes: row.notes,
|
||||
isHotProject: row.isHotProject,
|
||||
isActive: row.isActive,
|
||||
pipelineStage: row.pipelineStage as EnquiryPipelineStage,
|
||||
assignedToUserId: row.assignedToUserId,
|
||||
assignedToName: options?.assignedToName ?? null,
|
||||
assignedAt: row.assignedAt?.toISOString() ?? null,
|
||||
@@ -113,6 +140,41 @@ function mapEnquiryRecord(
|
||||
};
|
||||
}
|
||||
|
||||
function hasEnquiryFullAccess(context: EnquiryAccessContext) {
|
||||
return (
|
||||
context.membershipRole === 'admin' || ENQUIRY_FULL_ACCESS_ROLES.has(context.businessRole)
|
||||
);
|
||||
}
|
||||
|
||||
function canAccessEnquiryRow(
|
||||
row: typeof crmEnquiries.$inferSelect,
|
||||
context: EnquiryAccessContext
|
||||
) {
|
||||
if (hasEnquiryFullAccess(context)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (SALES_OWNED_SCOPE_ROLES.has(context.businessRole)) {
|
||||
return row.createdBy === context.userId || row.assignedToUserId === context.userId;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildAccessScopedFilters(context: EnquiryAccessContext): SQL[] {
|
||||
if (hasEnquiryFullAccess(context)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (SALES_OWNED_SCOPE_ROLES.has(context.businessRole)) {
|
||||
return [
|
||||
or(eq(crmEnquiries.createdBy, context.userId), eq(crmEnquiries.assignedToUserId, context.userId))!
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function mapFollowupRecord(row: typeof crmEnquiryFollowups.$inferSelect): EnquiryFollowupRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -218,6 +280,19 @@ async function assertMasterOptionValue(
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveOptionCodeById(
|
||||
organizationId: string,
|
||||
category: string,
|
||||
optionId?: string | null
|
||||
) {
|
||||
if (!optionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const options = await getActiveOptionsByCategory(category, { organizationId });
|
||||
return options.find((option) => option.id === optionId)?.code ?? null;
|
||||
}
|
||||
|
||||
export async function assertCustomerBelongsToOrganization(id: string, organizationId: string) {
|
||||
const [customer] = await db
|
||||
.select()
|
||||
@@ -309,7 +384,11 @@ export async function assertAssignableUserBelongsToOrganization(
|
||||
return membership;
|
||||
}
|
||||
|
||||
async function assertEnquiryBelongsToOrganization(id: string, organizationId: string) {
|
||||
async function assertEnquiryBelongsToOrganization(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
accessContext?: EnquiryAccessContext
|
||||
) {
|
||||
const [enquiry] = await db
|
||||
.select()
|
||||
.from(crmEnquiries)
|
||||
@@ -326,6 +405,10 @@ async function assertEnquiryBelongsToOrganization(id: string, organizationId: st
|
||||
throw new AuthError('Enquiry not found', 404);
|
||||
}
|
||||
|
||||
if (accessContext && !canAccessEnquiryRow(enquiry, accessContext)) {
|
||||
throw new AuthError('Forbidden', 403);
|
||||
}
|
||||
|
||||
return enquiry;
|
||||
}
|
||||
|
||||
@@ -395,9 +478,14 @@ async function validateEnquiryPayload(organizationId: string, payload: EnquiryMu
|
||||
async function validateFollowupPayload(
|
||||
organizationId: string,
|
||||
enquiryId: string,
|
||||
payload: EnquiryFollowupMutationPayload
|
||||
payload: EnquiryFollowupMutationPayload,
|
||||
accessContext?: EnquiryAccessContext
|
||||
) {
|
||||
const enquiry = await assertEnquiryBelongsToOrganization(enquiryId, organizationId);
|
||||
const enquiry = await assertEnquiryBelongsToOrganization(
|
||||
enquiryId,
|
||||
organizationId,
|
||||
accessContext
|
||||
);
|
||||
|
||||
await assertMasterOptionValue(
|
||||
organizationId,
|
||||
@@ -410,7 +498,8 @@ async function validateFollowupPayload(
|
||||
}
|
||||
}
|
||||
|
||||
function buildEnquiryFilters(organizationId: string, filters: EnquiryFilters): SQL[] {
|
||||
function buildEnquiryFilters(context: EnquiryAccessContext, filters: EnquiryFilters): SQL[] {
|
||||
const pipelineStages = splitFilterValue(filters.pipelineStage);
|
||||
const statuses = splitFilterValue(filters.status);
|
||||
const productTypes = splitFilterValue(filters.productType);
|
||||
const priorities = splitFilterValue(filters.priority);
|
||||
@@ -418,8 +507,10 @@ function buildEnquiryFilters(organizationId: string, filters: EnquiryFilters): S
|
||||
const customers = splitFilterValue(filters.customer);
|
||||
|
||||
return [
|
||||
eq(crmEnquiries.organizationId, organizationId),
|
||||
eq(crmEnquiries.organizationId, context.organizationId),
|
||||
isNull(crmEnquiries.deletedAt),
|
||||
...buildAccessScopedFilters(context),
|
||||
...(pipelineStages.length ? [inArray(crmEnquiries.pipelineStage, pipelineStages)] : []),
|
||||
...(statuses.length ? [inArray(crmEnquiries.status, statuses)] : []),
|
||||
...(productTypes.length ? [inArray(crmEnquiries.productType, productTypes)] : []),
|
||||
...(priorities.length ? [inArray(crmEnquiries.priority, priorities)] : []),
|
||||
@@ -438,6 +529,54 @@ function buildEnquiryFilters(organizationId: string, filters: EnquiryFilters): S
|
||||
];
|
||||
}
|
||||
|
||||
function derivePipelineStageFromRole(businessRole: string): EnquiryPipelineStage {
|
||||
return SALES_CREATED_ENQUIRY_ROLES.has(businessRole) ? 'enquiry' : 'lead';
|
||||
}
|
||||
|
||||
async function resolvePipelineStageForCreate(
|
||||
organizationId: string,
|
||||
businessRole: string,
|
||||
statusId: string
|
||||
): Promise<EnquiryPipelineStage> {
|
||||
const statusCode = await resolveOptionCodeById(
|
||||
organizationId,
|
||||
ENQUIRY_OPTION_CATEGORIES.status,
|
||||
statusId
|
||||
);
|
||||
|
||||
if (statusCode && CLOSED_LOST_STATUS_CODES.has(statusCode)) {
|
||||
return 'closed_lost';
|
||||
}
|
||||
|
||||
return derivePipelineStageFromRole(businessRole);
|
||||
}
|
||||
|
||||
async function resolvePipelineStageForUpdate(
|
||||
organizationId: string,
|
||||
current: typeof crmEnquiries.$inferSelect,
|
||||
payload: EnquiryMutationPayload
|
||||
): Promise<EnquiryPipelineStage> {
|
||||
const statusCode = await resolveOptionCodeById(
|
||||
organizationId,
|
||||
ENQUIRY_OPTION_CATEGORIES.status,
|
||||
payload.status
|
||||
);
|
||||
|
||||
if (statusCode && CLOSED_LOST_STATUS_CODES.has(statusCode)) {
|
||||
return 'closed_lost';
|
||||
}
|
||||
|
||||
if (current.pipelineStage === 'closed_won') {
|
||||
return 'closed_won';
|
||||
}
|
||||
|
||||
if (current.pipelineStage === 'enquiry' || current.assignedToUserId) {
|
||||
return 'enquiry';
|
||||
}
|
||||
|
||||
return 'lead';
|
||||
}
|
||||
|
||||
export async function getEnquiryReferenceData(
|
||||
organizationId: string
|
||||
): Promise<EnquiryReferenceData> {
|
||||
@@ -505,12 +644,12 @@ export async function getEnquiryReferenceData(
|
||||
}
|
||||
|
||||
export async function listEnquiries(
|
||||
organizationId: string,
|
||||
context: EnquiryAccessContext,
|
||||
filters: EnquiryFilters
|
||||
): Promise<{ items: EnquiryListItem[]; totalItems: number }> {
|
||||
const page = filters.page ?? 1;
|
||||
const limit = filters.limit ?? 10;
|
||||
const whereFilters = buildEnquiryFilters(organizationId, filters);
|
||||
const whereFilters = buildEnquiryFilters(context, filters);
|
||||
const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters);
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
@@ -528,16 +667,17 @@ export async function listEnquiries(
|
||||
const assignedUserIds = [
|
||||
...new Set(rows.map((row) => row.assignedToUserId).filter(Boolean) as string[])
|
||||
];
|
||||
const createdByUserIds = [...new Set(rows.map((row) => row.createdBy).filter(Boolean) as string[])];
|
||||
const enquiryIds = rows.map((row) => row.id);
|
||||
|
||||
const [customers, contacts, assignedUsers, followupCounts] = await Promise.all([
|
||||
const [customers, contacts, assignedUsers, createdByUsers, followupCounts, quotationCounts, nextFollowups] = await Promise.all([
|
||||
customerIds.length
|
||||
? db
|
||||
.select()
|
||||
.from(crmCustomers)
|
||||
.where(
|
||||
and(
|
||||
eq(crmCustomers.organizationId, organizationId),
|
||||
eq(crmCustomers.organizationId, context.organizationId),
|
||||
inArray(crmCustomers.id, customerIds)
|
||||
)
|
||||
)
|
||||
@@ -548,7 +688,7 @@ export async function listEnquiries(
|
||||
.from(crmCustomerContacts)
|
||||
.where(
|
||||
and(
|
||||
eq(crmCustomerContacts.organizationId, organizationId),
|
||||
eq(crmCustomerContacts.organizationId, context.organizationId),
|
||||
inArray(crmCustomerContacts.id, contactIds)
|
||||
)
|
||||
)
|
||||
@@ -559,6 +699,12 @@ export async function listEnquiries(
|
||||
.from(users)
|
||||
.where(inArray(users.id, assignedUserIds))
|
||||
: [],
|
||||
createdByUserIds.length
|
||||
? db
|
||||
.select({ id: users.id, name: users.name })
|
||||
.from(users)
|
||||
.where(inArray(users.id, createdByUserIds))
|
||||
: [],
|
||||
enquiryIds.length
|
||||
? db
|
||||
.select({
|
||||
@@ -568,19 +714,68 @@ export async function listEnquiries(
|
||||
.from(crmEnquiryFollowups)
|
||||
.where(
|
||||
and(
|
||||
eq(crmEnquiryFollowups.organizationId, organizationId),
|
||||
eq(crmEnquiryFollowups.organizationId, context.organizationId),
|
||||
inArray(crmEnquiryFollowups.enquiryId, enquiryIds),
|
||||
isNull(crmEnquiryFollowups.deletedAt)
|
||||
)
|
||||
)
|
||||
.groupBy(crmEnquiryFollowups.enquiryId)
|
||||
: [],
|
||||
enquiryIds.length
|
||||
? db
|
||||
.select({
|
||||
enquiryId: crmQuotations.enquiryId,
|
||||
value: count()
|
||||
})
|
||||
.from(crmQuotations)
|
||||
.where(
|
||||
and(
|
||||
eq(crmQuotations.organizationId, context.organizationId),
|
||||
inArray(crmQuotations.enquiryId, enquiryIds),
|
||||
isNull(crmQuotations.deletedAt)
|
||||
)
|
||||
)
|
||||
.groupBy(crmQuotations.enquiryId)
|
||||
: [],
|
||||
enquiryIds.length
|
||||
? db
|
||||
.select({
|
||||
enquiryId: crmEnquiryFollowups.enquiryId,
|
||||
nextFollowupDate: crmEnquiryFollowups.nextFollowupDate
|
||||
})
|
||||
.from(crmEnquiryFollowups)
|
||||
.where(
|
||||
and(
|
||||
eq(crmEnquiryFollowups.organizationId, context.organizationId),
|
||||
inArray(crmEnquiryFollowups.enquiryId, enquiryIds),
|
||||
isNull(crmEnquiryFollowups.deletedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(crmEnquiryFollowups.nextFollowupDate))
|
||||
: []
|
||||
]);
|
||||
|
||||
const customerMap = new Map(customers.map((customer) => [customer.id, customer.name]));
|
||||
const contactMap = new Map(contacts.map((contact) => [contact.id, contact.name]));
|
||||
const assignedUserMap = new Map(assignedUsers.map((user) => [user.id, user.name]));
|
||||
const createdByUserMap = new Map(createdByUsers.map((user) => [user.id, user.name]));
|
||||
const followupCountMap = new Map(followupCounts.map((item) => [item.enquiryId, item.value]));
|
||||
const quotationCountMap = new Map(
|
||||
quotationCounts
|
||||
.filter((item) => item.enquiryId !== null)
|
||||
.map((item) => [item.enquiryId as string, item.value])
|
||||
);
|
||||
const nextFollowupMap = new Map<string, string | null>();
|
||||
|
||||
for (const item of nextFollowups) {
|
||||
if (!item.nextFollowupDate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!nextFollowupMap.has(item.enquiryId)) {
|
||||
nextFollowupMap.set(item.enquiryId, item.nextFollowupDate.toISOString());
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
items: rows.map((row) => ({
|
||||
@@ -591,14 +786,21 @@ export async function listEnquiries(
|
||||
}),
|
||||
customerName: customerMap.get(row.customerId) ?? 'Unknown customer',
|
||||
contactName: row.contactId ? (contactMap.get(row.contactId) ?? null) : null,
|
||||
followupCount: followupCountMap.get(row.id) ?? 0
|
||||
followupCount: followupCountMap.get(row.id) ?? 0,
|
||||
quotationCount: quotationCountMap.get(row.id) ?? 0,
|
||||
nextFollowupDate: nextFollowupMap.get(row.id) ?? null,
|
||||
createdByName: createdByUserMap.get(row.createdBy) ?? null
|
||||
})),
|
||||
totalItems: totalResult?.value ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
export async function getEnquiryDetail(id: string, organizationId: string): Promise<EnquiryRecord> {
|
||||
const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId);
|
||||
export async function getEnquiryDetail(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
accessContext?: EnquiryAccessContext
|
||||
): Promise<EnquiryRecord> {
|
||||
const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
|
||||
const relatedUserIds = [enquiry.assignedToUserId, enquiry.assignedBy].filter(Boolean) as string[];
|
||||
const userRows = relatedUserIds.length
|
||||
? await db
|
||||
@@ -711,9 +913,10 @@ async function syncEnquiryProjectParties(
|
||||
|
||||
export async function listEnquiryProjectParties(
|
||||
id: string,
|
||||
organizationId: string
|
||||
organizationId: string,
|
||||
accessContext?: EnquiryAccessContext
|
||||
): Promise<EnquiryProjectPartyListItem[]> {
|
||||
await assertEnquiryBelongsToOrganization(id, organizationId);
|
||||
await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
|
||||
const [relations, customers, roleOptions] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
@@ -754,8 +957,10 @@ export async function listEnquiryProjectParties(
|
||||
|
||||
export async function getEnquiryActivity(
|
||||
id: string,
|
||||
organizationId: string
|
||||
organizationId: string,
|
||||
accessContext?: EnquiryAccessContext
|
||||
): Promise<EnquiryActivityRecord[]> {
|
||||
await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
|
||||
const auditLogs = await listAuditLogs({
|
||||
organizationId,
|
||||
entityId: id,
|
||||
@@ -791,9 +996,15 @@ export async function getEnquiryActivity(
|
||||
export async function createEnquiry(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
businessRole: string,
|
||||
payload: EnquiryMutationPayload
|
||||
) {
|
||||
await validateEnquiryPayload(organizationId, payload);
|
||||
const pipelineStage = await resolvePipelineStageForCreate(
|
||||
organizationId,
|
||||
businessRole,
|
||||
payload.status
|
||||
);
|
||||
|
||||
const documentCode = await generateNextDocumentCode({
|
||||
organizationId,
|
||||
@@ -828,6 +1039,7 @@ export async function createEnquiry(
|
||||
notes: payload.notes?.trim() || null,
|
||||
isHotProject: payload.isHotProject ?? false,
|
||||
isActive: payload.isActive ?? true,
|
||||
pipelineStage,
|
||||
createdBy: userId,
|
||||
updatedBy: userId
|
||||
})
|
||||
@@ -849,10 +1061,12 @@ export async function updateEnquiry(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: EnquiryMutationPayload
|
||||
payload: EnquiryMutationPayload,
|
||||
accessContext?: EnquiryAccessContext
|
||||
) {
|
||||
await validateEnquiryPayload(organizationId, payload);
|
||||
await assertEnquiryBelongsToOrganization(id, organizationId);
|
||||
const current = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
|
||||
const pipelineStage = await resolvePipelineStageForUpdate(organizationId, current, payload);
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
const [updated] = await tx
|
||||
@@ -878,6 +1092,7 @@ export async function updateEnquiry(
|
||||
notes: payload.notes?.trim() || null,
|
||||
isHotProject: payload.isHotProject ?? false,
|
||||
isActive: payload.isActive ?? true,
|
||||
pipelineStage,
|
||||
updatedBy: userId,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
@@ -896,8 +1111,13 @@ export async function updateEnquiry(
|
||||
});
|
||||
}
|
||||
|
||||
export async function softDeleteEnquiry(id: string, organizationId: string, userId: string) {
|
||||
await assertEnquiryBelongsToOrganization(id, organizationId);
|
||||
export async function softDeleteEnquiry(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
accessContext?: EnquiryAccessContext
|
||||
) {
|
||||
await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
|
||||
const now = new Date();
|
||||
|
||||
const [updated] = await db
|
||||
@@ -964,6 +1184,7 @@ async function updateEnquiryAssignment(
|
||||
const [updated] = await db
|
||||
.update(crmEnquiries)
|
||||
.set({
|
||||
pipelineStage: 'enquiry',
|
||||
assignedToUserId: payload.assignedToUserId,
|
||||
assignedAt: new Date(),
|
||||
assignedBy: userId,
|
||||
@@ -995,8 +1216,12 @@ export async function reassignEnquiryToUser(
|
||||
return updateEnquiryAssignment(id, organizationId, userId, payload, 'reassign');
|
||||
}
|
||||
|
||||
export async function listEnquiryFollowups(id: string, organizationId: string) {
|
||||
await assertEnquiryBelongsToOrganization(id, organizationId);
|
||||
export async function listEnquiryFollowups(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
accessContext?: EnquiryAccessContext
|
||||
) {
|
||||
await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(crmEnquiryFollowups)
|
||||
@@ -1016,9 +1241,10 @@ export async function createEnquiryFollowup(
|
||||
enquiryId: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: EnquiryFollowupMutationPayload
|
||||
payload: EnquiryFollowupMutationPayload,
|
||||
accessContext?: EnquiryAccessContext
|
||||
) {
|
||||
await validateFollowupPayload(organizationId, enquiryId, payload);
|
||||
await validateFollowupPayload(organizationId, enquiryId, payload, accessContext);
|
||||
|
||||
const [created] = await db
|
||||
.insert(crmEnquiryFollowups)
|
||||
@@ -1046,9 +1272,10 @@ export async function updateEnquiryFollowup(
|
||||
followupId: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: EnquiryFollowupMutationPayload
|
||||
payload: EnquiryFollowupMutationPayload,
|
||||
accessContext?: EnquiryAccessContext
|
||||
) {
|
||||
await validateFollowupPayload(organizationId, enquiryId, payload);
|
||||
await validateFollowupPayload(organizationId, enquiryId, payload, accessContext);
|
||||
await assertFollowupBelongsToEnquiry(followupId, enquiryId, organizationId);
|
||||
|
||||
const [updated] = await db
|
||||
@@ -1074,8 +1301,10 @@ export async function softDeleteEnquiryFollowup(
|
||||
enquiryId: string,
|
||||
followupId: string,
|
||||
organizationId: string,
|
||||
userId: string
|
||||
userId: string,
|
||||
accessContext?: EnquiryAccessContext
|
||||
) {
|
||||
await assertEnquiryBelongsToOrganization(enquiryId, organizationId, accessContext);
|
||||
await assertFollowupBelongsToEnquiry(followupId, enquiryId, organizationId);
|
||||
|
||||
const [updated] = await db
|
||||
@@ -1117,3 +1346,39 @@ export async function listCustomerEnquiryRelations(
|
||||
updatedAt: row.updatedAt.toISOString()
|
||||
}));
|
||||
}
|
||||
|
||||
export async function syncEnquiryPipelineStageFromQuotationStatus(
|
||||
enquiryId: string,
|
||||
organizationId: string,
|
||||
quotationStatusCode: string | null
|
||||
) {
|
||||
if (!quotationStatusCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextStage: EnquiryPipelineStage | null = CLOSED_WON_QUOTATION_STATUS_CODES.has(
|
||||
quotationStatusCode
|
||||
)
|
||||
? 'closed_won'
|
||||
: CLOSED_LOST_QUOTATION_STATUS_CODES.has(quotationStatusCode)
|
||||
? 'closed_lost'
|
||||
: null;
|
||||
|
||||
if (!nextStage) {
|
||||
return;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(crmEnquiries)
|
||||
.set({
|
||||
pipelineStage: nextStage,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(crmEnquiries.id, enquiryId),
|
||||
eq(crmEnquiries.organizationId, organizationId),
|
||||
isNull(crmEnquiries.deletedAt)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { listAuditLogs } from '@/features/foundation/audit-log/service';
|
||||
import { getUserBranches, validateBranchAccess } from '@/features/foundation/branch-scope/service';
|
||||
import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service';
|
||||
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||
import { syncEnquiryPipelineStageFromQuotationStatus } from '@/features/crm/enquiries/server/service';
|
||||
import type {
|
||||
QuotationActivityRecord,
|
||||
QuotationAttachmentMutationPayload,
|
||||
@@ -1215,6 +1216,11 @@ export async function createQuotation(
|
||||
payload: QuotationMutationPayload
|
||||
) {
|
||||
await validateQuotationPayload(organizationId, payload);
|
||||
const quotationStatusCode = await resolveOptionCodeById(
|
||||
organizationId,
|
||||
QUOTATION_OPTION_CATEGORIES.status,
|
||||
payload.status
|
||||
);
|
||||
|
||||
const enquiry = payload.enquiryId
|
||||
? await assertEnquiryBelongsToOrganization(payload.enquiryId, organizationId)
|
||||
@@ -1277,6 +1283,14 @@ export async function createQuotation(
|
||||
payload.projectParties ?? []
|
||||
);
|
||||
|
||||
if (created.enquiryId) {
|
||||
await syncEnquiryPipelineStageFromQuotationStatus(
|
||||
created.enquiryId,
|
||||
organizationId,
|
||||
quotationStatusCode
|
||||
);
|
||||
}
|
||||
|
||||
return created;
|
||||
});
|
||||
}
|
||||
@@ -1288,6 +1302,11 @@ export async function updateQuotation(
|
||||
payload: QuotationMutationPayload
|
||||
) {
|
||||
await validateQuotationPayload(organizationId, payload);
|
||||
const quotationStatusCode = await resolveOptionCodeById(
|
||||
organizationId,
|
||||
QUOTATION_OPTION_CATEGORIES.status,
|
||||
payload.status
|
||||
);
|
||||
const existing = await assertQuotationBelongsToOrganization(id, organizationId);
|
||||
const enquiry = payload.enquiryId
|
||||
? await assertEnquiryBelongsToOrganization(payload.enquiryId, organizationId)
|
||||
@@ -1336,6 +1355,14 @@ export async function updateQuotation(
|
||||
payload.projectParties ?? []
|
||||
);
|
||||
|
||||
if (row.enquiryId) {
|
||||
await syncEnquiryPipelineStageFromQuotationStatus(
|
||||
row.enquiryId,
|
||||
organizationId,
|
||||
quotationStatusCode
|
||||
);
|
||||
}
|
||||
|
||||
return row;
|
||||
});
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ const membershipRoleOptions = [
|
||||
] as const;
|
||||
|
||||
const businessRoleOptions = [
|
||||
{ value: 'marketing', label: 'Marketing' },
|
||||
{ value: 'sales_manager', label: 'Sales Manager' },
|
||||
{ value: 'sales', label: 'Sales' },
|
||||
{ value: 'sales_support', label: 'Sales Support' },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const SYSTEM_ROLES = ['super_admin', 'user'] as const;
|
||||
export const MEMBERSHIP_ROLES = ['admin', 'user'] as const;
|
||||
export const BUSINESS_ROLES = [
|
||||
'marketing',
|
||||
'sales',
|
||||
'sales_support',
|
||||
'sales_manager',
|
||||
@@ -18,6 +19,11 @@ export const PERMISSIONS = {
|
||||
organizationManage: 'organization:manage',
|
||||
usersManage: 'users:manage',
|
||||
reportRead: 'report:read',
|
||||
crmLeadRead: 'crm.lead.read',
|
||||
crmLeadCreate: 'crm.lead.create',
|
||||
crmLeadUpdate: 'crm.lead.update',
|
||||
crmLeadAssign: 'crm.lead.assign',
|
||||
crmLeadDelete: 'crm.lead.delete',
|
||||
crmCustomerRead: 'crm.customer.read',
|
||||
crmCustomerCreate: 'crm.customer.create',
|
||||
crmCustomerUpdate: 'crm.customer.update',
|
||||
@@ -76,6 +82,50 @@ export const PERMISSIONS = {
|
||||
|
||||
export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
|
||||
|
||||
const MANAGER_PERMISSIONS: Permission[] = [
|
||||
PERMISSIONS.crmLeadRead,
|
||||
PERMISSIONS.crmLeadCreate,
|
||||
PERMISSIONS.crmLeadUpdate,
|
||||
PERMISSIONS.crmLeadAssign,
|
||||
PERMISSIONS.crmLeadDelete,
|
||||
PERMISSIONS.crmCustomerRead,
|
||||
PERMISSIONS.crmCustomerCreate,
|
||||
PERMISSIONS.crmCustomerUpdate,
|
||||
PERMISSIONS.crmContactRead,
|
||||
PERMISSIONS.crmContactCreate,
|
||||
PERMISSIONS.crmContactUpdate,
|
||||
PERMISSIONS.crmEnquiryRead,
|
||||
PERMISSIONS.crmEnquiryCreate,
|
||||
PERMISSIONS.crmEnquiryUpdate,
|
||||
PERMISSIONS.crmEnquiryAssign,
|
||||
PERMISSIONS.crmEnquiryReassign,
|
||||
PERMISSIONS.crmEnquiryFollowupRead,
|
||||
PERMISSIONS.crmEnquiryFollowupCreate,
|
||||
PERMISSIONS.crmEnquiryFollowupUpdate,
|
||||
PERMISSIONS.crmQuotationRead,
|
||||
PERMISSIONS.crmQuotationCreate,
|
||||
PERMISSIONS.crmQuotationUpdate,
|
||||
PERMISSIONS.crmQuotationItemManage,
|
||||
PERMISSIONS.crmQuotationCustomerManage,
|
||||
PERMISSIONS.crmQuotationTopicManage,
|
||||
PERMISSIONS.crmQuotationFollowupManage,
|
||||
PERMISSIONS.crmQuotationAttachmentManage,
|
||||
PERMISSIONS.crmQuotationRevisionCreate,
|
||||
PERMISSIONS.crmApprovalRead,
|
||||
PERMISSIONS.crmApprovalSubmit,
|
||||
PERMISSIONS.crmApprovalWorkflowRead,
|
||||
PERMISSIONS.crmDashboardRead,
|
||||
PERMISSIONS.crmDashboardExport,
|
||||
PERMISSIONS.crmDocumentTemplateRead,
|
||||
PERMISSIONS.crmDocumentSequenceRead,
|
||||
PERMISSIONS.crmDocumentArtifactRead,
|
||||
PERMISSIONS.crmDocumentArtifactDownload,
|
||||
PERMISSIONS.crmDocumentArtifactVoid,
|
||||
PERMISSIONS.crmQuotationDocumentPreview,
|
||||
PERMISSIONS.crmQuotationPdfPreview,
|
||||
PERMISSIONS.crmQuotationPdfDownload
|
||||
];
|
||||
|
||||
function getMembershipBasePermissions(role: MembershipRole): Permission[] {
|
||||
if (role === 'admin') {
|
||||
return [
|
||||
@@ -83,6 +133,11 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] {
|
||||
PERMISSIONS.productsWrite,
|
||||
PERMISSIONS.organizationManage,
|
||||
PERMISSIONS.usersManage,
|
||||
PERMISSIONS.crmLeadRead,
|
||||
PERMISSIONS.crmLeadCreate,
|
||||
PERMISSIONS.crmLeadUpdate,
|
||||
PERMISSIONS.crmLeadAssign,
|
||||
PERMISSIONS.crmLeadDelete,
|
||||
PERMISSIONS.crmCustomerRead,
|
||||
PERMISSIONS.crmCustomerCreate,
|
||||
PERMISSIONS.crmCustomerUpdate,
|
||||
@@ -142,6 +197,7 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] {
|
||||
|
||||
return [
|
||||
PERMISSIONS.productsRead,
|
||||
PERMISSIONS.crmLeadRead,
|
||||
PERMISSIONS.crmCustomerRead,
|
||||
PERMISSIONS.crmContactRead,
|
||||
PERMISSIONS.crmEnquiryRead,
|
||||
@@ -162,45 +218,23 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] {
|
||||
|
||||
export function getBusinessRolePermissions(role: BusinessRole): Permission[] {
|
||||
switch (role) {
|
||||
case 'sales_manager':
|
||||
case 'marketing':
|
||||
return [
|
||||
PERMISSIONS.crmLeadRead,
|
||||
PERMISSIONS.crmLeadCreate,
|
||||
PERMISSIONS.crmLeadUpdate,
|
||||
PERMISSIONS.crmLeadAssign,
|
||||
PERMISSIONS.crmLeadDelete,
|
||||
PERMISSIONS.crmCustomerRead,
|
||||
PERMISSIONS.crmCustomerCreate,
|
||||
PERMISSIONS.crmCustomerUpdate,
|
||||
PERMISSIONS.crmContactRead,
|
||||
PERMISSIONS.crmContactCreate,
|
||||
PERMISSIONS.crmContactUpdate,
|
||||
PERMISSIONS.crmEnquiryRead,
|
||||
PERMISSIONS.crmEnquiryCreate,
|
||||
PERMISSIONS.crmEnquiryUpdate,
|
||||
PERMISSIONS.crmEnquiryAssign,
|
||||
PERMISSIONS.crmEnquiryReassign,
|
||||
PERMISSIONS.crmEnquiryFollowupRead,
|
||||
PERMISSIONS.crmEnquiryFollowupCreate,
|
||||
PERMISSIONS.crmEnquiryFollowupUpdate,
|
||||
PERMISSIONS.crmQuotationRead,
|
||||
PERMISSIONS.crmQuotationCreate,
|
||||
PERMISSIONS.crmQuotationUpdate,
|
||||
PERMISSIONS.crmQuotationItemManage,
|
||||
PERMISSIONS.crmQuotationCustomerManage,
|
||||
PERMISSIONS.crmQuotationTopicManage,
|
||||
PERMISSIONS.crmQuotationFollowupManage,
|
||||
PERMISSIONS.crmQuotationAttachmentManage,
|
||||
PERMISSIONS.crmQuotationRevisionCreate,
|
||||
PERMISSIONS.crmApprovalRead,
|
||||
PERMISSIONS.crmApprovalSubmit,
|
||||
PERMISSIONS.crmApprovalWorkflowRead,
|
||||
PERMISSIONS.crmDashboardRead,
|
||||
PERMISSIONS.crmDashboardExport,
|
||||
PERMISSIONS.crmDocumentTemplateRead,
|
||||
PERMISSIONS.crmDocumentSequenceRead,
|
||||
PERMISSIONS.crmDocumentArtifactRead,
|
||||
PERMISSIONS.crmDocumentArtifactDownload,
|
||||
PERMISSIONS.crmDocumentArtifactVoid,
|
||||
PERMISSIONS.crmQuotationDocumentPreview,
|
||||
PERMISSIONS.crmQuotationPdfPreview,
|
||||
PERMISSIONS.crmQuotationPdfDownload
|
||||
PERMISSIONS.crmDashboardRead
|
||||
];
|
||||
case 'sales_manager':
|
||||
return MANAGER_PERMISSIONS;
|
||||
case 'sales':
|
||||
return [
|
||||
PERMISSIONS.crmCustomerRead,
|
||||
@@ -264,6 +298,7 @@ export function getBusinessRolePermissions(role: BusinessRole): Permission[] {
|
||||
];
|
||||
case 'department_manager':
|
||||
case 'top_manager':
|
||||
return MANAGER_PERMISSIONS;
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user