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,
|
"when": 1781747812297,
|
||||||
"tag": "0011_goofy_the_hood",
|
"tag": "0011_goofy_the_hood",
|
||||||
"breakpoints": true
|
"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,
|
organizationId: organization.id,
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
membershipRole: membership.role,
|
membershipRole: membership.role,
|
||||||
businessRole: membership.businessRole
|
businessRole: membership.businessRole,
|
||||||
|
permissions: membership.permissions
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
dateFrom: searchParams.get('dateFrom'),
|
dateFrom: searchParams.get('dateFrom'),
|
||||||
@@ -84,11 +85,11 @@ export async function GET(request: NextRequest) {
|
|||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
rows = [
|
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) => [
|
...data.salesRanking.map((row) => [
|
||||||
row.salesPersonName,
|
row.salesPersonName,
|
||||||
String(row.leadCount),
|
String(row.leadCount),
|
||||||
String(row.opportunityCount),
|
String(row.enquiryCount),
|
||||||
String(row.quotationCount),
|
String(row.quotationCount),
|
||||||
String(row.approvedQuotations),
|
String(row.approvedQuotations),
|
||||||
String(row.wonRevenue),
|
String(row.wonRevenue),
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ export async function GET(request: NextRequest) {
|
|||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
membershipRole: membership.role,
|
membershipRole: membership.role,
|
||||||
businessRole: membership.businessRole
|
businessRole: membership.businessRole,
|
||||||
|
permissions: membership.permissions
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
dateFrom: searchParams.get('dateFrom'),
|
dateFrom: searchParams.get('dateFrom'),
|
||||||
|
|||||||
@@ -13,11 +13,16 @@ type Params = {
|
|||||||
export async function POST(request: NextRequest, { params }: Params) {
|
export async function POST(request: NextRequest, { params }: Params) {
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { organization, session } = await requireOrganizationAccess({
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmEnquiryAssign
|
permission: PERMISSIONS.crmEnquiryAssign
|
||||||
});
|
});
|
||||||
const payload = enquiryAssignmentRequestSchema.parse(await request.json());
|
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);
|
const updated = await assignEnquiryToUser(id, organization.id, session.user.id, payload);
|
||||||
|
|
||||||
await auditAction({
|
await auditAction({
|
||||||
@@ -26,19 +31,21 @@ export async function POST(request: NextRequest, { params }: Params) {
|
|||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
entityType: 'crm_enquiry',
|
entityType: 'crm_enquiry',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
action: 'assign',
|
action: 'lead_to_enquiry',
|
||||||
beforeData: {
|
beforeData: {
|
||||||
oldAssignedToUserId: before.assignedToUserId
|
oldAssignedToUserId: before.assignedToUserId,
|
||||||
|
oldPipelineStage: before.pipelineStage
|
||||||
},
|
},
|
||||||
afterData: {
|
afterData: {
|
||||||
newAssignedToUserId: updated.assignedToUserId,
|
newAssignedToUserId: updated.assignedToUserId,
|
||||||
|
newPipelineStage: updated.pipelineStage,
|
||||||
remark: updated.assignmentRemark
|
remark: updated.assignmentRemark
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Enquiry assigned successfully',
|
message: 'Lead converted to enquiry successfully',
|
||||||
enquiry: updated
|
enquiry: updated
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -10,10 +10,15 @@ type Params = {
|
|||||||
export async function GET(_request: NextRequest, { params }: Params) {
|
export async function GET(_request: NextRequest, { params }: Params) {
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { organization } = await requireOrganizationAccess({
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmEnquiryRead
|
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({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -22,11 +22,18 @@ const followupRequestSchema = enquiryFollowupSchema.extend({
|
|||||||
export async function PATCH(request: NextRequest, { params }: Params) {
|
export async function PATCH(request: NextRequest, { params }: Params) {
|
||||||
try {
|
try {
|
||||||
const { id, followupId } = await params;
|
const { id, followupId } = await params;
|
||||||
const { organization, session } = await requireOrganizationAccess({
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmEnquiryFollowupUpdate
|
permission: PERMISSIONS.crmEnquiryFollowupUpdate
|
||||||
});
|
});
|
||||||
const payload = followupRequestSchema.parse(await request.json());
|
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
|
(item) => item.id === followupId
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -39,7 +46,13 @@ export async function PATCH(request: NextRequest, { params }: Params) {
|
|||||||
followupId,
|
followupId,
|
||||||
organization.id,
|
organization.id,
|
||||||
session.user.id,
|
session.user.id,
|
||||||
payload
|
payload,
|
||||||
|
{
|
||||||
|
organizationId: organization.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
membershipRole: membership.role,
|
||||||
|
businessRole: membership.businessRole
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
await auditUpdate({
|
await auditUpdate({
|
||||||
@@ -79,10 +92,17 @@ export async function PATCH(request: NextRequest, { params }: Params) {
|
|||||||
export async function DELETE(_request: NextRequest, { params }: Params) {
|
export async function DELETE(_request: NextRequest, { params }: Params) {
|
||||||
try {
|
try {
|
||||||
const { id, followupId } = await params;
|
const { id, followupId } = await params;
|
||||||
const { organization, session } = await requireOrganizationAccess({
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmEnquiryFollowupDelete
|
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
|
(item) => item.id === followupId
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -94,7 +114,13 @@ export async function DELETE(_request: NextRequest, { params }: Params) {
|
|||||||
id,
|
id,
|
||||||
followupId,
|
followupId,
|
||||||
organization.id,
|
organization.id,
|
||||||
session.user.id
|
session.user.id,
|
||||||
|
{
|
||||||
|
organizationId: organization.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
membershipRole: membership.role,
|
||||||
|
businessRole: membership.businessRole
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
await auditDelete({
|
await auditDelete({
|
||||||
|
|||||||
@@ -21,10 +21,15 @@ const followupRequestSchema = enquiryFollowupSchema.extend({
|
|||||||
export async function GET(_request: NextRequest, { params }: Params) {
|
export async function GET(_request: NextRequest, { params }: Params) {
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { organization } = await requireOrganizationAccess({
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmEnquiryFollowupRead
|
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({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -48,11 +53,22 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
|||||||
export async function POST(request: NextRequest, { params }: Params) {
|
export async function POST(request: NextRequest, { params }: Params) {
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { organization, session } = await requireOrganizationAccess({
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmEnquiryFollowupCreate
|
permission: PERMISSIONS.crmEnquiryFollowupCreate
|
||||||
});
|
});
|
||||||
const payload = followupRequestSchema.parse(await request.json());
|
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({
|
await auditCreate({
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
|
|||||||
@@ -13,11 +13,16 @@ type Params = {
|
|||||||
export async function POST(request: NextRequest, { params }: Params) {
|
export async function POST(request: NextRequest, { params }: Params) {
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { organization, session } = await requireOrganizationAccess({
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmEnquiryReassign
|
permission: PERMISSIONS.crmEnquiryReassign
|
||||||
});
|
});
|
||||||
const payload = enquiryAssignmentRequestSchema.parse(await request.json());
|
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);
|
const updated = await reassignEnquiryToUser(id, organization.id, session.user.id, payload);
|
||||||
|
|
||||||
await auditAction({
|
await auditAction({
|
||||||
@@ -28,10 +33,12 @@ export async function POST(request: NextRequest, { params }: Params) {
|
|||||||
entityId: id,
|
entityId: id,
|
||||||
action: 'reassign',
|
action: 'reassign',
|
||||||
beforeData: {
|
beforeData: {
|
||||||
oldAssignedToUserId: before.assignedToUserId
|
oldAssignedToUserId: before.assignedToUserId,
|
||||||
|
oldPipelineStage: before.pipelineStage
|
||||||
},
|
},
|
||||||
afterData: {
|
afterData: {
|
||||||
newAssignedToUserId: updated.assignedToUserId,
|
newAssignedToUserId: updated.assignedToUserId,
|
||||||
|
newPipelineStage: updated.pipelineStage,
|
||||||
remark: updated.assignmentRemark
|
remark: updated.assignmentRemark
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,12 +25,18 @@ const enquiryRequestSchema = enquirySchema.extend({
|
|||||||
export async function GET(_request: NextRequest, { params }: Params) {
|
export async function GET(_request: NextRequest, { params }: Params) {
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { organization } = await requireOrganizationAccess({
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmEnquiryRead
|
permission: PERMISSIONS.crmEnquiryRead
|
||||||
});
|
});
|
||||||
|
const accessContext = {
|
||||||
|
organizationId: organization.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
membershipRole: membership.role,
|
||||||
|
businessRole: membership.businessRole
|
||||||
|
};
|
||||||
const [enquiry, activity] = await Promise.all([
|
const [enquiry, activity] = await Promise.all([
|
||||||
getEnquiryDetail(id, organization.id),
|
getEnquiryDetail(id, organization.id, accessContext),
|
||||||
getEnquiryActivity(id, organization.id)
|
getEnquiryActivity(id, organization.id, accessContext)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -56,12 +62,22 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
|||||||
export async function PATCH(request: NextRequest, { params }: Params) {
|
export async function PATCH(request: NextRequest, { params }: Params) {
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { organization, session } = await requireOrganizationAccess({
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmEnquiryUpdate
|
permission: PERMISSIONS.crmEnquiryUpdate
|
||||||
});
|
});
|
||||||
const payload = enquiryRequestSchema.parse(await request.json());
|
const payload = enquiryRequestSchema.parse(await request.json());
|
||||||
const before = await getEnquiryDetail(id, organization.id);
|
const before = await getEnquiryDetail(id, organization.id, {
|
||||||
const updated = await updateEnquiry(id, organization.id, session.user.id, payload);
|
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({
|
await auditUpdate({
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
@@ -101,11 +117,21 @@ export async function PATCH(request: NextRequest, { params }: Params) {
|
|||||||
export async function DELETE(_request: NextRequest, { params }: Params) {
|
export async function DELETE(_request: NextRequest, { params }: Params) {
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { organization, session } = await requireOrganizationAccess({
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmEnquiryDelete
|
permission: PERMISSIONS.crmEnquiryDelete
|
||||||
});
|
});
|
||||||
const before = await getEnquiryDetail(id, organization.id);
|
const before = await getEnquiryDetail(id, organization.id, {
|
||||||
const updated = await softDeleteEnquiry(id, organization.id, session.user.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({
|
await auditDelete({
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
|
|||||||
@@ -15,30 +15,40 @@ const enquiryRequestSchema = enquirySchema.extend({
|
|||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { organization } = await requireOrganizationAccess({
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmEnquiryRead
|
permission: PERMISSIONS.crmEnquiryRead
|
||||||
});
|
});
|
||||||
const { searchParams } = request.nextUrl;
|
const { searchParams } = request.nextUrl;
|
||||||
const page = Number(searchParams.get('page') ?? 1);
|
const page = Number(searchParams.get('page') ?? 1);
|
||||||
const limit = Number(searchParams.get('limit') ?? 10);
|
const limit = Number(searchParams.get('limit') ?? 10);
|
||||||
const search = searchParams.get('search') ?? undefined;
|
const search = searchParams.get('search') ?? undefined;
|
||||||
|
const pipelineStage = searchParams.get('pipelineStage') ?? undefined;
|
||||||
const status = searchParams.get('status') ?? undefined;
|
const status = searchParams.get('status') ?? undefined;
|
||||||
const productType = searchParams.get('productType') ?? undefined;
|
const productType = searchParams.get('productType') ?? undefined;
|
||||||
const priority = searchParams.get('priority') ?? undefined;
|
const priority = searchParams.get('priority') ?? undefined;
|
||||||
const branch = searchParams.get('branch') ?? undefined;
|
const branch = searchParams.get('branch') ?? undefined;
|
||||||
const customer = searchParams.get('customer') ?? undefined;
|
const customer = searchParams.get('customer') ?? undefined;
|
||||||
const sort = searchParams.get('sort') ?? 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,
|
page,
|
||||||
limit,
|
limit,
|
||||||
search,
|
search,
|
||||||
|
pipelineStage: pipelineStage as 'lead' | 'enquiry' | 'closed_won' | 'closed_lost' | undefined,
|
||||||
status,
|
status,
|
||||||
productType,
|
productType,
|
||||||
priority,
|
priority,
|
||||||
branch,
|
branch,
|
||||||
customer,
|
customer,
|
||||||
sort
|
sort
|
||||||
});
|
}
|
||||||
|
);
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -65,11 +75,16 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { organization, session } = await requireOrganizationAccess({
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmEnquiryCreate
|
permission: PERMISSIONS.crmEnquiryCreate
|
||||||
});
|
});
|
||||||
const payload = enquiryRequestSchema.parse(await request.json());
|
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({
|
await auditCreate({
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
|
|||||||
(!!session?.user?.activeOrganizationId &&
|
(!!session?.user?.activeOrganizationId &&
|
||||||
(session.user.activeMembershipRole === 'admin' ||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryReassign)));
|
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();
|
const queryClient = getQueryClient();
|
||||||
|
|
||||||
if (canRead) {
|
if (canRead) {
|
||||||
@@ -67,14 +72,14 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
|
|||||||
? await getEnquiryReferenceData(session.user.activeOrganizationId)
|
? await getEnquiryReferenceData(session.user.activeOrganizationId)
|
||||||
: null;
|
: null;
|
||||||
const relatedQuotations =
|
const relatedQuotations =
|
||||||
canRead && session?.user?.activeOrganizationId
|
canRead && canReadQuotation && session?.user?.activeOrganizationId
|
||||||
? await listEnquiryQuotationRelations(id, session.user.activeOrganizationId)
|
? await listEnquiryQuotationRelations(id, session.user.activeOrganizationId)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer
|
<PageContainer
|
||||||
pageTitle='Enquiry Detail'
|
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}
|
access={canRead}
|
||||||
accessFallback={
|
accessFallback={
|
||||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
|
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
|
||||||
@@ -88,9 +93,11 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
|
|||||||
enquiryId={id}
|
enquiryId={id}
|
||||||
referenceData={referenceData}
|
referenceData={referenceData}
|
||||||
relatedQuotations={relatedQuotations}
|
relatedQuotations={relatedQuotations}
|
||||||
|
workspace='enquiry'
|
||||||
canUpdate={canUpdate}
|
canUpdate={canUpdate}
|
||||||
canAssign={canAssign}
|
canAssign={canAssign}
|
||||||
canReassign={canReassign}
|
canReassign={canReassign}
|
||||||
|
canViewRelatedQuotations={canReadQuotation}
|
||||||
canManageFollowups={{
|
canManageFollowups={{
|
||||||
create: canFollowupCreate,
|
create: canFollowupCreate,
|
||||||
update: canFollowupUpdate,
|
update: canFollowupUpdate,
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export default async function EnquiriesRoute(props: PageProps) {
|
|||||||
session.user.activePermissions.includes(
|
session.user.activePermissions.includes(
|
||||||
PERMISSIONS.crmEnquiryReassign,
|
PERMISSIONS.crmEnquiryReassign,
|
||||||
)));
|
)));
|
||||||
|
const businessRole = session?.user?.activeBusinessRole ?? null;
|
||||||
|
|
||||||
searchParamsCache.parse(searchParams);
|
searchParamsCache.parse(searchParams);
|
||||||
|
|
||||||
@@ -60,8 +61,8 @@ export default async function EnquiriesRoute(props: PageProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer
|
<PageContainer
|
||||||
pageTitle="Enquiries (Lead)"
|
pageTitle="Enquiries"
|
||||||
pageDescription="Production opportunity registry with customer linkage, follow-up workflow, and audit-backed activity."
|
pageDescription="Sales workspace for active enquiries, follow-up workflow, and quotation preparation."
|
||||||
access={canRead}
|
access={canRead}
|
||||||
accessFallback={
|
accessFallback={
|
||||||
<div className="text-muted-foreground rounded-lg border border-dashed p-8 text-center">
|
<div className="text-muted-foreground rounded-lg border border-dashed p-8 text-center">
|
||||||
@@ -70,13 +71,14 @@ export default async function EnquiriesRoute(props: PageProps) {
|
|||||||
}
|
}
|
||||||
pageHeaderAction={
|
pageHeaderAction={
|
||||||
canCreate && referenceData ? (
|
canCreate && referenceData ? (
|
||||||
<EnquiryFormSheetTrigger referenceData={referenceData} />
|
<EnquiryFormSheetTrigger referenceData={referenceData} workspace='enquiry' />
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{referenceData ? (
|
{referenceData ? (
|
||||||
<EnquiryListing
|
<EnquiryListing
|
||||||
referenceData={referenceData}
|
referenceData={referenceData}
|
||||||
|
workspace='enquiry'
|
||||||
canUpdate={canUpdate}
|
canUpdate={canUpdate}
|
||||||
canDelete={canDelete}
|
canDelete={canDelete}
|
||||||
canAssign={canAssign}
|
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" },
|
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",
|
url: "/dashboard/crm/enquiries",
|
||||||
icon: "forms",
|
icon: "forms",
|
||||||
isActive: false,
|
isActive: false,
|
||||||
|
|||||||
@@ -216,6 +216,7 @@ export const crmEnquiries = pgTable(
|
|||||||
notes: text('notes'),
|
notes: text('notes'),
|
||||||
isHotProject: boolean('is_hot_project').default(false).notNull(),
|
isHotProject: boolean('is_hot_project').default(false).notNull(),
|
||||||
isActive: boolean('is_active').default(true).notNull(),
|
isActive: boolean('is_active').default(true).notNull(),
|
||||||
|
pipelineStage: text('pipeline_stage').default('lead').notNull(),
|
||||||
assignedToUserId: text('assigned_to_user_id'),
|
assignedToUserId: text('assigned_to_user_id'),
|
||||||
assignedAt: timestamp('assigned_at', { withTimezone: true }),
|
assignedAt: timestamp('assigned_at', { withTimezone: true }),
|
||||||
assignedBy: text('assigned_by'),
|
assignedBy: text('assigned_by'),
|
||||||
|
|||||||
@@ -32,11 +32,13 @@ export interface CrmDashboardSummary {
|
|||||||
unassignedLeads: number;
|
unassignedLeads: number;
|
||||||
averageLeadAgingDays: number;
|
averageLeadAgingDays: number;
|
||||||
};
|
};
|
||||||
opportunity: {
|
enquiry: {
|
||||||
opportunityCount: number;
|
enquiryCount: number;
|
||||||
openOpportunities: number;
|
activeEnquiries: number;
|
||||||
hotOpportunities: number;
|
wonCount: number;
|
||||||
opportunityValue: number;
|
lostCount: number;
|
||||||
|
hotEnquiries: number;
|
||||||
|
enquiryValue: number;
|
||||||
};
|
};
|
||||||
quotation: {
|
quotation: {
|
||||||
draftQuotations: number;
|
draftQuotations: number;
|
||||||
@@ -73,7 +75,7 @@ export interface CrmDashboardSalesRankingRow {
|
|||||||
salesPersonId: string;
|
salesPersonId: string;
|
||||||
salesPersonName: string;
|
salesPersonName: string;
|
||||||
leadCount: number;
|
leadCount: number;
|
||||||
opportunityCount: number;
|
enquiryCount: number;
|
||||||
quotationCount: number;
|
quotationCount: number;
|
||||||
approvedQuotations: number;
|
approvedQuotations: number;
|
||||||
wonRevenue: number;
|
wonRevenue: number;
|
||||||
@@ -92,7 +94,7 @@ export interface CrmDashboardFollowupRow {
|
|||||||
sourceType: 'enquiry' | 'quotation';
|
sourceType: 'enquiry' | 'quotation';
|
||||||
followupDate: string;
|
followupDate: string;
|
||||||
customerName: string;
|
customerName: string;
|
||||||
opportunityName: string;
|
enquiryName: string;
|
||||||
assignedSalesName: string | null;
|
assignedSalesName: string | null;
|
||||||
outcome: string | null;
|
outcome: string | null;
|
||||||
}
|
}
|
||||||
@@ -148,4 +150,8 @@ export interface CrmDashboardResponse {
|
|||||||
myPendingApprovals: CrmDashboardApprovalRow[];
|
myPendingApprovals: CrmDashboardApprovalRow[];
|
||||||
};
|
};
|
||||||
hotProjects: CrmDashboardHotProjectRow[];
|
hotProjects: CrmDashboardHotProjectRow[];
|
||||||
|
visibility: {
|
||||||
|
canViewCommercialData: boolean;
|
||||||
|
canViewApprovalData: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,16 +36,29 @@ export function CrmDashboard({ canExport }: { canExport: boolean }) {
|
|||||||
return (
|
return (
|
||||||
<div className='space-y-6'>
|
<div className='space-y-6'>
|
||||||
<DashboardFilters referenceData={data.referenceData} canExport={canExport} />
|
<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]'>
|
<div className='grid gap-6 2xl:grid-cols-[1.35fr_1fr]'>
|
||||||
<DashboardFunnel steps={data.funnel} />
|
<DashboardFunnel steps={data.funnel} />
|
||||||
<DashboardHotProjects rows={data.hotProjects} />
|
<DashboardHotProjects rows={data.hotProjects} />
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
{data.visibility.canViewCommercialData ? (
|
||||||
<DashboardRevenueCards revenueAnalytics={data.revenueAnalytics} />
|
<DashboardRevenueCards revenueAnalytics={data.revenueAnalytics} />
|
||||||
|
) : null}
|
||||||
|
{data.visibility.canViewCommercialData || data.visibility.canViewApprovalData ? (
|
||||||
<div className='grid gap-6 2xl:grid-cols-[1.2fr_1fr]'>
|
<div className='grid gap-6 2xl:grid-cols-[1.2fr_1fr]'>
|
||||||
|
{data.visibility.canViewCommercialData ? (
|
||||||
<DashboardSalesRanking rows={data.salesRanking} />
|
<DashboardSalesRanking rows={data.salesRanking} />
|
||||||
|
) : null}
|
||||||
|
{data.visibility.canViewApprovalData ? (
|
||||||
<DashboardApprovalMetrics approvals={data.approvals} />
|
<DashboardApprovalMetrics approvals={data.approvals} />
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
<DashboardFollowups followups={data.followups} />
|
<DashboardFollowups followups={data.followups} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ export function DashboardFollowups({
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Date</TableHead>
|
<TableHead>Date</TableHead>
|
||||||
<TableHead>Customer</TableHead>
|
<TableHead>Customer</TableHead>
|
||||||
<TableHead>Opportunity</TableHead>
|
<TableHead>Enquiry</TableHead>
|
||||||
<TableHead>Assigned Sales</TableHead>
|
<TableHead>Enquiry Owner</TableHead>
|
||||||
<TableHead>Source</TableHead>
|
<TableHead>Source</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -53,7 +53,7 @@ export function DashboardFollowups({
|
|||||||
<TableRow key={`${row.sourceType}-${row.id}`}>
|
<TableRow key={`${row.sourceType}-${row.id}`}>
|
||||||
<TableCell>{new Date(row.followupDate).toLocaleDateString()}</TableCell>
|
<TableCell>{new Date(row.followupDate).toLocaleDateString()}</TableCell>
|
||||||
<TableCell>{row.customerName}</TableCell>
|
<TableCell>{row.customerName}</TableCell>
|
||||||
<TableCell>{row.opportunityName}</TableCell>
|
<TableCell>{row.enquiryName}</TableCell>
|
||||||
<TableCell>{row.assignedSalesName ?? '-'}</TableCell>
|
<TableCell>{row.assignedSalesName ?? '-'}</TableCell>
|
||||||
<TableCell className='capitalize'>{row.sourceType}</TableCell>
|
<TableCell className='capitalize'>{row.sourceType}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function DashboardFunnel({ steps }: { steps: CrmDashboardFunnelStep[] })
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Sales Pipeline Funnel</CardTitle>
|
<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>
|
</CardHeader>
|
||||||
<CardContent className='space-y-4'>
|
<CardContent className='space-y-4'>
|
||||||
{steps.map((step) => (
|
{steps.map((step) => (
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ export function DashboardHotProjects({ rows }: { rows: CrmDashboardHotProjectRow
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Top Hot Opportunities</CardTitle>
|
<CardTitle>Top Active Enquiries</CardTitle>
|
||||||
<CardDescription>Production hot projects from enquiries, ranked by commercial value.</CardDescription>
|
<CardDescription>Sales-owned hot enquiries ranked by estimated commercial value.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Table>
|
<Table>
|
||||||
@@ -22,7 +22,7 @@ export function DashboardHotProjects({ rows }: { rows: CrmDashboardHotProjectRow
|
|||||||
<TableHead>Project</TableHead>
|
<TableHead>Project</TableHead>
|
||||||
<TableHead>Customer</TableHead>
|
<TableHead>Customer</TableHead>
|
||||||
<TableHead className='text-right'>Value</TableHead>
|
<TableHead className='text-right'>Value</TableHead>
|
||||||
<TableHead>Assigned Sales</TableHead>
|
<TableHead>Enquiry Owner</TableHead>
|
||||||
<TableHead className='text-right'>Probability</TableHead>
|
<TableHead className='text-right'>Probability</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export function DashboardSalesRanking({ rows }: { rows: CrmDashboardSalesRanking
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Sales Ranking</CardTitle>
|
<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>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Table>
|
<Table>
|
||||||
@@ -21,7 +21,7 @@ export function DashboardSalesRanking({ rows }: { rows: CrmDashboardSalesRanking
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Sales Person</TableHead>
|
<TableHead>Sales Person</TableHead>
|
||||||
<TableHead className='text-right'>Lead</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'>Quotation</TableHead>
|
||||||
<TableHead className='text-right'>Approved</TableHead>
|
<TableHead className='text-right'>Approved</TableHead>
|
||||||
<TableHead className='text-right'>Won Revenue</TableHead>
|
<TableHead className='text-right'>Won Revenue</TableHead>
|
||||||
@@ -40,7 +40,7 @@ export function DashboardSalesRanking({ rows }: { rows: CrmDashboardSalesRanking
|
|||||||
<TableRow key={row.salesPersonId}>
|
<TableRow key={row.salesPersonId}>
|
||||||
<TableCell className='font-medium'>{row.salesPersonName}</TableCell>
|
<TableCell className='font-medium'>{row.salesPersonName}</TableCell>
|
||||||
<TableCell className='text-right'>{formatNumber(row.leadCount)}</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.quotationCount)}</TableCell>
|
||||||
<TableCell className='text-right'>{formatNumber(row.approvedQuotations)}</TableCell>
|
<TableCell className='text-right'>{formatNumber(row.approvedQuotations)}</TableCell>
|
||||||
<TableCell className='text-right'>{formatNumber(row.wonRevenue)}</TableCell>
|
<TableCell className='text-right'>{formatNumber(row.wonRevenue)}</TableCell>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import type { CrmDashboardSummary } from '../api/types';
|
import type { CrmDashboardSummary } from '../api/types';
|
||||||
|
|
||||||
@@ -10,13 +11,15 @@ function formatNumber(value: number) {
|
|||||||
function SummaryCard({
|
function SummaryCard({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
items
|
items,
|
||||||
|
href
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
items: Array<{ label: string; value: number; suffix?: string }>;
|
items: Array<{ label: string; value: number; suffix?: string }>;
|
||||||
|
href?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
const content = (
|
||||||
<Card className='h-full'>
|
<Card className='h-full'>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{title}</CardTitle>
|
<CardTitle>{title}</CardTitle>
|
||||||
@@ -35,14 +38,23 @@ function SummaryCard({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 (
|
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
|
<SummaryCard
|
||||||
title='Lead'
|
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={[
|
items={[
|
||||||
{ label: 'Lead Count', value: summary.lead.leadCount },
|
{ label: 'Lead Count', value: summary.lead.leadCount },
|
||||||
{ label: 'New Leads', value: summary.lead.newLeads },
|
{ label: 'New Leads', value: summary.lead.newLeads },
|
||||||
@@ -51,15 +63,17 @@ export function DashboardSummaryCards({ summary }: { summary: CrmDashboardSummar
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
title='Opportunity'
|
title='Enquiry'
|
||||||
description='Assigned enquiries that remain active in the pipeline.'
|
description='Sales-owned enquiry-stage records plus closed outcomes.'
|
||||||
|
href='/dashboard/crm/enquiries'
|
||||||
items={[
|
items={[
|
||||||
{ label: 'Opportunity Count', value: summary.opportunity.opportunityCount },
|
{ label: 'Enquiry Count', value: summary.enquiry.enquiryCount },
|
||||||
{ label: 'Open Opportunities', value: summary.opportunity.openOpportunities },
|
{ label: 'Active Enquiries', value: summary.enquiry.activeEnquiries },
|
||||||
{ label: 'Hot Opportunities', value: summary.opportunity.hotOpportunities },
|
{ label: 'Closed Won', value: summary.enquiry.wonCount },
|
||||||
{ label: 'Opportunity Value', value: summary.opportunity.opportunityValue }
|
{ label: 'Closed Lost', value: summary.enquiry.lostCount }
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
{canViewCommercialData ? (
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
title='Quotation'
|
title='Quotation'
|
||||||
description='Live quotation status counts from production data.'
|
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 }
|
{ label: 'Sent Quotations', value: summary.quotation.sentQuotations }
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
|
{canViewCommercialData ? (
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
title='Revenue'
|
title='Revenue'
|
||||||
description='Quotation, won, and lost values using frozen dashboard rules.'
|
description='Quotation, won, and lost values using governed revenue rules.'
|
||||||
items={[
|
items={[
|
||||||
{ label: 'Quotation Value', value: summary.revenue.quotationValue },
|
{ label: 'Quotation Value', value: summary.revenue.quotationValue },
|
||||||
{ label: 'Won Value', value: summary.revenue.wonValue },
|
{ label: 'Won Value', value: summary.revenue.wonValue },
|
||||||
{ label: 'Lost Value', value: summary.revenue.lostValue }
|
{ label: 'Lost Value', value: summary.revenue.lostValue }
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
users
|
users
|
||||||
} from '@/db/schema';
|
} from '@/db/schema';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
import { getUserBranches } from '@/features/foundation/branch-scope/service';
|
import { getUserBranches } from '@/features/foundation/branch-scope/service';
|
||||||
import { listApprovalRequests } from '@/features/foundation/approval/server/service';
|
import { listApprovalRequests } from '@/features/foundation/approval/server/service';
|
||||||
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||||
@@ -54,6 +55,7 @@ type DashboardContext = {
|
|||||||
userId: string;
|
userId: string;
|
||||||
membershipRole: string;
|
membershipRole: string;
|
||||||
businessRole: string;
|
businessRole: string;
|
||||||
|
permissions: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type NormalizedFilters = {
|
type NormalizedFilters = {
|
||||||
@@ -70,6 +72,15 @@ type StatusMaps = {
|
|||||||
quotationStatusById: Map<string, string>;
|
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 {
|
function normalizeDashboardFilters(filters: CrmDashboardFilters): NormalizedFilters {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const monthStart = startOfDay(new Date(now.getFullYear(), now.getMonth(), 1));
|
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);
|
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> {
|
async function getStatusMaps(organizationId: string): Promise<StatusMaps> {
|
||||||
const [enquiryOptions, quotationOptions] = await Promise.all([
|
const [enquiryOptions, quotationOptions] = await Promise.all([
|
||||||
getActiveOptionsByCategory(ENQUIRY_STATUS_CATEGORY, { organizationId }),
|
getActiveOptionsByCategory(ENQUIRY_STATUS_CATEGORY, { organizationId }),
|
||||||
@@ -172,35 +231,39 @@ function matchesQuotationFilters(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isLeadStatus(statusCode: string | undefined, enquiry: typeof crmEnquiries.$inferSelect) {
|
function isPipelineStage(
|
||||||
return enquiry.assignedToUserId === null && !['closed_lost', 'cancelled'].includes(statusCode ?? '');
|
enquiry: typeof crmEnquiries.$inferSelect,
|
||||||
}
|
stage: 'lead' | 'enquiry' | 'closed_won' | 'closed_lost'
|
||||||
|
|
||||||
function isOpportunityStatus(
|
|
||||||
statusCode: string | undefined,
|
|
||||||
enquiry: typeof crmEnquiries.$inferSelect
|
|
||||||
) {
|
) {
|
||||||
return enquiry.assignedToUserId !== null && !['closed_lost', 'cancelled'].includes(statusCode ?? '');
|
return enquiry.pipelineStage === stage;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadScopedRows(
|
async function loadScopedRows(
|
||||||
organizationId: string,
|
context: DashboardContext,
|
||||||
filters: NormalizedFilters
|
filters: NormalizedFilters
|
||||||
) {
|
) {
|
||||||
const [enquiries, quotations] = await Promise.all([
|
const [enquiries, quotations] = await Promise.all([
|
||||||
db
|
db
|
||||||
.select()
|
.select()
|
||||||
.from(crmEnquiries)
|
.from(crmEnquiries)
|
||||||
.where(and(eq(crmEnquiries.organizationId, organizationId), isNull(crmEnquiries.deletedAt))),
|
.where(
|
||||||
|
and(eq(crmEnquiries.organizationId, context.organizationId), isNull(crmEnquiries.deletedAt))
|
||||||
|
),
|
||||||
db
|
db
|
||||||
.select()
|
.select()
|
||||||
.from(crmQuotations)
|
.from(crmQuotations)
|
||||||
.where(and(eq(crmQuotations.organizationId, organizationId), isNull(crmQuotations.deletedAt)))
|
.where(
|
||||||
|
and(eq(crmQuotations.organizationId, context.organizationId), isNull(crmQuotations.deletedAt))
|
||||||
|
)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enquiries: enquiries.filter((row) => matchesEnquiryFilters(row, filters)),
|
enquiries: enquiries
|
||||||
quotations: quotations.filter((row) => matchesQuotationFilters(row, filters))
|
.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
|
statusMaps: StatusMaps
|
||||||
): CrmDashboardSummary {
|
): CrmDashboardSummary {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const leadRows = scopedEnquiries.filter((row) =>
|
const leadRows = scopedEnquiries.filter((row) => isPipelineStage(row, 'lead'));
|
||||||
isLeadStatus(statusMaps.enquiryStatusById.get(row.status), row)
|
const enquiryRows = scopedEnquiries.filter((row) => isPipelineStage(row, 'enquiry'));
|
||||||
);
|
const wonRows = scopedEnquiries.filter((row) => isPipelineStage(row, 'closed_won'));
|
||||||
const opportunityRows = scopedEnquiries.filter((row) =>
|
const lostRows = scopedEnquiries.filter((row) => isPipelineStage(row, 'closed_lost'));
|
||||||
isOpportunityStatus(statusMaps.enquiryStatusById.get(row.status), row)
|
|
||||||
);
|
|
||||||
|
|
||||||
const leadAges = leadRows.map((row) => (now.getTime() - row.createdAt.getTime()) / (1000 * 60 * 60 * 24));
|
const leadAges = leadRows.map((row) => (now.getTime() - row.createdAt.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
const quotationStatusCodes = scopedQuotations.map((row) => statusMaps.quotationStatusById.get(row.status) ?? '');
|
const quotationStatusCodes = scopedQuotations.map((row) => statusMaps.quotationStatusById.get(row.status) ?? '');
|
||||||
@@ -224,14 +285,16 @@ function buildSummary(
|
|||||||
lead: {
|
lead: {
|
||||||
leadCount: leadRows.length,
|
leadCount: leadRows.length,
|
||||||
newLeads: leadRows.length,
|
newLeads: leadRows.length,
|
||||||
unassignedLeads: leadRows.length,
|
unassignedLeads: leadRows.filter((row) => row.assignedToUserId === null).length,
|
||||||
averageLeadAgingDays: average(leadAges)
|
averageLeadAgingDays: average(leadAges)
|
||||||
},
|
},
|
||||||
opportunity: {
|
enquiry: {
|
||||||
opportunityCount: opportunityRows.length,
|
enquiryCount: enquiryRows.length,
|
||||||
openOpportunities: opportunityRows.length,
|
activeEnquiries: enquiryRows.length,
|
||||||
hotOpportunities: opportunityRows.filter((row) => row.isHotProject).length,
|
wonCount: wonRows.length,
|
||||||
opportunityValue: round2(opportunityRows.reduce((sum, row) => sum + (row.estimatedValue ?? 0), 0))
|
lostCount: lostRows.length,
|
||||||
|
hotEnquiries: enquiryRows.filter((row) => row.isHotProject).length,
|
||||||
|
enquiryValue: round2(enquiryRows.reduce((sum, row) => sum + (row.estimatedValue ?? 0), 0))
|
||||||
},
|
},
|
||||||
quotation: {
|
quotation: {
|
||||||
draftQuotations: quotationStatusCodes.filter((code) => code === 'draft').length,
|
draftQuotations: quotationStatusCodes.filter((code) => code === 'draft').length,
|
||||||
@@ -274,10 +337,10 @@ function buildFunnel(
|
|||||||
value: 0
|
value: 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'opportunity',
|
key: 'enquiry',
|
||||||
label: 'Opportunity',
|
label: 'Enquiry',
|
||||||
count: summary.opportunity.opportunityCount,
|
count: summary.enquiry.enquiryCount,
|
||||||
value: summary.opportunity.opportunityValue
|
value: summary.enquiry.enquiryValue
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'quotation',
|
key: 'quotation',
|
||||||
@@ -312,9 +375,7 @@ function buildFunnel(
|
|||||||
{
|
{
|
||||||
key: 'closed_won',
|
key: 'closed_won',
|
||||||
label: 'Closed Won',
|
label: 'Closed Won',
|
||||||
count: scopedQuotations.filter(
|
count: summary.enquiry.wonCount,
|
||||||
(row) => (statusMaps.quotationStatusById.get(row.status) ?? '') === 'accepted'
|
|
||||||
).length,
|
|
||||||
value: scopedQuotations
|
value: scopedQuotations
|
||||||
.filter((row) => (statusMaps.quotationStatusById.get(row.status) ?? '') === 'accepted')
|
.filter((row) => (statusMaps.quotationStatusById.get(row.status) ?? '') === 'accepted')
|
||||||
.reduce((sum, row) => sum + row.totalAmount, 0)
|
.reduce((sum, row) => sum + row.totalAmount, 0)
|
||||||
@@ -415,19 +476,19 @@ async function buildSalesRanking(
|
|||||||
salesPersonId: row.assignedToUserId,
|
salesPersonId: row.assignedToUserId,
|
||||||
salesPersonName: userMap.get(row.assignedToUserId) ?? 'Unknown',
|
salesPersonName: userMap.get(row.assignedToUserId) ?? 'Unknown',
|
||||||
leadCount: 0,
|
leadCount: 0,
|
||||||
opportunityCount: 0,
|
enquiryCount: 0,
|
||||||
quotationCount: 0,
|
quotationCount: 0,
|
||||||
approvedQuotations: 0,
|
approvedQuotations: 0,
|
||||||
wonRevenue: 0,
|
wonRevenue: 0,
|
||||||
conversionRate: 0
|
conversionRate: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLeadStatus(statusMaps.enquiryStatusById.get(row.status), row)) {
|
if (isPipelineStage(row, 'lead')) {
|
||||||
current.leadCount += 1;
|
current.leadCount += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isOpportunityStatus(statusMaps.enquiryStatusById.get(row.status), row)) {
|
if (isPipelineStage(row, 'enquiry')) {
|
||||||
current.opportunityCount += 1;
|
current.enquiryCount += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
rankingMap.set(row.assignedToUserId, current);
|
rankingMap.set(row.assignedToUserId, current);
|
||||||
@@ -441,7 +502,7 @@ async function buildSalesRanking(
|
|||||||
salesPersonId: row.salesmanId,
|
salesPersonId: row.salesmanId,
|
||||||
salesPersonName: userMap.get(row.salesmanId) ?? 'Unknown',
|
salesPersonName: userMap.get(row.salesmanId) ?? 'Unknown',
|
||||||
leadCount: 0,
|
leadCount: 0,
|
||||||
opportunityCount: 0,
|
enquiryCount: 0,
|
||||||
quotationCount: 0,
|
quotationCount: 0,
|
||||||
approvedQuotations: 0,
|
approvedQuotations: 0,
|
||||||
wonRevenue: 0,
|
wonRevenue: 0,
|
||||||
@@ -480,20 +541,33 @@ async function buildSalesRanking(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function buildFollowups(
|
async function buildFollowups(
|
||||||
organizationId: string,
|
context: DashboardContext,
|
||||||
filters: NormalizedFilters
|
filters: NormalizedFilters
|
||||||
): Promise<{ summary: CrmDashboardFollowupSummary; upcoming: CrmDashboardFollowupRow[] }> {
|
): Promise<{ summary: CrmDashboardFollowupSummary; upcoming: CrmDashboardFollowupRow[] }> {
|
||||||
|
const allowQuotationFollowups = canViewCommercialData(context);
|
||||||
const [enquiryFollowups, quotationFollowups] = await Promise.all([
|
const [enquiryFollowups, quotationFollowups] = await Promise.all([
|
||||||
db
|
db
|
||||||
.select()
|
.select()
|
||||||
.from(crmEnquiryFollowups)
|
.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)),
|
.orderBy(asc(crmEnquiryFollowups.followupDate)),
|
||||||
db
|
allowQuotationFollowups
|
||||||
|
? db
|
||||||
.select()
|
.select()
|
||||||
.from(crmQuotationFollowups)
|
.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))
|
.orderBy(asc(crmQuotationFollowups.followupDate))
|
||||||
|
: []
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const { from, to } = toDayBounds(filters);
|
const { from, to } = toDayBounds(filters);
|
||||||
@@ -522,6 +596,10 @@ async function buildFollowups(
|
|||||||
const salesMap = new Map(salesUsers.map((row) => [row.id, row.name]));
|
const salesMap = new Map(salesUsers.map((row) => [row.id, row.name]));
|
||||||
|
|
||||||
const enquiryRows = enquiryFollowups
|
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))
|
.filter((row) => !isBefore(row.followupDate, from) && !isAfter(row.followupDate, to))
|
||||||
.flatMap<CrmDashboardFollowupRow>((row) => {
|
.flatMap<CrmDashboardFollowupRow>((row) => {
|
||||||
const enquiry = enquiryMap.get(row.enquiryId);
|
const enquiry = enquiryMap.get(row.enquiryId);
|
||||||
@@ -536,7 +614,7 @@ async function buildFollowups(
|
|||||||
sourceType: 'enquiry',
|
sourceType: 'enquiry',
|
||||||
followupDate: row.followupDate.toISOString(),
|
followupDate: row.followupDate.toISOString(),
|
||||||
customerName: customerMap.get(enquiry.customerId) ?? 'Unknown customer',
|
customerName: customerMap.get(enquiry.customerId) ?? 'Unknown customer',
|
||||||
opportunityName: enquiry.projectName ?? enquiry.title,
|
enquiryName: enquiry.projectName ?? enquiry.title,
|
||||||
assignedSalesName: enquiry.assignedToUserId
|
assignedSalesName: enquiry.assignedToUserId
|
||||||
? (salesMap.get(enquiry.assignedToUserId) ?? null)
|
? (salesMap.get(enquiry.assignedToUserId) ?? null)
|
||||||
: null,
|
: null,
|
||||||
@@ -545,6 +623,10 @@ async function buildFollowups(
|
|||||||
];
|
];
|
||||||
});
|
});
|
||||||
const quotationRows = quotationFollowups
|
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))
|
.filter((row) => !isBefore(row.followupDate, from) && !isAfter(row.followupDate, to))
|
||||||
.flatMap<CrmDashboardFollowupRow>((row) => {
|
.flatMap<CrmDashboardFollowupRow>((row) => {
|
||||||
const quotation = quotationMap.get(row.quotationId);
|
const quotation = quotationMap.get(row.quotationId);
|
||||||
@@ -559,7 +641,7 @@ async function buildFollowups(
|
|||||||
sourceType: 'quotation',
|
sourceType: 'quotation',
|
||||||
followupDate: row.followupDate.toISOString(),
|
followupDate: row.followupDate.toISOString(),
|
||||||
customerName: customerMap.get(quotation.customerId) ?? 'Unknown customer',
|
customerName: customerMap.get(quotation.customerId) ?? 'Unknown customer',
|
||||||
opportunityName: quotation.projectName ?? quotation.code,
|
enquiryName: quotation.projectName ?? quotation.code,
|
||||||
assignedSalesName: quotation.salesmanId
|
assignedSalesName: quotation.salesmanId
|
||||||
? (salesMap.get(quotation.salesmanId) ?? null)
|
? (salesMap.get(quotation.salesmanId) ?? null)
|
||||||
: null,
|
: null,
|
||||||
@@ -636,18 +718,25 @@ async function buildApprovalAnalytics(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function buildHotProjects(
|
async function buildHotProjects(
|
||||||
organizationId: string,
|
context: DashboardContext,
|
||||||
filters: NormalizedFilters,
|
filters: NormalizedFilters,
|
||||||
statusMaps: StatusMaps
|
statusMaps: StatusMaps
|
||||||
): Promise<CrmDashboardHotProjectRow[]> {
|
): Promise<CrmDashboardHotProjectRow[]> {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(crmEnquiries)
|
.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));
|
.orderBy(desc(crmEnquiries.updatedAt));
|
||||||
const scoped = rows
|
const scoped = rows
|
||||||
|
.filter((row) => canAccessDashboardEnquiry(row, context))
|
||||||
.filter((row) => matchesEnquiryFilters(row, filters))
|
.filter((row) => matchesEnquiryFilters(row, filters))
|
||||||
.filter((row) => isOpportunityStatus(statusMaps.enquiryStatusById.get(row.status), row))
|
.filter((row) => isPipelineStage(row, 'enquiry'))
|
||||||
.slice(0, 10);
|
.slice(0, 10);
|
||||||
const customerIds = [...new Set(scoped.map((row) => row.customerId))];
|
const customerIds = [...new Set(scoped.map((row) => row.customerId))];
|
||||||
const salesIds = [...new Set(scoped.map((row) => row.assignedToUserId).filter(Boolean))] as string[];
|
const salesIds = [...new Set(scoped.map((row) => row.assignedToUserId).filter(Boolean))] as string[];
|
||||||
@@ -674,18 +763,42 @@ export async function getCrmDashboardData(
|
|||||||
rawFilters: CrmDashboardFilters
|
rawFilters: CrmDashboardFilters
|
||||||
): Promise<CrmDashboardResponse> {
|
): Promise<CrmDashboardResponse> {
|
||||||
const filters = normalizeDashboardFilters(rawFilters);
|
const filters = normalizeDashboardFilters(rawFilters);
|
||||||
|
const allowCommercialData = canViewCommercialData(context);
|
||||||
|
const allowApprovalData = canViewApprovalData(context);
|
||||||
const [referenceData, statusMaps, scoped] = await Promise.all([
|
const [referenceData, statusMaps, scoped] = await Promise.all([
|
||||||
getReferenceData(context.organizationId),
|
getReferenceData(context.organizationId),
|
||||||
getStatusMaps(context.organizationId),
|
getStatusMaps(context.organizationId),
|
||||||
loadScopedRows(context.organizationId, filters)
|
loadScopedRows(context, filters)
|
||||||
]);
|
]);
|
||||||
const summary = buildSummary(scoped.enquiries, scoped.quotations, statusMaps);
|
const summary = buildSummary(scoped.enquiries, scoped.quotations, statusMaps);
|
||||||
const [revenueAnalytics, salesRanking, followups, approvals, hotProjects] = await Promise.all([
|
const [revenueAnalytics, salesRanking, followups, approvals, hotProjects] = await Promise.all([
|
||||||
buildRevenueAnalytics(context.organizationId, filters),
|
allowCommercialData
|
||||||
buildSalesRanking(scoped.enquiries, scoped.quotations, statusMaps),
|
? buildRevenueAnalytics(context.organizationId, filters)
|
||||||
buildFollowups(context.organizationId, filters),
|
: Promise.resolve({
|
||||||
buildApprovalAnalytics(context),
|
topEndCustomers: [],
|
||||||
buildHotProjects(context.organizationId, filters, statusMaps)
|
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 {
|
return {
|
||||||
@@ -700,6 +813,10 @@ export async function getCrmDashboardData(
|
|||||||
salesRanking,
|
salesRanking,
|
||||||
followups,
|
followups,
|
||||||
approvals,
|
approvals,
|
||||||
hotProjects
|
hotProjects,
|
||||||
|
visibility: {
|
||||||
|
canViewCommercialData: allowCommercialData,
|
||||||
|
canViewApprovalData: allowApprovalData
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,11 +16,16 @@ import type {
|
|||||||
EnquiryFollowupMutationPayload,
|
EnquiryFollowupMutationPayload,
|
||||||
EnquiryMutationPayload
|
EnquiryMutationPayload
|
||||||
} from './types';
|
} from './types';
|
||||||
|
import { crmDashboardKeys } from '@/features/crm/dashboard/api/queries';
|
||||||
|
|
||||||
async function invalidateEnquiryLists() {
|
async function invalidateEnquiryLists() {
|
||||||
await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.lists() });
|
await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.lists() });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function invalidateDashboardQueries() {
|
||||||
|
await getQueryClient().invalidateQueries({ queryKey: crmDashboardKeys.all });
|
||||||
|
}
|
||||||
|
|
||||||
async function invalidateEnquiryDetail(id: string) {
|
async function invalidateEnquiryDetail(id: string) {
|
||||||
await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.detail(id) });
|
await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.detail(id) });
|
||||||
}
|
}
|
||||||
@@ -37,7 +42,8 @@ export async function invalidateEnquiryMutationQueries(id: string) {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
invalidateEnquiryLists(),
|
invalidateEnquiryLists(),
|
||||||
invalidateEnquiryDetail(id),
|
invalidateEnquiryDetail(id),
|
||||||
invalidateEnquiryProjectParties(id)
|
invalidateEnquiryProjectParties(id),
|
||||||
|
invalidateDashboardQueries()
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,7 +51,8 @@ export async function invalidateEnquiryFollowupMutationQueries(enquiryId: string
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
invalidateEnquiryLists(),
|
invalidateEnquiryLists(),
|
||||||
invalidateEnquiryDetail(enquiryId),
|
invalidateEnquiryDetail(enquiryId),
|
||||||
invalidateEnquiryFollowups(enquiryId)
|
invalidateEnquiryFollowups(enquiryId),
|
||||||
|
invalidateDashboardQueries()
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +60,7 @@ export const createEnquiryMutation = mutationOptions({
|
|||||||
mutationFn: (data: EnquiryMutationPayload) => createEnquiry(data),
|
mutationFn: (data: EnquiryMutationPayload) => createEnquiry(data),
|
||||||
onSettled: async (_data, error) => {
|
onSettled: async (_data, error) => {
|
||||||
if (!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.detail(id) });
|
||||||
getQueryClient().removeQueries({ queryKey: enquiryKeys.projectParties(id) });
|
getQueryClient().removeQueries({ queryKey: enquiryKeys.projectParties(id) });
|
||||||
getQueryClient().removeQueries({ queryKey: enquiryKeys.followups(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.page) searchParams.set('page', String(filters.page));
|
||||||
if (filters.limit) searchParams.set('limit', String(filters.limit));
|
if (filters.limit) searchParams.set('limit', String(filters.limit));
|
||||||
if (filters.search) searchParams.set('search', filters.search);
|
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.status) searchParams.set('status', filters.status);
|
||||||
if (filters.productType) searchParams.set('productType', filters.productType);
|
if (filters.productType) searchParams.set('productType', filters.productType);
|
||||||
if (filters.priority) searchParams.set('priority', filters.priority);
|
if (filters.priority) searchParams.set('priority', filters.priority);
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ export interface EnquiryAssignableUserLookup {
|
|||||||
businessRole: string;
|
businessRole: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type EnquiryPipelineStage = 'lead' | 'enquiry' | 'closed_won' | 'closed_lost';
|
||||||
|
|
||||||
export interface EnquiryRecord {
|
export interface EnquiryRecord {
|
||||||
id: string;
|
id: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
@@ -78,6 +80,7 @@ export interface EnquiryRecord {
|
|||||||
notes: string | null;
|
notes: string | null;
|
||||||
isHotProject: boolean;
|
isHotProject: boolean;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
pipelineStage: EnquiryPipelineStage;
|
||||||
assignedToUserId: string | null;
|
assignedToUserId: string | null;
|
||||||
assignedToName: string | null;
|
assignedToName: string | null;
|
||||||
assignedAt: string | null;
|
assignedAt: string | null;
|
||||||
@@ -95,6 +98,9 @@ export interface EnquiryListItem extends EnquiryRecord {
|
|||||||
customerName: string;
|
customerName: string;
|
||||||
contactName: string | null;
|
contactName: string | null;
|
||||||
followupCount: number;
|
followupCount: number;
|
||||||
|
quotationCount: number;
|
||||||
|
nextFollowupDate: string | null;
|
||||||
|
createdByName: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnquiryFollowupRecord {
|
export interface EnquiryFollowupRecord {
|
||||||
@@ -142,6 +148,7 @@ export interface EnquiryFilters {
|
|||||||
page?: number;
|
page?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
search?: string;
|
search?: string;
|
||||||
|
pipelineStage?: EnquiryPipelineStage;
|
||||||
status?: string;
|
status?: string;
|
||||||
productType?: string;
|
productType?: string;
|
||||||
priority?: string;
|
priority?: string;
|
||||||
|
|||||||
@@ -13,20 +13,22 @@ import { getEnquiryColumns } from './enquiry-columns';
|
|||||||
|
|
||||||
export function EnquiriesTable({
|
export function EnquiriesTable({
|
||||||
referenceData,
|
referenceData,
|
||||||
|
workspace,
|
||||||
canUpdate,
|
canUpdate,
|
||||||
canDelete,
|
canDelete,
|
||||||
canAssign,
|
canAssign,
|
||||||
canReassign
|
canReassign
|
||||||
}: {
|
}: {
|
||||||
referenceData: EnquiryReferenceData;
|
referenceData: EnquiryReferenceData;
|
||||||
|
workspace: 'lead' | 'enquiry';
|
||||||
canUpdate: boolean;
|
canUpdate: boolean;
|
||||||
canDelete: boolean;
|
canDelete: boolean;
|
||||||
canAssign: boolean;
|
canAssign: boolean;
|
||||||
canReassign: boolean;
|
canReassign: boolean;
|
||||||
}) {
|
}) {
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() => getEnquiryColumns({ referenceData, canUpdate, canDelete, canAssign, canReassign }),
|
() => getEnquiryColumns({ referenceData, workspace, canUpdate, canDelete, canAssign, canReassign }),
|
||||||
[referenceData, canUpdate, canDelete, canAssign, canReassign]
|
[referenceData, workspace, canUpdate, canDelete, canAssign, canReassign]
|
||||||
);
|
);
|
||||||
const columnIds = columns.map((column) => column.id).filter(Boolean) as string[];
|
const columnIds = columns.map((column) => column.id).filter(Boolean) as string[];
|
||||||
const [params] = useQueryStates({
|
const [params] = useQueryStates({
|
||||||
@@ -44,6 +46,7 @@ export function EnquiriesTable({
|
|||||||
const filters = {
|
const filters = {
|
||||||
page: params.page,
|
page: params.page,
|
||||||
limit: params.perPage,
|
limit: params.perPage,
|
||||||
|
pipelineStage: workspace,
|
||||||
...(params.name && { search: params.name }),
|
...(params.name && { search: params.name }),
|
||||||
...(params.status && { status: params.status }),
|
...(params.status && { status: params.status }),
|
||||||
...(params.productType && { productType: params.productType }),
|
...(params.productType && { productType: params.productType }),
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function EnquiryAssignmentDialog({
|
|||||||
const assignMutation = useMutation({
|
const assignMutation = useMutation({
|
||||||
...assignEnquiryMutation,
|
...assignEnquiryMutation,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Enquiry assigned successfully');
|
toast.success('Lead converted to enquiry successfully');
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
},
|
},
|
||||||
onError: (error) =>
|
onError: (error) =>
|
||||||
@@ -109,10 +109,10 @@ export function EnquiryAssignmentDialog({
|
|||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{mode === 'assign' ? 'Assign Sales' : 'Reassign Sales'}</DialogTitle>
|
<DialogTitle>{mode === 'assign' ? 'Convert To Enquiry' : 'Reassign Enquiry Owner'}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{mode === 'assign'
|
{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.'}
|
: 'Move this enquiry to a different sales owner and keep the handoff note visible.'}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -127,7 +127,7 @@ export function EnquiryAssignmentDialog({
|
|||||||
<>
|
<>
|
||||||
<FormSelectField
|
<FormSelectField
|
||||||
name='assignedToUserId'
|
name='assignedToUserId'
|
||||||
label='Sales User'
|
label='Enquiry Owner'
|
||||||
required
|
required
|
||||||
options={referenceData.assignableUsers.map((user) => ({
|
options={referenceData.assignableUsers.map((user) => ({
|
||||||
value: user.id,
|
value: user.id,
|
||||||
@@ -156,7 +156,7 @@ export function EnquiryAssignmentDialog({
|
|||||||
disabled={noUsersAvailable}
|
disabled={noUsersAvailable}
|
||||||
>
|
>
|
||||||
<Icons.userPen className='mr-2 h-4 w-4' />
|
<Icons.userPen className='mr-2 h-4 w-4' />
|
||||||
{mode === 'assign' ? 'Assign Sales' : 'Reassign Sales'}
|
{mode === 'assign' ? 'Convert To Enquiry' : 'Reassign Owner'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { EnquiryFormSheet } from './enquiry-form-sheet';
|
|||||||
export function EnquiryCellAction({
|
export function EnquiryCellAction({
|
||||||
data,
|
data,
|
||||||
referenceData,
|
referenceData,
|
||||||
|
workspace,
|
||||||
canUpdate,
|
canUpdate,
|
||||||
canDelete,
|
canDelete,
|
||||||
canAssign: _canAssign,
|
canAssign: _canAssign,
|
||||||
@@ -28,6 +29,7 @@ export function EnquiryCellAction({
|
|||||||
}: {
|
}: {
|
||||||
data: EnquiryListItem;
|
data: EnquiryListItem;
|
||||||
referenceData: EnquiryReferenceData;
|
referenceData: EnquiryReferenceData;
|
||||||
|
workspace: 'lead' | 'enquiry';
|
||||||
canUpdate: boolean;
|
canUpdate: boolean;
|
||||||
canDelete: boolean;
|
canDelete: boolean;
|
||||||
canAssign: boolean;
|
canAssign: boolean;
|
||||||
@@ -40,11 +42,15 @@ export function EnquiryCellAction({
|
|||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
...deleteEnquiryMutation,
|
...deleteEnquiryMutation,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Enquiry deleted successfully');
|
toast.success(`${workspace === 'lead' ? 'Lead' : 'Enquiry'} deleted successfully`);
|
||||||
setDeleteOpen(false);
|
setDeleteOpen(false);
|
||||||
},
|
},
|
||||||
onError: (error) =>
|
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 (
|
return (
|
||||||
@@ -60,6 +66,7 @@ export function EnquiryCellAction({
|
|||||||
open={editOpen}
|
open={editOpen}
|
||||||
onOpenChange={setEditOpen}
|
onOpenChange={setEditOpen}
|
||||||
referenceData={referenceData}
|
referenceData={referenceData}
|
||||||
|
workspace={workspace}
|
||||||
/>
|
/>
|
||||||
<DropdownMenu modal={false}>
|
<DropdownMenu modal={false}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -70,11 +77,19 @@ export function EnquiryCellAction({
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align='end'>
|
<DropdownMenuContent align='end'>
|
||||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
<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
|
<Icons.arrowRight className='mr-2 h-4 w-4' /> View
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => setEditOpen(true)} disabled={!canUpdate}>
|
<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>
|
||||||
<DropdownMenuItem onClick={() => setDeleteOpen(true)} disabled={!canDelete}>
|
<DropdownMenuItem onClick={() => setDeleteOpen(true)} disabled={!canDelete}>
|
||||||
<Icons.trash className='mr-2 h-4 w-4' /> Delete
|
<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 { EnquiryCellAction } from './enquiry-cell-action';
|
||||||
import { EnquiryStatusBadge } from './enquiry-status-badge';
|
import { EnquiryStatusBadge } from './enquiry-status-badge';
|
||||||
|
|
||||||
export function getEnquiryColumns({
|
function getLeadColumns({
|
||||||
referenceData,
|
referenceData,
|
||||||
canUpdate,
|
canUpdate,
|
||||||
canDelete,
|
canDelete,
|
||||||
canAssign,
|
canAssign,
|
||||||
canReassign
|
canReassign
|
||||||
}: {
|
}: SharedColumnsConfig): ColumnDef<EnquiryListItem>[] {
|
||||||
referenceData: EnquiryReferenceData;
|
|
||||||
canUpdate: boolean;
|
|
||||||
canDelete: boolean;
|
|
||||||
canAssign: boolean;
|
|
||||||
canReassign: boolean;
|
|
||||||
}): ColumnDef<EnquiryListItem>[] {
|
|
||||||
const statusMap = new Map(referenceData.statuses.map((item) => [item.id, item]));
|
const statusMap = new Map(referenceData.statuses.map((item) => [item.id, item]));
|
||||||
const productTypeMap = new Map(referenceData.productTypes.map((item) => [item.id, item]));
|
const leadChannelMap = new Map(referenceData.leadChannels.map((item) => [item.id, item.label]));
|
||||||
const priorityMap = new Map(referenceData.priorities.map((item) => [item.id, item]));
|
|
||||||
const branchMap = new Map(referenceData.branches.map((item) => [item.id, item]));
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: 'title',
|
id: 'code',
|
||||||
accessorKey: 'title',
|
accessorKey: 'code',
|
||||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||||
<DataTableColumnHeader column={column} title='Enquiry' />
|
<DataTableColumnHeader column={column} title='Code' />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className='flex flex-col'>
|
<div className='flex flex-col'>
|
||||||
<Link
|
<Link
|
||||||
href={`/dashboard/crm/enquiries/${row.original.id}`}
|
href={`/dashboard/crm/leads/${row.original.id}`}
|
||||||
className='font-medium hover:underline'
|
className='font-medium hover:underline'
|
||||||
>
|
>
|
||||||
{row.original.title}
|
{row.original.code}
|
||||||
</Link>
|
</Link>
|
||||||
<span className='text-muted-foreground text-xs'>
|
<span className='text-muted-foreground text-xs'>{row.original.title}</span>
|
||||||
{row.original.code} • {row.original.customerName}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
meta: {
|
meta: {
|
||||||
label: 'Enquiry',
|
label: 'Code',
|
||||||
placeholder: 'Search enquiries...',
|
placeholder: 'Search leads...',
|
||||||
variant: 'text' as const,
|
variant: 'text' as const,
|
||||||
icon: Icons.search
|
icon: Icons.search
|
||||||
},
|
},
|
||||||
enableColumnFilter: true
|
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',
|
id: 'status',
|
||||||
accessorKey: 'status',
|
accessorKey: 'status',
|
||||||
@@ -76,79 +140,74 @@ export function getEnquiryColumns({
|
|||||||
enableColumnFilter: true
|
enableColumnFilter: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'productType',
|
id: 'actions',
|
||||||
accessorKey: 'productType',
|
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> }) => (
|
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||||
<DataTableColumnHeader column={column} title='Product Type' />
|
<DataTableColumnHeader column={column} title='Code' />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Badge variant='outline'>
|
<div className='flex flex-col'>
|
||||||
{productTypeMap.get(row.original.productType)?.label ?? 'Unknown'}
|
<Link
|
||||||
</Badge>
|
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: {
|
meta: {
|
||||||
label: 'Product Type',
|
label: 'Code',
|
||||||
variant: 'multiSelect' as const,
|
placeholder: 'Search enquiries...',
|
||||||
options: referenceData.productTypes.map((item) => ({
|
variant: 'text' as const,
|
||||||
value: item.id,
|
icon: Icons.search
|
||||||
label: item.label
|
|
||||||
}))
|
|
||||||
},
|
},
|
||||||
enableColumnFilter: true
|
enableColumnFilter: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'priority',
|
id: 'projectName',
|
||||||
accessorKey: 'priority',
|
accessorFn: (row) => row.projectName ?? row.title,
|
||||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||||
<DataTableColumnHeader column={column} title='Priority' />
|
<DataTableColumnHeader column={column} title='Project' />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => <span>{row.original.projectName ?? row.original.title}</span>
|
||||||
<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
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'customer',
|
id: 'customer',
|
||||||
accessorFn: (row) => row.customerId,
|
accessorFn: (row) => row.customerId,
|
||||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
||||||
<DataTableColumnHeader column={column} title='Customer' />
|
<DataTableColumnHeader column={column} title='Billing Customer' />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => <span>{row.original.customerName}</span>,
|
cell: ({ row }) => <span>{row.original.customerName}</span>,
|
||||||
meta: {
|
meta: {
|
||||||
label: 'Customer',
|
label: 'Billing Customer',
|
||||||
variant: 'multiSelect' as const,
|
variant: 'multiSelect' as const,
|
||||||
options: referenceData.customers.map((item) => ({
|
options: referenceData.customers.map((item) => ({
|
||||||
value: item.id,
|
value: item.id,
|
||||||
@@ -159,25 +218,77 @@ export function getEnquiryColumns({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'assignedSales',
|
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 }) => (
|
cell: ({ row }) => (
|
||||||
<div className='flex flex-col'>
|
<span>{row.original.chancePercent !== null ? `${row.original.chancePercent}%` : '-'}</span>
|
||||||
<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>
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'followupCount',
|
id: 'estimatedValue',
|
||||||
accessorKey: 'followupCount',
|
accessorKey: 'estimatedValue',
|
||||||
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
|
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',
|
id: 'actions',
|
||||||
@@ -185,6 +296,7 @@ export function getEnquiryColumns({
|
|||||||
<EnquiryCellAction
|
<EnquiryCellAction
|
||||||
data={row.original}
|
data={row.original}
|
||||||
referenceData={referenceData}
|
referenceData={referenceData}
|
||||||
|
workspace='enquiry'
|
||||||
canUpdate={canUpdate}
|
canUpdate={canUpdate}
|
||||||
canDelete={canDelete}
|
canDelete={canDelete}
|
||||||
canAssign={canAssign}
|
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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { enquiryByIdOptions, enquiryProjectPartiesOptions } from '../api/queries';
|
|
||||||
import type { EnquiryReferenceData } from '../api/types';
|
|
||||||
import type { QuotationRelationItem } from '@/features/crm/quotations/api/types';
|
import type { QuotationRelationItem } from '@/features/crm/quotations/api/types';
|
||||||
|
import { enquiryByIdOptions, enquiryProjectPartiesOptions } from '../api/queries';
|
||||||
|
import type { EnquiryRecord, EnquiryReferenceData } from '../api/types';
|
||||||
import { EnquiryAssignmentDialog } from './enquiry-assignment-dialog';
|
import { EnquiryAssignmentDialog } from './enquiry-assignment-dialog';
|
||||||
import { EnquiryFormSheet } from './enquiry-form-sheet';
|
|
||||||
import { EnquiryFollowupsTab } from './enquiry-followups-tab';
|
import { EnquiryFollowupsTab } from './enquiry-followups-tab';
|
||||||
|
import { EnquiryFormSheet } from './enquiry-form-sheet';
|
||||||
import { EnquiryStatusBadge } from './enquiry-status-badge';
|
import { EnquiryStatusBadge } from './enquiry-status-badge';
|
||||||
|
|
||||||
function FieldItem({ label, value }: { label: string; value: string | null | undefined }) {
|
function FieldItem({ label, value }: { label: string; value: string | null | undefined }) {
|
||||||
@@ -26,27 +26,48 @@ function FieldItem({ label, value }: { label: string; value: string | null | und
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EnquiryDetail({
|
function getPipelineStageLabel(stage: EnquiryRecord['pipelineStage']) {
|
||||||
enquiryId,
|
switch (stage) {
|
||||||
referenceData,
|
case 'lead':
|
||||||
relatedQuotations,
|
return 'Lead';
|
||||||
canUpdate,
|
case 'enquiry':
|
||||||
canAssign,
|
return 'Enquiry';
|
||||||
canReassign,
|
case 'closed_won':
|
||||||
canManageFollowups
|
return 'Closed Won';
|
||||||
}: {
|
case 'closed_lost':
|
||||||
|
return 'Closed Lost';
|
||||||
|
default:
|
||||||
|
return stage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EnquiryDetailProps {
|
||||||
enquiryId: string;
|
enquiryId: string;
|
||||||
referenceData: EnquiryReferenceData;
|
referenceData: EnquiryReferenceData;
|
||||||
relatedQuotations: QuotationRelationItem[];
|
relatedQuotations: QuotationRelationItem[];
|
||||||
|
workspace: 'lead' | 'enquiry';
|
||||||
canUpdate: boolean;
|
canUpdate: boolean;
|
||||||
canAssign: boolean;
|
canAssign: boolean;
|
||||||
canReassign: boolean;
|
canReassign: boolean;
|
||||||
|
canViewRelatedQuotations: boolean;
|
||||||
canManageFollowups: {
|
canManageFollowups: {
|
||||||
create: boolean;
|
create: boolean;
|
||||||
update: boolean;
|
update: boolean;
|
||||||
delete: boolean;
|
delete: boolean;
|
||||||
};
|
};
|
||||||
}) {
|
}
|
||||||
|
|
||||||
|
export function EnquiryDetail({
|
||||||
|
enquiryId,
|
||||||
|
referenceData,
|
||||||
|
relatedQuotations,
|
||||||
|
workspace,
|
||||||
|
canUpdate,
|
||||||
|
canAssign,
|
||||||
|
canReassign,
|
||||||
|
canViewRelatedQuotations,
|
||||||
|
canManageFollowups
|
||||||
|
}: EnquiryDetailProps) {
|
||||||
const { data } = useSuspenseQuery(enquiryByIdOptions(enquiryId));
|
const { data } = useSuspenseQuery(enquiryByIdOptions(enquiryId));
|
||||||
const { data: projectPartiesData } = useSuspenseQuery(enquiryProjectPartiesOptions(enquiryId));
|
const { data: projectPartiesData } = useSuspenseQuery(enquiryProjectPartiesOptions(enquiryId));
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
@@ -85,6 +106,29 @@ export function EnquiryDetail({
|
|||||||
const status = statusMap.get(enquiry.status);
|
const status = statusMap.get(enquiry.status);
|
||||||
const canManageAssignment = enquiry.assignedToUserId ? canReassign : canAssign;
|
const canManageAssignment = enquiry.assignedToUserId ? canReassign : canAssign;
|
||||||
const assignmentMode = enquiry.assignedToUserId ? 'reassign' : 'assign';
|
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 (
|
return (
|
||||||
<div className='space-y-6'>
|
<div className='space-y-6'>
|
||||||
@@ -92,7 +136,7 @@ export function EnquiryDetail({
|
|||||||
<div className='space-y-3'>
|
<div className='space-y-3'>
|
||||||
<div className='flex items-center gap-2'>
|
<div className='flex items-center gap-2'>
|
||||||
<Button asChild variant='ghost' size='icon'>
|
<Button asChild variant='ghost' size='icon'>
|
||||||
<Link href='/dashboard/crm/enquiries'>
|
<Link href={backHref}>
|
||||||
<Icons.chevronLeft className='h-4 w-4' />
|
<Icons.chevronLeft className='h-4 w-4' />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -103,8 +147,8 @@ export function EnquiryDetail({
|
|||||||
<div>
|
<div>
|
||||||
<h2 className='text-2xl font-semibold'>{enquiry.title}</h2>
|
<h2 className='text-2xl font-semibold'>{enquiry.title}</h2>
|
||||||
<p className='text-muted-foreground text-sm'>
|
<p className='text-muted-foreground text-sm'>
|
||||||
{customer?.name ?? 'Unknown customer'}
|
{pipelineStageLabel} | {customer?.name ?? 'Unknown customer'}
|
||||||
{contact ? ` • ${contact.name}` : ''}
|
{contact ? ` | ${contact.name}` : ''}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -112,12 +156,13 @@ export function EnquiryDetail({
|
|||||||
{canManageAssignment ? (
|
{canManageAssignment ? (
|
||||||
<Button variant='outline' onClick={() => setAssignmentOpen(true)}>
|
<Button variant='outline' onClick={() => setAssignmentOpen(true)}>
|
||||||
<Icons.userPen className='mr-2 h-4 w-4' />
|
<Icons.userPen className='mr-2 h-4 w-4' />
|
||||||
{assignmentMode === 'assign' ? 'Assign Sales' : 'Reassign Sales'}
|
{assignmentButtonLabel}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{canUpdate ? (
|
{canUpdate ? (
|
||||||
<Button variant='outline' onClick={() => setEditOpen(true)}>
|
<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>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -130,20 +175,23 @@ export function EnquiryDetail({
|
|||||||
<Tabs defaultValue='overview' className='gap-4'>
|
<Tabs defaultValue='overview' className='gap-4'>
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value='overview'>Overview</TabsTrigger>
|
<TabsTrigger value='overview'>Overview</TabsTrigger>
|
||||||
<TabsTrigger value='followups'>Follow-ups</TabsTrigger>
|
<TabsTrigger value='followups'>{followupTabLabel}</TabsTrigger>
|
||||||
<TabsTrigger value='activity'>Activity</TabsTrigger>
|
<TabsTrigger value='activity'>Activity</TabsTrigger>
|
||||||
<TabsTrigger value='related'>Related Quotations</TabsTrigger>
|
{canViewRelatedQuotations ? (
|
||||||
|
<TabsTrigger value='related'>{relatedTabLabel}</TabsTrigger>
|
||||||
|
) : null}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value='overview'>
|
<TabsContent value='overview'>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Overview</CardTitle>
|
<CardTitle>{infoCardTitle}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>{infoCardDescription}</CardDescription>
|
||||||
Opportunity scope, project context, and commercial direction.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
||||||
<FieldItem label='Billing Customer' value={customer?.name} />
|
<FieldItem
|
||||||
|
label={isLeadWorkspace ? 'Company' : 'Billing Customer'}
|
||||||
|
value={customer?.name}
|
||||||
|
/>
|
||||||
<FieldItem label='Contact' value={contact?.name} />
|
<FieldItem label='Contact' value={contact?.name} />
|
||||||
<FieldItem
|
<FieldItem
|
||||||
label='Branch'
|
label='Branch'
|
||||||
@@ -159,7 +207,7 @@ export function EnquiryDetail({
|
|||||||
value={priorityMap.get(enquiry.priority)?.label}
|
value={priorityMap.get(enquiry.priority)?.label}
|
||||||
/>
|
/>
|
||||||
<FieldItem
|
<FieldItem
|
||||||
label='Lead Channel'
|
label='Lead Source'
|
||||||
value={leadChannelMap.get(enquiry.leadChannel ?? '')?.label}
|
value={leadChannelMap.get(enquiry.leadChannel ?? '')?.label}
|
||||||
/>
|
/>
|
||||||
<FieldItem
|
<FieldItem
|
||||||
@@ -184,7 +232,8 @@ export function EnquiryDetail({
|
|||||||
/>
|
/>
|
||||||
<FieldItem label='Competitor' value={enquiry.competitor} />
|
<FieldItem label='Competitor' value={enquiry.competitor} />
|
||||||
<FieldItem label='Source' value={enquiry.source} />
|
<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
|
<FieldItem
|
||||||
label='Assigned At'
|
label='Assigned At'
|
||||||
value={
|
value={
|
||||||
@@ -195,10 +244,14 @@ export function EnquiryDetail({
|
|||||||
<FieldItem label='Assignment Remark' value={enquiry.assignmentRemark} />
|
<FieldItem label='Assignment Remark' value={enquiry.assignmentRemark} />
|
||||||
<div className='md:col-span-2 xl:col-span-4'>
|
<div className='md:col-span-2 xl:col-span-4'>
|
||||||
<div className='space-y-3'>
|
<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 ? (
|
{!projectPartiesData.items.length ? (
|
||||||
<div className='text-muted-foreground rounded-lg border border-dashed p-4 text-sm'>
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className='space-y-3'>
|
<div className='space-y-3'>
|
||||||
@@ -217,11 +270,10 @@ export function EnquiryDetail({
|
|||||||
</div>
|
</div>
|
||||||
<div className='md:col-span-2 xl:col-span-4'>
|
<div className='md:col-span-2 xl:col-span-4'>
|
||||||
<FieldItem
|
<FieldItem
|
||||||
label='Project'
|
label={projectSectionLabel}
|
||||||
value={
|
value={
|
||||||
[enquiry.projectName, enquiry.projectLocation]
|
[enquiry.projectName, enquiry.projectLocation].filter(Boolean).join(' | ') ||
|
||||||
.filter(Boolean)
|
null
|
||||||
.join(' • ') || null
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -250,9 +302,9 @@ export function EnquiryDetail({
|
|||||||
<TabsContent value='activity'>
|
<TabsContent value='activity'>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Activity</CardTitle>
|
<CardTitle>{isLeadWorkspace ? 'Lead Activity' : 'Activity'}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Audit events for the enquiry and its follow-up records.
|
Audit events for this record and its follow-up history.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='space-y-4'>
|
<CardContent className='space-y-4'>
|
||||||
@@ -289,18 +341,19 @@ export function EnquiryDetail({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
{canViewRelatedQuotations ? (
|
||||||
<TabsContent value='related'>
|
<TabsContent value='related'>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Related Quotations</CardTitle>
|
<CardTitle>{relatedTabLabel}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Production quotations already linked to this enquiry.
|
Production quotations already linked to this record.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{!relatedQuotations.length ? (
|
{!relatedQuotations.length ? (
|
||||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className='space-y-3'>
|
<div className='space-y-3'>
|
||||||
@@ -326,6 +379,7 @@ export function EnquiryDetail({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
) : null}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -335,12 +389,11 @@ export function EnquiryDetail({
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Record Snapshot</CardTitle>
|
<CardTitle>Record Snapshot</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>{snapshotDescription}</CardDescription>
|
||||||
Operational metadata and assignment state for this enquiry row.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='space-y-4'>
|
<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='Assigned By' value={enquiry.assignedByName} />
|
||||||
<FieldItem label='Created By' value={enquiry.createdBy} />
|
<FieldItem label='Created By' value={enquiry.createdBy} />
|
||||||
<FieldItem label='Updated By' value={enquiry.updatedBy} />
|
<FieldItem label='Updated By' value={enquiry.updatedBy} />
|
||||||
@@ -367,6 +420,7 @@ export function EnquiryDetail({
|
|||||||
open={editOpen}
|
open={editOpen}
|
||||||
onOpenChange={setEditOpen}
|
onOpenChange={setEditOpen}
|
||||||
referenceData={referenceData}
|
referenceData={referenceData}
|
||||||
|
workspace={workspace}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -64,12 +64,14 @@ export function EnquiryFormSheet({
|
|||||||
enquiry,
|
enquiry,
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
referenceData
|
referenceData,
|
||||||
|
workspace = 'enquiry'
|
||||||
}: {
|
}: {
|
||||||
enquiry?: EnquiryRecord;
|
enquiry?: EnquiryRecord;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
referenceData: EnquiryReferenceData;
|
referenceData: EnquiryReferenceData;
|
||||||
|
workspace?: 'lead' | 'enquiry';
|
||||||
}) {
|
}) {
|
||||||
const isEdit = !!enquiry;
|
const isEdit = !!enquiry;
|
||||||
const defaultValues = useMemo(
|
const defaultValues = useMemo(
|
||||||
@@ -88,21 +90,21 @@ export function EnquiryFormSheet({
|
|||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
...createEnquiryMutation,
|
...createEnquiryMutation,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Enquiry created successfully');
|
toast.success(`${recordLabel} created successfully`);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
},
|
},
|
||||||
onError: (error) =>
|
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({
|
const updateMutation = useMutation({
|
||||||
...updateEnquiryMutation,
|
...updateEnquiryMutation,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Enquiry updated successfully');
|
toast.success(`${recordLabel} updated successfully`);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
},
|
},
|
||||||
onError: (error) =>
|
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(
|
const contactsForCustomer = referenceData.contacts.filter(
|
||||||
@@ -177,13 +179,17 @@ export function EnquiryFormSheet({
|
|||||||
|
|
||||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
|
const recordLabel = workspace === 'lead' ? 'Lead' : 'Enquiry';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
<SheetContent className='flex flex-col sm:max-w-4xl'>
|
<SheetContent className='flex flex-col sm:max-w-4xl'>
|
||||||
<SheetHeader>
|
<SheetHeader>
|
||||||
<SheetTitle>{isEdit ? 'Edit Enquiry' : 'New Enquiry'}</SheetTitle>
|
<SheetTitle>{isEdit ? `Edit ${recordLabel}` : `New ${recordLabel}`}</SheetTitle>
|
||||||
<SheetDescription>
|
<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>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
@@ -261,7 +267,7 @@ export function EnquiryFormSheet({
|
|||||||
name='title'
|
name='title'
|
||||||
label='Title'
|
label='Title'
|
||||||
required
|
required
|
||||||
placeholder='Warehouse crane upgrade opportunity'
|
placeholder='Warehouse crane upgrade enquiry'
|
||||||
/>
|
/>
|
||||||
<FormTextField
|
<FormTextField
|
||||||
name='projectName'
|
name='projectName'
|
||||||
@@ -338,7 +344,7 @@ export function EnquiryFormSheet({
|
|||||||
<FormTextareaField
|
<FormTextareaField
|
||||||
name='description'
|
name='description'
|
||||||
label='Description'
|
label='Description'
|
||||||
placeholder='High-level summary of the opportunity'
|
placeholder='High-level summary of the lead or enquiry'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className='md:col-span-2'>
|
<div className='md:col-span-2'>
|
||||||
@@ -352,19 +358,19 @@ export function EnquiryFormSheet({
|
|||||||
<FormTextareaField
|
<FormTextareaField
|
||||||
name='notes'
|
name='notes'
|
||||||
label='Notes'
|
label='Notes'
|
||||||
placeholder='Internal notes, blockers, or guidance for the sales team'
|
placeholder='Internal notes, blockers, or handoff guidance'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className='md:col-span-2 grid gap-4 md:grid-cols-2'>
|
<div className='md:col-span-2 grid gap-4 md:grid-cols-2'>
|
||||||
<FormSwitchField
|
<FormSwitchField
|
||||||
name='isHotProject'
|
name='isHotProject'
|
||||||
label='Hot Project'
|
label='Hot Project'
|
||||||
description='Use for urgent or highly strategic opportunities.'
|
description='Use for urgent or highly strategic enquiries.'
|
||||||
/>
|
/>
|
||||||
<FormSwitchField
|
<FormSwitchField
|
||||||
name='isActive'
|
name='isActive'
|
||||||
label='Active Record'
|
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>
|
||||||
<div className='md:col-span-2'>
|
<div className='md:col-span-2'>
|
||||||
@@ -385,7 +391,7 @@ export function EnquiryFormSheet({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button type='submit' form='enquiry-form-sheet' isLoading={isPending}>
|
<Button type='submit' form='enquiry-form-sheet' isLoading={isPending}>
|
||||||
<Icons.check className='mr-2 h-4 w-4' />
|
<Icons.check className='mr-2 h-4 w-4' />
|
||||||
{isEdit ? 'Update Enquiry' : 'Create Enquiry'}
|
{isEdit ? `Update ${recordLabel}` : `Create ${recordLabel}`}
|
||||||
</Button>
|
</Button>
|
||||||
</SheetFooter>
|
</SheetFooter>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
@@ -394,18 +400,25 @@ export function EnquiryFormSheet({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function EnquiryFormSheetTrigger({
|
export function EnquiryFormSheetTrigger({
|
||||||
referenceData
|
referenceData,
|
||||||
|
workspace = 'enquiry'
|
||||||
}: {
|
}: {
|
||||||
referenceData: EnquiryReferenceData;
|
referenceData: EnquiryReferenceData;
|
||||||
|
workspace?: 'lead' | 'enquiry';
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button onClick={() => setOpen(true)}>
|
<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>
|
</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({
|
export default function EnquiryListing({
|
||||||
referenceData,
|
referenceData,
|
||||||
|
workspace,
|
||||||
canUpdate,
|
canUpdate,
|
||||||
canDelete,
|
canDelete,
|
||||||
canAssign,
|
canAssign,
|
||||||
canReassign
|
canReassign
|
||||||
}: {
|
}: {
|
||||||
referenceData: EnquiryReferenceData;
|
referenceData: EnquiryReferenceData;
|
||||||
|
workspace: 'lead' | 'enquiry';
|
||||||
canUpdate: boolean;
|
canUpdate: boolean;
|
||||||
canDelete: boolean;
|
canDelete: boolean;
|
||||||
canAssign: boolean;
|
canAssign: boolean;
|
||||||
@@ -30,6 +32,7 @@ export default function EnquiryListing({
|
|||||||
const filters = {
|
const filters = {
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
|
pipelineStage: workspace,
|
||||||
...(search && { search }),
|
...(search && { search }),
|
||||||
...(status && { status }),
|
...(status && { status }),
|
||||||
...(productType && { productType }),
|
...(productType && { productType }),
|
||||||
@@ -46,6 +49,7 @@ export default function EnquiryListing({
|
|||||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
<EnquiriesTable
|
<EnquiriesTable
|
||||||
referenceData={referenceData}
|
referenceData={referenceData}
|
||||||
|
workspace={workspace}
|
||||||
canUpdate={canUpdate}
|
canUpdate={canUpdate}
|
||||||
canDelete={canDelete}
|
canDelete={canDelete}
|
||||||
canAssign={canAssign}
|
canAssign={canAssign}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
crmEnquiries,
|
crmEnquiries,
|
||||||
crmEnquiryCustomers,
|
crmEnquiryCustomers,
|
||||||
crmEnquiryFollowups,
|
crmEnquiryFollowups,
|
||||||
|
crmQuotations,
|
||||||
memberships,
|
memberships,
|
||||||
users
|
users
|
||||||
} from '@/db/schema';
|
} from '@/db/schema';
|
||||||
@@ -29,6 +30,7 @@ import type {
|
|||||||
EnquiryProjectPartyListItem,
|
EnquiryProjectPartyListItem,
|
||||||
EnquiryProjectPartyMutationPayload,
|
EnquiryProjectPartyMutationPayload,
|
||||||
EnquiryProjectPartyRecord,
|
EnquiryProjectPartyRecord,
|
||||||
|
EnquiryPipelineStage,
|
||||||
EnquiryRecord,
|
EnquiryRecord,
|
||||||
EnquiryReferenceData
|
EnquiryReferenceData
|
||||||
} from '../api/types';
|
} from '../api/types';
|
||||||
@@ -48,6 +50,30 @@ const ENQUIRY_OPTION_CATEGORIES = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const ASSIGNABLE_BUSINESS_ROLES = new Set(['sales_manager', 'sales', 'sales_support']);
|
const ASSIGNABLE_BUSINESS_ROLES = new Set(['sales_manager', 'sales', 'sales_support']);
|
||||||
|
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(
|
function mapOption(
|
||||||
option: Awaited<ReturnType<typeof getActiveOptionsByCategory>>[number]
|
option: Awaited<ReturnType<typeof getActiveOptionsByCategory>>[number]
|
||||||
@@ -99,6 +125,7 @@ function mapEnquiryRecord(
|
|||||||
notes: row.notes,
|
notes: row.notes,
|
||||||
isHotProject: row.isHotProject,
|
isHotProject: row.isHotProject,
|
||||||
isActive: row.isActive,
|
isActive: row.isActive,
|
||||||
|
pipelineStage: row.pipelineStage as EnquiryPipelineStage,
|
||||||
assignedToUserId: row.assignedToUserId,
|
assignedToUserId: row.assignedToUserId,
|
||||||
assignedToName: options?.assignedToName ?? null,
|
assignedToName: options?.assignedToName ?? null,
|
||||||
assignedAt: row.assignedAt?.toISOString() ?? 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 {
|
function mapFollowupRecord(row: typeof crmEnquiryFollowups.$inferSelect): EnquiryFollowupRecord {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
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) {
|
export async function assertCustomerBelongsToOrganization(id: string, organizationId: string) {
|
||||||
const [customer] = await db
|
const [customer] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -309,7 +384,11 @@ export async function assertAssignableUserBelongsToOrganization(
|
|||||||
return membership;
|
return membership;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function assertEnquiryBelongsToOrganization(id: string, organizationId: string) {
|
async function assertEnquiryBelongsToOrganization(
|
||||||
|
id: string,
|
||||||
|
organizationId: string,
|
||||||
|
accessContext?: EnquiryAccessContext
|
||||||
|
) {
|
||||||
const [enquiry] = await db
|
const [enquiry] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(crmEnquiries)
|
.from(crmEnquiries)
|
||||||
@@ -326,6 +405,10 @@ async function assertEnquiryBelongsToOrganization(id: string, organizationId: st
|
|||||||
throw new AuthError('Enquiry not found', 404);
|
throw new AuthError('Enquiry not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (accessContext && !canAccessEnquiryRow(enquiry, accessContext)) {
|
||||||
|
throw new AuthError('Forbidden', 403);
|
||||||
|
}
|
||||||
|
|
||||||
return enquiry;
|
return enquiry;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,9 +478,14 @@ async function validateEnquiryPayload(organizationId: string, payload: EnquiryMu
|
|||||||
async function validateFollowupPayload(
|
async function validateFollowupPayload(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
enquiryId: string,
|
enquiryId: string,
|
||||||
payload: EnquiryFollowupMutationPayload
|
payload: EnquiryFollowupMutationPayload,
|
||||||
|
accessContext?: EnquiryAccessContext
|
||||||
) {
|
) {
|
||||||
const enquiry = await assertEnquiryBelongsToOrganization(enquiryId, organizationId);
|
const enquiry = await assertEnquiryBelongsToOrganization(
|
||||||
|
enquiryId,
|
||||||
|
organizationId,
|
||||||
|
accessContext
|
||||||
|
);
|
||||||
|
|
||||||
await assertMasterOptionValue(
|
await assertMasterOptionValue(
|
||||||
organizationId,
|
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 statuses = splitFilterValue(filters.status);
|
||||||
const productTypes = splitFilterValue(filters.productType);
|
const productTypes = splitFilterValue(filters.productType);
|
||||||
const priorities = splitFilterValue(filters.priority);
|
const priorities = splitFilterValue(filters.priority);
|
||||||
@@ -418,8 +507,10 @@ function buildEnquiryFilters(organizationId: string, filters: EnquiryFilters): S
|
|||||||
const customers = splitFilterValue(filters.customer);
|
const customers = splitFilterValue(filters.customer);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
eq(crmEnquiries.organizationId, organizationId),
|
eq(crmEnquiries.organizationId, context.organizationId),
|
||||||
isNull(crmEnquiries.deletedAt),
|
isNull(crmEnquiries.deletedAt),
|
||||||
|
...buildAccessScopedFilters(context),
|
||||||
|
...(pipelineStages.length ? [inArray(crmEnquiries.pipelineStage, pipelineStages)] : []),
|
||||||
...(statuses.length ? [inArray(crmEnquiries.status, statuses)] : []),
|
...(statuses.length ? [inArray(crmEnquiries.status, statuses)] : []),
|
||||||
...(productTypes.length ? [inArray(crmEnquiries.productType, productTypes)] : []),
|
...(productTypes.length ? [inArray(crmEnquiries.productType, productTypes)] : []),
|
||||||
...(priorities.length ? [inArray(crmEnquiries.priority, priorities)] : []),
|
...(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(
|
export async function getEnquiryReferenceData(
|
||||||
organizationId: string
|
organizationId: string
|
||||||
): Promise<EnquiryReferenceData> {
|
): Promise<EnquiryReferenceData> {
|
||||||
@@ -505,12 +644,12 @@ export async function getEnquiryReferenceData(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function listEnquiries(
|
export async function listEnquiries(
|
||||||
organizationId: string,
|
context: EnquiryAccessContext,
|
||||||
filters: EnquiryFilters
|
filters: EnquiryFilters
|
||||||
): Promise<{ items: EnquiryListItem[]; totalItems: number }> {
|
): Promise<{ items: EnquiryListItem[]; totalItems: number }> {
|
||||||
const page = filters.page ?? 1;
|
const page = filters.page ?? 1;
|
||||||
const limit = filters.limit ?? 10;
|
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 where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters);
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
@@ -528,16 +667,17 @@ export async function listEnquiries(
|
|||||||
const assignedUserIds = [
|
const assignedUserIds = [
|
||||||
...new Set(rows.map((row) => row.assignedToUserId).filter(Boolean) as string[])
|
...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 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
|
customerIds.length
|
||||||
? db
|
? db
|
||||||
.select()
|
.select()
|
||||||
.from(crmCustomers)
|
.from(crmCustomers)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(crmCustomers.organizationId, organizationId),
|
eq(crmCustomers.organizationId, context.organizationId),
|
||||||
inArray(crmCustomers.id, customerIds)
|
inArray(crmCustomers.id, customerIds)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -548,7 +688,7 @@ export async function listEnquiries(
|
|||||||
.from(crmCustomerContacts)
|
.from(crmCustomerContacts)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(crmCustomerContacts.organizationId, organizationId),
|
eq(crmCustomerContacts.organizationId, context.organizationId),
|
||||||
inArray(crmCustomerContacts.id, contactIds)
|
inArray(crmCustomerContacts.id, contactIds)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -559,6 +699,12 @@ export async function listEnquiries(
|
|||||||
.from(users)
|
.from(users)
|
||||||
.where(inArray(users.id, assignedUserIds))
|
.where(inArray(users.id, assignedUserIds))
|
||||||
: [],
|
: [],
|
||||||
|
createdByUserIds.length
|
||||||
|
? db
|
||||||
|
.select({ id: users.id, name: users.name })
|
||||||
|
.from(users)
|
||||||
|
.where(inArray(users.id, createdByUserIds))
|
||||||
|
: [],
|
||||||
enquiryIds.length
|
enquiryIds.length
|
||||||
? db
|
? db
|
||||||
.select({
|
.select({
|
||||||
@@ -568,19 +714,68 @@ export async function listEnquiries(
|
|||||||
.from(crmEnquiryFollowups)
|
.from(crmEnquiryFollowups)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(crmEnquiryFollowups.organizationId, organizationId),
|
eq(crmEnquiryFollowups.organizationId, context.organizationId),
|
||||||
inArray(crmEnquiryFollowups.enquiryId, enquiryIds),
|
inArray(crmEnquiryFollowups.enquiryId, enquiryIds),
|
||||||
isNull(crmEnquiryFollowups.deletedAt)
|
isNull(crmEnquiryFollowups.deletedAt)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.groupBy(crmEnquiryFollowups.enquiryId)
|
.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 customerMap = new Map(customers.map((customer) => [customer.id, customer.name]));
|
||||||
const contactMap = new Map(contacts.map((contact) => [contact.id, contact.name]));
|
const contactMap = new Map(contacts.map((contact) => [contact.id, contact.name]));
|
||||||
const assignedUserMap = new Map(assignedUsers.map((user) => [user.id, user.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 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 {
|
return {
|
||||||
items: rows.map((row) => ({
|
items: rows.map((row) => ({
|
||||||
@@ -591,14 +786,21 @@ export async function listEnquiries(
|
|||||||
}),
|
}),
|
||||||
customerName: customerMap.get(row.customerId) ?? 'Unknown customer',
|
customerName: customerMap.get(row.customerId) ?? 'Unknown customer',
|
||||||
contactName: row.contactId ? (contactMap.get(row.contactId) ?? null) : null,
|
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
|
totalItems: totalResult?.value ?? 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getEnquiryDetail(id: string, organizationId: string): Promise<EnquiryRecord> {
|
export async function getEnquiryDetail(
|
||||||
const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId);
|
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 relatedUserIds = [enquiry.assignedToUserId, enquiry.assignedBy].filter(Boolean) as string[];
|
||||||
const userRows = relatedUserIds.length
|
const userRows = relatedUserIds.length
|
||||||
? await db
|
? await db
|
||||||
@@ -711,9 +913,10 @@ async function syncEnquiryProjectParties(
|
|||||||
|
|
||||||
export async function listEnquiryProjectParties(
|
export async function listEnquiryProjectParties(
|
||||||
id: string,
|
id: string,
|
||||||
organizationId: string
|
organizationId: string,
|
||||||
|
accessContext?: EnquiryAccessContext
|
||||||
): Promise<EnquiryProjectPartyListItem[]> {
|
): Promise<EnquiryProjectPartyListItem[]> {
|
||||||
await assertEnquiryBelongsToOrganization(id, organizationId);
|
await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
|
||||||
const [relations, customers, roleOptions] = await Promise.all([
|
const [relations, customers, roleOptions] = await Promise.all([
|
||||||
db
|
db
|
||||||
.select()
|
.select()
|
||||||
@@ -754,8 +957,10 @@ export async function listEnquiryProjectParties(
|
|||||||
|
|
||||||
export async function getEnquiryActivity(
|
export async function getEnquiryActivity(
|
||||||
id: string,
|
id: string,
|
||||||
organizationId: string
|
organizationId: string,
|
||||||
|
accessContext?: EnquiryAccessContext
|
||||||
): Promise<EnquiryActivityRecord[]> {
|
): Promise<EnquiryActivityRecord[]> {
|
||||||
|
await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
|
||||||
const auditLogs = await listAuditLogs({
|
const auditLogs = await listAuditLogs({
|
||||||
organizationId,
|
organizationId,
|
||||||
entityId: id,
|
entityId: id,
|
||||||
@@ -791,9 +996,15 @@ export async function getEnquiryActivity(
|
|||||||
export async function createEnquiry(
|
export async function createEnquiry(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
|
businessRole: string,
|
||||||
payload: EnquiryMutationPayload
|
payload: EnquiryMutationPayload
|
||||||
) {
|
) {
|
||||||
await validateEnquiryPayload(organizationId, payload);
|
await validateEnquiryPayload(organizationId, payload);
|
||||||
|
const pipelineStage = await resolvePipelineStageForCreate(
|
||||||
|
organizationId,
|
||||||
|
businessRole,
|
||||||
|
payload.status
|
||||||
|
);
|
||||||
|
|
||||||
const documentCode = await generateNextDocumentCode({
|
const documentCode = await generateNextDocumentCode({
|
||||||
organizationId,
|
organizationId,
|
||||||
@@ -828,6 +1039,7 @@ export async function createEnquiry(
|
|||||||
notes: payload.notes?.trim() || null,
|
notes: payload.notes?.trim() || null,
|
||||||
isHotProject: payload.isHotProject ?? false,
|
isHotProject: payload.isHotProject ?? false,
|
||||||
isActive: payload.isActive ?? true,
|
isActive: payload.isActive ?? true,
|
||||||
|
pipelineStage,
|
||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
updatedBy: userId
|
updatedBy: userId
|
||||||
})
|
})
|
||||||
@@ -849,10 +1061,12 @@ export async function updateEnquiry(
|
|||||||
id: string,
|
id: string,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
payload: EnquiryMutationPayload
|
payload: EnquiryMutationPayload,
|
||||||
|
accessContext?: EnquiryAccessContext
|
||||||
) {
|
) {
|
||||||
await validateEnquiryPayload(organizationId, payload);
|
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) => {
|
return db.transaction(async (tx) => {
|
||||||
const [updated] = await tx
|
const [updated] = await tx
|
||||||
@@ -878,6 +1092,7 @@ export async function updateEnquiry(
|
|||||||
notes: payload.notes?.trim() || null,
|
notes: payload.notes?.trim() || null,
|
||||||
isHotProject: payload.isHotProject ?? false,
|
isHotProject: payload.isHotProject ?? false,
|
||||||
isActive: payload.isActive ?? true,
|
isActive: payload.isActive ?? true,
|
||||||
|
pipelineStage,
|
||||||
updatedBy: userId,
|
updatedBy: userId,
|
||||||
updatedAt: new Date()
|
updatedAt: new Date()
|
||||||
})
|
})
|
||||||
@@ -896,8 +1111,13 @@ export async function updateEnquiry(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function softDeleteEnquiry(id: string, organizationId: string, userId: string) {
|
export async function softDeleteEnquiry(
|
||||||
await assertEnquiryBelongsToOrganization(id, organizationId);
|
id: string,
|
||||||
|
organizationId: string,
|
||||||
|
userId: string,
|
||||||
|
accessContext?: EnquiryAccessContext
|
||||||
|
) {
|
||||||
|
await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
@@ -964,6 +1184,7 @@ async function updateEnquiryAssignment(
|
|||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
.update(crmEnquiries)
|
.update(crmEnquiries)
|
||||||
.set({
|
.set({
|
||||||
|
pipelineStage: 'enquiry',
|
||||||
assignedToUserId: payload.assignedToUserId,
|
assignedToUserId: payload.assignedToUserId,
|
||||||
assignedAt: new Date(),
|
assignedAt: new Date(),
|
||||||
assignedBy: userId,
|
assignedBy: userId,
|
||||||
@@ -995,8 +1216,12 @@ export async function reassignEnquiryToUser(
|
|||||||
return updateEnquiryAssignment(id, organizationId, userId, payload, 'reassign');
|
return updateEnquiryAssignment(id, organizationId, userId, payload, 'reassign');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listEnquiryFollowups(id: string, organizationId: string) {
|
export async function listEnquiryFollowups(
|
||||||
await assertEnquiryBelongsToOrganization(id, organizationId);
|
id: string,
|
||||||
|
organizationId: string,
|
||||||
|
accessContext?: EnquiryAccessContext
|
||||||
|
) {
|
||||||
|
await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(crmEnquiryFollowups)
|
.from(crmEnquiryFollowups)
|
||||||
@@ -1016,9 +1241,10 @@ export async function createEnquiryFollowup(
|
|||||||
enquiryId: string,
|
enquiryId: string,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
payload: EnquiryFollowupMutationPayload
|
payload: EnquiryFollowupMutationPayload,
|
||||||
|
accessContext?: EnquiryAccessContext
|
||||||
) {
|
) {
|
||||||
await validateFollowupPayload(organizationId, enquiryId, payload);
|
await validateFollowupPayload(organizationId, enquiryId, payload, accessContext);
|
||||||
|
|
||||||
const [created] = await db
|
const [created] = await db
|
||||||
.insert(crmEnquiryFollowups)
|
.insert(crmEnquiryFollowups)
|
||||||
@@ -1046,9 +1272,10 @@ export async function updateEnquiryFollowup(
|
|||||||
followupId: string,
|
followupId: string,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
userId: 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);
|
await assertFollowupBelongsToEnquiry(followupId, enquiryId, organizationId);
|
||||||
|
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
@@ -1074,8 +1301,10 @@ export async function softDeleteEnquiryFollowup(
|
|||||||
enquiryId: string,
|
enquiryId: string,
|
||||||
followupId: string,
|
followupId: string,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
userId: string
|
userId: string,
|
||||||
|
accessContext?: EnquiryAccessContext
|
||||||
) {
|
) {
|
||||||
|
await assertEnquiryBelongsToOrganization(enquiryId, organizationId, accessContext);
|
||||||
await assertFollowupBelongsToEnquiry(followupId, enquiryId, organizationId);
|
await assertFollowupBelongsToEnquiry(followupId, enquiryId, organizationId);
|
||||||
|
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
@@ -1117,3 +1346,39 @@ export async function listCustomerEnquiryRelations(
|
|||||||
updatedAt: row.updatedAt.toISOString()
|
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 { getUserBranches, validateBranchAccess } from '@/features/foundation/branch-scope/service';
|
||||||
import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service';
|
import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service';
|
||||||
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||||
|
import { syncEnquiryPipelineStageFromQuotationStatus } from '@/features/crm/enquiries/server/service';
|
||||||
import type {
|
import type {
|
||||||
QuotationActivityRecord,
|
QuotationActivityRecord,
|
||||||
QuotationAttachmentMutationPayload,
|
QuotationAttachmentMutationPayload,
|
||||||
@@ -1215,6 +1216,11 @@ export async function createQuotation(
|
|||||||
payload: QuotationMutationPayload
|
payload: QuotationMutationPayload
|
||||||
) {
|
) {
|
||||||
await validateQuotationPayload(organizationId, payload);
|
await validateQuotationPayload(organizationId, payload);
|
||||||
|
const quotationStatusCode = await resolveOptionCodeById(
|
||||||
|
organizationId,
|
||||||
|
QUOTATION_OPTION_CATEGORIES.status,
|
||||||
|
payload.status
|
||||||
|
);
|
||||||
|
|
||||||
const enquiry = payload.enquiryId
|
const enquiry = payload.enquiryId
|
||||||
? await assertEnquiryBelongsToOrganization(payload.enquiryId, organizationId)
|
? await assertEnquiryBelongsToOrganization(payload.enquiryId, organizationId)
|
||||||
@@ -1277,6 +1283,14 @@ export async function createQuotation(
|
|||||||
payload.projectParties ?? []
|
payload.projectParties ?? []
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (created.enquiryId) {
|
||||||
|
await syncEnquiryPipelineStageFromQuotationStatus(
|
||||||
|
created.enquiryId,
|
||||||
|
organizationId,
|
||||||
|
quotationStatusCode
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return created;
|
return created;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1288,6 +1302,11 @@ export async function updateQuotation(
|
|||||||
payload: QuotationMutationPayload
|
payload: QuotationMutationPayload
|
||||||
) {
|
) {
|
||||||
await validateQuotationPayload(organizationId, payload);
|
await validateQuotationPayload(organizationId, payload);
|
||||||
|
const quotationStatusCode = await resolveOptionCodeById(
|
||||||
|
organizationId,
|
||||||
|
QUOTATION_OPTION_CATEGORIES.status,
|
||||||
|
payload.status
|
||||||
|
);
|
||||||
const existing = await assertQuotationBelongsToOrganization(id, organizationId);
|
const existing = await assertQuotationBelongsToOrganization(id, organizationId);
|
||||||
const enquiry = payload.enquiryId
|
const enquiry = payload.enquiryId
|
||||||
? await assertEnquiryBelongsToOrganization(payload.enquiryId, organizationId)
|
? await assertEnquiryBelongsToOrganization(payload.enquiryId, organizationId)
|
||||||
@@ -1336,6 +1355,14 @@ export async function updateQuotation(
|
|||||||
payload.projectParties ?? []
|
payload.projectParties ?? []
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (row.enquiryId) {
|
||||||
|
await syncEnquiryPipelineStageFromQuotationStatus(
|
||||||
|
row.enquiryId,
|
||||||
|
organizationId,
|
||||||
|
quotationStatusCode
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return row;
|
return row;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ const membershipRoleOptions = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const businessRoleOptions = [
|
const businessRoleOptions = [
|
||||||
|
{ value: 'marketing', label: 'Marketing' },
|
||||||
{ value: 'sales_manager', label: 'Sales Manager' },
|
{ value: 'sales_manager', label: 'Sales Manager' },
|
||||||
{ value: 'sales', label: 'Sales' },
|
{ value: 'sales', label: 'Sales' },
|
||||||
{ value: 'sales_support', label: 'Sales Support' },
|
{ value: 'sales_support', label: 'Sales Support' },
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export const SYSTEM_ROLES = ['super_admin', 'user'] as const;
|
export const SYSTEM_ROLES = ['super_admin', 'user'] as const;
|
||||||
export const MEMBERSHIP_ROLES = ['admin', 'user'] as const;
|
export const MEMBERSHIP_ROLES = ['admin', 'user'] as const;
|
||||||
export const BUSINESS_ROLES = [
|
export const BUSINESS_ROLES = [
|
||||||
|
'marketing',
|
||||||
'sales',
|
'sales',
|
||||||
'sales_support',
|
'sales_support',
|
||||||
'sales_manager',
|
'sales_manager',
|
||||||
@@ -18,6 +19,11 @@ export const PERMISSIONS = {
|
|||||||
organizationManage: 'organization:manage',
|
organizationManage: 'organization:manage',
|
||||||
usersManage: 'users:manage',
|
usersManage: 'users:manage',
|
||||||
reportRead: 'report:read',
|
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',
|
crmCustomerRead: 'crm.customer.read',
|
||||||
crmCustomerCreate: 'crm.customer.create',
|
crmCustomerCreate: 'crm.customer.create',
|
||||||
crmCustomerUpdate: 'crm.customer.update',
|
crmCustomerUpdate: 'crm.customer.update',
|
||||||
@@ -76,6 +82,50 @@ export const PERMISSIONS = {
|
|||||||
|
|
||||||
export type Permission = (typeof PERMISSIONS)[keyof typeof 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[] {
|
function getMembershipBasePermissions(role: MembershipRole): Permission[] {
|
||||||
if (role === 'admin') {
|
if (role === 'admin') {
|
||||||
return [
|
return [
|
||||||
@@ -83,6 +133,11 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] {
|
|||||||
PERMISSIONS.productsWrite,
|
PERMISSIONS.productsWrite,
|
||||||
PERMISSIONS.organizationManage,
|
PERMISSIONS.organizationManage,
|
||||||
PERMISSIONS.usersManage,
|
PERMISSIONS.usersManage,
|
||||||
|
PERMISSIONS.crmLeadRead,
|
||||||
|
PERMISSIONS.crmLeadCreate,
|
||||||
|
PERMISSIONS.crmLeadUpdate,
|
||||||
|
PERMISSIONS.crmLeadAssign,
|
||||||
|
PERMISSIONS.crmLeadDelete,
|
||||||
PERMISSIONS.crmCustomerRead,
|
PERMISSIONS.crmCustomerRead,
|
||||||
PERMISSIONS.crmCustomerCreate,
|
PERMISSIONS.crmCustomerCreate,
|
||||||
PERMISSIONS.crmCustomerUpdate,
|
PERMISSIONS.crmCustomerUpdate,
|
||||||
@@ -142,6 +197,7 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] {
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
PERMISSIONS.productsRead,
|
PERMISSIONS.productsRead,
|
||||||
|
PERMISSIONS.crmLeadRead,
|
||||||
PERMISSIONS.crmCustomerRead,
|
PERMISSIONS.crmCustomerRead,
|
||||||
PERMISSIONS.crmContactRead,
|
PERMISSIONS.crmContactRead,
|
||||||
PERMISSIONS.crmEnquiryRead,
|
PERMISSIONS.crmEnquiryRead,
|
||||||
@@ -162,45 +218,23 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] {
|
|||||||
|
|
||||||
export function getBusinessRolePermissions(role: BusinessRole): Permission[] {
|
export function getBusinessRolePermissions(role: BusinessRole): Permission[] {
|
||||||
switch (role) {
|
switch (role) {
|
||||||
case 'sales_manager':
|
case 'marketing':
|
||||||
return [
|
return [
|
||||||
|
PERMISSIONS.crmLeadRead,
|
||||||
|
PERMISSIONS.crmLeadCreate,
|
||||||
|
PERMISSIONS.crmLeadUpdate,
|
||||||
|
PERMISSIONS.crmLeadAssign,
|
||||||
|
PERMISSIONS.crmLeadDelete,
|
||||||
PERMISSIONS.crmCustomerRead,
|
PERMISSIONS.crmCustomerRead,
|
||||||
PERMISSIONS.crmCustomerCreate,
|
|
||||||
PERMISSIONS.crmCustomerUpdate,
|
|
||||||
PERMISSIONS.crmContactRead,
|
PERMISSIONS.crmContactRead,
|
||||||
PERMISSIONS.crmContactCreate,
|
|
||||||
PERMISSIONS.crmContactUpdate,
|
|
||||||
PERMISSIONS.crmEnquiryRead,
|
PERMISSIONS.crmEnquiryRead,
|
||||||
PERMISSIONS.crmEnquiryCreate,
|
PERMISSIONS.crmEnquiryCreate,
|
||||||
PERMISSIONS.crmEnquiryUpdate,
|
|
||||||
PERMISSIONS.crmEnquiryAssign,
|
PERMISSIONS.crmEnquiryAssign,
|
||||||
PERMISSIONS.crmEnquiryReassign,
|
|
||||||
PERMISSIONS.crmEnquiryFollowupRead,
|
PERMISSIONS.crmEnquiryFollowupRead,
|
||||||
PERMISSIONS.crmEnquiryFollowupCreate,
|
PERMISSIONS.crmDashboardRead
|
||||||
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
|
|
||||||
];
|
];
|
||||||
|
case 'sales_manager':
|
||||||
|
return MANAGER_PERMISSIONS;
|
||||||
case 'sales':
|
case 'sales':
|
||||||
return [
|
return [
|
||||||
PERMISSIONS.crmCustomerRead,
|
PERMISSIONS.crmCustomerRead,
|
||||||
@@ -264,6 +298,7 @@ export function getBusinessRolePermissions(role: BusinessRole): Permission[] {
|
|||||||
];
|
];
|
||||||
case 'department_manager':
|
case 'department_manager':
|
||||||
case 'top_manager':
|
case 'top_manager':
|
||||||
|
return MANAGER_PERMISSIONS;
|
||||||
default:
|
default:
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user