diff --git a/docs/implementation/task-k2-pipeline-reports.md b/docs/implementation/task-k2-pipeline-reports.md new file mode 100644 index 0000000..9f34546 --- /dev/null +++ b/docs/implementation/task-k2-pipeline-reports.md @@ -0,0 +1,65 @@ +# Task K.2 Pipeline Reports + +## Delivered + +- Added production CRM report APIs for: + - `GET /api/crm/reports/pipeline` + - `GET /api/crm/reports/lead-aging` + - `GET /api/crm/reports/enquiry-aging` + - `GET /api/crm/reports/export` +- Added production CRM report pages: + - `/dashboard/crm/reports/pipeline` + - `/dashboard/crm/reports/lead-aging` + - `/dashboard/crm/reports/enquiry-aging` +- Added shared dataset layer at `src/features/crm/reports/server/datasets/pipeline-report.dataset.ts` +- Extended report query contracts, React Query keys, and API client services for the new report suite +- Added report export support for CSV and Excel with scoped security enforcement +- Added audit logging for: + - `view_pipeline_report` + - `export_pipeline_report_csv` + - `export_pipeline_report_excel` +- Extended CRM report definitions seed with: + - `lead_pipeline` + - `lead_aging` + - `enquiry_pipeline` + - `enquiry_aging` + - `pipeline_funnel` + - `lead_conversion` + - `enquiry_conversion` +- Added report navigation entries under CRM side menu and report catalog deep links + +## Security and Scope + +- Report data is filtered by: + - organization + - branch scope + - product scope + - own-record scope for `ownershipScope = own` +- Revenue-like funnel values are hidden when the user does not have quotation pricing visibility +- Export uses the same filter contract and resolved access context as on-screen reports + +## Report Coverage + +- Pipeline suite page includes: + - Lead Pipeline + - Enquiry Pipeline + - Lead to Enquiry Conversion + - Enquiry to Quotation Conversion + - Pipeline Funnel +- Lead Aging page includes bucket summary and drill-down rows +- Enquiry Aging page includes bucket summary and drill-down rows with last follow-up visibility + +## Verification + +- `npx tsc --noEmit` + - passed +- `npx oxlint src/features/crm/reports src/app/api/crm/reports src/app/dashboard/crm/reports src/config/nav-config.ts` + - passed +- `npm run lint` + - still fails because of pre-existing repository-wide lint issues outside Task K.2 scope + +## Notes + +- Lead reporting is derived from the frozen lifecycle stored on `crm_enquiries` +- Lead conversion is treated as progression beyond `lead` stage or assignment into active sales handling +- Enquiry to quotation conversion is derived from `crm_quotations.enquiry_id` diff --git a/drizzle/0019_sleepy_ultron.sql b/drizzle/0019_sleepy_ultron.sql new file mode 100644 index 0000000..81faa66 --- /dev/null +++ b/drizzle/0019_sleepy_ultron.sql @@ -0,0 +1,14 @@ +CREATE TABLE "crm_report_definitions" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "code" text NOT NULL, + "name" text NOT NULL, + "description" text, + "category" text NOT NULL, + "is_system" boolean DEFAULT true NOT NULL, + "is_active" boolean DEFAULT true NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX "crm_report_definitions_org_code_idx" ON "crm_report_definitions" USING btree ("organization_id","code"); \ No newline at end of file diff --git a/drizzle/meta/0019_snapshot.json b/drizzle/meta/0019_snapshot.json new file mode 100644 index 0000000..76f155a --- /dev/null +++ b/drizzle/meta/0019_snapshot.json @@ -0,0 +1,4062 @@ +{ + "id": "e5c31781-e57a-4d70-b80a-94ffa916f4b0", + "prevId": "665cc7e6-1576-4132-9f6b-cba29531e089", + "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_contact_shares": { + "name": "crm_contact_shares", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "shared_to_user_id": { + "name": "shared_to_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "shared_by_user_id": { + "name": "shared_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "shared_at": { + "name": "shared_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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": { + "crm_contact_shares_org_contact_shared_user_idx": { + "name": "crm_contact_shares_org_contact_shared_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "contact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shared_to_user_id", + "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_customer_owner_history": { + "name": "crm_customer_owner_history", + "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 + }, + "old_owner_user_id": { + "name": "old_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "new_owner_user_id": { + "name": "new_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "changed_by": { + "name": "changed_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "changed_at": { + "name": "changed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "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_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 + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_assigned_at": { + "name": "owner_assigned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "owner_assigned_by": { + "name": "owner_assigned_by", + "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'" + }, + "closed_won_at": { + "name": "closed_won_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "closed_lost_at": { + "name": "closed_lost_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "closed_by_user_id": { + "name": "closed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "po_number": { + "name": "po_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "po_date": { + "name": "po_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "po_amount": { + "name": "po_amount", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "po_currency": { + "name": "po_currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lost_reason": { + "name": "lost_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lost_competitor": { + "name": "lost_competitor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lost_remark": { + "name": "lost_remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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_attachments": { + "name": "crm_enquiry_attachments", + "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 + }, + "category": { + "name": "category", + "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 + }, + "storage_provider": { + "name": "storage_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "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_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.crm_report_definitions": { + "name": "crm_report_definitions", + "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 + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_system": { + "name": "is_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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()" + } + }, + "indexes": { + "crm_report_definitions_org_code_idx": { + "name": "crm_report_definitions_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_role_profiles": { + "name": "crm_role_profiles", + "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 + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permissions": { + "name": "permissions", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "branch_scope_mode": { + "name": "branch_scope_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'assigned'" + }, + "product_scope_mode": { + "name": "product_scope_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'assigned'" + }, + "ownership_scope": { + "name": "ownership_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'own'" + }, + "approval_authority": { + "name": "approval_authority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "is_system": { + "name": "is_system", + "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_role_profiles_org_code_idx": { + "name": "crm_role_profiles_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_user_role_assignments": { + "name": "crm_user_role_assignments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_profile_id": { + "name": "role_profile_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_scope_mode": { + "name": "branch_scope_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'inherit'" + }, + "branch_scope_ids": { + "name": "branch_scope_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "product_type_scope_mode": { + "name": "product_type_scope_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'inherit'" + }, + "product_type_scope_ids": { + "name": "product_type_scope_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_at": { + "name": "assigned_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": { + "crm_user_role_assignments_org_user_role_idx": { + "name": "crm_user_role_assignments_org_user_role_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role_profile_id", + "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": "'{}'" + }, + "branch_scope_ids": { + "name": "branch_scope_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "product_type_scope_ids": { + "name": "product_type_scope_ids", + "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 5f68729..040e14a 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -134,6 +134,13 @@ "when": 1782799200000, "tag": "0018_report_foundation", "breakpoints": true + }, + { + "idx": 19, + "version": "7", + "when": 1782137095907, + "tag": "0019_sleepy_ultron", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/plans/task-k.2.md b/plans/task-k.2.md new file mode 100644 index 0000000..b9ea1d2 --- /dev/null +++ b/plans/task-k.2.md @@ -0,0 +1,549 @@ +# Task K.2: Pipeline Reports + +## Objective + +Deliver the first production report suite for CRM by providing operational visibility into: + +* Leads +* Enquiries +* Quotation Conversion +* Pipeline Aging + +using the Report Foundation built in Task K.1. + +--- + +# Background + +Completed: + +* Lead / Enquiry Ownership Model (D.3) +* Lead / Enquiry Navigation Separation (D.3.1) +* Won / Lost Governance (D.4) +* Dashboard KPI (J) +* Report Foundation (K.1) + +Pipeline lifecycle is frozen as: + +```txt +Lead +→ Enquiry +→ Quotation +→ Closed Won +→ Closed Lost +``` + +Task K.2 builds reporting on top of this lifecycle. + +--- + +# Scope K2.1 Lead Pipeline Report + +Add: + +```txt +Lead Pipeline Report +``` + +Purpose: + +Marketing visibility. + +Metrics: + +```txt +Total Leads +New Leads +Assigned Leads +Unassigned Leads +Converted Leads +``` + +Dimensions: + +```txt +Lead Source +Customer Owner +Created By +Branch +Product Type +``` + +Filters: + +```txt +Date Range +Lead Source +Branch +Product Type +Customer Owner +``` + +--- + +# Scope K2.2 Lead Aging Report + +Purpose: + +Identify stagnant leads. + +Metrics: + +```txt +Lead Count +Average Aging Days +Maximum Aging Days +``` + +Buckets: + +```txt +0-7 Days +8-14 Days +15-30 Days +31-60 Days +60+ Days +``` + +Drill-down: + +```txt +Lead Number +Customer +Assigned User +Created Date +Aging Days +``` + +--- + +# Scope K2.3 Enquiry Pipeline Report + +Purpose: + +Sales pipeline visibility. + +Metrics: + +```txt +Open Enquiries +Converted To Quotation +Won +Lost +``` + +Dimensions: + +```txt +Salesman +Branch +Product Type +Customer Owner +``` + +Filters: + +```txt +Date Range +Salesman +Branch +Product Type +``` + +--- + +# Scope K2.4 Enquiry Aging Report + +Purpose: + +Identify stalled opportunities. + +Metrics: + +```txt +Open Enquiries +Average Aging +Maximum Aging +``` + +Buckets: + +```txt +0-14 Days +15-30 Days +31-60 Days +60+ Days +``` + +Drill-down: + +```txt +Enquiry No +Customer +Salesman +Last Follow-Up +Aging Days +``` + +--- + +# Scope K2.5 Lead → Enquiry Conversion Report + +Purpose: + +Marketing effectiveness. + +Formula: + +```txt +Converted Enquiries +/ +Total Leads +``` + +Show: + +```txt +Conversion Rate % +``` + +Dimensions: + +```txt +Lead Source +Branch +Product Type +Marketing User +``` + +--- + +# Scope K2.6 Enquiry → Quotation Conversion Report + +Purpose: + +Sales effectiveness. + +Formula: + +```txt +Enquiries With Quotation +/ +Total Enquiries +``` + +Dimensions: + +```txt +Salesman +Branch +Product Type +``` + +--- + +# Scope K2.7 Pipeline Funnel Report + +Add dedicated report. + +Stages: + +```txt +Lead +Enquiry +Quotation +Closed Won +Closed Lost +``` + +Show: + +```txt +Count +Value +Conversion % +``` + +Support: + +```txt +Date Range +Branch +Product Type +Salesman +``` + +--- + +# Scope K2.8 Pipeline Detail Dataset + +Create reusable dataset: + +```txt +pipeline-report.dataset.ts +``` + +Shared by: + +```txt +Lead Report +Enquiry Report +Funnel Report +``` + +No duplicate SQL. + +--- + +# Scope K2.9 Report Pages + +Add: + +```txt +/dashboard/crm/reports/pipeline +/dashboard/crm/reports/lead-aging +/dashboard/crm/reports/enquiry-aging +``` + +Use: + +```txt +Report Foundation +Shared Filters +Export Foundation +``` + +from K.1 + +--- + +# Scope K2.10 Export Support + +All Pipeline Reports support: + +```txt +CSV +Excel +``` + +Exports must respect: + +```txt +ResolvedReportContext +CRM Authorization +Branch Scope +Product Scope +Ownership Scope +``` + +--- + +# Scope K2.11 Security + +Marketing: + +Can see: + +```txt +Lead Reports +Pipeline Reports +Outcome Summary +``` + +Cannot see: + +```txt +Quotation Pricing +Revenue Values +``` + +--- + +Sales: + +Can see: + +```txt +Own / Scoped Pipeline +``` + +--- + +Managers: + +Can see: + +```txt +Team Pipeline +``` + +--- + +CRM Admin: + +Can see: + +```txt +Organization-wide Pipeline +``` + +--- + +# Scope K2.12 Audit Logging + +Entity: + +```txt +crm_report +``` + +Actions: + +```txt +view_pipeline_report +export_pipeline_report_csv +export_pipeline_report_excel +``` + +Payload: + +```txt +reportCode +filters +recordCount +``` + +--- + +# Scope K2.13 Report Definitions + +Register: + +```txt +lead_pipeline +lead_aging +enquiry_pipeline +enquiry_aging +pipeline_funnel +lead_conversion +enquiry_conversion +``` + +in: + +```txt +crm_report_definitions +``` + +--- + +# Verification + +Scenario 1 + +```txt +Lead +→ Assign +→ Enquiry +``` + +Expected: + +```txt +Lead Conversion Report updates +``` + +--- + +Scenario 2 + +```txt +Enquiry +→ Create Quotation +``` + +Expected: + +```txt +Quotation Conversion updates +``` + +--- + +Scenario 3 + +```txt +Won +Lost +``` + +Expected: + +```txt +Pipeline Funnel updates +``` + +--- + +Scenario 4 + +Marketing User + +Expected: + +```txt +No Revenue Values +``` + +--- + +Scenario 5 + +Sales User + +Expected: + +```txt +Scoped Pipeline Only +``` + +--- + +# Deliverables + +1. Lead Pipeline Report +2. Lead Aging Report +3. Enquiry Pipeline Report +4. Enquiry Aging Report +5. Conversion Reports +6. Pipeline Funnel Report +7. Shared Dataset Layer +8. Export Support +9. Security Enforcement +10. Report Registration + +--- + +# Definition of Done + +Task K.2 is complete when: + +* Lead reports work +* Enquiry reports work +* Funnel report works +* Conversion reports work +* Aging reports work +* Export works +* Security works +* Audit logging works + +Result: + +```txt +Marketing Pipeline Analytics = READY +Sales Pipeline Analytics = READY + +Ready for: + +K.3 Outcome Analytics +``` diff --git a/src/app/api/crm/reports/enquiry-aging/route.ts b/src/app/api/crm/reports/enquiry-aging/route.ts new file mode 100644 index 0000000..c3d2ed7 --- /dev/null +++ b/src/app/api/crm/reports/enquiry-aging/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { resolveCrmAccess } from '@/features/crm/reports/server/context'; +import { parseSharedReportFilters } from '@/features/crm/reports/server/filters/shared'; +import { getCrmEnquiryAgingReportResponse } from '@/features/crm/reports/server/service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +export async function GET(request: NextRequest) { + try { + const { organization, session, membership } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmReportRead + }); + + return NextResponse.json( + await getCrmEnquiryAgingReportResponse( + resolveCrmAccess({ + organizationId: organization.id, + userId: session.user.id, + membership + }), + parseSharedReportFilters(request.nextUrl.searchParams) + ) + ); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + return NextResponse.json( + { message: 'Unable to load CRM enquiry aging report' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/crm/reports/export/route.ts b/src/app/api/crm/reports/export/route.ts new file mode 100644 index 0000000..95ebec2 --- /dev/null +++ b/src/app/api/crm/reports/export/route.ts @@ -0,0 +1,205 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { resolveCrmAccess } from '@/features/crm/reports/server/context'; +import { + auditReportExport, + buildReportCsv, + buildReportExcel +} from '@/features/crm/reports/server/exports/service'; +import { parseSharedReportFilters } from '@/features/crm/reports/server/filters/shared'; +import { + getCrmEnquiryAgingReportResponse, + getCrmLeadAgingReportResponse, + getCrmPipelineReportResponse +} from '@/features/crm/reports/server/service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +function buildExportRows(input: { + reportCode: string; + pipeline?: Awaited>; + leadAging?: Awaited>; + enquiryAging?: Awaited>; +}) { + switch (input.reportCode) { + case 'lead_pipeline': + return [ + ['Lead Source', 'Customer Owner', 'Created By', 'Branch', 'Product Type', 'Total Leads', 'New Leads', 'Assigned Leads', 'Unassigned Leads', 'Converted Leads'], + ...(input.pipeline?.leadPipeline ?? []).map((row) => [ + row.leadSourceLabel, + row.customerOwnerName, + row.createdByName, + row.branchLabel, + row.productTypeLabel, + String(row.totalLeads), + String(row.newLeads), + String(row.assignedLeads), + String(row.unassignedLeads), + String(row.convertedLeads) + ]) + ]; + case 'enquiry_pipeline': + return [ + ['Salesman', 'Branch', 'Product Type', 'Customer Owner', 'Open Enquiries', 'Converted To Quotation', 'Won', 'Lost'], + ...(input.pipeline?.enquiryPipeline ?? []).map((row) => [ + row.salesmanName, + row.branchLabel, + row.productTypeLabel, + row.customerOwnerName, + String(row.openEnquiries), + String(row.convertedToQuotation), + String(row.won), + String(row.lost) + ]) + ]; + case 'lead_conversion': + return [ + ['Lead Source', 'Branch', 'Product Type', 'Marketing User', 'Total Leads', 'Converted Enquiries', 'Conversion Rate %'], + ...(input.pipeline?.leadConversion ?? []).map((row) => [ + row.leadSourceLabel, + row.branchLabel, + row.productTypeLabel, + row.marketingUserName, + String(row.totalLeads), + String(row.convertedEnquiries), + String(row.conversionRate) + ]) + ]; + case 'enquiry_conversion': + return [ + ['Salesman', 'Branch', 'Product Type', 'Total Enquiries', 'Enquiries With Quotation', 'Conversion Rate %'], + ...(input.pipeline?.enquiryConversion ?? []).map((row) => [ + row.salesmanName, + row.branchLabel, + row.productTypeLabel, + String(row.totalEnquiries), + String(row.enquiriesWithQuotation), + String(row.conversionRate) + ]) + ]; + case 'pipeline_funnel': + return [ + ['Stage', 'Count', 'Value', 'Conversion Rate %'], + ...(input.pipeline?.funnel ?? []).map((row) => [ + row.stageLabel, + String(row.count), + row.value === null ? '' : String(row.value), + row.conversionRate === null ? '' : String(row.conversionRate) + ]) + ]; + case 'lead_aging': + return [ + ['Lead Number', 'Customer', 'Assigned User', 'Created Date', 'Aging Days'], + ...(input.leadAging?.rows ?? []).map((row) => [ + row.leadCode, + row.customerName, + row.assignedUserName ?? '', + row.createdDate, + String(row.agingDays) + ]) + ]; + case 'enquiry_aging': + return [ + ['Enquiry No', 'Customer', 'Salesman', 'Last Follow-Up', 'Aging Days'], + ...(input.enquiryAging?.rows ?? []).map((row) => [ + row.enquiryCode, + row.customerName, + row.salesmanName ?? '', + row.lastFollowupDate ?? '', + String(row.agingDays) + ]) + ]; + default: + return null; + } +} + +function getRecordCount(input: { + reportCode: string; + pipeline?: Awaited>; + leadAging?: Awaited>; + enquiryAging?: Awaited>; +}) { + switch (input.reportCode) { + case 'lead_pipeline': + return input.pipeline?.leadPipeline.length ?? 0; + case 'enquiry_pipeline': + return input.pipeline?.enquiryPipeline.length ?? 0; + case 'lead_conversion': + return input.pipeline?.leadConversion.length ?? 0; + case 'enquiry_conversion': + return input.pipeline?.enquiryConversion.length ?? 0; + case 'pipeline_funnel': + return input.pipeline?.funnel.length ?? 0; + case 'lead_aging': + return input.leadAging?.rows.length ?? 0; + case 'enquiry_aging': + return input.enquiryAging?.rows.length ?? 0; + default: + return 0; + } +} + +export async function GET(request: NextRequest) { + try { + const { organization, session, membership } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmReportExport + }); + const reportCode = request.nextUrl.searchParams.get('reportCode') ?? ''; + const format = request.nextUrl.searchParams.get('format') === 'xls' ? 'xls' : 'csv'; + const filters = parseSharedReportFilters(request.nextUrl.searchParams); + const context = resolveCrmAccess({ + organizationId: organization.id, + userId: session.user.id, + membership + }); + + const pipelineCodes = new Set([ + 'lead_pipeline', + 'enquiry_pipeline', + 'lead_conversion', + 'enquiry_conversion', + 'pipeline_funnel' + ]); + const pipeline = pipelineCodes.has(reportCode) + ? await getCrmPipelineReportResponse(context, filters) + : undefined; + const leadAging = + reportCode === 'lead_aging' ? await getCrmLeadAgingReportResponse(context, filters) : undefined; + const enquiryAging = + reportCode === 'enquiry_aging' + ? await getCrmEnquiryAgingReportResponse(context, filters) + : undefined; + const rows = buildExportRows({ reportCode, pipeline, leadAging, enquiryAging }); + + if (!rows) { + return NextResponse.json({ message: 'Unsupported report code' }, { status: 400 }); + } + + const body = format === 'xls' ? buildReportExcel(rows) : buildReportCsv(rows); + const extension = format === 'xls' ? 'xls' : 'csv'; + const contentType = + format === 'xls' ? 'application/vnd.ms-excel; charset=utf-8' : 'text/csv; charset=utf-8'; + + await auditReportExport({ + organizationId: organization.id, + userId: session.user.id, + reportCode, + format, + filters: { ...filters }, + recordCount: getRecordCount({ reportCode, pipeline, leadAging, enquiryAging }) + }); + + return new NextResponse(body, { + headers: { + 'Content-Type': contentType, + 'Content-Disposition': `attachment; filename="crm-report-${reportCode}.${extension}"` + } + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + return NextResponse.json({ message: 'Unable to export CRM report' }, { status: 500 }); + } +} diff --git a/src/app/api/crm/reports/lead-aging/route.ts b/src/app/api/crm/reports/lead-aging/route.ts new file mode 100644 index 0000000..8303da7 --- /dev/null +++ b/src/app/api/crm/reports/lead-aging/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { resolveCrmAccess } from '@/features/crm/reports/server/context'; +import { parseSharedReportFilters } from '@/features/crm/reports/server/filters/shared'; +import { getCrmLeadAgingReportResponse } from '@/features/crm/reports/server/service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +export async function GET(request: NextRequest) { + try { + const { organization, session, membership } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmReportRead + }); + + return NextResponse.json( + await getCrmLeadAgingReportResponse( + resolveCrmAccess({ + organizationId: organization.id, + userId: session.user.id, + membership + }), + parseSharedReportFilters(request.nextUrl.searchParams) + ) + ); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + return NextResponse.json({ message: 'Unable to load CRM lead aging report' }, { status: 500 }); + } +} diff --git a/src/app/api/crm/reports/pipeline/route.ts b/src/app/api/crm/reports/pipeline/route.ts new file mode 100644 index 0000000..c80db57 --- /dev/null +++ b/src/app/api/crm/reports/pipeline/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { resolveCrmAccess } from '@/features/crm/reports/server/context'; +import { parseSharedReportFilters } from '@/features/crm/reports/server/filters/shared'; +import { getCrmPipelineReportResponse } from '@/features/crm/reports/server/service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +export async function GET(request: NextRequest) { + try { + const { organization, session, membership } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmReportRead + }); + + return NextResponse.json( + await getCrmPipelineReportResponse( + resolveCrmAccess({ + organizationId: organization.id, + userId: session.user.id, + membership + }), + parseSharedReportFilters(request.nextUrl.searchParams) + ) + ); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + return NextResponse.json({ message: 'Unable to load CRM pipeline report' }, { status: 500 }); + } +} diff --git a/src/app/dashboard/crm/reports/enquiry-aging/page.tsx b/src/app/dashboard/crm/reports/enquiry-aging/page.tsx new file mode 100644 index 0000000..867530e --- /dev/null +++ b/src/app/dashboard/crm/reports/enquiry-aging/page.tsx @@ -0,0 +1,72 @@ +import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; +import { auth } from '@/auth'; +import PageContainer from '@/components/layout/page-container'; +import { + crmEnquiryAgingReportQueryOptions, + crmReportFiltersQueryOptions +} from '@/features/crm/reports/api/queries'; +import { AgingReportView } from '@/features/crm/reports/components/aging-report-view'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { getQueryClient } from '@/lib/query-client'; +import type { SearchParams } from 'nuqs/server'; + +export const metadata = { + title: 'CRM Enquiry Aging Report' +}; + +type PageProps = { + searchParams: Promise; +}; + +function getFilterValue(value: string | string[] | undefined): string | null { + return typeof value === 'string' ? value : null; +} + +export default async function CrmEnquiryAgingRoute(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.crmReportRead))); + const canExport = + session?.user?.systemRole === 'super_admin' || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === 'admin' || + session.user.activePermissions.includes(PERMISSIONS.crmReportExport))); + const queryClient = getQueryClient(); + const filters = { + dateFrom: getFilterValue(searchParams.dateFrom), + dateTo: getFilterValue(searchParams.dateTo), + branch: getFilterValue(searchParams.branch), + productType: getFilterValue(searchParams.productType), + sales: getFilterValue(searchParams.sales), + customerOwner: null, + leadSource: null + }; + + if (canRead) { + void queryClient.prefetchQuery(crmReportFiltersQueryOptions()); + void queryClient.prefetchQuery(crmEnquiryAgingReportQueryOptions(filters)); + } + + return ( + + You do not have access to CRM enquiry aging reports. + + } + > + {canRead ? ( + + + + ) : null} + + ); +} diff --git a/src/app/dashboard/crm/reports/lead-aging/page.tsx b/src/app/dashboard/crm/reports/lead-aging/page.tsx new file mode 100644 index 0000000..daa23e6 --- /dev/null +++ b/src/app/dashboard/crm/reports/lead-aging/page.tsx @@ -0,0 +1,72 @@ +import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; +import { auth } from '@/auth'; +import PageContainer from '@/components/layout/page-container'; +import { + crmLeadAgingReportQueryOptions, + crmReportFiltersQueryOptions +} from '@/features/crm/reports/api/queries'; +import { AgingReportView } from '@/features/crm/reports/components/aging-report-view'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { getQueryClient } from '@/lib/query-client'; +import type { SearchParams } from 'nuqs/server'; + +export const metadata = { + title: 'CRM Lead Aging Report' +}; + +type PageProps = { + searchParams: Promise; +}; + +function getFilterValue(value: string | string[] | undefined): string | null { + return typeof value === 'string' ? value : null; +} + +export default async function CrmLeadAgingRoute(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.crmReportRead))); + const canExport = + session?.user?.systemRole === 'super_admin' || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === 'admin' || + session.user.activePermissions.includes(PERMISSIONS.crmReportExport))); + const queryClient = getQueryClient(); + const filters = { + dateFrom: getFilterValue(searchParams.dateFrom), + dateTo: getFilterValue(searchParams.dateTo), + branch: getFilterValue(searchParams.branch), + productType: getFilterValue(searchParams.productType), + sales: null, + customerOwner: getFilterValue(searchParams.customerOwner), + leadSource: getFilterValue(searchParams.leadSource) + }; + + if (canRead) { + void queryClient.prefetchQuery(crmReportFiltersQueryOptions()); + void queryClient.prefetchQuery(crmLeadAgingReportQueryOptions(filters)); + } + + return ( + + You do not have access to CRM lead aging reports. + + } + > + {canRead ? ( + + + + ) : null} + + ); +} diff --git a/src/app/dashboard/crm/reports/page.tsx b/src/app/dashboard/crm/reports/page.tsx index 2aa70c3..a432eab 100644 --- a/src/app/dashboard/crm/reports/page.tsx +++ b/src/app/dashboard/crm/reports/page.tsx @@ -30,7 +30,7 @@ export default async function CrmReportsRoute() { return ( diff --git a/src/app/dashboard/crm/reports/pipeline/page.tsx b/src/app/dashboard/crm/reports/pipeline/page.tsx new file mode 100644 index 0000000..75a739c --- /dev/null +++ b/src/app/dashboard/crm/reports/pipeline/page.tsx @@ -0,0 +1,74 @@ +import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; +import { auth } from '@/auth'; +import PageContainer from '@/components/layout/page-container'; +import { + crmPipelineReportQueryOptions, + crmReportFiltersQueryOptions +} from '@/features/crm/reports/api/queries'; +import { PipelineReportView } from '@/features/crm/reports/components/pipeline-report-view'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { getQueryClient } from '@/lib/query-client'; +import type { SearchParams } from 'nuqs/server'; + +export const metadata = { + title: 'CRM Pipeline Reports' +}; + +type PageProps = { + searchParams: Promise; +}; + +function getFilterValue( + value: string | string[] | undefined +): string | null { + return typeof value === 'string' ? value : null; +} + +export default async function CrmPipelineReportsRoute(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.crmReportRead))); + const canExport = + session?.user?.systemRole === 'super_admin' || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === 'admin' || + session.user.activePermissions.includes(PERMISSIONS.crmReportExport))); + const queryClient = getQueryClient(); + const filters = { + dateFrom: getFilterValue(searchParams.dateFrom), + dateTo: getFilterValue(searchParams.dateTo), + branch: getFilterValue(searchParams.branch), + productType: getFilterValue(searchParams.productType), + sales: getFilterValue(searchParams.sales), + customerOwner: getFilterValue(searchParams.customerOwner), + leadSource: getFilterValue(searchParams.leadSource) + }; + + if (canRead) { + void queryClient.prefetchQuery(crmReportFiltersQueryOptions()); + void queryClient.prefetchQuery(crmPipelineReportQueryOptions(filters)); + } + + return ( + + You do not have access to CRM pipeline reports. + + } + > + {canRead ? ( + + + + ) : null} + + ); +} diff --git a/src/config/nav-config.ts b/src/config/nav-config.ts index 92362fb..901331a 100644 --- a/src/config/nav-config.ts +++ b/src/config/nav-config.ts @@ -136,7 +136,23 @@ export const navGroups: NavGroup[] = [ url: "/dashboard/crm/reports", icon: "dashboard", isActive: false, - items: [], + items: [ + { + title: "Pipeline Suite", + url: "/dashboard/crm/reports/pipeline", + access: { requireOrg: true, permission: "crm.report.read" }, + }, + { + title: "Lead Aging", + url: "/dashboard/crm/reports/lead-aging", + access: { requireOrg: true, permission: "crm.report.read" }, + }, + { + title: "Enquiry Aging", + url: "/dashboard/crm/reports/enquiry-aging", + access: { requireOrg: true, permission: "crm.report.read" }, + }, + ], access: { requireOrg: true, permission: "crm.report.read" }, }, { diff --git a/src/db/seeds/foundation.seed.ts b/src/db/seeds/foundation.seed.ts index 24d4a2c..d645953 100644 --- a/src/db/seeds/foundation.seed.ts +++ b/src/db/seeds/foundation.seed.ts @@ -358,13 +358,43 @@ const REPORT_DEFINITIONS: ReportDefinitionSeed[] = [ { code: 'lead_pipeline', name: 'Lead Pipeline', - description: 'Foundation catalog entry for future lead pipeline reporting.', + description: 'Operational report for marketing lead intake, assignment, and conversion status.', + category: 'pipeline' + }, + { + code: 'lead_aging', + name: 'Lead Aging', + description: 'Operational report for stagnant lead monitoring and follow-up prioritization.', category: 'pipeline' }, { code: 'enquiry_pipeline', name: 'Enquiry Pipeline', - description: 'Foundation catalog entry for opportunity stage reporting.', + description: 'Operational report for open enquiries, quotation progress, and sales pipeline health.', + category: 'pipeline' + }, + { + code: 'enquiry_aging', + name: 'Enquiry Aging', + description: 'Operational report for stalled enquiries and aging opportunity follow-ups.', + category: 'pipeline' + }, + { + code: 'pipeline_funnel', + name: 'Pipeline Funnel', + description: 'Lifecycle funnel from lead through quotation to won and lost outcomes.', + category: 'pipeline' + }, + { + code: 'lead_conversion', + name: 'Lead Conversion', + description: 'Marketing conversion report from lead creation into active enquiries.', + category: 'pipeline' + }, + { + code: 'enquiry_conversion', + name: 'Enquiry Conversion', + description: 'Sales conversion report from enquiry creation into quotation generation.', category: 'pipeline' }, { diff --git a/src/features/crm/reports/api/queries.ts b/src/features/crm/reports/api/queries.ts index 5b5a69f..1bef3b9 100644 --- a/src/features/crm/reports/api/queries.ts +++ b/src/features/crm/reports/api/queries.ts @@ -1,11 +1,25 @@ import { queryOptions } from '@tanstack/react-query'; -import { getCrmReportCatalog, getCrmReportFilters, getCrmReports } from './service'; +import type { CrmSharedReportFilters } from './types'; +import { + getCrmEnquiryAgingReport, + getCrmLeadAgingReport, + getCrmPipelineReport, + getCrmReportCatalog, + getCrmReportFilters, + getCrmReports +} from './service'; export const crmReportKeys = { all: ['crm-reports'] as const, list: () => [...crmReportKeys.all, 'list'] as const, catalog: () => [...crmReportKeys.all, 'catalog'] as const, - filters: () => [...crmReportKeys.all, 'filters'] as const + filters: () => [...crmReportKeys.all, 'filters'] as const, + pipelineRoot: () => [...crmReportKeys.all, 'pipeline'] as const, + pipeline: (filters: CrmSharedReportFilters) => [...crmReportKeys.pipelineRoot(), filters] as const, + leadAgingRoot: () => [...crmReportKeys.all, 'lead-aging'] as const, + leadAging: (filters: CrmSharedReportFilters) => [...crmReportKeys.leadAgingRoot(), filters] as const, + enquiryAgingRoot: () => [...crmReportKeys.all, 'enquiry-aging'] as const, + enquiryAging: (filters: CrmSharedReportFilters) => [...crmReportKeys.enquiryAgingRoot(), filters] as const }; export const crmReportsQueryOptions = () => @@ -25,3 +39,21 @@ export const crmReportFiltersQueryOptions = () => queryKey: crmReportKeys.filters(), queryFn: () => getCrmReportFilters() }); + +export const crmPipelineReportQueryOptions = (filters: CrmSharedReportFilters) => + queryOptions({ + queryKey: crmReportKeys.pipeline(filters), + queryFn: () => getCrmPipelineReport(filters) + }); + +export const crmLeadAgingReportQueryOptions = (filters: CrmSharedReportFilters) => + queryOptions({ + queryKey: crmReportKeys.leadAging(filters), + queryFn: () => getCrmLeadAgingReport(filters) + }); + +export const crmEnquiryAgingReportQueryOptions = (filters: CrmSharedReportFilters) => + queryOptions({ + queryKey: crmReportKeys.enquiryAging(filters), + queryFn: () => getCrmEnquiryAgingReport(filters) + }); diff --git a/src/features/crm/reports/api/service.ts b/src/features/crm/reports/api/service.ts index 2f866aa..e8e819e 100644 --- a/src/features/crm/reports/api/service.ts +++ b/src/features/crm/reports/api/service.ts @@ -2,9 +2,31 @@ import { apiClient } from '@/lib/api-client'; import type { CrmReportCatalogResponse, CrmReportFiltersResponse, - CrmReportsResponse + CrmReportsResponse, + CrmSharedReportFilters, + CrmPipelineReportResponse, + CrmLeadAgingReportResponse, + CrmEnquiryAgingReportResponse } from './types'; +function buildReportQuery(filters: CrmSharedReportFilters) { + const searchParams = new URLSearchParams(); + + if (filters.dateFrom) searchParams.set('dateFrom', filters.dateFrom); + if (filters.dateTo) searchParams.set('dateTo', filters.dateTo); + if (filters.branch) searchParams.set('branch', filters.branch); + if (filters.productType) searchParams.set('productType', filters.productType); + if (filters.sales) searchParams.set('sales', filters.sales); + if (filters.customer) searchParams.set('customer', filters.customer); + if (filters.customerOwner) searchParams.set('customerOwner', filters.customerOwner); + if (filters.leadSource) searchParams.set('leadSource', filters.leadSource); + if (filters.pipelineStage) searchParams.set('pipelineStage', filters.pipelineStage); + if (filters.outcome) searchParams.set('outcome', filters.outcome); + if (filters.lostReason) searchParams.set('lostReason', filters.lostReason); + + return searchParams.toString(); +} + export async function getCrmReports() { return apiClient('/crm/reports'); } @@ -16,3 +38,22 @@ export async function getCrmReportCatalog() { export async function getCrmReportFilters() { return apiClient('/crm/reports/filters'); } + +export async function getCrmPipelineReport(filters: CrmSharedReportFilters) { + const query = buildReportQuery(filters); + return apiClient(`/crm/reports/pipeline${query ? `?${query}` : ''}`); +} + +export async function getCrmLeadAgingReport(filters: CrmSharedReportFilters) { + const query = buildReportQuery(filters); + return apiClient( + `/crm/reports/lead-aging${query ? `?${query}` : ''}` + ); +} + +export async function getCrmEnquiryAgingReport(filters: CrmSharedReportFilters) { + const query = buildReportQuery(filters); + return apiClient( + `/crm/reports/enquiry-aging${query ? `?${query}` : ''}` + ); +} diff --git a/src/features/crm/reports/api/types.ts b/src/features/crm/reports/api/types.ts index c79373a..bcdb25c 100644 --- a/src/features/crm/reports/api/types.ts +++ b/src/features/crm/reports/api/types.ts @@ -73,3 +73,128 @@ export interface CrmReportFiltersResponse { message: string; filters: CrmReportFilterMetadata; } + +export interface CrmPipelineReportRow { + leadSourceLabel: string; + customerOwnerName: string; + createdByName: string; + branchLabel: string; + productTypeLabel: string; + totalLeads: number; + newLeads: number; + assignedLeads: number; + unassignedLeads: number; + convertedLeads: number; +} + +export interface CrmEnquiryPipelineReportRow { + salesmanName: string; + branchLabel: string; + productTypeLabel: string; + customerOwnerName: string; + openEnquiries: number; + convertedToQuotation: number; + won: number; + lost: number; +} + +export interface CrmLeadConversionReportRow { + leadSourceLabel: string; + branchLabel: string; + productTypeLabel: string; + marketingUserName: string; + totalLeads: number; + convertedEnquiries: number; + conversionRate: number; +} + +export interface CrmEnquiryConversionReportRow { + salesmanName: string; + branchLabel: string; + productTypeLabel: string; + totalEnquiries: number; + enquiriesWithQuotation: number; + conversionRate: number; +} + +export interface CrmPipelineFunnelRow { + stageKey: 'lead' | 'enquiry' | 'quotation' | 'closed_won' | 'closed_lost'; + stageLabel: string; + count: number; + value: number | null; + conversionRate: number | null; +} + +export interface CrmPipelineKpiSummary { + totalLeads: number; + openEnquiries: number; + quotations: number; + won: number; + lost: number; + canViewRevenueValues: boolean; +} + +export interface CrmPipelineReportResponse { + success: boolean; + time: string; + message: string; + filters: Required; + summary: CrmPipelineKpiSummary; + leadPipeline: CrmPipelineReportRow[]; + enquiryPipeline: CrmEnquiryPipelineReportRow[]; + leadConversion: CrmLeadConversionReportRow[]; + enquiryConversion: CrmEnquiryConversionReportRow[]; + funnel: CrmPipelineFunnelRow[]; +} + +export interface CrmAgingBucket { + key: string; + label: string; + count: number; +} + +export interface CrmLeadAgingRow { + leadId: string; + leadCode: string; + customerName: string; + assignedUserName: string | null; + createdDate: string; + agingDays: number; +} + +export interface CrmLeadAgingReportResponse { + success: boolean; + time: string; + message: string; + filters: Required; + summary: { + leadCount: number; + averageAgingDays: number; + maximumAgingDays: number; + }; + buckets: CrmAgingBucket[]; + rows: CrmLeadAgingRow[]; +} + +export interface CrmEnquiryAgingRow { + enquiryId: string; + enquiryCode: string; + customerName: string; + salesmanName: string | null; + lastFollowupDate: string | null; + agingDays: number; +} + +export interface CrmEnquiryAgingReportResponse { + success: boolean; + time: string; + message: string; + filters: Required; + summary: { + openEnquiries: number; + averageAgingDays: number; + maximumAgingDays: number; + }; + buckets: CrmAgingBucket[]; + rows: CrmEnquiryAgingRow[]; +} diff --git a/src/features/crm/reports/components/aging-report-view.tsx b/src/features/crm/reports/components/aging-report-view.tsx new file mode 100644 index 0000000..dbcce6e --- /dev/null +++ b/src/features/crm/reports/components/aging-report-view.tsx @@ -0,0 +1,242 @@ +'use client'; + +import { useMemo } from 'react'; +import { parseAsString, useQueryStates } from 'nuqs'; +import { useSuspenseQuery } from '@tanstack/react-query'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from '@/components/ui/table'; +import type { + CrmEnquiryAgingReportResponse, + CrmLeadAgingReportResponse +} from '../api/types'; +import { + crmEnquiryAgingReportQueryOptions, + crmLeadAgingReportQueryOptions, + crmReportFiltersQueryOptions +} from '../api/queries'; +import { ReportFilterBar } from './report-filter-bar'; + +function formatNumber(value: number) { + return new Intl.NumberFormat('en-US').format(value); +} + +function AgingReportLayout({ + variant, + canExport, + data +}: { + variant: 'lead' | 'enquiry'; + canExport: boolean; + data: CrmLeadAgingReportResponse | CrmEnquiryAgingReportResponse; +}) { + const { data: filterData } = useSuspenseQuery(crmReportFiltersQueryOptions()); + + const summaryCards = + variant === 'lead' && 'leadCount' in data.summary + ? [ + ['Lead Count', formatNumber(data.summary.leadCount)], + ['Average Aging', `${data.summary.averageAgingDays} days`], + ['Maximum Aging', `${data.summary.maximumAgingDays} days`] + ] + : [ + [ + 'Open Enquiries', + formatNumber('openEnquiries' in data.summary ? data.summary.openEnquiries : 0) + ], + ['Average Aging', `${data.summary.averageAgingDays} days`], + ['Maximum Aging', `${data.summary.maximumAgingDays} days`] + ]; + + return ( +
+ + +
+ {summaryCards.map(([label, value]) => ( + + + {label} + + +
{value}
+
+
+ ))} +
+ + + + {variant === 'lead' ? 'Lead Aging Buckets' : 'Enquiry Aging Buckets'} + Bucketed view to spot stagnant records quickly. + + + {data.buckets.map((bucket) => ( +
+
{bucket.label}
+
{formatNumber(bucket.count)}
+
+ ))} +
+
+ + + + {variant === 'lead' ? 'Lead Drill-down' : 'Enquiry Drill-down'} + + {variant === 'lead' + ? 'Open leads sorted from oldest to newest.' + : 'Open enquiries with latest follow-up visibility.'} + + + +
+ + + + {variant === 'lead' ? ( + <> + Lead Number + Customer + Assigned User + Created Date + Aging Days + + ) : ( + <> + Enquiry No + Customer + Salesman + Last Follow-Up + Aging Days + + )} + + + + {data.rows.length ? ( + data.rows.map((row) => ( + + {'leadId' in row ? ( + <> + {row.leadCode} + {row.customerName} + {row.assignedUserName ?? '-'} + {new Date(row.createdDate).toLocaleDateString('en-CA')} + {formatNumber(row.agingDays)} + + ) : ( + <> + {row.enquiryCode} + {row.customerName} + {row.salesmanName ?? '-'} + + {row.lastFollowupDate + ? new Date(row.lastFollowupDate).toLocaleDateString('en-CA') + : '-'} + + {formatNumber(row.agingDays)} + + )} + + )) + ) : ( + + + No records found. + + + )} + +
+
+
+
+
+ ); +} + +function LeadAgingReportInner({ canExport }: { canExport: boolean }) { + const [params] = useQueryStates({ + dateFrom: parseAsString, + dateTo: parseAsString, + branch: parseAsString, + productType: parseAsString, + customerOwner: parseAsString, + leadSource: parseAsString + }); + const filters = useMemo( + () => ({ + dateFrom: params.dateFrom, + dateTo: params.dateTo, + branch: params.branch, + productType: params.productType, + sales: null, + customerOwner: params.customerOwner, + leadSource: params.leadSource + }), + [params] + ); + const { data } = useSuspenseQuery(crmLeadAgingReportQueryOptions(filters)); + + return ; +} + +function EnquiryAgingReportInner({ canExport }: { canExport: boolean }) { + const [params] = useQueryStates({ + dateFrom: parseAsString, + dateTo: parseAsString, + branch: parseAsString, + productType: parseAsString, + sales: parseAsString + }); + const filters = useMemo( + () => ({ + dateFrom: params.dateFrom, + dateTo: params.dateTo, + branch: params.branch, + productType: params.productType, + sales: params.sales, + customerOwner: null, + leadSource: null + }), + [params] + ); + const { data } = useSuspenseQuery(crmEnquiryAgingReportQueryOptions(filters)); + + return ; +} + +export function AgingReportView({ + variant, + canExport +}: { + variant: 'lead' | 'enquiry'; + canExport: boolean; +}) { + return variant === 'lead' ? ( + + ) : ( + + ); +} diff --git a/src/features/crm/reports/components/pipeline-report-view.tsx b/src/features/crm/reports/components/pipeline-report-view.tsx new file mode 100644 index 0000000..eceeedf --- /dev/null +++ b/src/features/crm/reports/components/pipeline-report-view.tsx @@ -0,0 +1,255 @@ +'use client'; + +import { useMemo } from 'react'; +import { parseAsString, useQueryStates } from 'nuqs'; +import { useSuspenseQuery } from '@tanstack/react-query'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from '@/components/ui/table'; +import { + crmPipelineReportQueryOptions, + crmReportFiltersQueryOptions +} from '../api/queries'; +import { ReportFilterBar } from './report-filter-bar'; + +function formatNumber(value: number) { + return new Intl.NumberFormat('en-US').format(value); +} + +function formatValue(value: number | null) { + if (value === null) { + return 'Hidden'; + } + + return new Intl.NumberFormat('en-US', { + minimumFractionDigits: 0, + maximumFractionDigits: 2 + }).format(value); +} + +function SectionTable({ + title, + description, + headers, + rows, + reportCode +}: { + title: string; + description: string; + headers: string[]; + rows: string[][]; + reportCode?: string; +}) { + return ( + + +
+ {title} + {description} +
+ {reportCode ?
{reportCode}
: null} +
+ +
+ + + + {headers.map((header) => ( + {header} + ))} + + + + {rows.length ? ( + rows.map((row, index) => ( + + {row.map((cell, cellIndex) => ( + {cell} + ))} + + )) + ) : ( + + + No records found. + + + )} + +
+
+
+
+ ); +} + +export function PipelineReportView({ canExport }: { canExport: boolean }) { + const [params] = useQueryStates({ + dateFrom: parseAsString, + dateTo: parseAsString, + branch: parseAsString, + productType: parseAsString, + sales: parseAsString, + customerOwner: parseAsString, + leadSource: parseAsString + }); + const filters = useMemo( + () => ({ + dateFrom: params.dateFrom, + dateTo: params.dateTo, + branch: params.branch, + productType: params.productType, + sales: params.sales, + customerOwner: params.customerOwner, + leadSource: params.leadSource + }), + [params] + ); + const { data: filterData } = useSuspenseQuery(crmReportFiltersQueryOptions()); + const { data } = useSuspenseQuery(crmPipelineReportQueryOptions(filters)); + + return ( +
+ + +
+ {[ + ['Total Leads', formatNumber(data.summary.totalLeads)], + ['Open Enquiries', formatNumber(data.summary.openEnquiries)], + ['Quotations', formatNumber(data.summary.quotations)], + ['Closed Won', formatNumber(data.summary.won)], + ['Closed Lost', formatNumber(data.summary.lost)] + ].map(([label, value]) => ( + + + {label} + + +
{value}
+
+
+ ))} +
+ +
+ ); +} diff --git a/src/features/crm/reports/components/report-catalog.tsx b/src/features/crm/reports/components/report-catalog.tsx index f38e377..9f62976 100644 --- a/src/features/crm/reports/components/report-catalog.tsx +++ b/src/features/crm/reports/components/report-catalog.tsx @@ -1,10 +1,22 @@ 'use client'; +import Link from 'next/link'; import { useSuspenseQuery } from '@tanstack/react-query'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; import { crmReportCatalogQueryOptions, crmReportFiltersQueryOptions } from '../api/queries'; +const REPORT_LINKS: Record = { + lead_pipeline: '/dashboard/crm/reports/pipeline', + enquiry_pipeline: '/dashboard/crm/reports/pipeline', + lead_conversion: '/dashboard/crm/reports/pipeline', + enquiry_conversion: '/dashboard/crm/reports/pipeline', + pipeline_funnel: '/dashboard/crm/reports/pipeline', + lead_aging: '/dashboard/crm/reports/lead-aging', + enquiry_aging: '/dashboard/crm/reports/enquiry-aging' +}; + export function ReportCatalog() { const { data: catalog } = useSuspenseQuery(crmReportCatalogQueryOptions()); const { data: filterData } = useSuspenseQuery(crmReportFiltersQueryOptions()); @@ -48,7 +60,7 @@ export function ReportCatalog() { {group.categoryLabel} - {group.items.length} report definition{group.items.length === 1 ? '' : 's'} ready for future modules. + {group.items.length} report definition{group.items.length === 1 ? '' : 's'} available in the CRM report center. @@ -62,6 +74,11 @@ export function ReportCatalog() { {item.isSystem ? System : null}
{item.code}
+ {REPORT_LINKS[item.code] ? ( + + ) : null} ))}
diff --git a/src/features/crm/reports/components/report-filter-bar.tsx b/src/features/crm/reports/components/report-filter-bar.tsx new file mode 100644 index 0000000..20758a8 --- /dev/null +++ b/src/features/crm/reports/components/report-filter-bar.tsx @@ -0,0 +1,167 @@ +'use client'; + +import { useMemo } from 'react'; +import Link from 'next/link'; +import { parseAsString, useQueryStates } from 'nuqs'; +import { Icons } from '@/components/icons'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select'; +import type { CrmReportFilterMetadata } from '../api/types'; + +type FilterKey = + | 'dateFrom' + | 'dateTo' + | 'branch' + | 'productType' + | 'sales' + | 'customerOwner' + | 'leadSource'; + +const FILTER_LABELS: Record, string> = { + branch: 'Branch', + productType: 'Product Type', + sales: 'Salesman', + customerOwner: 'Customer Owner', + leadSource: 'Lead Source' +}; + +export function ReportFilterBar({ + filterMetadata, + filterKeys, + canExport, + reportCode, + pageLinks +}: { + filterMetadata: CrmReportFilterMetadata; + filterKeys: FilterKey[]; + canExport: boolean; + reportCode?: string; + pageLinks?: Array<{ href: string; label: string; active?: boolean }>; +}) { + const [params, setParams] = useQueryStates({ + dateFrom: parseAsString, + dateTo: parseAsString, + branch: parseAsString, + productType: parseAsString, + sales: parseAsString, + customerOwner: parseAsString, + leadSource: parseAsString + }); + + const exportQuery = useMemo(() => { + const searchParams = new URLSearchParams(); + + if (params.dateFrom) searchParams.set('dateFrom', params.dateFrom); + if (params.dateTo) searchParams.set('dateTo', params.dateTo); + if (params.branch) searchParams.set('branch', params.branch); + if (params.productType) searchParams.set('productType', params.productType); + if (params.sales) searchParams.set('sales', params.sales); + if (params.customerOwner) searchParams.set('customerOwner', params.customerOwner); + if (params.leadSource) searchParams.set('leadSource', params.leadSource); + if (reportCode) searchParams.set('reportCode', reportCode); + + return searchParams.toString(); + }, [params, reportCode]); + + const optionMap = { + branch: filterMetadata.branches.map((item) => ({ value: item.id, label: item.label })), + productType: filterMetadata.productTypes.map((item) => ({ value: item.id, label: item.label })), + sales: filterMetadata.sales.map((item) => ({ value: item.id, label: item.name })), + customerOwner: filterMetadata.customerOwners.map((item) => ({ value: item.id, label: item.name })), + leadSource: filterMetadata.leadSources.map((item) => ({ value: item.id, label: item.label })) + } as const; + + return ( +
+ {pageLinks?.length ? ( +
+ {pageLinks.map((link) => ( + + ))} +
+ ) : null} + +
+ {filterKeys.includes('dateFrom') ? ( + void setParams({ dateFrom: event.target.value || null })} + /> + ) : null} + {filterKeys.includes('dateTo') ? ( + void setParams({ dateTo: event.target.value || null })} + /> + ) : null} + {( + ['branch', 'productType', 'sales', 'customerOwner', 'leadSource'] as const + ).filter((key) => filterKeys.includes(key)).map((key) => ( + + ))} +
+ +
+ + {canExport && reportCode ? ( + <> + + + + ) : null} +
+
+ ); +} diff --git a/src/features/crm/reports/server/datasets/pipeline-report.dataset.ts b/src/features/crm/reports/server/datasets/pipeline-report.dataset.ts new file mode 100644 index 0000000..de874f4 --- /dev/null +++ b/src/features/crm/reports/server/datasets/pipeline-report.dataset.ts @@ -0,0 +1,262 @@ +import { and, asc, count, eq, inArray, isNull, sql } from 'drizzle-orm'; +import { + crmCustomers, + crmEnquiries, + crmEnquiryFollowups, + crmQuotations, + users +} from '@/db/schema'; +import { getUserBranches } from '@/features/foundation/branch-scope/service'; +import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service'; +import { hasBranchScopeAccess, hasProductScopeAccess } from '@/lib/auth/crm-access'; +import { db } from '@/lib/db'; +import type { CrmSharedReportFilters } from '../../api/types'; +import type { + ParsedCrmReportFilters, + PipelineDatasetRecord, + ResolvedReportContext +} from '../types'; + +function buildRequiredFilters(filters: CrmSharedReportFilters): ParsedCrmReportFilters { + return { + dateFrom: filters.dateFrom ?? null, + dateTo: filters.dateTo ?? null, + branch: filters.branch ?? null, + productType: filters.productType ?? null, + sales: filters.sales ?? null, + customer: filters.customer ?? null, + customerOwner: filters.customerOwner ?? null, + leadSource: filters.leadSource ?? null, + pipelineStage: filters.pipelineStage ?? null, + outcome: filters.outcome ?? null, + lostReason: filters.lostReason ?? null + }; +} + +function getDateRange(filters: ParsedCrmReportFilters) { + const start = filters.dateFrom ? new Date(`${filters.dateFrom}T00:00:00.000Z`) : null; + const end = filters.dateTo ? new Date(`${filters.dateTo}T23:59:59.999Z`) : null; + + return { start, end }; +} + +function matchesDateRange(value: Date, start: Date | null, end: Date | null) { + if (start && value < start) { + return false; + } + + if (end && value > end) { + return false; + } + + return true; +} + +function isOpenOutcome(stage: string) { + return stage !== 'closed_won' && stage !== 'closed_lost'; +} + +function canAccessRecord( + context: ResolvedReportContext, + row: { + branchId: string | null; + productType: string; + createdBy: string; + assignedToUserId: string | null; + } +) { + if (!hasBranchScopeAccess(context, row.branchId)) { + return false; + } + + if (!hasProductScopeAccess(context, row.productType)) { + return false; + } + + if (context.membershipRole === 'admin' || context.ownershipScope !== 'own') { + return true; + } + + return row.createdBy === context.userId || row.assignedToUserId === context.userId; +} + +export async function listPipelineReportDataset( + context: ResolvedReportContext, + filters: CrmSharedReportFilters +): Promise<{ + filters: ParsedCrmReportFilters; + rows: PipelineDatasetRecord[]; +}> { + const requiredFilters = buildRequiredFilters(filters); + const { start, end } = getDateRange(requiredFilters); + const [branches, productTypes, leadSources, enquiryRows, quotationRows, followupRows] = + await Promise.all([ + getUserBranches(), + getActiveOptionsByCategory('crm_product_type', { organizationId: context.organizationId }), + getActiveOptionsByCategory('crm_lead_channel', { organizationId: context.organizationId }), + db + .select({ + id: crmEnquiries.id, + code: crmEnquiries.code, + customerId: crmEnquiries.customerId, + branchId: crmEnquiries.branchId, + productType: crmEnquiries.productType, + leadChannel: crmEnquiries.leadChannel, + pipelineStage: crmEnquiries.pipelineStage, + createdAt: crmEnquiries.createdAt, + createdBy: crmEnquiries.createdBy, + assignedToUserId: crmEnquiries.assignedToUserId, + estimatedValue: crmEnquiries.estimatedValue, + poAmount: crmEnquiries.poAmount, + closedWonAt: crmEnquiries.closedWonAt, + closedLostAt: crmEnquiries.closedLostAt, + lostReason: crmEnquiries.lostReason, + customerName: crmCustomers.name, + customerOwnerUserId: crmCustomers.ownerUserId + }) + .from(crmEnquiries) + .innerJoin(crmCustomers, eq(crmCustomers.id, crmEnquiries.customerId)) + .where( + and( + eq(crmEnquiries.organizationId, context.organizationId), + isNull(crmEnquiries.deletedAt), + eq(crmEnquiries.isActive, true), + isNull(crmCustomers.deletedAt), + eq(crmCustomers.isActive, true) + ) + ) + .orderBy(asc(crmEnquiries.createdAt)), + db + .select({ + enquiryId: crmQuotations.enquiryId, + quotationCount: count(crmQuotations.id), + quotationValue: sql`coalesce(sum(${crmQuotations.totalAmount}), 0)` + }) + .from(crmQuotations) + .where( + and( + eq(crmQuotations.organizationId, context.organizationId), + isNull(crmQuotations.deletedAt), + eq(crmQuotations.isActive, true), + sql`${crmQuotations.enquiryId} is not null` + ) + ) + .groupBy(crmQuotations.enquiryId), + db + .select({ + enquiryId: crmEnquiryFollowups.enquiryId, + lastFollowupDate: sql`max(${crmEnquiryFollowups.followupDate})` + }) + .from(crmEnquiryFollowups) + .where( + and( + eq(crmEnquiryFollowups.organizationId, context.organizationId), + isNull(crmEnquiryFollowups.deletedAt) + ) + ) + .groupBy(crmEnquiryFollowups.enquiryId) + ]); + + const userIds = [ + ...new Set( + enquiryRows.flatMap((row) => [ + row.createdBy, + row.assignedToUserId, + row.customerOwnerUserId + ]).filter((value): value is string => Boolean(value)) + ) + ]; + const userRows = userIds.length + ? await db + .select({ id: users.id, name: users.name }) + .from(users) + .where(inArray(users.id, userIds)) + : []; + + const branchLabelMap = new Map(branches.map((item) => [item.id, item.name])); + const productTypeLabelMap = new Map(productTypes.map((item) => [item.id, item.label])); + const leadSourceLabelMap = new Map(leadSources.map((item) => [item.id, item.label])); + const quotationMap = new Map( + quotationRows + .filter((row): row is typeof row & { enquiryId: string } => typeof row.enquiryId === 'string') + .map((row) => [ + row.enquiryId, + { + quotationCount: Number(row.quotationCount ?? 0), + quotationValue: Number(row.quotationValue ?? 0) + } + ]) + ); + const followupMap = new Map(followupRows.map((row) => [row.enquiryId, row.lastFollowupDate])); + const userMap = new Map(userRows.map((row) => [row.id, row.name])); + + const rows = enquiryRows + .filter((row) => canAccessRecord(context, row)) + .filter((row) => matchesDateRange(row.createdAt, start, end)) + .filter((row) => (requiredFilters.branch ? row.branchId === requiredFilters.branch : true)) + .filter((row) => (requiredFilters.productType ? row.productType === requiredFilters.productType : true)) + .filter((row) => (requiredFilters.sales ? row.assignedToUserId === requiredFilters.sales : true)) + .filter((row) => (requiredFilters.customer ? row.customerId === requiredFilters.customer : true)) + .filter((row) => + requiredFilters.customerOwner ? row.customerOwnerUserId === requiredFilters.customerOwner : true + ) + .filter((row) => (requiredFilters.leadSource ? row.leadChannel === requiredFilters.leadSource : true)) + .filter((row) => (requiredFilters.pipelineStage ? row.pipelineStage === requiredFilters.pipelineStage : true)) + .filter((row) => { + if (!requiredFilters.outcome) { + return true; + } + + if (requiredFilters.outcome === 'won') { + return row.pipelineStage === 'closed_won'; + } + + if (requiredFilters.outcome === 'lost') { + return row.pipelineStage === 'closed_lost'; + } + + return isOpenOutcome(row.pipelineStage); + }) + .filter((row) => (requiredFilters.lostReason ? row.lostReason === requiredFilters.lostReason : true)) + .map((row) => { + const quotation = quotationMap.get(row.id); + + return { + enquiryId: row.id, + enquiryCode: row.code, + customerId: row.customerId, + customerName: row.customerName, + branchId: row.branchId, + branchLabel: row.branchId ? (branchLabelMap.get(row.branchId) ?? row.branchId) : '-', + productTypeId: row.productType, + productTypeLabel: productTypeLabelMap.get(row.productType) ?? row.productType ?? '-', + leadSourceId: row.leadChannel, + leadSourceLabel: row.leadChannel + ? (leadSourceLabelMap.get(row.leadChannel) ?? row.leadChannel) + : '-', + pipelineStage: row.pipelineStage, + createdAt: row.createdAt, + createdByUserId: row.createdBy, + createdByName: userMap.get(row.createdBy) ?? row.createdBy, + assignedToUserId: row.assignedToUserId, + assignedToName: row.assignedToUserId ? (userMap.get(row.assignedToUserId) ?? row.assignedToUserId) : null, + customerOwnerUserId: row.customerOwnerUserId, + customerOwnerName: row.customerOwnerUserId + ? (userMap.get(row.customerOwnerUserId) ?? row.customerOwnerUserId) + : '-', + quotationCount: quotation?.quotationCount ?? 0, + quotationValue: quotation?.quotationValue ?? 0, + estimatedValue: Number(row.estimatedValue ?? 0), + poAmount: Number(row.poAmount ?? 0), + lastFollowupDate: followupMap.get(row.id) ?? null, + closedWonAt: row.closedWonAt, + closedLostAt: row.closedLostAt, + lostReason: row.lostReason + }; + }); + + return { + filters: requiredFilters, + rows + }; +} diff --git a/src/features/crm/reports/server/exports/service.ts b/src/features/crm/reports/server/exports/service.ts index 884eb87..5e54d3c 100644 --- a/src/features/crm/reports/server/exports/service.ts +++ b/src/features/crm/reports/server/exports/service.ts @@ -28,17 +28,19 @@ export async function auditReportExport(input: { reportCode: string; format: 'csv' | 'xls'; filters?: Record; + recordCount?: number; }) { await auditAction({ organizationId: input.organizationId, userId: input.userId, entityType: 'crm_report', entityId: input.reportCode, - action: input.format === 'xls' ? 'export_excel' : 'export_csv', + action: input.format === 'xls' ? 'export_pipeline_report_excel' : 'export_pipeline_report_csv', afterData: { reportCode: input.reportCode, filters: input.filters ?? {}, - userId: input.userId + userId: input.userId, + recordCount: input.recordCount ?? 0 } }); } diff --git a/src/features/crm/reports/server/filters/shared.ts b/src/features/crm/reports/server/filters/shared.ts index c8540f5..c8e4fbb 100644 --- a/src/features/crm/reports/server/filters/shared.ts +++ b/src/features/crm/reports/server/filters/shared.ts @@ -4,7 +4,7 @@ import { db } from '@/lib/db'; import { getUserBranches } from '@/features/foundation/branch-scope/service'; import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service'; import { hasBranchScopeAccess, hasProductScopeAccess } from '@/lib/auth/crm-access'; -import type { CrmReportFilterMetadata } from '../../api/types'; +import type { CrmReportFilterMetadata, CrmSharedReportFilters } from '../../api/types'; import type { ResolvedReportContext } from '../types'; const PIPELINE_STAGE_OPTIONS = [ @@ -69,3 +69,19 @@ export async function buildSharedReportFilters( lostReasons: lostReasons.map((item) => ({ id: item.id, code: item.code, label: item.label })) }; } + +export function parseSharedReportFilters(searchParams: URLSearchParams): CrmSharedReportFilters { + return { + dateFrom: searchParams.get('dateFrom'), + dateTo: searchParams.get('dateTo'), + branch: searchParams.get('branch'), + productType: searchParams.get('productType'), + sales: searchParams.get('sales'), + customer: searchParams.get('customer'), + customerOwner: searchParams.get('customerOwner'), + leadSource: searchParams.get('leadSource'), + pipelineStage: searchParams.get('pipelineStage'), + outcome: searchParams.get('outcome'), + lostReason: searchParams.get('lostReason') + }; +} diff --git a/src/features/crm/reports/server/service.ts b/src/features/crm/reports/server/service.ts index dc3bf1d..52179eb 100644 --- a/src/features/crm/reports/server/service.ts +++ b/src/features/crm/reports/server/service.ts @@ -1,30 +1,357 @@ import { auditAction } from '@/features/foundation/audit-log/service'; +import { canViewQuotationPricing } from '@/lib/auth/crm-access'; import type { CrmReportCatalogResponse, CrmReportFiltersResponse, - CrmReportsResponse + CrmReportsResponse, + CrmSharedReportFilters, + CrmPipelineReportResponse, + CrmLeadAgingReportResponse, + CrmEnquiryAgingReportResponse } from '../api/types'; import { buildReportCatalogGroups } from './builders/catalog-builder'; import { resolveCrmAccess } from './context'; import { listReportRegistry } from './datasets/catalog'; +import { listPipelineReportDataset } from './datasets/pipeline-report.dataset'; import { buildSharedReportFilters } from './filters/shared'; -import type { ResolvedReportContext } from './types'; +import type { ParsedCrmReportFilters, PipelineDatasetRecord, ResolvedReportContext } from './types'; export function buildResolvedReportContext(input: Parameters[0]) { return resolveCrmAccess(input); } -async function auditReportView(context: ResolvedReportContext, reportCode: string, filters?: Record) { +function getDayDifference(start: Date, end: Date) { + const milliseconds = end.getTime() - start.getTime(); + return Math.max(0, Math.floor(milliseconds / 86_400_000)); +} + +function toPercent(numerator: number, denominator: number) { + if (denominator <= 0) { + return 0; + } + + return Number(((numerator / denominator) * 100).toFixed(2)); +} + +function average(values: number[]) { + if (!values.length) { + return 0; + } + + return Number((values.reduce((sum, value) => sum + value, 0) / values.length).toFixed(2)); +} + +function sumValues(items: PipelineDatasetRecord[], selector: (row: PipelineDatasetRecord) => number) { + return items.reduce((sum, row) => sum + selector(row), 0); +} + +function isConvertedLead(row: PipelineDatasetRecord) { + return row.pipelineStage !== 'lead' || Boolean(row.assignedToUserId); +} + +function isOpenEnquiry(row: PipelineDatasetRecord) { + return row.pipelineStage === 'enquiry'; +} + +function buildLeadPipelineRows(rows: PipelineDatasetRecord[]) { + const grouped = new Map(); + + for (const row of rows) { + const key = [ + row.leadSourceLabel, + row.customerOwnerName, + row.createdByName, + row.branchLabel, + row.productTypeLabel + ].join('|'); + const current = + grouped.get(key) ?? + { + leadSourceLabel: row.leadSourceLabel, + customerOwnerName: row.customerOwnerName, + createdByName: row.createdByName, + branchLabel: row.branchLabel, + productTypeLabel: row.productTypeLabel, + totalLeads: 0, + newLeads: 0, + assignedLeads: 0, + unassignedLeads: 0, + convertedLeads: 0 + }; + + current.totalLeads += 1; + current.newLeads += 1; + + if (row.assignedToUserId) { + current.assignedLeads += 1; + } else { + current.unassignedLeads += 1; + } + + if (isConvertedLead(row)) { + current.convertedLeads += 1; + } + + grouped.set(key, current); + } + + return [...grouped.values()].toSorted((left, right) => right.totalLeads - left.totalLeads); +} + +function buildEnquiryPipelineRows(rows: PipelineDatasetRecord[]) { + const grouped = new Map(); + + for (const row of rows) { + const key = [ + row.assignedToName ?? 'Unassigned', + row.branchLabel, + row.productTypeLabel, + row.customerOwnerName + ].join('|'); + const current = + grouped.get(key) ?? + { + salesmanName: row.assignedToName ?? 'Unassigned', + branchLabel: row.branchLabel, + productTypeLabel: row.productTypeLabel, + customerOwnerName: row.customerOwnerName, + openEnquiries: 0, + convertedToQuotation: 0, + won: 0, + lost: 0 + }; + + if (isOpenEnquiry(row)) { + current.openEnquiries += 1; + } + + if (row.quotationCount > 0) { + current.convertedToQuotation += 1; + } + + if (row.pipelineStage === 'closed_won') { + current.won += 1; + } + + if (row.pipelineStage === 'closed_lost') { + current.lost += 1; + } + + grouped.set(key, current); + } + + return [...grouped.values()].toSorted( + (left, right) => + right.openEnquiries + right.convertedToQuotation + right.won + right.lost - + (left.openEnquiries + left.convertedToQuotation + left.won + left.lost) + ); +} + +function buildLeadConversionRows(rows: PipelineDatasetRecord[]) { + const grouped = new Map(); + + for (const row of rows) { + const key = [ + row.leadSourceLabel, + row.branchLabel, + row.productTypeLabel, + row.createdByName + ].join('|'); + const current = + grouped.get(key) ?? + { + leadSourceLabel: row.leadSourceLabel, + branchLabel: row.branchLabel, + productTypeLabel: row.productTypeLabel, + marketingUserName: row.createdByName, + totalLeads: 0, + convertedEnquiries: 0, + conversionRate: 0 + }; + + current.totalLeads += 1; + if (isConvertedLead(row)) { + current.convertedEnquiries += 1; + } + + grouped.set(key, current); + } + + return [...grouped.values()] + .map((row) => ({ + ...row, + conversionRate: toPercent(row.convertedEnquiries, row.totalLeads) + })) + .toSorted((left, right) => right.totalLeads - left.totalLeads); +} + +function buildEnquiryConversionRows(rows: PipelineDatasetRecord[]) { + const enquiryRows = rows.filter((row) => row.pipelineStage !== 'lead' || row.assignedToUserId); + const grouped = new Map(); + + for (const row of enquiryRows) { + const key = [(row.assignedToName ?? 'Unassigned'), row.branchLabel, row.productTypeLabel].join('|'); + const current = + grouped.get(key) ?? + { + salesmanName: row.assignedToName ?? 'Unassigned', + branchLabel: row.branchLabel, + productTypeLabel: row.productTypeLabel, + totalEnquiries: 0, + enquiriesWithQuotation: 0, + conversionRate: 0 + }; + + current.totalEnquiries += 1; + if (row.quotationCount > 0) { + current.enquiriesWithQuotation += 1; + } + + grouped.set(key, current); + } + + return [...grouped.values()] + .map((row) => ({ + ...row, + conversionRate: toPercent(row.enquiriesWithQuotation, row.totalEnquiries) + })) + .toSorted((left, right) => right.totalEnquiries - left.totalEnquiries); +} + +function buildFunnelRows(rows: PipelineDatasetRecord[], canViewRevenueValues: boolean) { + const leadCount = rows.length; + const enquiryRows = rows.filter((row) => isConvertedLead(row)); + const quotationRows = rows.filter((row) => row.quotationCount > 0); + const wonRows = rows.filter((row) => row.pipelineStage === 'closed_won'); + const lostRows = rows.filter((row) => row.pipelineStage === 'closed_lost'); + + const valueOrNull = (value: number) => (canViewRevenueValues ? value : null); + + return [ + { + stageKey: 'lead' as const, + stageLabel: 'Lead', + count: leadCount, + value: valueOrNull(sumValues(rows, (row) => row.estimatedValue)), + conversionRate: null + }, + { + stageKey: 'enquiry' as const, + stageLabel: 'Enquiry', + count: enquiryRows.length, + value: valueOrNull(sumValues(enquiryRows, (row) => row.estimatedValue)), + conversionRate: toPercent(enquiryRows.length, leadCount) + }, + { + stageKey: 'quotation' as const, + stageLabel: 'Quotation', + count: quotationRows.length, + value: valueOrNull(sumValues(quotationRows, (row) => row.quotationValue)), + conversionRate: toPercent(quotationRows.length, enquiryRows.length) + }, + { + stageKey: 'closed_won' as const, + stageLabel: 'Closed Won', + count: wonRows.length, + value: valueOrNull(sumValues(wonRows, (row) => row.poAmount || row.quotationValue || row.estimatedValue)), + conversionRate: toPercent(wonRows.length, quotationRows.length) + }, + { + stageKey: 'closed_lost' as const, + stageLabel: 'Closed Lost', + count: lostRows.length, + value: valueOrNull(sumValues(lostRows, (row) => row.estimatedValue || row.quotationValue)), + conversionRate: toPercent(lostRows.length, quotationRows.length) + } + ]; +} + +function buildLeadAging(rows: PipelineDatasetRecord[], filters: ParsedCrmReportFilters): CrmLeadAgingReportResponse { + const now = new Date(); + const leadRows = rows + .filter((row) => row.pipelineStage === 'lead') + .map((row) => ({ + leadId: row.enquiryId, + leadCode: row.enquiryCode, + customerName: row.customerName, + assignedUserName: row.assignedToName, + createdDate: row.createdAt.toISOString(), + agingDays: getDayDifference(row.createdAt, now) + })) + .toSorted((left, right) => right.agingDays - left.agingDays); + const ages = leadRows.map((row) => row.agingDays); + + return { + success: true, + time: new Date().toISOString(), + message: 'CRM lead aging report loaded successfully', + filters, + summary: { + leadCount: leadRows.length, + averageAgingDays: average(ages), + maximumAgingDays: ages.length ? Math.max(...ages) : 0 + }, + buckets: [ + { key: '0_7', label: '0-7 Days', count: leadRows.filter((row) => row.agingDays <= 7).length }, + { key: '8_14', label: '8-14 Days', count: leadRows.filter((row) => row.agingDays >= 8 && row.agingDays <= 14).length }, + { key: '15_30', label: '15-30 Days', count: leadRows.filter((row) => row.agingDays >= 15 && row.agingDays <= 30).length }, + { key: '31_60', label: '31-60 Days', count: leadRows.filter((row) => row.agingDays >= 31 && row.agingDays <= 60).length }, + { key: '60_plus', label: '60+ Days', count: leadRows.filter((row) => row.agingDays > 60).length } + ], + rows: leadRows + }; +} + +function buildEnquiryAging( + rows: PipelineDatasetRecord[], + filters: ParsedCrmReportFilters +): CrmEnquiryAgingReportResponse { + const now = new Date(); + const enquiryRows = rows + .filter((row) => row.pipelineStage === 'enquiry') + .map((row) => ({ + enquiryId: row.enquiryId, + enquiryCode: row.enquiryCode, + customerName: row.customerName, + salesmanName: row.assignedToName, + lastFollowupDate: row.lastFollowupDate?.toISOString() ?? null, + agingDays: getDayDifference(row.createdAt, now) + })) + .toSorted((left, right) => right.agingDays - left.agingDays); + const ages = enquiryRows.map((row) => row.agingDays); + + return { + success: true, + time: new Date().toISOString(), + message: 'CRM enquiry aging report loaded successfully', + filters, + summary: { + openEnquiries: enquiryRows.length, + averageAgingDays: average(ages), + maximumAgingDays: ages.length ? Math.max(...ages) : 0 + }, + buckets: [ + { key: '0_14', label: '0-14 Days', count: enquiryRows.filter((row) => row.agingDays <= 14).length }, + { key: '15_30', label: '15-30 Days', count: enquiryRows.filter((row) => row.agingDays >= 15 && row.agingDays <= 30).length }, + { key: '31_60', label: '31-60 Days', count: enquiryRows.filter((row) => row.agingDays >= 31 && row.agingDays <= 60).length }, + { key: '60_plus', label: '60+ Days', count: enquiryRows.filter((row) => row.agingDays > 60).length } + ], + rows: enquiryRows + }; +} + +async function auditReportView(context: ResolvedReportContext, reportCode: string, filters?: Record, recordCount?: number) { await auditAction({ organizationId: context.organizationId, userId: context.userId, entityType: 'crm_report', entityId: reportCode, - action: 'view', + action: 'view_pipeline_report', afterData: { reportCode, filters: filters ?? {}, - userId: context.userId + userId: context.userId, + recordCount: recordCount ?? 0 } }); } @@ -76,3 +403,58 @@ export async function getCrmReportFiltersResponse( filters }; } + +export async function getCrmPipelineReportResponse( + context: ResolvedReportContext, + filters: CrmSharedReportFilters +): Promise { + const dataset = await listPipelineReportDataset(context, filters); + const canViewRevenueValues = canViewQuotationPricing(context); + const response: CrmPipelineReportResponse = { + success: true, + time: new Date().toISOString(), + message: 'CRM pipeline report loaded successfully', + filters: dataset.filters, + summary: { + totalLeads: dataset.rows.length, + openEnquiries: dataset.rows.filter((row) => row.pipelineStage === 'enquiry').length, + quotations: dataset.rows.filter((row) => row.quotationCount > 0).length, + won: dataset.rows.filter((row) => row.pipelineStage === 'closed_won').length, + lost: dataset.rows.filter((row) => row.pipelineStage === 'closed_lost').length, + canViewRevenueValues + }, + leadPipeline: buildLeadPipelineRows(dataset.rows), + enquiryPipeline: buildEnquiryPipelineRows(dataset.rows), + leadConversion: buildLeadConversionRows(dataset.rows), + enquiryConversion: buildEnquiryConversionRows(dataset.rows), + funnel: buildFunnelRows(dataset.rows, canViewRevenueValues) + }; + + await auditReportView(context, 'pipeline_suite', { ...dataset.filters }, dataset.rows.length); + + return response; +} + +export async function getCrmLeadAgingReportResponse( + context: ResolvedReportContext, + filters: CrmSharedReportFilters +): Promise { + const dataset = await listPipelineReportDataset(context, filters); + const response = buildLeadAging(dataset.rows, dataset.filters); + + await auditReportView(context, 'lead_aging', { ...dataset.filters }, response.rows.length); + + return response; +} + +export async function getCrmEnquiryAgingReportResponse( + context: ResolvedReportContext, + filters: CrmSharedReportFilters +): Promise { + const dataset = await listPipelineReportDataset(context, filters); + const response = buildEnquiryAging(dataset.rows, dataset.filters); + + await auditReportView(context, 'enquiry_aging', { ...dataset.filters }, response.rows.length); + + return response; +} diff --git a/src/features/crm/reports/server/types.ts b/src/features/crm/reports/server/types.ts index 42f888f..358fd27 100644 --- a/src/features/crm/reports/server/types.ts +++ b/src/features/crm/reports/server/types.ts @@ -23,3 +23,46 @@ export interface ReportRegistryRow { isSystem: boolean; isActive: boolean; } + +export interface ParsedCrmReportFilters { + dateFrom: string | null; + dateTo: string | null; + branch: string | null; + productType: string | null; + sales: string | null; + customer: string | null; + customerOwner: string | null; + leadSource: string | null; + pipelineStage: string | null; + outcome: string | null; + lostReason: string | null; +} + +export interface PipelineDatasetRecord { + enquiryId: string; + enquiryCode: string; + customerId: string; + customerName: string; + branchId: string | null; + branchLabel: string; + productTypeId: string | null; + productTypeLabel: string; + leadSourceId: string | null; + leadSourceLabel: string; + pipelineStage: string; + createdAt: Date; + createdByUserId: string; + createdByName: string; + assignedToUserId: string | null; + assignedToName: string | null; + customerOwnerUserId: string | null; + customerOwnerName: string; + quotationCount: number; + quotationValue: number; + estimatedValue: number; + poAmount: number; + lastFollowupDate: Date | null; + closedWonAt: Date | null; + closedLostAt: Date | null; + lostReason: string | null; +}