diff --git a/docs/adr/0011-lead-enquiry-ownership-model.md b/docs/adr/0011-lead-enquiry-ownership-model.md new file mode 100644 index 0000000..b9a03a8 --- /dev/null +++ b/docs/adr/0011-lead-enquiry-ownership-model.md @@ -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. diff --git a/docs/implementation/task-d3-lead-enquiry-ownership-refinement.md b/docs/implementation/task-d3-lead-enquiry-ownership-refinement.md new file mode 100644 index 0000000..979c1bc --- /dev/null +++ b/docs/implementation/task-d3-lead-enquiry-ownership-refinement.md @@ -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` diff --git a/docs/implementation/task-d31-leads-enquiries-navigation-separation.md b/docs/implementation/task-d31-leads-enquiries-navigation-separation.md new file mode 100644 index 0000000..f120378 --- /dev/null +++ b/docs/implementation/task-d31-leads-enquiries-navigation-separation.md @@ -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. diff --git a/drizzle/0012_odd_fat_cobra.sql b/drizzle/0012_odd_fat_cobra.sql new file mode 100644 index 0000000..4f82574 --- /dev/null +++ b/drizzle/0012_odd_fat_cobra.sql @@ -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')); diff --git a/drizzle/0012_simple_bucky.sql b/drizzle/0012_simple_bucky.sql new file mode 100644 index 0000000..cac9e64 --- /dev/null +++ b/drizzle/0012_simple_bucky.sql @@ -0,0 +1 @@ +ALTER TABLE "crm_enquiries" ADD COLUMN "pipeline_stage" text DEFAULT 'lead' NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/0012_snapshot.json b/drizzle/meta/0012_snapshot.json new file mode 100644 index 0000000..799cc5b --- /dev/null +++ b/drizzle/meta/0012_snapshot.json @@ -0,0 +1,3280 @@ +{ + "id": "0295d780-7a89-4886-914b-61fa8f6c59b7", + "prevId": "5557525b-6ce9-4689-b870-7dc8c8c902a0", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.crm_approval_actions": { + "name": "crm_approval_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approval_request_id": { + "name": "approval_request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "step_number": { + "name": "step_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acted_by": { + "name": "acted_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "acted_at": { + "name": "acted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_approval_requests": { + "name": "crm_approval_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_step": { + "name": "current_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_approval_steps": { + "name": "crm_approval_steps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "step_number": { + "name": "step_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role_code": { + "name": "role_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_name": { + "name": "role_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_required": { + "name": "is_required", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "crm_approval_steps_workflow_step_idx": { + "name": "crm_approval_steps_workflow_step_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "step_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_approval_workflows": { + "name": "crm_approval_workflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "crm_approval_workflows_org_code_idx": { + "name": "crm_approval_workflows_org_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_customer_contacts": { + "name": "crm_customer_contacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "department": { + "name": "department", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mobile": { + "name": "mobile", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_customers": { + "name": "crm_customers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "abbr": { + "name": "abbr", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tax_id": { + "name": "tax_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_type": { + "name": "customer_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_status": { + "name": "customer_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "province": { + "name": "province", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district": { + "name": "district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sub_district": { + "name": "sub_district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "postal_code": { + "name": "postal_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fax": { + "name": "fax", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lead_channel": { + "name": "lead_channel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_group": { + "name": "customer_group", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_sub_group": { + "name": "customer_sub_group", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "crm_customers_org_code_idx": { + "name": "crm_customers_org_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_document_artifacts": { + "name": "crm_document_artifacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_type": { + "name": "document_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "artifact_type": { + "name": "artifact_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_version_id": { + "name": "template_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "storage_provider": { + "name": "storage_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "generated_by": { + "name": "generated_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generated_at": { + "name": "generated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "locked_by": { + "name": "locked_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "voided_at": { + "name": "voided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "voided_by": { + "name": "voided_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "void_reason": { + "name": "void_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_document_template_mappings": { + "name": "crm_document_template_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_version_id": { + "name": "template_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "placeholder_key": { + "name": "placeholder_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_path": { + "name": "source_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data_type": { + "name": "data_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sheet_name": { + "name": "sheet_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_value": { + "name": "default_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "format_mask": { + "name": "format_mask", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "crm_document_template_mappings_version_placeholder_idx": { + "name": "crm_document_template_mappings_version_placeholder_idx", + "columns": [ + { + "expression": "template_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "placeholder_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_document_template_table_columns": { + "name": "crm_document_template_table_columns", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mapping_id": { + "name": "mapping_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "column_name": { + "name": "column_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_field": { + "name": "source_field", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "column_letter": { + "name": "column_letter", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "format_mask": { + "name": "format_mask", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "crm_document_template_table_columns_mapping_name_idx": { + "name": "crm_document_template_table_columns_mapping_name_idx", + "columns": [ + { + "expression": "mapping_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "column_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_document_template_versions": { + "name": "crm_document_template_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema_json": { + "name": "schema_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "preview_image_url": { + "name": "preview_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "crm_document_template_versions_template_version_idx": { + "name": "crm_document_template_versions_template_version_idx", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_document_templates": { + "name": "crm_document_templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_type": { + "name": "document_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_type": { + "name": "product_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_type": { + "name": "file_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template_name": { + "name": "template_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "crm_document_templates_org_doc_product_file_name_idx": { + "name": "crm_document_templates_org_doc_product_file_name_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "product_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "template_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_enquiries": { + "name": "crm_enquiries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requirement": { + "name": "requirement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_name": { + "name": "project_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_location": { + "name": "project_location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_type": { + "name": "product_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lead_channel": { + "name": "lead_channel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "estimated_value": { + "name": "estimated_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "chance_percent": { + "name": "chance_percent", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "expected_close_date": { + "name": "expected_close_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "competitor": { + "name": "competitor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_hot_project": { + "name": "is_hot_project", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "pipeline_stage": { + "name": "pipeline_stage", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'lead'" + }, + "assigned_to_user_id": { + "name": "assigned_to_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignment_remark": { + "name": "assignment_remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "crm_enquiries_org_code_idx": { + "name": "crm_enquiries_org_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_enquiry_customers": { + "name": "crm_enquiry_customers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enquiry_id": { + "name": "enquiry_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_enquiry_followups": { + "name": "crm_enquiry_followups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enquiry_id": { + "name": "enquiry_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "followup_date": { + "name": "followup_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "followup_type": { + "name": "followup_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_followup_date": { + "name": "next_followup_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action": { + "name": "next_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotation_attachments": { + "name": "crm_quotation_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quotation_id": { + "name": "quotation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_file_name": { + "name": "original_file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "file_type": { + "name": "file_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotation_customers": { + "name": "crm_quotation_customers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quotation_id": { + "name": "quotation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotation_followups": { + "name": "crm_quotation_followups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quotation_id": { + "name": "quotation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "followup_date": { + "name": "followup_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "followup_type": { + "name": "followup_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_followup_date": { + "name": "next_followup_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action": { + "name": "next_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotation_items": { + "name": "crm_quotation_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quotation_id": { + "name": "quotation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_number": { + "name": "item_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "product_type": { + "name": "product_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unit_price": { + "name": "unit_price", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "discount": { + "name": "discount", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "discount_type": { + "name": "discount_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tax_rate": { + "name": "tax_rate", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_price": { + "name": "total_price", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotation_topic_items": { + "name": "crm_quotation_topic_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "topic_id": { + "name": "topic_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotation_topics": { + "name": "crm_quotation_topics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quotation_id": { + "name": "quotation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "topic_type": { + "name": "topic_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotations": { + "name": "crm_quotations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enquiry_id": { + "name": "enquiry_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quotation_date": { + "name": "quotation_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "valid_until": { + "name": "valid_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "quotation_type": { + "name": "quotation_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_name": { + "name": "project_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_location": { + "name": "project_location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attention": { + "name": "attention", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference": { + "name": "reference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "parent_quotation_id": { + "name": "parent_quotation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision_remark": { + "name": "revision_remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "exchange_rate": { + "name": "exchange_rate", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "subtotal": { + "name": "subtotal", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "discount": { + "name": "discount", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "discount_type": { + "name": "discount_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tax_rate": { + "name": "tax_rate", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tax_amount": { + "name": "tax_amount", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_amount": { + "name": "total_amount", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "chance_percent": { + "name": "chance_percent", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_hot_project": { + "name": "is_hot_project", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "competitor": { + "name": "competitor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "salesman_id": { + "name": "salesman_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_sent": { + "name": "is_sent", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sent_via": { + "name": "sent_via", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "approved_artifact_id": { + "name": "approved_artifact_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_pdf_url": { + "name": "approved_pdf_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_snapshot": { + "name": "approved_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "approved_template_version_id": { + "name": "approved_template_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rejected_at": { + "name": "rejected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "crm_quotations_org_code_idx": { + "name": "crm_quotations_org_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_sequences": { + "name": "document_sequences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "document_type": { + "name": "document_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period": { + "name": "period", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_number": { + "name": "current_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "padding_length": { + "name": "padding_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "document_sequences_org_doc_period_branch_idx": { + "name": "document_sequences_org_doc_period_branch_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "branch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "business_role": { + "name": "business_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'sales_support'" + }, + "permissions": { + "name": "permissions", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ms_options": { + "name": "ms_options", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ms_options_org_category_code_idx": { + "name": "ms_options_org_category_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organizations_slug_idx": { + "name": "organizations_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.products": { + "name": "products", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "products_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "photo_url": { + "name": "photo_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "price": { + "name": "price", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tr_audit_logs": { + "name": "tr_audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "before_data": { + "name": "before_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after_data": { + "name": "after_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "system_role": { + "name": "system_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 77f09ed..ca7f0d6 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/plans/task-d.3.1.md b/plans/task-d.3.1.md new file mode 100644 index 0000000..3b510d1 --- /dev/null +++ b/plans/task-d.3.1.md @@ -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 diff --git a/plans/task-d.3.md b/plans/task-d.3.md new file mode 100644 index 0000000..e3ac88d --- /dev/null +++ b/plans/task-d.3.md @@ -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 diff --git a/src/app/api/crm/dashboard/export/route.ts b/src/app/api/crm/dashboard/export/route.ts index dfe6ebd..51321a2 100644 --- a/src/app/api/crm/dashboard/export/route.ts +++ b/src/app/api/crm/dashboard/export/route.ts @@ -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), diff --git a/src/app/api/crm/dashboard/route.ts b/src/app/api/crm/dashboard/route.ts index af8f332..3f7d586 100644 --- a/src/app/api/crm/dashboard/route.ts +++ b/src/app/api/crm/dashboard/route.ts @@ -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'), diff --git a/src/app/api/crm/enquiries/[id]/assign/route.ts b/src/app/api/crm/enquiries/[id]/assign/route.ts index 4b46925..a73c419 100644 --- a/src/app/api/crm/enquiries/[id]/assign/route.ts +++ b/src/app/api/crm/enquiries/[id]/assign/route.ts @@ -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) { diff --git a/src/app/api/crm/enquiries/[id]/customers/route.ts b/src/app/api/crm/enquiries/[id]/customers/route.ts index f3b82de..ea03042 100644 --- a/src/app/api/crm/enquiries/[id]/customers/route.ts +++ b/src/app/api/crm/enquiries/[id]/customers/route.ts @@ -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, diff --git a/src/app/api/crm/enquiries/[id]/followups/[followupId]/route.ts b/src/app/api/crm/enquiries/[id]/followups/[followupId]/route.ts index ef6466e..1b38555 100644 --- a/src/app/api/crm/enquiries/[id]/followups/[followupId]/route.ts +++ b/src/app/api/crm/enquiries/[id]/followups/[followupId]/route.ts @@ -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({ diff --git a/src/app/api/crm/enquiries/[id]/followups/route.ts b/src/app/api/crm/enquiries/[id]/followups/route.ts index 0cd69b4..ae9894d 100644 --- a/src/app/api/crm/enquiries/[id]/followups/route.ts +++ b/src/app/api/crm/enquiries/[id]/followups/route.ts @@ -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, diff --git a/src/app/api/crm/enquiries/[id]/reassign/route.ts b/src/app/api/crm/enquiries/[id]/reassign/route.ts index 29997fd..fec49c6 100644 --- a/src/app/api/crm/enquiries/[id]/reassign/route.ts +++ b/src/app/api/crm/enquiries/[id]/reassign/route.ts @@ -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 } }); diff --git a/src/app/api/crm/enquiries/[id]/route.ts b/src/app/api/crm/enquiries/[id]/route.ts index 462564f..698f45e 100644 --- a/src/app/api/crm/enquiries/[id]/route.ts +++ b/src/app/api/crm/enquiries/[id]/route.ts @@ -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, diff --git a/src/app/api/crm/enquiries/route.ts b/src/app/api/crm/enquiries/route.ts index 6e54356..056d70b 100644 --- a/src/app/api/crm/enquiries/route.ts +++ b/src/app/api/crm/enquiries/route.ts @@ -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, { - page, - limit, - search, - status, - productType, - priority, - branch, - customer, - sort - }); + 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, diff --git a/src/app/dashboard/crm/enquiries/[id]/page.tsx b/src/app/dashboard/crm/enquiries/[id]/page.tsx index 12ab477..4cca6b3 100644 --- a/src/app/dashboard/crm/enquiries/[id]/page.tsx +++ b/src/app/dashboard/crm/enquiries/[id]/page.tsx @@ -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 ( @@ -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, diff --git a/src/app/dashboard/crm/enquiries/page.tsx b/src/app/dashboard/crm/enquiries/page.tsx index 076929a..962e2f6 100644 --- a/src/app/dashboard/crm/enquiries/page.tsx +++ b/src/app/dashboard/crm/enquiries/page.tsx @@ -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 ( @@ -70,13 +71,14 @@ export default async function EnquiriesRoute(props: PageProps) { } pageHeaderAction={ canCreate && referenceData ? ( - + ) : null } > {referenceData ? ( ; +}; + +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 ( + + You do not have access to this lead. + + } + > + {referenceData ? ( + + + + ) : null} + + ); +} diff --git a/src/app/dashboard/crm/leads/page.tsx b/src/app/dashboard/crm/leads/page.tsx new file mode 100644 index 0000000..623835f --- /dev/null +++ b/src/app/dashboard/crm/leads/page.tsx @@ -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; +}; + +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 ( + + You do not have access to CRM leads. + + } + pageHeaderAction={ + canCreate && referenceData ? ( + + ) : null + } + > + {referenceData ? ( + + ) : null} + + ); +} diff --git a/src/config/nav-config.ts b/src/config/nav-config.ts index bd12fbc..bc1fab9 100644 --- a/src/config/nav-config.ts +++ b/src/config/nav-config.ts @@ -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, diff --git a/src/db/schema.ts b/src/db/schema.ts index ef9f406..d8995b7 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -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'), diff --git a/src/features/crm/dashboard/api/types.ts b/src/features/crm/dashboard/api/types.ts index b2d4fff..3508045 100644 --- a/src/features/crm/dashboard/api/types.ts +++ b/src/features/crm/dashboard/api/types.ts @@ -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; + }; } diff --git a/src/features/crm/dashboard/components/crm-dashboard.tsx b/src/features/crm/dashboard/components/crm-dashboard.tsx index 7e8cf4f..f834cb6 100644 --- a/src/features/crm/dashboard/components/crm-dashboard.tsx +++ b/src/features/crm/dashboard/components/crm-dashboard.tsx @@ -36,16 +36,29 @@ export function CrmDashboard({ canExport }: { canExport: boolean }) { return (
- -
- - -
- -
- - -
+ + {data.visibility.canViewCommercialData ? ( +
+ + +
+ ) : null} + {data.visibility.canViewCommercialData ? ( + + ) : null} + {data.visibility.canViewCommercialData || data.visibility.canViewApprovalData ? ( +
+ {data.visibility.canViewCommercialData ? ( + + ) : null} + {data.visibility.canViewApprovalData ? ( + + ) : null} +
+ ) : null}
); diff --git a/src/features/crm/dashboard/components/dashboard-followups.tsx b/src/features/crm/dashboard/components/dashboard-followups.tsx index 273d74f..2374787 100644 --- a/src/features/crm/dashboard/components/dashboard-followups.tsx +++ b/src/features/crm/dashboard/components/dashboard-followups.tsx @@ -36,8 +36,8 @@ export function DashboardFollowups({ Date Customer - Opportunity - Assigned Sales + Enquiry + Enquiry Owner Source @@ -53,7 +53,7 @@ export function DashboardFollowups({ {new Date(row.followupDate).toLocaleDateString()} {row.customerName} - {row.opportunityName} + {row.enquiryName} {row.assignedSalesName ?? '-'} {row.sourceType} diff --git a/src/features/crm/dashboard/components/dashboard-funnel.tsx b/src/features/crm/dashboard/components/dashboard-funnel.tsx index b02f278..3a144d7 100644 --- a/src/features/crm/dashboard/components/dashboard-funnel.tsx +++ b/src/features/crm/dashboard/components/dashboard-funnel.tsx @@ -14,7 +14,7 @@ export function DashboardFunnel({ steps }: { steps: CrmDashboardFunnelStep[] }) Sales Pipeline Funnel - Frozen lead, opportunity, quotation, approval, and won stages. + Lead, enquiry, quotation, approval, and won stages from the current pipeline model. {steps.map((step) => ( diff --git a/src/features/crm/dashboard/components/dashboard-hot-projects.tsx b/src/features/crm/dashboard/components/dashboard-hot-projects.tsx index 3168051..568b3b1 100644 --- a/src/features/crm/dashboard/components/dashboard-hot-projects.tsx +++ b/src/features/crm/dashboard/components/dashboard-hot-projects.tsx @@ -12,8 +12,8 @@ export function DashboardHotProjects({ rows }: { rows: CrmDashboardHotProjectRow return ( - Top Hot Opportunities - Production hot projects from enquiries, ranked by commercial value. + Top Active Enquiries + Sales-owned hot enquiries ranked by estimated commercial value. @@ -22,7 +22,7 @@ export function DashboardHotProjects({ rows }: { rows: CrmDashboardHotProjectRow ProjectCustomerValue - Assigned Sales + Enquiry OwnerProbability diff --git a/src/features/crm/dashboard/components/dashboard-sales-ranking.tsx b/src/features/crm/dashboard/components/dashboard-sales-ranking.tsx index c5f3820..1bd1705 100644 --- a/src/features/crm/dashboard/components/dashboard-sales-ranking.tsx +++ b/src/features/crm/dashboard/components/dashboard-sales-ranking.tsx @@ -13,7 +13,7 @@ export function DashboardSalesRanking({ rows }: { rows: CrmDashboardSalesRanking Sales Ranking - Lead and opportunity ownership follow assignment rules. Quotation ownership uses salesman. + Lead and enquiry ownership follow assignment rules. Quotation ownership uses salesman.
@@ -21,7 +21,7 @@ export function DashboardSalesRanking({ rows }: { rows: CrmDashboardSalesRanking Sales Person Lead - Opportunity + Enquiry Quotation Approved Won Revenue @@ -40,7 +40,7 @@ export function DashboardSalesRanking({ rows }: { rows: CrmDashboardSalesRanking {row.salesPersonName} {formatNumber(row.leadCount)} - {formatNumber(row.opportunityCount)} + {formatNumber(row.enquiryCount)} {formatNumber(row.quotationCount)} {formatNumber(row.approvedQuotations)} {formatNumber(row.wonRevenue)} diff --git a/src/features/crm/dashboard/components/dashboard-summary-cards.tsx b/src/features/crm/dashboard/components/dashboard-summary-cards.tsx index a3edd47..4b83234 100644 --- a/src/features/crm/dashboard/components/dashboard-summary-cards.tsx +++ b/src/features/crm/dashboard/components/dashboard-summary-cards.tsx @@ -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 = ( {title} @@ -35,14 +38,23 @@ function SummaryCard({ ); + + return href ? {content} : content; } -export function DashboardSummaryCards({ summary }: { summary: CrmDashboardSummary }) { +export function DashboardSummaryCards({ + summary, + canViewCommercialData +}: { + summary: CrmDashboardSummary; + canViewCommercialData: boolean; +}) { return ( -
+
- - + {canViewCommercialData ? ( + + ) : null} + {canViewCommercialData ? ( + + ) : null}
); } diff --git a/src/features/crm/dashboard/server/service.ts b/src/features/crm/dashboard/server/service.ts index 2650709..d16136e 100644 --- a/src/features/crm/dashboard/server/service.ts +++ b/src/features/crm/dashboard/server/service.ts @@ -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; }; +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 { 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 - .select() - .from(crmQuotationFollowups) - .where(and(eq(crmQuotationFollowups.organizationId, organizationId), isNull(crmQuotationFollowups.deletedAt))) - .orderBy(asc(crmQuotationFollowups.followupDate)) + allowQuotationFollowups + ? db + .select() + .from(crmQuotationFollowups) + .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((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((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 { 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 { 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 + } }; } diff --git a/src/features/crm/enquiries/api/mutations.ts b/src/features/crm/enquiries/api/mutations.ts index b7926b6..bfe22e7 100644 --- a/src/features/crm/enquiries/api/mutations.ts +++ b/src/features/crm/enquiries/api/mutations.ts @@ -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()]); } } }); diff --git a/src/features/crm/enquiries/api/service.ts b/src/features/crm/enquiries/api/service.ts index 1c3aea4..cf21616 100644 --- a/src/features/crm/enquiries/api/service.ts +++ b/src/features/crm/enquiries/api/service.ts @@ -17,6 +17,7 @@ export async function getEnquiries(filters: EnquiryFilters): Promise 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 }), diff --git a/src/features/crm/enquiries/components/enquiry-assignment-dialog.tsx b/src/features/crm/enquiries/components/enquiry-assignment-dialog.tsx index 57de061..3035957 100644 --- a/src/features/crm/enquiries/components/enquiry-assignment-dialog.tsx +++ b/src/features/crm/enquiries/components/enquiry-assignment-dialog.tsx @@ -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({ - {mode === 'assign' ? 'Assign Sales' : 'Reassign Sales'} + {mode === 'assign' ? 'Convert To Enquiry' : 'Reassign Enquiry Owner'} {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.'} @@ -127,7 +127,7 @@ export function EnquiryAssignmentDialog({ <> ({ value: user.id, @@ -156,7 +156,7 @@ export function EnquiryAssignmentDialog({ disabled={noUsersAvailable} > - {mode === 'assign' ? 'Assign Sales' : 'Reassign Sales'} + {mode === 'assign' ? 'Convert To Enquiry' : 'Reassign Owner'} diff --git a/src/features/crm/enquiries/components/enquiry-cell-action.tsx b/src/features/crm/enquiries/components/enquiry-cell-action.tsx index 666f6e3..9976b45 100644 --- a/src/features/crm/enquiries/components/enquiry-cell-action.tsx +++ b/src/features/crm/enquiries/components/enquiry-cell-action.tsx @@ -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} /> @@ -70,11 +77,19 @@ export function EnquiryCellAction({ Actions - router.push(`/dashboard/crm/enquiries/${data.id}`)}> + + router.push( + workspace === 'lead' + ? `/dashboard/crm/leads/${data.id}` + : `/dashboard/crm/enquiries/${data.id}` + ) + } + > View setEditOpen(true)} disabled={!canUpdate}> - Edit + {workspace === 'lead' ? 'Edit Lead' : 'Edit Enquiry'} setDeleteOpen(true)} disabled={!canDelete}> Delete diff --git a/src/features/crm/enquiries/components/enquiry-columns.tsx b/src/features/crm/enquiries/components/enquiry-columns.tsx index 1795300..477d3b2 100644 --- a/src/features/crm/enquiries/components/enquiry-columns.tsx +++ b/src/features/crm/enquiries/components/enquiry-columns.tsx @@ -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[] { +}: SharedColumnsConfig): ColumnDef[] { 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 }) => ( - + ), cell: ({ row }) => (
- {row.original.title} + {row.original.code} - - {row.original.code} • {row.original.customerName} - + {row.original.title}
), 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 }) => ( + + ), + cell: ({ row }) => {leadChannelMap.get(row.original.leadChannel ?? '') ?? '-'} + }, + { + id: 'customer', + accessorFn: (row) => row.customerId, + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => {row.original.customerName}, + 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 }) => ( + + ), + cell: ({ row }) => {row.original.contactName ?? '-'} + }, + { + id: 'assignedSales', + accessorFn: (row) => row.assignedToName ?? '', + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => {row.original.assignedToName ?? 'Unassigned'} + }, + { + id: 'createdBy', + accessorFn: (row) => row.createdByName ?? row.createdBy, + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => {row.original.createdByName ?? row.original.createdBy} + }, + { + id: 'createdAt', + accessorKey: 'createdAt', + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => {new Date(row.original.createdAt).toLocaleDateString()} + }, + { + id: 'age', + accessorFn: (row) => row.createdAt, + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => { + const ageInDays = Math.max( + 0, + Math.floor( + (Date.now() - new Date(row.original.createdAt).getTime()) / (1000 * 60 * 60 * 24) + ) + ); + + return {ageInDays} days; + } + }, { id: 'status', accessorKey: 'status', @@ -76,79 +140,74 @@ export function getEnquiryColumns({ enableColumnFilter: true }, { - id: 'productType', - accessorKey: 'productType', + id: 'actions', + cell: ({ row }) => ( + + ) + } + ]; +} + +function getEnquiryWorkspaceColumns({ + referenceData, + canUpdate, + canDelete, + canAssign, + canReassign +}: SharedColumnsConfig): ColumnDef[] { + const statusMap = new Map(referenceData.statuses.map((item) => [item.id, item])); + + return [ + { + id: 'code', + accessorKey: 'code', header: ({ column }: { column: Column }) => ( - + ), cell: ({ row }) => ( - - {productTypeMap.get(row.original.productType)?.label ?? 'Unknown'} - +
+ + {row.original.code} + + {row.original.title} +
), 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 }) => ( - + ), - cell: ({ row }) => ( - - {priorityMap.get(row.original.priority)?.label ?? 'Unknown'} - - ), - 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 }) => ( - - ), - cell: ({ row }) => ( - - {row.original.branchId - ? (branchMap.get(row.original.branchId)?.name ?? 'Unknown') - : 'Unassigned'} - - ), - meta: { - label: 'Branch', - variant: 'multiSelect' as const, - options: referenceData.branches.map((item) => ({ - value: item.id, - label: item.name - })) - }, - enableColumnFilter: true + cell: ({ row }) => {row.original.projectName ?? row.original.title} }, { id: 'customer', accessorFn: (row) => row.customerId, header: ({ column }: { column: Column }) => ( - + ), cell: ({ row }) => {row.original.customerName}, 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 }) => ( + + ), + cell: ({ row }) => {row.original.assignedToName ?? '-'} + }, + { + id: 'chancePercent', + accessorKey: 'chancePercent', + header: ({ column }: { column: Column }) => ( + + ), cell: ({ row }) => ( -
- {row.original.assignedToName ?? 'Unassigned'} - {row.original.assignedAt ? ( - - {new Date(row.original.assignedAt).toLocaleDateString()} - - ) : null} -
+ {row.original.chancePercent !== null ? `${row.original.chancePercent}%` : '-'} ) }, { - id: 'followupCount', - accessorKey: 'followupCount', + id: 'estimatedValue', + accessorKey: 'estimatedValue', header: ({ column }: { column: Column }) => ( - + ), - cell: ({ row }) => {row.original.followupCount} + cell: ({ row }) => ( + + {row.original.estimatedValue !== null + ? row.original.estimatedValue.toLocaleString() + : '-'} + + ) + }, + { + id: 'quotationCount', + accessorKey: 'quotationCount', + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => {row.original.quotationCount} + }, + { + id: 'nextFollowupDate', + accessorKey: 'nextFollowupDate', + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.nextFollowupDate + ? new Date(row.original.nextFollowupDate).toLocaleDateString() + : '-'} + + ) + }, + { + id: 'status', + accessorKey: 'status', + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => { + const status = statusMap.get(row.original.status); + return ; + }, + 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({ [] { + const sharedConfig = { + referenceData, + canUpdate, + canDelete, + canAssign, + canReassign + }; + + return workspace === 'lead' + ? getLeadColumns(sharedConfig) + : getEnquiryWorkspaceColumns(sharedConfig); +} diff --git a/src/features/crm/enquiries/components/enquiry-detail.tsx b/src/features/crm/enquiries/components/enquiry-detail.tsx index ecbdb30..50d1694 100644 --- a/src/features/crm/enquiries/components/enquiry-detail.tsx +++ b/src/features/crm/enquiries/components/enquiry-detail.tsx @@ -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 (
@@ -92,7 +136,7 @@ export function EnquiryDetail({
@@ -103,8 +147,8 @@ export function EnquiryDetail({

{enquiry.title}

- {customer?.name ?? 'Unknown customer'} - {contact ? ` • ${contact.name}` : ''} + {pipelineStageLabel} | {customer?.name ?? 'Unknown customer'} + {contact ? ` | ${contact.name}` : ''}

@@ -112,12 +156,13 @@ export function EnquiryDetail({ {canManageAssignment ? ( ) : null} {canUpdate ? ( ) : null}
@@ -130,20 +175,23 @@ export function EnquiryDetail({ Overview - Follow-ups + {followupTabLabel} Activity - Related Quotations + {canViewRelatedQuotations ? ( + {relatedTabLabel} + ) : null} - Overview - - Opportunity scope, project context, and commercial direction. - + {infoCardTitle} + {infoCardDescription} - + - + +
-
Project Parties
+
+ {isLeadWorkspace ? 'Lead Parties' : 'Project Parties'} +
{!projectPartiesData.items.length ? (
- No project parties have been added yet. + {isLeadWorkspace + ? 'No lead parties have been added yet.' + : 'No project parties have been added yet.'}
) : (
@@ -217,11 +270,10 @@ export function EnquiryDetail({
@@ -250,9 +302,9 @@ export function EnquiryDetail({ - Activity + {isLeadWorkspace ? 'Lead Activity' : 'Activity'} - Audit events for the enquiry and its follow-up records. + Audit events for this record and its follow-up history. @@ -289,43 +341,45 @@ export function EnquiryDetail({ - - - - Related Quotations - - Production quotations already linked to this enquiry. - - - - {!relatedQuotations.length ? ( -
- No quotations have been linked to this enquiry yet. -
- ) : ( -
- {relatedQuotations.map((quotation) => ( - -
-
-
{quotation.code}
-
- {quotation.totalAmount.toLocaleString()} + {canViewRelatedQuotations ? ( + + + + {relatedTabLabel} + + Production quotations already linked to this record. + + + + {!relatedQuotations.length ? ( +
+ No quotations have been linked to this record yet. +
+ ) : ( +
+ {relatedQuotations.map((quotation) => ( + +
+
+
{quotation.code}
+
+ {quotation.totalAmount.toLocaleString()} +
+
- -
- - ))} -
- )} - - - + + ))} +
+ )} + + + + ) : null} @@ -335,12 +389,11 @@ export function EnquiryDetail({ Record Snapshot - - Operational metadata and assignment state for this enquiry row. - + {snapshotDescription} - + + @@ -367,6 +420,7 @@ export function EnquiryDetail({ open={editOpen} onOpenChange={setEditOpen} referenceData={referenceData} + workspace={workspace} />
); diff --git a/src/features/crm/enquiries/components/enquiry-form-sheet.tsx b/src/features/crm/enquiries/components/enquiry-form-sheet.tsx index 4c68c0a..48cd907 100644 --- a/src/features/crm/enquiries/components/enquiry-form-sheet.tsx +++ b/src/features/crm/enquiries/components/enquiry-form-sheet.tsx @@ -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 ( - {isEdit ? 'Edit Enquiry' : 'New Enquiry'} + {isEdit ? `Edit ${recordLabel}` : `New ${recordLabel}`} - 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.'} @@ -261,7 +267,7 @@ export function EnquiryFormSheet({ name='title' label='Title' required - placeholder='Warehouse crane upgrade opportunity' + placeholder='Warehouse crane upgrade enquiry' />
@@ -352,19 +358,19 @@ export function EnquiryFormSheet({
@@ -385,7 +391,7 @@ export function EnquiryFormSheet({ @@ -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 ( <> - + ); } diff --git a/src/features/crm/enquiries/components/enquiry-listing.tsx b/src/features/crm/enquiries/components/enquiry-listing.tsx index 433a24c..32dab7f 100644 --- a/src/features/crm/enquiries/components/enquiry-listing.tsx +++ b/src/features/crm/enquiries/components/enquiry-listing.tsx @@ -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({ >[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 { + 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 { + 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 { @@ -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(); + + 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 { - const enquiry = await assertEnquiryBelongsToOrganization(id, organizationId); +export async function getEnquiryDetail( + id: string, + organizationId: string, + accessContext?: EnquiryAccessContext +): Promise { + 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 { - 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 { + 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) + ) + ); +} diff --git a/src/features/crm/quotations/server/service.ts b/src/features/crm/quotations/server/service.ts index a755f18..1f5ec64 100644 --- a/src/features/crm/quotations/server/service.ts +++ b/src/features/crm/quotations/server/service.ts @@ -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; }); diff --git a/src/features/users/components/user-form-sheet.tsx b/src/features/users/components/user-form-sheet.tsx index 597133e..6ecc14b 100644 --- a/src/features/users/components/user-form-sheet.tsx +++ b/src/features/users/components/user-form-sheet.tsx @@ -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' }, diff --git a/src/lib/auth/rbac.ts b/src/lib/auth/rbac.ts index edb26e7..e81799c 100644 --- a/src/lib/auth/rbac.ts +++ b/src/lib/auth/rbac.ts @@ -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, @@ -141,8 +196,9 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] { } return [ - PERMISSIONS.productsRead, - PERMISSIONS.crmCustomerRead, + PERMISSIONS.productsRead, + PERMISSIONS.crmLeadRead, + PERMISSIONS.crmCustomerRead, PERMISSIONS.crmContactRead, PERMISSIONS.crmEnquiryRead, PERMISSIONS.crmEnquiryFollowupRead, @@ -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 []; }