lead menu

nquiry menu
This commit is contained in:
phaichayon
2026-06-19 10:39:11 +07:00
parent 0650cf6297
commit c0cc880be2
46 changed files with 5985 additions and 420 deletions

View 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.

View 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`

View File

@@ -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.

View 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'));

View File

@@ -0,0 +1 @@
ALTER TABLE "crm_enquiries" ADD COLUMN "pipeline_stage" text DEFAULT 'lead' NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -85,6 +85,13 @@
"when": 1781747812297,
"tag": "0011_goofy_the_hood",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1781836261952,
"tag": "0012_simple_bucky",
"breakpoints": true
}
]
}

439
plans/task-d.3.1.md Normal file
View 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
View 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

View File

@@ -39,7 +39,8 @@ export async function GET(request: NextRequest) {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
businessRole: membership.businessRole,
permissions: membership.permissions
},
{
dateFrom: searchParams.get('dateFrom'),
@@ -84,11 +85,11 @@ export async function GET(request: NextRequest) {
];
} else {
rows = [
['Sales Person', 'Lead Count', 'Opportunity Count', 'Quotation Count', 'Approved Quotations', 'Won Revenue', 'Conversion Rate'],
['Sales Person', 'Lead Count', 'Enquiry Count', 'Quotation Count', 'Approved Quotations', 'Won Revenue', 'Conversion Rate'],
...data.salesRanking.map((row) => [
row.salesPersonName,
String(row.leadCount),
String(row.opportunityCount),
String(row.enquiryCount),
String(row.quotationCount),
String(row.approvedQuotations),
String(row.wonRevenue),

View File

@@ -14,7 +14,8 @@ export async function GET(request: NextRequest) {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
businessRole: membership.businessRole,
permissions: membership.permissions
},
{
dateFrom: searchParams.get('dateFrom'),

View File

@@ -13,11 +13,16 @@ type Params = {
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryAssign
});
const payload = enquiryAssignmentRequestSchema.parse(await request.json());
const before = await getEnquiryDetail(id, organization.id);
const before = await getEnquiryDetail(id, organization.id, {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
});
const updated = await assignEnquiryToUser(id, organization.id, session.user.id, payload);
await auditAction({
@@ -26,19 +31,21 @@ export async function POST(request: NextRequest, { params }: Params) {
userId: session.user.id,
entityType: 'crm_enquiry',
entityId: id,
action: 'assign',
action: 'lead_to_enquiry',
beforeData: {
oldAssignedToUserId: before.assignedToUserId
oldAssignedToUserId: before.assignedToUserId,
oldPipelineStage: before.pipelineStage
},
afterData: {
newAssignedToUserId: updated.assignedToUserId,
newPipelineStage: updated.pipelineStage,
remark: updated.assignmentRemark
}
});
return NextResponse.json({
success: true,
message: 'Enquiry assigned successfully',
message: 'Lead converted to enquiry successfully',
enquiry: updated
});
} catch (error) {

View File

@@ -10,10 +10,15 @@ type Params = {
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization } = await requireOrganizationAccess({
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryRead
});
const items = await listEnquiryProjectParties(id, organization.id);
const items = await listEnquiryProjectParties(id, organization.id, {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
});
return NextResponse.json({
success: true,

View File

@@ -22,11 +22,18 @@ const followupRequestSchema = enquiryFollowupSchema.extend({
export async function PATCH(request: NextRequest, { params }: Params) {
try {
const { id, followupId } = await params;
const { organization, session } = await requireOrganizationAccess({
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryFollowupUpdate
});
const payload = followupRequestSchema.parse(await request.json());
const before = (await listEnquiryFollowups(id, organization.id)).find(
const before = (
await listEnquiryFollowups(id, organization.id, {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
})
).find(
(item) => item.id === followupId
);
@@ -39,7 +46,13 @@ export async function PATCH(request: NextRequest, { params }: Params) {
followupId,
organization.id,
session.user.id,
payload
payload,
{
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
}
);
await auditUpdate({
@@ -79,10 +92,17 @@ export async function PATCH(request: NextRequest, { params }: Params) {
export async function DELETE(_request: NextRequest, { params }: Params) {
try {
const { id, followupId } = await params;
const { organization, session } = await requireOrganizationAccess({
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryFollowupDelete
});
const before = (await listEnquiryFollowups(id, organization.id)).find(
const before = (
await listEnquiryFollowups(id, organization.id, {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
})
).find(
(item) => item.id === followupId
);
@@ -94,7 +114,13 @@ export async function DELETE(_request: NextRequest, { params }: Params) {
id,
followupId,
organization.id,
session.user.id
session.user.id,
{
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
}
);
await auditDelete({

View File

@@ -21,10 +21,15 @@ const followupRequestSchema = enquiryFollowupSchema.extend({
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization } = await requireOrganizationAccess({
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryFollowupRead
});
const items = await listEnquiryFollowups(id, organization.id);
const items = await listEnquiryFollowups(id, organization.id, {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
});
return NextResponse.json({
success: true,
@@ -48,11 +53,22 @@ export async function GET(_request: NextRequest, { params }: Params) {
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryFollowupCreate
});
const payload = followupRequestSchema.parse(await request.json());
const created = await createEnquiryFollowup(id, organization.id, session.user.id, payload);
const created = await createEnquiryFollowup(
id,
organization.id,
session.user.id,
payload,
{
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
}
);
await auditCreate({
organizationId: organization.id,

View File

@@ -13,11 +13,16 @@ type Params = {
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryReassign
});
const payload = enquiryAssignmentRequestSchema.parse(await request.json());
const before = await getEnquiryDetail(id, organization.id);
const before = await getEnquiryDetail(id, organization.id, {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
});
const updated = await reassignEnquiryToUser(id, organization.id, session.user.id, payload);
await auditAction({
@@ -28,10 +33,12 @@ export async function POST(request: NextRequest, { params }: Params) {
entityId: id,
action: 'reassign',
beforeData: {
oldAssignedToUserId: before.assignedToUserId
oldAssignedToUserId: before.assignedToUserId,
oldPipelineStage: before.pipelineStage
},
afterData: {
newAssignedToUserId: updated.assignedToUserId,
newPipelineStage: updated.pipelineStage,
remark: updated.assignmentRemark
}
});

View File

@@ -25,12 +25,18 @@ const enquiryRequestSchema = enquirySchema.extend({
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization } = await requireOrganizationAccess({
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryRead
});
const accessContext = {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
};
const [enquiry, activity] = await Promise.all([
getEnquiryDetail(id, organization.id),
getEnquiryActivity(id, organization.id)
getEnquiryDetail(id, organization.id, accessContext),
getEnquiryActivity(id, organization.id, accessContext)
]);
return NextResponse.json({
@@ -56,12 +62,22 @@ export async function GET(_request: NextRequest, { params }: Params) {
export async function PATCH(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryUpdate
});
const payload = enquiryRequestSchema.parse(await request.json());
const before = await getEnquiryDetail(id, organization.id);
const updated = await updateEnquiry(id, organization.id, session.user.id, payload);
const before = await getEnquiryDetail(id, organization.id, {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
});
const updated = await updateEnquiry(id, organization.id, session.user.id, payload, {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
});
await auditUpdate({
organizationId: organization.id,
@@ -101,11 +117,21 @@ export async function PATCH(request: NextRequest, { params }: Params) {
export async function DELETE(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryDelete
});
const before = await getEnquiryDetail(id, organization.id);
const updated = await softDeleteEnquiry(id, organization.id, session.user.id);
const before = await getEnquiryDetail(id, organization.id, {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
});
const updated = await softDeleteEnquiry(id, organization.id, session.user.id, {
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
});
await auditDelete({
organizationId: organization.id,

View File

@@ -15,30 +15,40 @@ const enquiryRequestSchema = enquirySchema.extend({
export async function GET(request: NextRequest) {
try {
const { organization } = await requireOrganizationAccess({
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryRead
});
const { searchParams } = request.nextUrl;
const page = Number(searchParams.get('page') ?? 1);
const limit = Number(searchParams.get('limit') ?? 10);
const search = searchParams.get('search') ?? undefined;
const pipelineStage = searchParams.get('pipelineStage') ?? undefined;
const status = searchParams.get('status') ?? undefined;
const productType = searchParams.get('productType') ?? undefined;
const priority = searchParams.get('priority') ?? undefined;
const branch = searchParams.get('branch') ?? undefined;
const customer = searchParams.get('customer') ?? undefined;
const sort = searchParams.get('sort') ?? undefined;
const result = await listEnquiries(organization.id, {
const result = await listEnquiries(
{
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole
},
{
page,
limit,
search,
pipelineStage: pipelineStage as 'lead' | 'enquiry' | 'closed_won' | 'closed_lost' | undefined,
status,
productType,
priority,
branch,
customer,
sort
});
}
);
const offset = (page - 1) * limit;
return NextResponse.json({
@@ -65,11 +75,16 @@ export async function GET(request: NextRequest) {
export async function POST(request: NextRequest) {
try {
const { organization, session } = await requireOrganizationAccess({
const { organization, session, membership } = await requireOrganizationAccess({
permission: PERMISSIONS.crmEnquiryCreate
});
const payload = enquiryRequestSchema.parse(await request.json());
const created = await createEnquiry(organization.id, session.user.id, payload);
const created = await createEnquiry(
organization.id,
session.user.id,
membership.businessRole,
payload
);
await auditCreate({
organizationId: organization.id,

View File

@@ -54,6 +54,11 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmEnquiryReassign)));
const canReadQuotation =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationRead)));
const queryClient = getQueryClient();
if (canRead) {
@@ -67,14 +72,14 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
? await getEnquiryReferenceData(session.user.activeOrganizationId)
: null;
const relatedQuotations =
canRead && session?.user?.activeOrganizationId
canRead && canReadQuotation && session?.user?.activeOrganizationId
? await listEnquiryQuotationRelations(id, session.user.activeOrganizationId)
: [];
return (
<PageContainer
pageTitle='Enquiry Detail'
pageDescription='Opportunity context, follow-up timeline, and future quotation handoff.'
pageDescription='Lead or enquiry context, follow-up timeline, and ownership lifecycle.'
access={canRead}
accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
@@ -88,9 +93,11 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
enquiryId={id}
referenceData={referenceData}
relatedQuotations={relatedQuotations}
workspace='enquiry'
canUpdate={canUpdate}
canAssign={canAssign}
canReassign={canReassign}
canViewRelatedQuotations={canReadQuotation}
canManageFollowups={{
create: canFollowupCreate,
update: canFollowupUpdate,

View File

@@ -50,6 +50,7 @@ export default async function EnquiriesRoute(props: PageProps) {
session.user.activePermissions.includes(
PERMISSIONS.crmEnquiryReassign,
)));
const businessRole = session?.user?.activeBusinessRole ?? null;
searchParamsCache.parse(searchParams);
@@ -60,8 +61,8 @@ export default async function EnquiriesRoute(props: PageProps) {
return (
<PageContainer
pageTitle="Enquiries (Lead)"
pageDescription="Production opportunity registry with customer linkage, follow-up workflow, and audit-backed activity."
pageTitle="Enquiries"
pageDescription="Sales workspace for active enquiries, follow-up workflow, and quotation preparation."
access={canRead}
accessFallback={
<div className="text-muted-foreground rounded-lg border border-dashed p-8 text-center">
@@ -70,13 +71,14 @@ export default async function EnquiriesRoute(props: PageProps) {
}
pageHeaderAction={
canCreate && referenceData ? (
<EnquiryFormSheetTrigger referenceData={referenceData} />
<EnquiryFormSheetTrigger referenceData={referenceData} workspace='enquiry' />
) : null
}
>
{referenceData ? (
<EnquiryListing
referenceData={referenceData}
workspace='enquiry'
canUpdate={canUpdate}
canDelete={canDelete}
canAssign={canAssign}

View 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>
);
}

View 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>
);
}

View File

@@ -100,7 +100,15 @@ export const navGroups: NavGroup[] = [
access: { requireOrg: true, permission: "crm.customer.read" },
},
{
title: "Enquiries (Lead)",
title: "Leads",
url: "/dashboard/crm/leads",
icon: "forms",
isActive: false,
items: [],
access: { requireOrg: true, permission: "crm.lead.read" },
},
{
title: "Enquiries",
url: "/dashboard/crm/enquiries",
icon: "forms",
isActive: false,

View File

@@ -216,6 +216,7 @@ export const crmEnquiries = pgTable(
notes: text('notes'),
isHotProject: boolean('is_hot_project').default(false).notNull(),
isActive: boolean('is_active').default(true).notNull(),
pipelineStage: text('pipeline_stage').default('lead').notNull(),
assignedToUserId: text('assigned_to_user_id'),
assignedAt: timestamp('assigned_at', { withTimezone: true }),
assignedBy: text('assigned_by'),

View File

@@ -32,11 +32,13 @@ export interface CrmDashboardSummary {
unassignedLeads: number;
averageLeadAgingDays: number;
};
opportunity: {
opportunityCount: number;
openOpportunities: number;
hotOpportunities: number;
opportunityValue: number;
enquiry: {
enquiryCount: number;
activeEnquiries: number;
wonCount: number;
lostCount: number;
hotEnquiries: number;
enquiryValue: number;
};
quotation: {
draftQuotations: number;
@@ -73,7 +75,7 @@ export interface CrmDashboardSalesRankingRow {
salesPersonId: string;
salesPersonName: string;
leadCount: number;
opportunityCount: number;
enquiryCount: number;
quotationCount: number;
approvedQuotations: number;
wonRevenue: number;
@@ -92,7 +94,7 @@ export interface CrmDashboardFollowupRow {
sourceType: 'enquiry' | 'quotation';
followupDate: string;
customerName: string;
opportunityName: string;
enquiryName: string;
assignedSalesName: string | null;
outcome: string | null;
}
@@ -148,4 +150,8 @@ export interface CrmDashboardResponse {
myPendingApprovals: CrmDashboardApprovalRow[];
};
hotProjects: CrmDashboardHotProjectRow[];
visibility: {
canViewCommercialData: boolean;
canViewApprovalData: boolean;
};
}

View File

@@ -36,16 +36,29 @@ export function CrmDashboard({ canExport }: { canExport: boolean }) {
return (
<div className='space-y-6'>
<DashboardFilters referenceData={data.referenceData} canExport={canExport} />
<DashboardSummaryCards summary={data.summary} />
<DashboardSummaryCards
summary={data.summary}
canViewCommercialData={data.visibility.canViewCommercialData}
/>
{data.visibility.canViewCommercialData ? (
<div className='grid gap-6 2xl:grid-cols-[1.35fr_1fr]'>
<DashboardFunnel steps={data.funnel} />
<DashboardHotProjects rows={data.hotProjects} />
</div>
) : null}
{data.visibility.canViewCommercialData ? (
<DashboardRevenueCards revenueAnalytics={data.revenueAnalytics} />
) : null}
{data.visibility.canViewCommercialData || data.visibility.canViewApprovalData ? (
<div className='grid gap-6 2xl:grid-cols-[1.2fr_1fr]'>
{data.visibility.canViewCommercialData ? (
<DashboardSalesRanking rows={data.salesRanking} />
) : null}
{data.visibility.canViewApprovalData ? (
<DashboardApprovalMetrics approvals={data.approvals} />
) : null}
</div>
) : null}
<DashboardFollowups followups={data.followups} />
</div>
);

View File

@@ -36,8 +36,8 @@ export function DashboardFollowups({
<TableRow>
<TableHead>Date</TableHead>
<TableHead>Customer</TableHead>
<TableHead>Opportunity</TableHead>
<TableHead>Assigned Sales</TableHead>
<TableHead>Enquiry</TableHead>
<TableHead>Enquiry Owner</TableHead>
<TableHead>Source</TableHead>
</TableRow>
</TableHeader>
@@ -53,7 +53,7 @@ export function DashboardFollowups({
<TableRow key={`${row.sourceType}-${row.id}`}>
<TableCell>{new Date(row.followupDate).toLocaleDateString()}</TableCell>
<TableCell>{row.customerName}</TableCell>
<TableCell>{row.opportunityName}</TableCell>
<TableCell>{row.enquiryName}</TableCell>
<TableCell>{row.assignedSalesName ?? '-'}</TableCell>
<TableCell className='capitalize'>{row.sourceType}</TableCell>
</TableRow>

View File

@@ -14,7 +14,7 @@ export function DashboardFunnel({ steps }: { steps: CrmDashboardFunnelStep[] })
<Card>
<CardHeader>
<CardTitle>Sales Pipeline Funnel</CardTitle>
<CardDescription>Frozen lead, opportunity, quotation, approval, and won stages.</CardDescription>
<CardDescription>Lead, enquiry, quotation, approval, and won stages from the current pipeline model.</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
{steps.map((step) => (

View File

@@ -12,8 +12,8 @@ export function DashboardHotProjects({ rows }: { rows: CrmDashboardHotProjectRow
return (
<Card>
<CardHeader>
<CardTitle>Top Hot Opportunities</CardTitle>
<CardDescription>Production hot projects from enquiries, ranked by commercial value.</CardDescription>
<CardTitle>Top Active Enquiries</CardTitle>
<CardDescription>Sales-owned hot enquiries ranked by estimated commercial value.</CardDescription>
</CardHeader>
<CardContent>
<Table>
@@ -22,7 +22,7 @@ export function DashboardHotProjects({ rows }: { rows: CrmDashboardHotProjectRow
<TableHead>Project</TableHead>
<TableHead>Customer</TableHead>
<TableHead className='text-right'>Value</TableHead>
<TableHead>Assigned Sales</TableHead>
<TableHead>Enquiry Owner</TableHead>
<TableHead className='text-right'>Probability</TableHead>
</TableRow>
</TableHeader>

View File

@@ -13,7 +13,7 @@ export function DashboardSalesRanking({ rows }: { rows: CrmDashboardSalesRanking
<Card>
<CardHeader>
<CardTitle>Sales Ranking</CardTitle>
<CardDescription>Lead and opportunity ownership follow assignment rules. Quotation ownership uses salesman.</CardDescription>
<CardDescription>Lead and enquiry ownership follow assignment rules. Quotation ownership uses salesman.</CardDescription>
</CardHeader>
<CardContent>
<Table>
@@ -21,7 +21,7 @@ export function DashboardSalesRanking({ rows }: { rows: CrmDashboardSalesRanking
<TableRow>
<TableHead>Sales Person</TableHead>
<TableHead className='text-right'>Lead</TableHead>
<TableHead className='text-right'>Opportunity</TableHead>
<TableHead className='text-right'>Enquiry</TableHead>
<TableHead className='text-right'>Quotation</TableHead>
<TableHead className='text-right'>Approved</TableHead>
<TableHead className='text-right'>Won Revenue</TableHead>
@@ -40,7 +40,7 @@ export function DashboardSalesRanking({ rows }: { rows: CrmDashboardSalesRanking
<TableRow key={row.salesPersonId}>
<TableCell className='font-medium'>{row.salesPersonName}</TableCell>
<TableCell className='text-right'>{formatNumber(row.leadCount)}</TableCell>
<TableCell className='text-right'>{formatNumber(row.opportunityCount)}</TableCell>
<TableCell className='text-right'>{formatNumber(row.enquiryCount)}</TableCell>
<TableCell className='text-right'>{formatNumber(row.quotationCount)}</TableCell>
<TableCell className='text-right'>{formatNumber(row.approvedQuotations)}</TableCell>
<TableCell className='text-right'>{formatNumber(row.wonRevenue)}</TableCell>

View File

@@ -1,5 +1,6 @@
'use client';
import Link from 'next/link';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import type { CrmDashboardSummary } from '../api/types';
@@ -10,13 +11,15 @@ function formatNumber(value: number) {
function SummaryCard({
title,
description,
items
items,
href
}: {
title: string;
description: string;
items: Array<{ label: string; value: number; suffix?: string }>;
href?: string;
}) {
return (
const content = (
<Card className='h-full'>
<CardHeader>
<CardTitle>{title}</CardTitle>
@@ -35,14 +38,23 @@ function SummaryCard({
</CardContent>
</Card>
);
return href ? <Link href={href} className='block'>{content}</Link> : content;
}
export function DashboardSummaryCards({ summary }: { summary: CrmDashboardSummary }) {
export function DashboardSummaryCards({
summary,
canViewCommercialData
}: {
summary: CrmDashboardSummary;
canViewCommercialData: boolean;
}) {
return (
<div className='grid gap-4 xl:grid-cols-2 2xl:grid-cols-4'>
<div className={`grid gap-4 ${canViewCommercialData ? 'xl:grid-cols-2 2xl:grid-cols-4' : 'xl:grid-cols-2'}`}>
<SummaryCard
title='Lead'
description='Lead-stage enquiries based on the frozen KPI rules.'
description='Marketing-owned lead-stage records based on pipeline stage.'
href='/dashboard/crm/leads'
items={[
{ label: 'Lead Count', value: summary.lead.leadCount },
{ label: 'New Leads', value: summary.lead.newLeads },
@@ -51,15 +63,17 @@ export function DashboardSummaryCards({ summary }: { summary: CrmDashboardSummar
]}
/>
<SummaryCard
title='Opportunity'
description='Assigned enquiries that remain active in the pipeline.'
title='Enquiry'
description='Sales-owned enquiry-stage records plus closed outcomes.'
href='/dashboard/crm/enquiries'
items={[
{ label: 'Opportunity Count', value: summary.opportunity.opportunityCount },
{ label: 'Open Opportunities', value: summary.opportunity.openOpportunities },
{ label: 'Hot Opportunities', value: summary.opportunity.hotOpportunities },
{ label: 'Opportunity Value', value: summary.opportunity.opportunityValue }
{ label: 'Enquiry Count', value: summary.enquiry.enquiryCount },
{ label: 'Active Enquiries', value: summary.enquiry.activeEnquiries },
{ label: 'Closed Won', value: summary.enquiry.wonCount },
{ label: 'Closed Lost', value: summary.enquiry.lostCount }
]}
/>
{canViewCommercialData ? (
<SummaryCard
title='Quotation'
description='Live quotation status counts from production data.'
@@ -70,15 +84,18 @@ export function DashboardSummaryCards({ summary }: { summary: CrmDashboardSummar
{ label: 'Sent Quotations', value: summary.quotation.sentQuotations }
]}
/>
) : null}
{canViewCommercialData ? (
<SummaryCard
title='Revenue'
description='Quotation, won, and lost values using frozen dashboard rules.'
description='Quotation, won, and lost values using governed revenue rules.'
items={[
{ label: 'Quotation Value', value: summary.revenue.quotationValue },
{ label: 'Won Value', value: summary.revenue.wonValue },
{ label: 'Lost Value', value: summary.revenue.lostValue }
]}
/>
) : null}
</div>
);
}

View File

@@ -20,6 +20,7 @@ import {
users
} from '@/db/schema';
import { db } from '@/lib/db';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { getUserBranches } from '@/features/foundation/branch-scope/service';
import { listApprovalRequests } from '@/features/foundation/approval/server/service';
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
@@ -54,6 +55,7 @@ type DashboardContext = {
userId: string;
membershipRole: string;
businessRole: string;
permissions: string[];
};
type NormalizedFilters = {
@@ -70,6 +72,15 @@ type StatusMaps = {
quotationStatusById: Map<string, string>;
};
const DASHBOARD_FULL_ACCESS_ROLES = new Set([
'marketing',
'sales_manager',
'department_manager',
'top_manager'
]);
const DASHBOARD_OWNED_SCOPE_ROLES = new Set(['sales', 'sales_support']);
function normalizeDashboardFilters(filters: CrmDashboardFilters): NormalizedFilters {
const now = new Date();
const monthStart = startOfDay(new Date(now.getFullYear(), now.getMonth(), 1));
@@ -103,6 +114,54 @@ function average(values: number[]) {
return round2(values.reduce((sum, value) => sum + value, 0) / values.length);
}
function canViewCommercialData(context: DashboardContext) {
return (
context.membershipRole === 'admin' || context.permissions.includes(PERMISSIONS.crmQuotationRead)
);
}
function canViewApprovalData(context: DashboardContext) {
return (
context.membershipRole === 'admin' || context.permissions.includes(PERMISSIONS.crmApprovalRead)
);
}
function hasDashboardFullScope(context: DashboardContext) {
return (
context.membershipRole === 'admin' || DASHBOARD_FULL_ACCESS_ROLES.has(context.businessRole)
);
}
function canAccessDashboardEnquiry(
row: typeof crmEnquiries.$inferSelect,
context: DashboardContext
) {
if (hasDashboardFullScope(context)) {
return true;
}
if (DASHBOARD_OWNED_SCOPE_ROLES.has(context.businessRole)) {
return row.createdBy === context.userId || row.assignedToUserId === context.userId;
}
return true;
}
function canAccessDashboardQuotation(
row: typeof crmQuotations.$inferSelect,
context: DashboardContext
) {
if (hasDashboardFullScope(context)) {
return true;
}
if (DASHBOARD_OWNED_SCOPE_ROLES.has(context.businessRole)) {
return row.salesmanId === context.userId;
}
return true;
}
async function getStatusMaps(organizationId: string): Promise<StatusMaps> {
const [enquiryOptions, quotationOptions] = await Promise.all([
getActiveOptionsByCategory(ENQUIRY_STATUS_CATEGORY, { organizationId }),
@@ -172,35 +231,39 @@ function matchesQuotationFilters(
return true;
}
function isLeadStatus(statusCode: string | undefined, enquiry: typeof crmEnquiries.$inferSelect) {
return enquiry.assignedToUserId === null && !['closed_lost', 'cancelled'].includes(statusCode ?? '');
}
function isOpportunityStatus(
statusCode: string | undefined,
enquiry: typeof crmEnquiries.$inferSelect
function isPipelineStage(
enquiry: typeof crmEnquiries.$inferSelect,
stage: 'lead' | 'enquiry' | 'closed_won' | 'closed_lost'
) {
return enquiry.assignedToUserId !== null && !['closed_lost', 'cancelled'].includes(statusCode ?? '');
return enquiry.pipelineStage === stage;
}
async function loadScopedRows(
organizationId: string,
context: DashboardContext,
filters: NormalizedFilters
) {
const [enquiries, quotations] = await Promise.all([
db
.select()
.from(crmEnquiries)
.where(and(eq(crmEnquiries.organizationId, organizationId), isNull(crmEnquiries.deletedAt))),
.where(
and(eq(crmEnquiries.organizationId, context.organizationId), isNull(crmEnquiries.deletedAt))
),
db
.select()
.from(crmQuotations)
.where(and(eq(crmQuotations.organizationId, organizationId), isNull(crmQuotations.deletedAt)))
.where(
and(eq(crmQuotations.organizationId, context.organizationId), isNull(crmQuotations.deletedAt))
)
]);
return {
enquiries: enquiries.filter((row) => matchesEnquiryFilters(row, filters)),
quotations: quotations.filter((row) => matchesQuotationFilters(row, filters))
enquiries: enquiries
.filter((row) => canAccessDashboardEnquiry(row, context))
.filter((row) => matchesEnquiryFilters(row, filters)),
quotations: quotations
.filter((row) => canAccessDashboardQuotation(row, context))
.filter((row) => matchesQuotationFilters(row, filters))
};
}
@@ -210,12 +273,10 @@ function buildSummary(
statusMaps: StatusMaps
): CrmDashboardSummary {
const now = new Date();
const leadRows = scopedEnquiries.filter((row) =>
isLeadStatus(statusMaps.enquiryStatusById.get(row.status), row)
);
const opportunityRows = scopedEnquiries.filter((row) =>
isOpportunityStatus(statusMaps.enquiryStatusById.get(row.status), row)
);
const leadRows = scopedEnquiries.filter((row) => isPipelineStage(row, 'lead'));
const enquiryRows = scopedEnquiries.filter((row) => isPipelineStage(row, 'enquiry'));
const wonRows = scopedEnquiries.filter((row) => isPipelineStage(row, 'closed_won'));
const lostRows = scopedEnquiries.filter((row) => isPipelineStage(row, 'closed_lost'));
const leadAges = leadRows.map((row) => (now.getTime() - row.createdAt.getTime()) / (1000 * 60 * 60 * 24));
const quotationStatusCodes = scopedQuotations.map((row) => statusMaps.quotationStatusById.get(row.status) ?? '');
@@ -224,14 +285,16 @@ function buildSummary(
lead: {
leadCount: leadRows.length,
newLeads: leadRows.length,
unassignedLeads: leadRows.length,
unassignedLeads: leadRows.filter((row) => row.assignedToUserId === null).length,
averageLeadAgingDays: average(leadAges)
},
opportunity: {
opportunityCount: opportunityRows.length,
openOpportunities: opportunityRows.length,
hotOpportunities: opportunityRows.filter((row) => row.isHotProject).length,
opportunityValue: round2(opportunityRows.reduce((sum, row) => sum + (row.estimatedValue ?? 0), 0))
enquiry: {
enquiryCount: enquiryRows.length,
activeEnquiries: enquiryRows.length,
wonCount: wonRows.length,
lostCount: lostRows.length,
hotEnquiries: enquiryRows.filter((row) => row.isHotProject).length,
enquiryValue: round2(enquiryRows.reduce((sum, row) => sum + (row.estimatedValue ?? 0), 0))
},
quotation: {
draftQuotations: quotationStatusCodes.filter((code) => code === 'draft').length,
@@ -274,10 +337,10 @@ function buildFunnel(
value: 0
},
{
key: 'opportunity',
label: 'Opportunity',
count: summary.opportunity.opportunityCount,
value: summary.opportunity.opportunityValue
key: 'enquiry',
label: 'Enquiry',
count: summary.enquiry.enquiryCount,
value: summary.enquiry.enquiryValue
},
{
key: 'quotation',
@@ -312,9 +375,7 @@ function buildFunnel(
{
key: 'closed_won',
label: 'Closed Won',
count: scopedQuotations.filter(
(row) => (statusMaps.quotationStatusById.get(row.status) ?? '') === 'accepted'
).length,
count: summary.enquiry.wonCount,
value: scopedQuotations
.filter((row) => (statusMaps.quotationStatusById.get(row.status) ?? '') === 'accepted')
.reduce((sum, row) => sum + row.totalAmount, 0)
@@ -415,19 +476,19 @@ async function buildSalesRanking(
salesPersonId: row.assignedToUserId,
salesPersonName: userMap.get(row.assignedToUserId) ?? 'Unknown',
leadCount: 0,
opportunityCount: 0,
enquiryCount: 0,
quotationCount: 0,
approvedQuotations: 0,
wonRevenue: 0,
conversionRate: 0
};
if (isLeadStatus(statusMaps.enquiryStatusById.get(row.status), row)) {
if (isPipelineStage(row, 'lead')) {
current.leadCount += 1;
}
if (isOpportunityStatus(statusMaps.enquiryStatusById.get(row.status), row)) {
current.opportunityCount += 1;
if (isPipelineStage(row, 'enquiry')) {
current.enquiryCount += 1;
}
rankingMap.set(row.assignedToUserId, current);
@@ -441,7 +502,7 @@ async function buildSalesRanking(
salesPersonId: row.salesmanId,
salesPersonName: userMap.get(row.salesmanId) ?? 'Unknown',
leadCount: 0,
opportunityCount: 0,
enquiryCount: 0,
quotationCount: 0,
approvedQuotations: 0,
wonRevenue: 0,
@@ -480,20 +541,33 @@ async function buildSalesRanking(
}
async function buildFollowups(
organizationId: string,
context: DashboardContext,
filters: NormalizedFilters
): Promise<{ summary: CrmDashboardFollowupSummary; upcoming: CrmDashboardFollowupRow[] }> {
const allowQuotationFollowups = canViewCommercialData(context);
const [enquiryFollowups, quotationFollowups] = await Promise.all([
db
.select()
.from(crmEnquiryFollowups)
.where(and(eq(crmEnquiryFollowups.organizationId, organizationId), isNull(crmEnquiryFollowups.deletedAt)))
.where(
and(
eq(crmEnquiryFollowups.organizationId, context.organizationId),
isNull(crmEnquiryFollowups.deletedAt)
)
)
.orderBy(asc(crmEnquiryFollowups.followupDate)),
db
allowQuotationFollowups
? db
.select()
.from(crmQuotationFollowups)
.where(and(eq(crmQuotationFollowups.organizationId, organizationId), isNull(crmQuotationFollowups.deletedAt)))
.where(
and(
eq(crmQuotationFollowups.organizationId, context.organizationId),
isNull(crmQuotationFollowups.deletedAt)
)
)
.orderBy(asc(crmQuotationFollowups.followupDate))
: []
]);
const { from, to } = toDayBounds(filters);
@@ -522,6 +596,10 @@ async function buildFollowups(
const salesMap = new Map(salesUsers.map((row) => [row.id, row.name]));
const enquiryRows = enquiryFollowups
.filter((row) => {
const enquiry = enquiryMap.get(row.enquiryId);
return enquiry ? canAccessDashboardEnquiry(enquiry, context) : false;
})
.filter((row) => !isBefore(row.followupDate, from) && !isAfter(row.followupDate, to))
.flatMap<CrmDashboardFollowupRow>((row) => {
const enquiry = enquiryMap.get(row.enquiryId);
@@ -536,7 +614,7 @@ async function buildFollowups(
sourceType: 'enquiry',
followupDate: row.followupDate.toISOString(),
customerName: customerMap.get(enquiry.customerId) ?? 'Unknown customer',
opportunityName: enquiry.projectName ?? enquiry.title,
enquiryName: enquiry.projectName ?? enquiry.title,
assignedSalesName: enquiry.assignedToUserId
? (salesMap.get(enquiry.assignedToUserId) ?? null)
: null,
@@ -545,6 +623,10 @@ async function buildFollowups(
];
});
const quotationRows = quotationFollowups
.filter((row) => {
const quotation = quotationMap.get(row.quotationId);
return quotation ? canAccessDashboardQuotation(quotation, context) : false;
})
.filter((row) => !isBefore(row.followupDate, from) && !isAfter(row.followupDate, to))
.flatMap<CrmDashboardFollowupRow>((row) => {
const quotation = quotationMap.get(row.quotationId);
@@ -559,7 +641,7 @@ async function buildFollowups(
sourceType: 'quotation',
followupDate: row.followupDate.toISOString(),
customerName: customerMap.get(quotation.customerId) ?? 'Unknown customer',
opportunityName: quotation.projectName ?? quotation.code,
enquiryName: quotation.projectName ?? quotation.code,
assignedSalesName: quotation.salesmanId
? (salesMap.get(quotation.salesmanId) ?? null)
: null,
@@ -636,18 +718,25 @@ async function buildApprovalAnalytics(
}
async function buildHotProjects(
organizationId: string,
context: DashboardContext,
filters: NormalizedFilters,
statusMaps: StatusMaps
): Promise<CrmDashboardHotProjectRow[]> {
const rows = await db
.select()
.from(crmEnquiries)
.where(and(eq(crmEnquiries.organizationId, organizationId), isNull(crmEnquiries.deletedAt), eq(crmEnquiries.isHotProject, true)))
.where(
and(
eq(crmEnquiries.organizationId, context.organizationId),
isNull(crmEnquiries.deletedAt),
eq(crmEnquiries.isHotProject, true)
)
)
.orderBy(desc(crmEnquiries.updatedAt));
const scoped = rows
.filter((row) => canAccessDashboardEnquiry(row, context))
.filter((row) => matchesEnquiryFilters(row, filters))
.filter((row) => isOpportunityStatus(statusMaps.enquiryStatusById.get(row.status), row))
.filter((row) => isPipelineStage(row, 'enquiry'))
.slice(0, 10);
const customerIds = [...new Set(scoped.map((row) => row.customerId))];
const salesIds = [...new Set(scoped.map((row) => row.assignedToUserId).filter(Boolean))] as string[];
@@ -674,18 +763,42 @@ export async function getCrmDashboardData(
rawFilters: CrmDashboardFilters
): Promise<CrmDashboardResponse> {
const filters = normalizeDashboardFilters(rawFilters);
const allowCommercialData = canViewCommercialData(context);
const allowApprovalData = canViewApprovalData(context);
const [referenceData, statusMaps, scoped] = await Promise.all([
getReferenceData(context.organizationId),
getStatusMaps(context.organizationId),
loadScopedRows(context.organizationId, filters)
loadScopedRows(context, filters)
]);
const summary = buildSummary(scoped.enquiries, scoped.quotations, statusMaps);
const [revenueAnalytics, salesRanking, followups, approvals, hotProjects] = await Promise.all([
buildRevenueAnalytics(context.organizationId, filters),
buildSalesRanking(scoped.enquiries, scoped.quotations, statusMaps),
buildFollowups(context.organizationId, filters),
buildApprovalAnalytics(context),
buildHotProjects(context.organizationId, filters, statusMaps)
allowCommercialData
? buildRevenueAnalytics(context.organizationId, filters)
: Promise.resolve({
topEndCustomers: [],
topBillingCustomers: [],
topContractors: [],
topConsultants: []
}),
allowCommercialData
? buildSalesRanking(scoped.enquiries, scoped.quotations, statusMaps)
: Promise.resolve([]),
buildFollowups(context, filters),
allowApprovalData
? buildApprovalAnalytics(context)
: Promise.resolve({
summary: {
pendingApprovals: 0,
approvedToday: 0,
rejectedToday: 0,
returnedToday: 0,
averageApprovalTimeHours: 0
},
myPendingApprovals: []
}),
allowCommercialData
? buildHotProjects(context, filters, statusMaps)
: Promise.resolve([])
]);
return {
@@ -700,6 +813,10 @@ export async function getCrmDashboardData(
salesRanking,
followups,
approvals,
hotProjects
hotProjects,
visibility: {
canViewCommercialData: allowCommercialData,
canViewApprovalData: allowApprovalData
}
};
}

View File

@@ -16,11 +16,16 @@ import type {
EnquiryFollowupMutationPayload,
EnquiryMutationPayload
} from './types';
import { crmDashboardKeys } from '@/features/crm/dashboard/api/queries';
async function invalidateEnquiryLists() {
await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.lists() });
}
async function invalidateDashboardQueries() {
await getQueryClient().invalidateQueries({ queryKey: crmDashboardKeys.all });
}
async function invalidateEnquiryDetail(id: string) {
await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.detail(id) });
}
@@ -37,7 +42,8 @@ export async function invalidateEnquiryMutationQueries(id: string) {
await Promise.all([
invalidateEnquiryLists(),
invalidateEnquiryDetail(id),
invalidateEnquiryProjectParties(id)
invalidateEnquiryProjectParties(id),
invalidateDashboardQueries()
]);
}
@@ -45,7 +51,8 @@ export async function invalidateEnquiryFollowupMutationQueries(enquiryId: string
await Promise.all([
invalidateEnquiryLists(),
invalidateEnquiryDetail(enquiryId),
invalidateEnquiryFollowups(enquiryId)
invalidateEnquiryFollowups(enquiryId),
invalidateDashboardQueries()
]);
}
@@ -53,7 +60,7 @@ export const createEnquiryMutation = mutationOptions({
mutationFn: (data: EnquiryMutationPayload) => createEnquiry(data),
onSettled: async (_data, error) => {
if (!error) {
await invalidateEnquiryLists();
await Promise.all([invalidateEnquiryLists(), invalidateDashboardQueries()]);
}
}
});
@@ -75,7 +82,7 @@ export const deleteEnquiryMutation = mutationOptions({
getQueryClient().removeQueries({ queryKey: enquiryKeys.detail(id) });
getQueryClient().removeQueries({ queryKey: enquiryKeys.projectParties(id) });
getQueryClient().removeQueries({ queryKey: enquiryKeys.followups(id) });
await invalidateEnquiryLists();
await Promise.all([invalidateEnquiryLists(), invalidateDashboardQueries()]);
}
}
});

View File

@@ -17,6 +17,7 @@ export async function getEnquiries(filters: EnquiryFilters): Promise<EnquiryList
if (filters.page) searchParams.set('page', String(filters.page));
if (filters.limit) searchParams.set('limit', String(filters.limit));
if (filters.search) searchParams.set('search', filters.search);
if (filters.pipelineStage) searchParams.set('pipelineStage', filters.pipelineStage);
if (filters.status) searchParams.set('status', filters.status);
if (filters.productType) searchParams.set('productType', filters.productType);
if (filters.priority) searchParams.set('priority', filters.priority);

View File

@@ -54,6 +54,8 @@ export interface EnquiryAssignableUserLookup {
businessRole: string;
}
export type EnquiryPipelineStage = 'lead' | 'enquiry' | 'closed_won' | 'closed_lost';
export interface EnquiryRecord {
id: string;
organizationId: string;
@@ -78,6 +80,7 @@ export interface EnquiryRecord {
notes: string | null;
isHotProject: boolean;
isActive: boolean;
pipelineStage: EnquiryPipelineStage;
assignedToUserId: string | null;
assignedToName: string | null;
assignedAt: string | null;
@@ -95,6 +98,9 @@ export interface EnquiryListItem extends EnquiryRecord {
customerName: string;
contactName: string | null;
followupCount: number;
quotationCount: number;
nextFollowupDate: string | null;
createdByName: string | null;
}
export interface EnquiryFollowupRecord {
@@ -142,6 +148,7 @@ export interface EnquiryFilters {
page?: number;
limit?: number;
search?: string;
pipelineStage?: EnquiryPipelineStage;
status?: string;
productType?: string;
priority?: string;

View File

@@ -13,20 +13,22 @@ import { getEnquiryColumns } from './enquiry-columns';
export function EnquiriesTable({
referenceData,
workspace,
canUpdate,
canDelete,
canAssign,
canReassign
}: {
referenceData: EnquiryReferenceData;
workspace: 'lead' | 'enquiry';
canUpdate: boolean;
canDelete: boolean;
canAssign: boolean;
canReassign: boolean;
}) {
const columns = useMemo(
() => getEnquiryColumns({ referenceData, canUpdate, canDelete, canAssign, canReassign }),
[referenceData, canUpdate, canDelete, canAssign, canReassign]
() => getEnquiryColumns({ referenceData, workspace, canUpdate, canDelete, canAssign, canReassign }),
[referenceData, workspace, canUpdate, canDelete, canAssign, canReassign]
);
const columnIds = columns.map((column) => column.id).filter(Boolean) as string[];
const [params] = useQueryStates({
@@ -44,6 +46,7 @@ export function EnquiriesTable({
const filters = {
page: params.page,
limit: params.perPage,
pipelineStage: workspace,
...(params.name && { search: params.name }),
...(params.status && { status: params.status }),
...(params.productType && { productType: params.productType }),

View File

@@ -57,7 +57,7 @@ export function EnquiryAssignmentDialog({
const assignMutation = useMutation({
...assignEnquiryMutation,
onSuccess: () => {
toast.success('Enquiry assigned successfully');
toast.success('Lead converted to enquiry successfully');
onOpenChange(false);
},
onError: (error) =>
@@ -109,10 +109,10 @@ export function EnquiryAssignmentDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{mode === 'assign' ? 'Assign Sales' : 'Reassign Sales'}</DialogTitle>
<DialogTitle>{mode === 'assign' ? 'Convert To Enquiry' : 'Reassign Enquiry Owner'}</DialogTitle>
<DialogDescription>
{mode === 'assign'
? 'Select the sales owner who should take this enquiry forward.'
? 'Assign this lead to sales and convert it into an enquiry.'
: 'Move this enquiry to a different sales owner and keep the handoff note visible.'}
</DialogDescription>
</DialogHeader>
@@ -127,7 +127,7 @@ export function EnquiryAssignmentDialog({
<>
<FormSelectField
name='assignedToUserId'
label='Sales User'
label='Enquiry Owner'
required
options={referenceData.assignableUsers.map((user) => ({
value: user.id,
@@ -156,7 +156,7 @@ export function EnquiryAssignmentDialog({
disabled={noUsersAvailable}
>
<Icons.userPen className='mr-2 h-4 w-4' />
{mode === 'assign' ? 'Assign Sales' : 'Reassign Sales'}
{mode === 'assign' ? 'Convert To Enquiry' : 'Reassign Owner'}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -21,6 +21,7 @@ import { EnquiryFormSheet } from './enquiry-form-sheet';
export function EnquiryCellAction({
data,
referenceData,
workspace,
canUpdate,
canDelete,
canAssign: _canAssign,
@@ -28,6 +29,7 @@ export function EnquiryCellAction({
}: {
data: EnquiryListItem;
referenceData: EnquiryReferenceData;
workspace: 'lead' | 'enquiry';
canUpdate: boolean;
canDelete: boolean;
canAssign: boolean;
@@ -40,11 +42,15 @@ export function EnquiryCellAction({
const deleteMutation = useMutation({
...deleteEnquiryMutation,
onSuccess: () => {
toast.success('Enquiry deleted successfully');
toast.success(`${workspace === 'lead' ? 'Lead' : 'Enquiry'} deleted successfully`);
setDeleteOpen(false);
},
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to delete enquiry')
toast.error(
error instanceof Error
? error.message
: `Failed to delete ${workspace === 'lead' ? 'lead' : 'enquiry'}`
)
});
return (
@@ -60,6 +66,7 @@ export function EnquiryCellAction({
open={editOpen}
onOpenChange={setEditOpen}
referenceData={referenceData}
workspace={workspace}
/>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
@@ -70,11 +77,19 @@ export function EnquiryCellAction({
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem onClick={() => router.push(`/dashboard/crm/enquiries/${data.id}`)}>
<DropdownMenuItem
onClick={() =>
router.push(
workspace === 'lead'
? `/dashboard/crm/leads/${data.id}`
: `/dashboard/crm/enquiries/${data.id}`
)
}
>
<Icons.arrowRight className='mr-2 h-4 w-4' /> View
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setEditOpen(true)} disabled={!canUpdate}>
<Icons.edit className='mr-2 h-4 w-4' /> Edit
<Icons.edit className='mr-2 h-4 w-4' /> {workspace === 'lead' ? 'Edit Lead' : 'Edit Enquiry'}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDeleteOpen(true)} disabled={!canDelete}>
<Icons.trash className='mr-2 h-4 w-4' /> Delete

View File

@@ -9,52 +9,116 @@ import type { EnquiryListItem, EnquiryReferenceData } from '../api/types';
import { EnquiryCellAction } from './enquiry-cell-action';
import { EnquiryStatusBadge } from './enquiry-status-badge';
export function getEnquiryColumns({
function getLeadColumns({
referenceData,
canUpdate,
canDelete,
canAssign,
canReassign
}: {
referenceData: EnquiryReferenceData;
canUpdate: boolean;
canDelete: boolean;
canAssign: boolean;
canReassign: boolean;
}): ColumnDef<EnquiryListItem>[] {
}: SharedColumnsConfig): ColumnDef<EnquiryListItem>[] {
const statusMap = new Map(referenceData.statuses.map((item) => [item.id, item]));
const productTypeMap = new Map(referenceData.productTypes.map((item) => [item.id, item]));
const priorityMap = new Map(referenceData.priorities.map((item) => [item.id, item]));
const branchMap = new Map(referenceData.branches.map((item) => [item.id, item]));
const leadChannelMap = new Map(referenceData.leadChannels.map((item) => [item.id, item.label]));
return [
{
id: 'title',
accessorKey: 'title',
id: 'code',
accessorKey: 'code',
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Enquiry' />
<DataTableColumnHeader column={column} title='Code' />
),
cell: ({ row }) => (
<div className='flex flex-col'>
<Link
href={`/dashboard/crm/enquiries/${row.original.id}`}
href={`/dashboard/crm/leads/${row.original.id}`}
className='font-medium hover:underline'
>
{row.original.title}
{row.original.code}
</Link>
<span className='text-muted-foreground text-xs'>
{row.original.code} {row.original.customerName}
</span>
<span className='text-muted-foreground text-xs'>{row.original.title}</span>
</div>
),
meta: {
label: 'Enquiry',
placeholder: 'Search enquiries...',
label: 'Code',
placeholder: 'Search leads...',
variant: 'text' as const,
icon: Icons.search
},
enableColumnFilter: true
},
{
id: 'leadChannel',
accessorFn: (row) => row.leadChannel ?? '',
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Lead Source' />
),
cell: ({ row }) => <span>{leadChannelMap.get(row.original.leadChannel ?? '') ?? '-'}</span>
},
{
id: 'customer',
accessorFn: (row) => row.customerId,
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Company' />
),
cell: ({ row }) => <span>{row.original.customerName}</span>,
meta: {
label: 'Company',
variant: 'multiSelect' as const,
options: referenceData.customers.map((item) => ({
value: item.id,
label: item.name
}))
},
enableColumnFilter: true
},
{
id: 'contact',
accessorFn: (row) => row.contactId ?? '',
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Contact' />
),
cell: ({ row }) => <span>{row.original.contactName ?? '-'}</span>
},
{
id: 'assignedSales',
accessorFn: (row) => row.assignedToName ?? '',
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Assigned Sales' />
),
cell: ({ row }) => <span>{row.original.assignedToName ?? 'Unassigned'}</span>
},
{
id: 'createdBy',
accessorFn: (row) => row.createdByName ?? row.createdBy,
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Created By' />
),
cell: ({ row }) => <span>{row.original.createdByName ?? row.original.createdBy}</span>
},
{
id: 'createdAt',
accessorKey: 'createdAt',
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Created At' />
),
cell: ({ row }) => <span>{new Date(row.original.createdAt).toLocaleDateString()}</span>
},
{
id: 'age',
accessorFn: (row) => row.createdAt,
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Age' />
),
cell: ({ row }) => {
const ageInDays = Math.max(
0,
Math.floor(
(Date.now() - new Date(row.original.createdAt).getTime()) / (1000 * 60 * 60 * 24)
)
);
return <span>{ageInDays} days</span>;
}
},
{
id: 'status',
accessorKey: 'status',
@@ -76,79 +140,74 @@ export function getEnquiryColumns({
enableColumnFilter: true
},
{
id: 'productType',
accessorKey: 'productType',
id: 'actions',
cell: ({ row }) => (
<EnquiryCellAction
data={row.original}
referenceData={referenceData}
workspace='lead'
canUpdate={canUpdate}
canDelete={canDelete}
canAssign={canAssign}
canReassign={canReassign}
/>
)
}
];
}
function getEnquiryWorkspaceColumns({
referenceData,
canUpdate,
canDelete,
canAssign,
canReassign
}: SharedColumnsConfig): ColumnDef<EnquiryListItem>[] {
const statusMap = new Map(referenceData.statuses.map((item) => [item.id, item]));
return [
{
id: 'code',
accessorKey: 'code',
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Product Type' />
<DataTableColumnHeader column={column} title='Code' />
),
cell: ({ row }) => (
<Badge variant='outline'>
{productTypeMap.get(row.original.productType)?.label ?? 'Unknown'}
</Badge>
<div className='flex flex-col'>
<Link
href={`/dashboard/crm/enquiries/${row.original.id}`}
className='font-medium hover:underline'
>
{row.original.code}
</Link>
<span className='text-muted-foreground text-xs'>{row.original.title}</span>
</div>
),
meta: {
label: 'Product Type',
variant: 'multiSelect' as const,
options: referenceData.productTypes.map((item) => ({
value: item.id,
label: item.label
}))
label: 'Code',
placeholder: 'Search enquiries...',
variant: 'text' as const,
icon: Icons.search
},
enableColumnFilter: true
},
{
id: 'priority',
accessorKey: 'priority',
id: 'projectName',
accessorFn: (row) => row.projectName ?? row.title,
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Priority' />
<DataTableColumnHeader column={column} title='Project' />
),
cell: ({ row }) => (
<Badge variant='secondary'>
{priorityMap.get(row.original.priority)?.label ?? 'Unknown'}
</Badge>
),
meta: {
label: 'Priority',
variant: 'multiSelect' as const,
options: referenceData.priorities.map((item) => ({
value: item.id,
label: item.label
}))
},
enableColumnFilter: true
},
{
id: 'branch',
accessorFn: (row) => row.branchId ?? '',
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Branch' />
),
cell: ({ row }) => (
<span>
{row.original.branchId
? (branchMap.get(row.original.branchId)?.name ?? 'Unknown')
: 'Unassigned'}
</span>
),
meta: {
label: 'Branch',
variant: 'multiSelect' as const,
options: referenceData.branches.map((item) => ({
value: item.id,
label: item.name
}))
},
enableColumnFilter: true
cell: ({ row }) => <span>{row.original.projectName ?? row.original.title}</span>
},
{
id: 'customer',
accessorFn: (row) => row.customerId,
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Customer' />
<DataTableColumnHeader column={column} title='Billing Customer' />
),
cell: ({ row }) => <span>{row.original.customerName}</span>,
meta: {
label: 'Customer',
label: 'Billing Customer',
variant: 'multiSelect' as const,
options: referenceData.customers.map((item) => ({
value: item.id,
@@ -159,25 +218,77 @@ export function getEnquiryColumns({
},
{
id: 'assignedSales',
header: 'Assigned Sales',
accessorFn: (row) => row.assignedToName ?? '',
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Enquiry Owner' />
),
cell: ({ row }) => <span>{row.original.assignedToName ?? '-'}</span>
},
{
id: 'chancePercent',
accessorKey: 'chancePercent',
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Chance %' />
),
cell: ({ row }) => (
<div className='flex flex-col'>
<span>{row.original.assignedToName ?? 'Unassigned'}</span>
{row.original.assignedAt ? (
<span className='text-muted-foreground text-xs'>
{new Date(row.original.assignedAt).toLocaleDateString()}
</span>
) : null}
</div>
<span>{row.original.chancePercent !== null ? `${row.original.chancePercent}%` : '-'}</span>
)
},
{
id: 'followupCount',
accessorKey: 'followupCount',
id: 'estimatedValue',
accessorKey: 'estimatedValue',
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Follow-ups' />
<DataTableColumnHeader column={column} title='Estimated Value' />
),
cell: ({ row }) => <span>{row.original.followupCount}</span>
cell: ({ row }) => (
<span>
{row.original.estimatedValue !== null
? row.original.estimatedValue.toLocaleString()
: '-'}
</span>
)
},
{
id: 'quotationCount',
accessorKey: 'quotationCount',
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Quotation Count' />
),
cell: ({ row }) => <span>{row.original.quotationCount}</span>
},
{
id: 'nextFollowupDate',
accessorKey: 'nextFollowupDate',
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Next Follow-up' />
),
cell: ({ row }) => (
<span>
{row.original.nextFollowupDate
? new Date(row.original.nextFollowupDate).toLocaleDateString()
: '-'}
</span>
)
},
{
id: 'status',
accessorKey: 'status',
header: ({ column }: { column: Column<EnquiryListItem, unknown> }) => (
<DataTableColumnHeader column={column} title='Status' />
),
cell: ({ row }) => {
const status = statusMap.get(row.original.status);
return <EnquiryStatusBadge code={status?.code} label={status?.label ?? 'Unknown'} />;
},
meta: {
label: 'Status',
variant: 'multiSelect' as const,
options: referenceData.statuses.map((item) => ({
value: item.id,
label: item.label
}))
},
enableColumnFilter: true
},
{
id: 'actions',
@@ -185,6 +296,7 @@ export function getEnquiryColumns({
<EnquiryCellAction
data={row.original}
referenceData={referenceData}
workspace='enquiry'
canUpdate={canUpdate}
canDelete={canDelete}
canAssign={canAssign}
@@ -194,3 +306,34 @@ export function getEnquiryColumns({
}
];
}
type SharedColumnsConfig = {
referenceData: EnquiryReferenceData;
canUpdate: boolean;
canDelete: boolean;
canAssign: boolean;
canReassign: boolean;
};
export function getEnquiryColumns({
referenceData,
workspace,
canUpdate,
canDelete,
canAssign,
canReassign
}: SharedColumnsConfig & {
workspace: 'lead' | 'enquiry';
}): ColumnDef<EnquiryListItem>[] {
const sharedConfig = {
referenceData,
canUpdate,
canDelete,
canAssign,
canReassign
};
return workspace === 'lead'
? getLeadColumns(sharedConfig)
: getEnquiryWorkspaceColumns(sharedConfig);
}

View File

@@ -9,12 +9,12 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { enquiryByIdOptions, enquiryProjectPartiesOptions } from '../api/queries';
import type { EnquiryReferenceData } from '../api/types';
import type { QuotationRelationItem } from '@/features/crm/quotations/api/types';
import { enquiryByIdOptions, enquiryProjectPartiesOptions } from '../api/queries';
import type { EnquiryRecord, EnquiryReferenceData } from '../api/types';
import { EnquiryAssignmentDialog } from './enquiry-assignment-dialog';
import { EnquiryFormSheet } from './enquiry-form-sheet';
import { EnquiryFollowupsTab } from './enquiry-followups-tab';
import { EnquiryFormSheet } from './enquiry-form-sheet';
import { EnquiryStatusBadge } from './enquiry-status-badge';
function FieldItem({ label, value }: { label: string; value: string | null | undefined }) {
@@ -26,27 +26,48 @@ function FieldItem({ label, value }: { label: string; value: string | null | und
);
}
export function EnquiryDetail({
enquiryId,
referenceData,
relatedQuotations,
canUpdate,
canAssign,
canReassign,
canManageFollowups
}: {
function getPipelineStageLabel(stage: EnquiryRecord['pipelineStage']) {
switch (stage) {
case 'lead':
return 'Lead';
case 'enquiry':
return 'Enquiry';
case 'closed_won':
return 'Closed Won';
case 'closed_lost':
return 'Closed Lost';
default:
return stage;
}
}
interface EnquiryDetailProps {
enquiryId: string;
referenceData: EnquiryReferenceData;
relatedQuotations: QuotationRelationItem[];
workspace: 'lead' | 'enquiry';
canUpdate: boolean;
canAssign: boolean;
canReassign: boolean;
canViewRelatedQuotations: boolean;
canManageFollowups: {
create: boolean;
update: boolean;
delete: boolean;
};
}) {
}
export function EnquiryDetail({
enquiryId,
referenceData,
relatedQuotations,
workspace,
canUpdate,
canAssign,
canReassign,
canViewRelatedQuotations,
canManageFollowups
}: EnquiryDetailProps) {
const { data } = useSuspenseQuery(enquiryByIdOptions(enquiryId));
const { data: projectPartiesData } = useSuspenseQuery(enquiryProjectPartiesOptions(enquiryId));
const [editOpen, setEditOpen] = useState(false);
@@ -85,6 +106,29 @@ export function EnquiryDetail({
const status = statusMap.get(enquiry.status);
const canManageAssignment = enquiry.assignedToUserId ? canReassign : canAssign;
const assignmentMode = enquiry.assignedToUserId ? 'reassign' : 'assign';
const pipelineStageLabel = getPipelineStageLabel(enquiry.pipelineStage);
const isLeadWorkspace = workspace === 'lead';
const backHref = isLeadWorkspace ? '/dashboard/crm/leads' : '/dashboard/crm/enquiries';
const recordLabel = isLeadWorkspace ? 'Lead' : 'Enquiry';
const ownerLabel = isLeadWorkspace ? 'Assigned Sales' : 'Enquiry Owner';
const infoCardTitle = isLeadWorkspace ? 'Lead Information' : 'Enquiry Information';
const infoCardDescription = isLeadWorkspace
? 'Lead source, qualification context, and sales handoff readiness.'
: 'Project scope, ownership context, and active sales execution details.';
const assignmentButtonLabel =
assignmentMode === 'assign'
? isLeadWorkspace
? 'Assign To Sales'
: 'Assign Enquiry Owner'
: isLeadWorkspace
? 'Reassign Sales Owner'
: 'Reassign Enquiry Owner';
const projectSectionLabel = isLeadWorkspace ? 'Lead Scope' : 'Project Information';
const followupTabLabel = isLeadWorkspace ? 'Lead Follow-ups' : 'Follow-ups';
const relatedTabLabel = isLeadWorkspace ? 'Related Quotations' : 'Quotations';
const snapshotDescription = isLeadWorkspace
? 'Operational metadata and lead ownership handoff state for this record.'
: 'Operational metadata and assignment state for this enquiry record.';
return (
<div className='space-y-6'>
@@ -92,7 +136,7 @@ export function EnquiryDetail({
<div className='space-y-3'>
<div className='flex items-center gap-2'>
<Button asChild variant='ghost' size='icon'>
<Link href='/dashboard/crm/enquiries'>
<Link href={backHref}>
<Icons.chevronLeft className='h-4 w-4' />
</Link>
</Button>
@@ -103,8 +147,8 @@ export function EnquiryDetail({
<div>
<h2 className='text-2xl font-semibold'>{enquiry.title}</h2>
<p className='text-muted-foreground text-sm'>
{customer?.name ?? 'Unknown customer'}
{contact ? ` ${contact.name}` : ''}
{pipelineStageLabel} | {customer?.name ?? 'Unknown customer'}
{contact ? ` | ${contact.name}` : ''}
</p>
</div>
</div>
@@ -112,12 +156,13 @@ export function EnquiryDetail({
{canManageAssignment ? (
<Button variant='outline' onClick={() => setAssignmentOpen(true)}>
<Icons.userPen className='mr-2 h-4 w-4' />
{assignmentMode === 'assign' ? 'Assign Sales' : 'Reassign Sales'}
{assignmentButtonLabel}
</Button>
) : null}
{canUpdate ? (
<Button variant='outline' onClick={() => setEditOpen(true)}>
<Icons.edit className='mr-2 h-4 w-4' /> Edit Enquiry
<Icons.edit className='mr-2 h-4 w-4' />
{`Edit ${recordLabel}`}
</Button>
) : null}
</div>
@@ -130,20 +175,23 @@ export function EnquiryDetail({
<Tabs defaultValue='overview' className='gap-4'>
<TabsList>
<TabsTrigger value='overview'>Overview</TabsTrigger>
<TabsTrigger value='followups'>Follow-ups</TabsTrigger>
<TabsTrigger value='followups'>{followupTabLabel}</TabsTrigger>
<TabsTrigger value='activity'>Activity</TabsTrigger>
<TabsTrigger value='related'>Related Quotations</TabsTrigger>
{canViewRelatedQuotations ? (
<TabsTrigger value='related'>{relatedTabLabel}</TabsTrigger>
) : null}
</TabsList>
<TabsContent value='overview'>
<Card>
<CardHeader>
<CardTitle>Overview</CardTitle>
<CardDescription>
Opportunity scope, project context, and commercial direction.
</CardDescription>
<CardTitle>{infoCardTitle}</CardTitle>
<CardDescription>{infoCardDescription}</CardDescription>
</CardHeader>
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
<FieldItem label='Billing Customer' value={customer?.name} />
<FieldItem
label={isLeadWorkspace ? 'Company' : 'Billing Customer'}
value={customer?.name}
/>
<FieldItem label='Contact' value={contact?.name} />
<FieldItem
label='Branch'
@@ -159,7 +207,7 @@ export function EnquiryDetail({
value={priorityMap.get(enquiry.priority)?.label}
/>
<FieldItem
label='Lead Channel'
label='Lead Source'
value={leadChannelMap.get(enquiry.leadChannel ?? '')?.label}
/>
<FieldItem
@@ -184,7 +232,8 @@ export function EnquiryDetail({
/>
<FieldItem label='Competitor' value={enquiry.competitor} />
<FieldItem label='Source' value={enquiry.source} />
<FieldItem label='Assigned Sales' value={enquiry.assignedToName} />
<FieldItem label='Pipeline Stage' value={pipelineStageLabel} />
<FieldItem label={ownerLabel} value={enquiry.assignedToName} />
<FieldItem
label='Assigned At'
value={
@@ -195,10 +244,14 @@ export function EnquiryDetail({
<FieldItem label='Assignment Remark' value={enquiry.assignmentRemark} />
<div className='md:col-span-2 xl:col-span-4'>
<div className='space-y-3'>
<div className='text-muted-foreground text-xs'>Project Parties</div>
<div className='text-muted-foreground text-xs'>
{isLeadWorkspace ? 'Lead Parties' : 'Project Parties'}
</div>
{!projectPartiesData.items.length ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-4 text-sm'>
No project parties have been added yet.
{isLeadWorkspace
? 'No lead parties have been added yet.'
: 'No project parties have been added yet.'}
</div>
) : (
<div className='space-y-3'>
@@ -217,11 +270,10 @@ export function EnquiryDetail({
</div>
<div className='md:col-span-2 xl:col-span-4'>
<FieldItem
label='Project'
label={projectSectionLabel}
value={
[enquiry.projectName, enquiry.projectLocation]
.filter(Boolean)
.join(' • ') || null
[enquiry.projectName, enquiry.projectLocation].filter(Boolean).join(' | ') ||
null
}
/>
</div>
@@ -250,9 +302,9 @@ export function EnquiryDetail({
<TabsContent value='activity'>
<Card>
<CardHeader>
<CardTitle>Activity</CardTitle>
<CardTitle>{isLeadWorkspace ? 'Lead Activity' : 'Activity'}</CardTitle>
<CardDescription>
Audit events for the enquiry and its follow-up records.
Audit events for this record and its follow-up history.
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
@@ -289,18 +341,19 @@ export function EnquiryDetail({
</CardContent>
</Card>
</TabsContent>
{canViewRelatedQuotations ? (
<TabsContent value='related'>
<Card>
<CardHeader>
<CardTitle>Related Quotations</CardTitle>
<CardTitle>{relatedTabLabel}</CardTitle>
<CardDescription>
Production quotations already linked to this enquiry.
Production quotations already linked to this record.
</CardDescription>
</CardHeader>
<CardContent>
{!relatedQuotations.length ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
No quotations have been linked to this enquiry yet.
No quotations have been linked to this record yet.
</div>
) : (
<div className='space-y-3'>
@@ -326,6 +379,7 @@ export function EnquiryDetail({
</CardContent>
</Card>
</TabsContent>
) : null}
</Tabs>
</CardContent>
</Card>
@@ -335,12 +389,11 @@ export function EnquiryDetail({
<Card>
<CardHeader>
<CardTitle>Record Snapshot</CardTitle>
<CardDescription>
Operational metadata and assignment state for this enquiry row.
</CardDescription>
<CardDescription>{snapshotDescription}</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<FieldItem label='Assigned Sales' value={enquiry.assignedToName} />
<FieldItem label='Pipeline Stage' value={pipelineStageLabel} />
<FieldItem label={ownerLabel} value={enquiry.assignedToName} />
<FieldItem label='Assigned By' value={enquiry.assignedByName} />
<FieldItem label='Created By' value={enquiry.createdBy} />
<FieldItem label='Updated By' value={enquiry.updatedBy} />
@@ -367,6 +420,7 @@ export function EnquiryDetail({
open={editOpen}
onOpenChange={setEditOpen}
referenceData={referenceData}
workspace={workspace}
/>
</div>
);

View File

@@ -64,12 +64,14 @@ export function EnquiryFormSheet({
enquiry,
open,
onOpenChange,
referenceData
referenceData,
workspace = 'enquiry'
}: {
enquiry?: EnquiryRecord;
open: boolean;
onOpenChange: (open: boolean) => void;
referenceData: EnquiryReferenceData;
workspace?: 'lead' | 'enquiry';
}) {
const isEdit = !!enquiry;
const defaultValues = useMemo(
@@ -88,21 +90,21 @@ export function EnquiryFormSheet({
const createMutation = useMutation({
...createEnquiryMutation,
onSuccess: () => {
toast.success('Enquiry created successfully');
toast.success(`${recordLabel} created successfully`);
onOpenChange(false);
},
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to create enquiry')
toast.error(error instanceof Error ? error.message : `Failed to create ${recordLabel.toLowerCase()}`)
});
const updateMutation = useMutation({
...updateEnquiryMutation,
onSuccess: () => {
toast.success('Enquiry updated successfully');
toast.success(`${recordLabel} updated successfully`);
onOpenChange(false);
},
onError: (error) =>
toast.error(error instanceof Error ? error.message : 'Failed to update enquiry')
toast.error(error instanceof Error ? error.message : `Failed to update ${recordLabel.toLowerCase()}`)
});
const contactsForCustomer = referenceData.contacts.filter(
@@ -177,13 +179,17 @@ export function EnquiryFormSheet({
const isPending = createMutation.isPending || updateMutation.isPending;
const recordLabel = workspace === 'lead' ? 'Lead' : 'Enquiry';
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className='flex flex-col sm:max-w-4xl'>
<SheetHeader>
<SheetTitle>{isEdit ? 'Edit Enquiry' : 'New Enquiry'}</SheetTitle>
<SheetTitle>{isEdit ? `Edit ${recordLabel}` : `New ${recordLabel}`}</SheetTitle>
<SheetDescription>
Capture the opportunity details before quotation and approval stages begin.
{workspace === 'lead'
? 'Capture marketing lead details before assignment to sales.'
: 'Capture sales enquiry details before quotation work begins.'}
</SheetDescription>
</SheetHeader>
@@ -261,7 +267,7 @@ export function EnquiryFormSheet({
name='title'
label='Title'
required
placeholder='Warehouse crane upgrade opportunity'
placeholder='Warehouse crane upgrade enquiry'
/>
<FormTextField
name='projectName'
@@ -338,7 +344,7 @@ export function EnquiryFormSheet({
<FormTextareaField
name='description'
label='Description'
placeholder='High-level summary of the opportunity'
placeholder='High-level summary of the lead or enquiry'
/>
</div>
<div className='md:col-span-2'>
@@ -352,19 +358,19 @@ export function EnquiryFormSheet({
<FormTextareaField
name='notes'
label='Notes'
placeholder='Internal notes, blockers, or guidance for the sales team'
placeholder='Internal notes, blockers, or handoff guidance'
/>
</div>
<div className='md:col-span-2 grid gap-4 md:grid-cols-2'>
<FormSwitchField
name='isHotProject'
label='Hot Project'
description='Use for urgent or highly strategic opportunities.'
description='Use for urgent or highly strategic enquiries.'
/>
<FormSwitchField
name='isActive'
label='Active Record'
description='Inactive enquiries stay in history but should not progress further.'
description='Inactive leads and enquiries stay in history but should not progress further.'
/>
</div>
<div className='md:col-span-2'>
@@ -385,7 +391,7 @@ export function EnquiryFormSheet({
</Button>
<Button type='submit' form='enquiry-form-sheet' isLoading={isPending}>
<Icons.check className='mr-2 h-4 w-4' />
{isEdit ? 'Update Enquiry' : 'Create Enquiry'}
{isEdit ? `Update ${recordLabel}` : `Create ${recordLabel}`}
</Button>
</SheetFooter>
</SheetContent>
@@ -394,18 +400,25 @@ export function EnquiryFormSheet({
}
export function EnquiryFormSheetTrigger({
referenceData
referenceData,
workspace = 'enquiry'
}: {
referenceData: EnquiryReferenceData;
workspace?: 'lead' | 'enquiry';
}) {
const [open, setOpen] = React.useState(false);
return (
<>
<Button onClick={() => setOpen(true)}>
<Icons.add className='mr-2 h-4 w-4' /> Add Enquiry
<Icons.add className='mr-2 h-4 w-4' /> {workspace === 'lead' ? 'Add Lead' : 'Add Enquiry'}
</Button>
<EnquiryFormSheet open={open} onOpenChange={setOpen} referenceData={referenceData} />
<EnquiryFormSheet
open={open}
onOpenChange={setOpen}
referenceData={referenceData}
workspace={workspace}
/>
</>
);
}

View File

@@ -7,12 +7,14 @@ import { EnquiriesTable } from './enquiries-table';
export default function EnquiryListing({
referenceData,
workspace,
canUpdate,
canDelete,
canAssign,
canReassign
}: {
referenceData: EnquiryReferenceData;
workspace: 'lead' | 'enquiry';
canUpdate: boolean;
canDelete: boolean;
canAssign: boolean;
@@ -30,6 +32,7 @@ export default function EnquiryListing({
const filters = {
page,
limit,
pipelineStage: workspace,
...(search && { search }),
...(status && { status }),
...(productType && { productType }),
@@ -46,6 +49,7 @@ export default function EnquiryListing({
<HydrationBoundary state={dehydrate(queryClient)}>
<EnquiriesTable
referenceData={referenceData}
workspace={workspace}
canUpdate={canUpdate}
canDelete={canDelete}
canAssign={canAssign}

View File

@@ -5,6 +5,7 @@ import {
crmEnquiries,
crmEnquiryCustomers,
crmEnquiryFollowups,
crmQuotations,
memberships,
users
} from '@/db/schema';
@@ -29,6 +30,7 @@ import type {
EnquiryProjectPartyListItem,
EnquiryProjectPartyMutationPayload,
EnquiryProjectPartyRecord,
EnquiryPipelineStage,
EnquiryRecord,
EnquiryReferenceData
} from '../api/types';
@@ -48,6 +50,30 @@ const ENQUIRY_OPTION_CATEGORIES = {
} as const;
const ASSIGNABLE_BUSINESS_ROLES = new Set(['sales_manager', 'sales', 'sales_support']);
const SALES_OWNED_SCOPE_ROLES = new Set(['sales', 'sales_support']);
const ENQUIRY_FULL_ACCESS_ROLES = new Set([
'marketing',
'sales_manager',
'department_manager',
'top_manager'
]);
const SALES_CREATED_ENQUIRY_ROLES = new Set([
'sales',
'sales_support',
'sales_manager',
'department_manager',
'top_manager'
]);
const CLOSED_LOST_STATUS_CODES = new Set(['closed_lost', 'cancelled']);
const CLOSED_WON_QUOTATION_STATUS_CODES = new Set(['accepted']);
const CLOSED_LOST_QUOTATION_STATUS_CODES = new Set(['lost', 'rejected']);
export interface EnquiryAccessContext {
organizationId: string;
userId: string;
membershipRole: string;
businessRole: string;
}
function mapOption(
option: Awaited<ReturnType<typeof getActiveOptionsByCategory>>[number]
@@ -99,6 +125,7 @@ function mapEnquiryRecord(
notes: row.notes,
isHotProject: row.isHotProject,
isActive: row.isActive,
pipelineStage: row.pipelineStage as EnquiryPipelineStage,
assignedToUserId: row.assignedToUserId,
assignedToName: options?.assignedToName ?? null,
assignedAt: row.assignedAt?.toISOString() ?? null,
@@ -113,6 +140,41 @@ function mapEnquiryRecord(
};
}
function hasEnquiryFullAccess(context: EnquiryAccessContext) {
return (
context.membershipRole === 'admin' || ENQUIRY_FULL_ACCESS_ROLES.has(context.businessRole)
);
}
function canAccessEnquiryRow(
row: typeof crmEnquiries.$inferSelect,
context: EnquiryAccessContext
) {
if (hasEnquiryFullAccess(context)) {
return true;
}
if (SALES_OWNED_SCOPE_ROLES.has(context.businessRole)) {
return row.createdBy === context.userId || row.assignedToUserId === context.userId;
}
return true;
}
function buildAccessScopedFilters(context: EnquiryAccessContext): SQL[] {
if (hasEnquiryFullAccess(context)) {
return [];
}
if (SALES_OWNED_SCOPE_ROLES.has(context.businessRole)) {
return [
or(eq(crmEnquiries.createdBy, context.userId), eq(crmEnquiries.assignedToUserId, context.userId))!
];
}
return [];
}
function mapFollowupRecord(row: typeof crmEnquiryFollowups.$inferSelect): EnquiryFollowupRecord {
return {
id: row.id,
@@ -218,6 +280,19 @@ async function assertMasterOptionValue(
}
}
async function resolveOptionCodeById(
organizationId: string,
category: string,
optionId?: string | null
) {
if (!optionId) {
return null;
}
const options = await getActiveOptionsByCategory(category, { organizationId });
return options.find((option) => option.id === optionId)?.code ?? null;
}
export async function assertCustomerBelongsToOrganization(id: string, organizationId: string) {
const [customer] = await db
.select()
@@ -309,7 +384,11 @@ export async function assertAssignableUserBelongsToOrganization(
return membership;
}
async function assertEnquiryBelongsToOrganization(id: string, organizationId: string) {
async function assertEnquiryBelongsToOrganization(
id: string,
organizationId: string,
accessContext?: EnquiryAccessContext
) {
const [enquiry] = await db
.select()
.from(crmEnquiries)
@@ -326,6 +405,10 @@ async function assertEnquiryBelongsToOrganization(id: string, organizationId: st
throw new AuthError('Enquiry not found', 404);
}
if (accessContext && !canAccessEnquiryRow(enquiry, accessContext)) {
throw new AuthError('Forbidden', 403);
}
return enquiry;
}
@@ -395,9 +478,14 @@ async function validateEnquiryPayload(organizationId: string, payload: EnquiryMu
async function validateFollowupPayload(
organizationId: string,
enquiryId: string,
payload: EnquiryFollowupMutationPayload
payload: EnquiryFollowupMutationPayload,
accessContext?: EnquiryAccessContext
) {
const enquiry = await assertEnquiryBelongsToOrganization(enquiryId, organizationId);
const enquiry = await assertEnquiryBelongsToOrganization(
enquiryId,
organizationId,
accessContext
);
await assertMasterOptionValue(
organizationId,
@@ -410,7 +498,8 @@ async function validateFollowupPayload(
}
}
function buildEnquiryFilters(organizationId: string, filters: EnquiryFilters): SQL[] {
function buildEnquiryFilters(context: EnquiryAccessContext, filters: EnquiryFilters): SQL[] {
const pipelineStages = splitFilterValue(filters.pipelineStage);
const statuses = splitFilterValue(filters.status);
const productTypes = splitFilterValue(filters.productType);
const priorities = splitFilterValue(filters.priority);
@@ -418,8 +507,10 @@ function buildEnquiryFilters(organizationId: string, filters: EnquiryFilters): S
const customers = splitFilterValue(filters.customer);
return [
eq(crmEnquiries.organizationId, organizationId),
eq(crmEnquiries.organizationId, context.organizationId),
isNull(crmEnquiries.deletedAt),
...buildAccessScopedFilters(context),
...(pipelineStages.length ? [inArray(crmEnquiries.pipelineStage, pipelineStages)] : []),
...(statuses.length ? [inArray(crmEnquiries.status, statuses)] : []),
...(productTypes.length ? [inArray(crmEnquiries.productType, productTypes)] : []),
...(priorities.length ? [inArray(crmEnquiries.priority, priorities)] : []),
@@ -438,6 +529,54 @@ function buildEnquiryFilters(organizationId: string, filters: EnquiryFilters): S
];
}
function derivePipelineStageFromRole(businessRole: string): EnquiryPipelineStage {
return SALES_CREATED_ENQUIRY_ROLES.has(businessRole) ? 'enquiry' : 'lead';
}
async function resolvePipelineStageForCreate(
organizationId: string,
businessRole: string,
statusId: string
): Promise<EnquiryPipelineStage> {
const statusCode = await resolveOptionCodeById(
organizationId,
ENQUIRY_OPTION_CATEGORIES.status,
statusId
);
if (statusCode && CLOSED_LOST_STATUS_CODES.has(statusCode)) {
return 'closed_lost';
}
return derivePipelineStageFromRole(businessRole);
}
async function resolvePipelineStageForUpdate(
organizationId: string,
current: typeof crmEnquiries.$inferSelect,
payload: EnquiryMutationPayload
): Promise<EnquiryPipelineStage> {
const statusCode = await resolveOptionCodeById(
organizationId,
ENQUIRY_OPTION_CATEGORIES.status,
payload.status
);
if (statusCode && CLOSED_LOST_STATUS_CODES.has(statusCode)) {
return 'closed_lost';
}
if (current.pipelineStage === 'closed_won') {
return 'closed_won';
}
if (current.pipelineStage === 'enquiry' || current.assignedToUserId) {
return 'enquiry';
}
return 'lead';
}
export async function getEnquiryReferenceData(
organizationId: string
): Promise<EnquiryReferenceData> {
@@ -505,12 +644,12 @@ export async function getEnquiryReferenceData(
}
export async function listEnquiries(
organizationId: string,
context: EnquiryAccessContext,
filters: EnquiryFilters
): Promise<{ items: EnquiryListItem[]; totalItems: number }> {
const page = filters.page ?? 1;
const limit = filters.limit ?? 10;
const whereFilters = buildEnquiryFilters(organizationId, filters);
const whereFilters = buildEnquiryFilters(context, filters);
const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters);
const offset = (page - 1) * limit;
@@ -528,16 +667,17 @@ export async function listEnquiries(
const assignedUserIds = [
...new Set(rows.map((row) => row.assignedToUserId).filter(Boolean) as string[])
];
const createdByUserIds = [...new Set(rows.map((row) => row.createdBy).filter(Boolean) as string[])];
const enquiryIds = rows.map((row) => row.id);
const [customers, contacts, assignedUsers, followupCounts] = await Promise.all([
const [customers, contacts, assignedUsers, createdByUsers, followupCounts, quotationCounts, nextFollowups] = await Promise.all([
customerIds.length
? db
.select()
.from(crmCustomers)
.where(
and(
eq(crmCustomers.organizationId, organizationId),
eq(crmCustomers.organizationId, context.organizationId),
inArray(crmCustomers.id, customerIds)
)
)
@@ -548,7 +688,7 @@ export async function listEnquiries(
.from(crmCustomerContacts)
.where(
and(
eq(crmCustomerContacts.organizationId, organizationId),
eq(crmCustomerContacts.organizationId, context.organizationId),
inArray(crmCustomerContacts.id, contactIds)
)
)
@@ -559,6 +699,12 @@ export async function listEnquiries(
.from(users)
.where(inArray(users.id, assignedUserIds))
: [],
createdByUserIds.length
? db
.select({ id: users.id, name: users.name })
.from(users)
.where(inArray(users.id, createdByUserIds))
: [],
enquiryIds.length
? db
.select({
@@ -568,19 +714,68 @@ export async function listEnquiries(
.from(crmEnquiryFollowups)
.where(
and(
eq(crmEnquiryFollowups.organizationId, organizationId),
eq(crmEnquiryFollowups.organizationId, context.organizationId),
inArray(crmEnquiryFollowups.enquiryId, enquiryIds),
isNull(crmEnquiryFollowups.deletedAt)
)
)
.groupBy(crmEnquiryFollowups.enquiryId)
: [],
enquiryIds.length
? db
.select({
enquiryId: crmQuotations.enquiryId,
value: count()
})
.from(crmQuotations)
.where(
and(
eq(crmQuotations.organizationId, context.organizationId),
inArray(crmQuotations.enquiryId, enquiryIds),
isNull(crmQuotations.deletedAt)
)
)
.groupBy(crmQuotations.enquiryId)
: [],
enquiryIds.length
? db
.select({
enquiryId: crmEnquiryFollowups.enquiryId,
nextFollowupDate: crmEnquiryFollowups.nextFollowupDate
})
.from(crmEnquiryFollowups)
.where(
and(
eq(crmEnquiryFollowups.organizationId, context.organizationId),
inArray(crmEnquiryFollowups.enquiryId, enquiryIds),
isNull(crmEnquiryFollowups.deletedAt)
)
)
.orderBy(asc(crmEnquiryFollowups.nextFollowupDate))
: []
]);
const customerMap = new Map(customers.map((customer) => [customer.id, customer.name]));
const contactMap = new Map(contacts.map((contact) => [contact.id, contact.name]));
const assignedUserMap = new Map(assignedUsers.map((user) => [user.id, user.name]));
const createdByUserMap = new Map(createdByUsers.map((user) => [user.id, user.name]));
const followupCountMap = new Map(followupCounts.map((item) => [item.enquiryId, item.value]));
const quotationCountMap = new Map(
quotationCounts
.filter((item) => item.enquiryId !== null)
.map((item) => [item.enquiryId as string, item.value])
);
const nextFollowupMap = new Map<string, string | null>();
for (const item of nextFollowups) {
if (!item.nextFollowupDate) {
continue;
}
if (!nextFollowupMap.has(item.enquiryId)) {
nextFollowupMap.set(item.enquiryId, item.nextFollowupDate.toISOString());
}
}
return {
items: rows.map((row) => ({
@@ -591,14 +786,21 @@ export async function listEnquiries(
}),
customerName: customerMap.get(row.customerId) ?? 'Unknown customer',
contactName: row.contactId ? (contactMap.get(row.contactId) ?? null) : null,
followupCount: followupCountMap.get(row.id) ?? 0
followupCount: followupCountMap.get(row.id) ?? 0,
quotationCount: quotationCountMap.get(row.id) ?? 0,
nextFollowupDate: nextFollowupMap.get(row.id) ?? null,
createdByName: createdByUserMap.get(row.createdBy) ?? null
})),
totalItems: totalResult?.value ?? 0
};
}
export async function getEnquiryDetail(id: string, organizationId: string): Promise<EnquiryRecord> {
const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId);
export async function getEnquiryDetail(
id: string,
organizationId: string,
accessContext?: EnquiryAccessContext
): Promise<EnquiryRecord> {
const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
const relatedUserIds = [enquiry.assignedToUserId, enquiry.assignedBy].filter(Boolean) as string[];
const userRows = relatedUserIds.length
? await db
@@ -711,9 +913,10 @@ async function syncEnquiryProjectParties(
export async function listEnquiryProjectParties(
id: string,
organizationId: string
organizationId: string,
accessContext?: EnquiryAccessContext
): Promise<EnquiryProjectPartyListItem[]> {
await assertEnquiryBelongsToOrganization(id, organizationId);
await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
const [relations, customers, roleOptions] = await Promise.all([
db
.select()
@@ -754,8 +957,10 @@ export async function listEnquiryProjectParties(
export async function getEnquiryActivity(
id: string,
organizationId: string
organizationId: string,
accessContext?: EnquiryAccessContext
): Promise<EnquiryActivityRecord[]> {
await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
const auditLogs = await listAuditLogs({
organizationId,
entityId: id,
@@ -791,9 +996,15 @@ export async function getEnquiryActivity(
export async function createEnquiry(
organizationId: string,
userId: string,
businessRole: string,
payload: EnquiryMutationPayload
) {
await validateEnquiryPayload(organizationId, payload);
const pipelineStage = await resolvePipelineStageForCreate(
organizationId,
businessRole,
payload.status
);
const documentCode = await generateNextDocumentCode({
organizationId,
@@ -828,6 +1039,7 @@ export async function createEnquiry(
notes: payload.notes?.trim() || null,
isHotProject: payload.isHotProject ?? false,
isActive: payload.isActive ?? true,
pipelineStage,
createdBy: userId,
updatedBy: userId
})
@@ -849,10 +1061,12 @@ export async function updateEnquiry(
id: string,
organizationId: string,
userId: string,
payload: EnquiryMutationPayload
payload: EnquiryMutationPayload,
accessContext?: EnquiryAccessContext
) {
await validateEnquiryPayload(organizationId, payload);
await assertEnquiryBelongsToOrganization(id, organizationId);
const current = await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
const pipelineStage = await resolvePipelineStageForUpdate(organizationId, current, payload);
return db.transaction(async (tx) => {
const [updated] = await tx
@@ -878,6 +1092,7 @@ export async function updateEnquiry(
notes: payload.notes?.trim() || null,
isHotProject: payload.isHotProject ?? false,
isActive: payload.isActive ?? true,
pipelineStage,
updatedBy: userId,
updatedAt: new Date()
})
@@ -896,8 +1111,13 @@ export async function updateEnquiry(
});
}
export async function softDeleteEnquiry(id: string, organizationId: string, userId: string) {
await assertEnquiryBelongsToOrganization(id, organizationId);
export async function softDeleteEnquiry(
id: string,
organizationId: string,
userId: string,
accessContext?: EnquiryAccessContext
) {
await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
const now = new Date();
const [updated] = await db
@@ -964,6 +1184,7 @@ async function updateEnquiryAssignment(
const [updated] = await db
.update(crmEnquiries)
.set({
pipelineStage: 'enquiry',
assignedToUserId: payload.assignedToUserId,
assignedAt: new Date(),
assignedBy: userId,
@@ -995,8 +1216,12 @@ export async function reassignEnquiryToUser(
return updateEnquiryAssignment(id, organizationId, userId, payload, 'reassign');
}
export async function listEnquiryFollowups(id: string, organizationId: string) {
await assertEnquiryBelongsToOrganization(id, organizationId);
export async function listEnquiryFollowups(
id: string,
organizationId: string,
accessContext?: EnquiryAccessContext
) {
await assertEnquiryBelongsToOrganization(id, organizationId, accessContext);
const rows = await db
.select()
.from(crmEnquiryFollowups)
@@ -1016,9 +1241,10 @@ export async function createEnquiryFollowup(
enquiryId: string,
organizationId: string,
userId: string,
payload: EnquiryFollowupMutationPayload
payload: EnquiryFollowupMutationPayload,
accessContext?: EnquiryAccessContext
) {
await validateFollowupPayload(organizationId, enquiryId, payload);
await validateFollowupPayload(organizationId, enquiryId, payload, accessContext);
const [created] = await db
.insert(crmEnquiryFollowups)
@@ -1046,9 +1272,10 @@ export async function updateEnquiryFollowup(
followupId: string,
organizationId: string,
userId: string,
payload: EnquiryFollowupMutationPayload
payload: EnquiryFollowupMutationPayload,
accessContext?: EnquiryAccessContext
) {
await validateFollowupPayload(organizationId, enquiryId, payload);
await validateFollowupPayload(organizationId, enquiryId, payload, accessContext);
await assertFollowupBelongsToEnquiry(followupId, enquiryId, organizationId);
const [updated] = await db
@@ -1074,8 +1301,10 @@ export async function softDeleteEnquiryFollowup(
enquiryId: string,
followupId: string,
organizationId: string,
userId: string
userId: string,
accessContext?: EnquiryAccessContext
) {
await assertEnquiryBelongsToOrganization(enquiryId, organizationId, accessContext);
await assertFollowupBelongsToEnquiry(followupId, enquiryId, organizationId);
const [updated] = await db
@@ -1117,3 +1346,39 @@ export async function listCustomerEnquiryRelations(
updatedAt: row.updatedAt.toISOString()
}));
}
export async function syncEnquiryPipelineStageFromQuotationStatus(
enquiryId: string,
organizationId: string,
quotationStatusCode: string | null
) {
if (!quotationStatusCode) {
return;
}
const nextStage: EnquiryPipelineStage | null = CLOSED_WON_QUOTATION_STATUS_CODES.has(
quotationStatusCode
)
? 'closed_won'
: CLOSED_LOST_QUOTATION_STATUS_CODES.has(quotationStatusCode)
? 'closed_lost'
: null;
if (!nextStage) {
return;
}
await db
.update(crmEnquiries)
.set({
pipelineStage: nextStage,
updatedAt: new Date()
})
.where(
and(
eq(crmEnquiries.id, enquiryId),
eq(crmEnquiries.organizationId, organizationId),
isNull(crmEnquiries.deletedAt)
)
);
}

View File

@@ -20,6 +20,7 @@ import { listAuditLogs } from '@/features/foundation/audit-log/service';
import { getUserBranches, validateBranchAccess } from '@/features/foundation/branch-scope/service';
import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service';
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
import { syncEnquiryPipelineStageFromQuotationStatus } from '@/features/crm/enquiries/server/service';
import type {
QuotationActivityRecord,
QuotationAttachmentMutationPayload,
@@ -1215,6 +1216,11 @@ export async function createQuotation(
payload: QuotationMutationPayload
) {
await validateQuotationPayload(organizationId, payload);
const quotationStatusCode = await resolveOptionCodeById(
organizationId,
QUOTATION_OPTION_CATEGORIES.status,
payload.status
);
const enquiry = payload.enquiryId
? await assertEnquiryBelongsToOrganization(payload.enquiryId, organizationId)
@@ -1277,6 +1283,14 @@ export async function createQuotation(
payload.projectParties ?? []
);
if (created.enquiryId) {
await syncEnquiryPipelineStageFromQuotationStatus(
created.enquiryId,
organizationId,
quotationStatusCode
);
}
return created;
});
}
@@ -1288,6 +1302,11 @@ export async function updateQuotation(
payload: QuotationMutationPayload
) {
await validateQuotationPayload(organizationId, payload);
const quotationStatusCode = await resolveOptionCodeById(
organizationId,
QUOTATION_OPTION_CATEGORIES.status,
payload.status
);
const existing = await assertQuotationBelongsToOrganization(id, organizationId);
const enquiry = payload.enquiryId
? await assertEnquiryBelongsToOrganization(payload.enquiryId, organizationId)
@@ -1336,6 +1355,14 @@ export async function updateQuotation(
payload.projectParties ?? []
);
if (row.enquiryId) {
await syncEnquiryPipelineStageFromQuotationStatus(
row.enquiryId,
organizationId,
quotationStatusCode
);
}
return row;
});

View File

@@ -47,6 +47,7 @@ const membershipRoleOptions = [
] as const;
const businessRoleOptions = [
{ value: 'marketing', label: 'Marketing' },
{ value: 'sales_manager', label: 'Sales Manager' },
{ value: 'sales', label: 'Sales' },
{ value: 'sales_support', label: 'Sales Support' },

View File

@@ -1,6 +1,7 @@
export const SYSTEM_ROLES = ['super_admin', 'user'] as const;
export const MEMBERSHIP_ROLES = ['admin', 'user'] as const;
export const BUSINESS_ROLES = [
'marketing',
'sales',
'sales_support',
'sales_manager',
@@ -18,6 +19,11 @@ export const PERMISSIONS = {
organizationManage: 'organization:manage',
usersManage: 'users:manage',
reportRead: 'report:read',
crmLeadRead: 'crm.lead.read',
crmLeadCreate: 'crm.lead.create',
crmLeadUpdate: 'crm.lead.update',
crmLeadAssign: 'crm.lead.assign',
crmLeadDelete: 'crm.lead.delete',
crmCustomerRead: 'crm.customer.read',
crmCustomerCreate: 'crm.customer.create',
crmCustomerUpdate: 'crm.customer.update',
@@ -76,6 +82,50 @@ export const PERMISSIONS = {
export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
const MANAGER_PERMISSIONS: Permission[] = [
PERMISSIONS.crmLeadRead,
PERMISSIONS.crmLeadCreate,
PERMISSIONS.crmLeadUpdate,
PERMISSIONS.crmLeadAssign,
PERMISSIONS.crmLeadDelete,
PERMISSIONS.crmCustomerRead,
PERMISSIONS.crmCustomerCreate,
PERMISSIONS.crmCustomerUpdate,
PERMISSIONS.crmContactRead,
PERMISSIONS.crmContactCreate,
PERMISSIONS.crmContactUpdate,
PERMISSIONS.crmEnquiryRead,
PERMISSIONS.crmEnquiryCreate,
PERMISSIONS.crmEnquiryUpdate,
PERMISSIONS.crmEnquiryAssign,
PERMISSIONS.crmEnquiryReassign,
PERMISSIONS.crmEnquiryFollowupRead,
PERMISSIONS.crmEnquiryFollowupCreate,
PERMISSIONS.crmEnquiryFollowupUpdate,
PERMISSIONS.crmQuotationRead,
PERMISSIONS.crmQuotationCreate,
PERMISSIONS.crmQuotationUpdate,
PERMISSIONS.crmQuotationItemManage,
PERMISSIONS.crmQuotationCustomerManage,
PERMISSIONS.crmQuotationTopicManage,
PERMISSIONS.crmQuotationFollowupManage,
PERMISSIONS.crmQuotationAttachmentManage,
PERMISSIONS.crmQuotationRevisionCreate,
PERMISSIONS.crmApprovalRead,
PERMISSIONS.crmApprovalSubmit,
PERMISSIONS.crmApprovalWorkflowRead,
PERMISSIONS.crmDashboardRead,
PERMISSIONS.crmDashboardExport,
PERMISSIONS.crmDocumentTemplateRead,
PERMISSIONS.crmDocumentSequenceRead,
PERMISSIONS.crmDocumentArtifactRead,
PERMISSIONS.crmDocumentArtifactDownload,
PERMISSIONS.crmDocumentArtifactVoid,
PERMISSIONS.crmQuotationDocumentPreview,
PERMISSIONS.crmQuotationPdfPreview,
PERMISSIONS.crmQuotationPdfDownload
];
function getMembershipBasePermissions(role: MembershipRole): Permission[] {
if (role === 'admin') {
return [
@@ -83,6 +133,11 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] {
PERMISSIONS.productsWrite,
PERMISSIONS.organizationManage,
PERMISSIONS.usersManage,
PERMISSIONS.crmLeadRead,
PERMISSIONS.crmLeadCreate,
PERMISSIONS.crmLeadUpdate,
PERMISSIONS.crmLeadAssign,
PERMISSIONS.crmLeadDelete,
PERMISSIONS.crmCustomerRead,
PERMISSIONS.crmCustomerCreate,
PERMISSIONS.crmCustomerUpdate,
@@ -142,6 +197,7 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] {
return [
PERMISSIONS.productsRead,
PERMISSIONS.crmLeadRead,
PERMISSIONS.crmCustomerRead,
PERMISSIONS.crmContactRead,
PERMISSIONS.crmEnquiryRead,
@@ -162,45 +218,23 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] {
export function getBusinessRolePermissions(role: BusinessRole): Permission[] {
switch (role) {
case 'sales_manager':
case 'marketing':
return [
PERMISSIONS.crmLeadRead,
PERMISSIONS.crmLeadCreate,
PERMISSIONS.crmLeadUpdate,
PERMISSIONS.crmLeadAssign,
PERMISSIONS.crmLeadDelete,
PERMISSIONS.crmCustomerRead,
PERMISSIONS.crmCustomerCreate,
PERMISSIONS.crmCustomerUpdate,
PERMISSIONS.crmContactRead,
PERMISSIONS.crmContactCreate,
PERMISSIONS.crmContactUpdate,
PERMISSIONS.crmEnquiryRead,
PERMISSIONS.crmEnquiryCreate,
PERMISSIONS.crmEnquiryUpdate,
PERMISSIONS.crmEnquiryAssign,
PERMISSIONS.crmEnquiryReassign,
PERMISSIONS.crmEnquiryFollowupRead,
PERMISSIONS.crmEnquiryFollowupCreate,
PERMISSIONS.crmEnquiryFollowupUpdate,
PERMISSIONS.crmQuotationRead,
PERMISSIONS.crmQuotationCreate,
PERMISSIONS.crmQuotationUpdate,
PERMISSIONS.crmQuotationItemManage,
PERMISSIONS.crmQuotationCustomerManage,
PERMISSIONS.crmQuotationTopicManage,
PERMISSIONS.crmQuotationFollowupManage,
PERMISSIONS.crmQuotationAttachmentManage,
PERMISSIONS.crmQuotationRevisionCreate,
PERMISSIONS.crmApprovalRead,
PERMISSIONS.crmApprovalSubmit,
PERMISSIONS.crmApprovalWorkflowRead,
PERMISSIONS.crmDashboardRead,
PERMISSIONS.crmDashboardExport,
PERMISSIONS.crmDocumentTemplateRead,
PERMISSIONS.crmDocumentSequenceRead,
PERMISSIONS.crmDocumentArtifactRead,
PERMISSIONS.crmDocumentArtifactDownload,
PERMISSIONS.crmDocumentArtifactVoid,
PERMISSIONS.crmQuotationDocumentPreview,
PERMISSIONS.crmQuotationPdfPreview,
PERMISSIONS.crmQuotationPdfDownload
PERMISSIONS.crmDashboardRead
];
case 'sales_manager':
return MANAGER_PERMISSIONS;
case 'sales':
return [
PERMISSIONS.crmCustomerRead,
@@ -264,6 +298,7 @@ export function getBusinessRolePermissions(role: BusinessRole): Permission[] {
];
case 'department_manager':
case 'top_manager':
return MANAGER_PERMISSIONS;
default:
return [];
}