diff --git a/docs/adr/0016-won-lost-lifecycle-governance.md b/docs/adr/0016-won-lost-lifecycle-governance.md new file mode 100644 index 0000000..f4e0e60 --- /dev/null +++ b/docs/adr/0016-won-lost-lifecycle-governance.md @@ -0,0 +1,58 @@ +# ADR 0016: Won / Lost Lifecycle Governance + +## Status + +Accepted + +## Context + +CRM dashboard and reporting previously treated quotation acceptance and rejection as proxies for opportunity outcomes. That made business outcome data unstable because: + +- quotation approval/acceptance does not equal PO received +- lost opportunities could be inferred without a required lost reason +- reopen rules were undefined +- revenue and win/loss KPI logic depended on quotation status instead of the enquiry lifecycle + +Business Discovery 3.0 froze the production rule set: + +- `closed_won` means PO received +- `closed_lost` requires a lost reason +- won cannot reopen +- lost can reopen with audit +- won revenue prefers PO amount and falls back to quotation total +- lost revenue uses quotation total + +## Decision + +We use `crm_enquiries` as the official business outcome record. + +We add outcome fields on `crm_enquiries`: + +- `closed_won_at` +- `closed_lost_at` +- `closed_by_user_id` +- `po_number` +- `po_date` +- `po_amount` +- `po_currency` +- `lost_reason` +- `lost_competitor` +- `lost_remark` + +We add `crm_enquiry_attachments` for purchase order files with category `purchase_order`. + +All lifecycle transitions must go through the enquiry outcome service layer: + +- `markEnquiryAsWon()` +- `markEnquiryAsLost()` +- `reopenLostEnquiry()` + +Quotation status is no longer allowed to mutate enquiry outcome automatically. + +## Consequences + +- dashboard won/lost KPI must read enquiry `pipeline_stage` +- marketing can see outcome state but PO amount remains server-side restricted +- PO attachment access follows commercial-data visibility rules +- audit coverage expands to `mark_won`, `mark_lost`, `reopen`, and `upload_po` +- downstream reporting can reuse a stable outcome model for Task K diff --git a/docs/implementation/task-d4-won-lost-lifecycle-governance.md b/docs/implementation/task-d4-won-lost-lifecycle-governance.md new file mode 100644 index 0000000..d1bd3e1 --- /dev/null +++ b/docs/implementation/task-d4-won-lost-lifecycle-governance.md @@ -0,0 +1,41 @@ +# Task D.4 Implementation Summary + +## Completed + +- extended `crm_enquiries` with official won/lost lifecycle fields +- added `crm_enquiry_attachments` for purchase order files +- seeded `crm_lost_reason` master data +- added permissions: + - `crm.enquiry.mark_won` + - `crm.enquiry.mark_lost` + - `crm.enquiry.reopen` +- added outcome service operations for: + - mark won + - mark lost + - reopen lost opportunity +- added API routes for outcome transitions and PO attachment upload/view/download +- added enquiry detail outcome card with: + - open / won / lost state + - PO data + - lost data + - closed by / closed date + - mark won / mark lost / reopen actions + - PO attachment upload and retrieval +- removed quotation-status-driven mutation of enquiry pipeline outcome +- migrated dashboard summary/funnel/sales won revenue to enquiry outcome helpers +- added reporting helpers: + - `getWonRevenue()` + - `getLostRevenue()` + - `getLostByReason()` + - `getLostByCompetitor()` + - `getWinRate()` + +## Verification + +- `npm exec tsc --noEmit` + +## Notes + +- marketing users still receive outcome state, but PO amount is redacted server-side unless commercial pricing visibility exists +- PO attachment endpoints are also restricted to commercial-data viewers +- revenue analytics cards still use existing quotation-attribution views for top-party ranking, but won/lost headline KPI and sales won revenue now follow the D.4 lifecycle rule diff --git a/drizzle/0016_won_lost_lifecycle_governance.sql b/drizzle/0016_won_lost_lifecycle_governance.sql new file mode 100644 index 0000000..2a9ab23 --- /dev/null +++ b/drizzle/0016_won_lost_lifecycle_governance.sql @@ -0,0 +1,30 @@ +ALTER TABLE "crm_enquiries" + ADD COLUMN IF NOT EXISTS "closed_won_at" timestamp with time zone, + ADD COLUMN IF NOT EXISTS "closed_lost_at" timestamp with time zone, + ADD COLUMN IF NOT EXISTS "closed_by_user_id" text, + ADD COLUMN IF NOT EXISTS "po_number" text, + ADD COLUMN IF NOT EXISTS "po_date" timestamp with time zone, + ADD COLUMN IF NOT EXISTS "po_amount" double precision, + ADD COLUMN IF NOT EXISTS "po_currency" text, + ADD COLUMN IF NOT EXISTS "lost_reason" text, + ADD COLUMN IF NOT EXISTS "lost_competitor" text, + ADD COLUMN IF NOT EXISTS "lost_remark" text; + +CREATE TABLE IF NOT EXISTS "crm_enquiry_attachments" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "enquiry_id" text NOT NULL, + "category" text NOT NULL, + "file_name" text NOT NULL, + "original_file_name" text NOT NULL, + "storage_provider" text NOT NULL, + "storage_key" text NOT NULL, + "file_size" integer, + "file_type" text, + "description" text, + "uploaded_at" timestamp with time zone DEFAULT now() NOT NULL, + "uploaded_by" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "deleted_at" timestamp with time zone +); diff --git a/drizzle/0017_short_swordsman.sql b/drizzle/0017_short_swordsman.sql new file mode 100644 index 0000000..9007356 --- /dev/null +++ b/drizzle/0017_short_swordsman.sql @@ -0,0 +1,61 @@ +CREATE TABLE "crm_contact_shares" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "contact_id" text NOT NULL, + "shared_to_user_id" text NOT NULL, + "shared_by_user_id" text NOT NULL, + "shared_at" timestamp with time zone DEFAULT now() NOT NULL, + "is_active" boolean DEFAULT true NOT NULL, + "remark" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "deleted_at" timestamp with time zone +); +--> statement-breakpoint +CREATE TABLE "crm_customer_owner_history" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "customer_id" text NOT NULL, + "old_owner_user_id" text, + "new_owner_user_id" text, + "changed_by" text NOT NULL, + "changed_at" timestamp with time zone DEFAULT now() NOT NULL, + "remark" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "deleted_at" timestamp with time zone +); +--> statement-breakpoint +CREATE TABLE "crm_enquiry_attachments" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "enquiry_id" text NOT NULL, + "category" text NOT NULL, + "file_name" text NOT NULL, + "original_file_name" text NOT NULL, + "storage_provider" text NOT NULL, + "storage_key" text NOT NULL, + "file_size" integer, + "file_type" text, + "description" text, + "uploaded_at" timestamp with time zone DEFAULT now() NOT NULL, + "uploaded_by" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "deleted_at" timestamp with time zone +); +--> statement-breakpoint +ALTER TABLE "crm_customers" ADD COLUMN "owner_user_id" text;--> statement-breakpoint +ALTER TABLE "crm_customers" ADD COLUMN "owner_assigned_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "crm_customers" ADD COLUMN "owner_assigned_by" text;--> statement-breakpoint +ALTER TABLE "crm_enquiries" ADD COLUMN "closed_won_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "crm_enquiries" ADD COLUMN "closed_lost_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "crm_enquiries" ADD COLUMN "closed_by_user_id" text;--> statement-breakpoint +ALTER TABLE "crm_enquiries" ADD COLUMN "po_number" text;--> statement-breakpoint +ALTER TABLE "crm_enquiries" ADD COLUMN "po_date" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "crm_enquiries" ADD COLUMN "po_amount" double precision;--> statement-breakpoint +ALTER TABLE "crm_enquiries" ADD COLUMN "po_currency" text;--> statement-breakpoint +ALTER TABLE "crm_enquiries" ADD COLUMN "lost_reason" text;--> statement-breakpoint +ALTER TABLE "crm_enquiries" ADD COLUMN "lost_competitor" text;--> statement-breakpoint +ALTER TABLE "crm_enquiries" ADD COLUMN "lost_remark" text;--> statement-breakpoint +CREATE UNIQUE INDEX "crm_contact_shares_org_contact_shared_user_idx" ON "crm_contact_shares" USING btree ("organization_id","contact_id","shared_to_user_id"); \ No newline at end of file diff --git a/drizzle/meta/0017_snapshot.json b/drizzle/meta/0017_snapshot.json new file mode 100644 index 0000000..2be826c --- /dev/null +++ b/drizzle/meta/0017_snapshot.json @@ -0,0 +1,3963 @@ +{ + "id": "665cc7e6-1576-4132-9f6b-cba29531e089", + "prevId": "0d1df50e-da6b-49f3-81ac-207230784d7d", + "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_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 78bc111..e618ec9 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -113,6 +113,20 @@ "when": 1782549600000, "tag": "0015_customer_ownership_contact_shares", "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1782795600000, + "tag": "0016_won_lost_lifecycle_governance", + "breakpoints": true + }, + { + "idx": 17, + "version": "7", + "when": 1782121929212, + "tag": "0017_short_swordsman", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/plans/task-d.4.md b/plans/task-d.4.md new file mode 100644 index 0000000..c888ca3 --- /dev/null +++ b/plans/task-d.4.md @@ -0,0 +1,710 @@ +# Task D.4: Won / Lost Lifecycle Governance + +## Objective + +Formalize CRM opportunity outcomes by introducing production-grade: + +* Closed Won lifecycle +* Closed Lost lifecycle +* PO tracking +* Lost reason governance +* Outcome analytics foundation + +This task replaces temporary dashboard assumptions and becomes the official business outcome model for CRM. + +--- + +# Background + +Business Discovery 3.0 has frozen: + +## Closed Won + +Definition: + +```txt +PO Received +``` + +Only a received Purchase Order can move an enquiry to: + +```txt +closed_won +``` + +Quotation approval or quotation acceptance alone does not create a Won outcome. + +--- + +## Closed Lost + +Definition: + +```txt +Sales opportunity permanently lost +``` + +Lost requires: + +```txt +Lost Reason +``` + +Optional: + +```txt +Lost Competitor +Lost Remark +``` + +--- + +# Scope D4.1 Outcome Schema + +Extend: + +```txt +crm_enquiries +``` + +Add: + +```txt +closedWonAt +closedLostAt + +closedByUserId + +poNumber +poDate +poAmount +poCurrency + +lostReason +lostCompetitor +lostRemark +``` + +Rules: + +```txt +Won +OR +Lost +``` + +never both. + +--- + +# Scope D4.2 Lost Reason Master Data + +Add master option category: + +```txt +crm_lost_reason +``` + +Seed: + +```txt +competitor_won +budget_not_approved +project_cancelled +customer_no_response +technical_requirement_mismatch +price_too_high +timeline_not_fit +other +``` + +Rules: + +```txt +Lost Reason +``` + +required when closing lost. + +--- + +# Scope D4.3 Outcome Service Layer + +Create centralized lifecycle service. + +Supported actions: + +```txt +markWon() +markLost() +reopenOpportunity() +``` + +All outcome transitions must go through service layer. + +No direct status updates. + +--- + +# Scope D4.4 Mark Won Workflow + +Action: + +```txt +Mark Won +``` + +Available only when: + +```txt +pipelineStage = enquiry +``` + +Required fields: + +```txt +PO Number +PO Date +``` + +Optional: + +```txt +PO Amount +PO Currency +PO Attachment +Remark +``` + +System behavior: + +```txt +pipelineStage = closed_won + +closedWonAt = now +closedByUserId = current user +``` + +Audit: + +```txt +crm_enquiry + +action = mark_won +``` + +--- + +# Scope D4.5 PO Attachment + +Add attachment support. + +Allowed: + +```txt +PDF +Image +Excel +``` + +Relationship: + +```txt +crm_enquiry +``` + +Attachment category: + +```txt +purchase_order +``` + +UI: + +```txt +Upload PO +View PO +Download PO +``` + +--- + +# Scope D4.6 Mark Lost Workflow + +Action: + +```txt +Mark Lost +``` + +Available only when: + +```txt +pipelineStage = enquiry +``` + +Required: + +```txt +Lost Reason +``` + +Optional: + +```txt +Lost Competitor +Lost Remark +``` + +System behavior: + +```txt +pipelineStage = closed_lost + +closedLostAt = now +closedByUserId = current user +``` + +Audit: + +```txt +crm_enquiry + +action = mark_lost +``` + +--- + +# Scope D4.7 Reopen Opportunity + +Allow controlled reopen. + +Available for: + +```txt +closed_lost +``` + +Only. + +Not allowed for: + +```txt +closed_won +``` + +Reason: + +```txt +Won must remain final once PO exists. +``` + +Required: + +```txt +Reopen Reason +``` + +Audit: + +```txt +action = reopen +``` + +--- + +# Scope D4.8 UI Changes + +Enquiry Detail + +Add: + +```txt +Outcome +``` + +Section. + +Display: + +```txt +Won +Lost +Open +``` + +Fields: + +```txt +PO Information +Lost Information +Closed By +Closed Date +``` + +Actions: + +```txt +Mark Won +Mark Lost +Reopen +``` + +--- + +# Scope D4.9 Dashboard KPI Migration + +Replace temporary logic. + +Current: + +```txt +quotation accepted +``` + +Proxy. + +New: + +```txt +pipelineStage = closed_won +``` + +Official. + +--- + +Update: + +## Won Count + +```txt +closed_won +``` + +--- + +## Lost Count + +```txt +closed_lost +``` + +--- + +## Win Rate + +```txt +Won / (Won + Lost) +``` + +--- + +## Lost Analysis + +```txt +Lost By Reason +Lost By Competitor +Lost By Sales +Lost By Product Type +Lost By Branch +``` + +--- + +# Scope D4.10 Revenue Rules + +Freeze: + +## Won Revenue + +Priority: + +```txt +PO Amount +``` + +If available. + +Fallback: + +```txt +Quotation Total +``` + +if PO Amount is empty. + +--- + +Lost Revenue: + +```txt +Quotation Total +``` + +for reporting opportunity loss. + +--- + +# Scope D4.11 Report Foundation + +Prepare data for Task K. + +Add reusable reporting helpers: + +```txt +getWonRevenue() +getLostRevenue() + +getLostByReason() + +getLostByCompetitor() + +getWinRate() +``` + +Organization scoped. + +--- + +# Scope D4.12 Permissions + +Add: + +```txt +crm.enquiry.mark_won +crm.enquiry.mark_lost +crm.enquiry.reopen +``` + +Default: + +```txt +sales +sales_manager +department_manager +crm_admin +``` + +Not granted: + +```txt +marketing +``` + +--- + +# Scope D4.13 Security + +Marketing may see: + +```txt +Won +Lost +Outcome Summary +``` + +Marketing may not see: + +```txt +PO Amount +Revenue +Quotation Pricing +``` + +Enforce server-side. + +--- + +# Scope D4.14 Audit + +Entity: + +```txt +crm_enquiry +``` + +Actions: + +```txt +mark_won +mark_lost +reopen +upload_po +``` + +Payload: + +```txt +poNumber +poAmount + +lostReason +lostCompetitor + +closedBy +closedAt +``` + +--- + +# Scope D4.15 Verification + +Scenario 1 + +```txt +Enquiry +→ Mark Won +``` + +Expected: + +```txt +closed_won +PO stored +audit created +``` + +--- + +Scenario 2 + +```txt +Enquiry +→ Mark Lost +``` + +Expected: + +```txt +closed_lost +lost reason stored +audit created +``` + +--- + +Scenario 3 + +```txt +Lost +→ Reopen +``` + +Expected: + +```txt +enquiry +reopen audit created +``` + +--- + +Scenario 4 + +```txt +Won +→ Reopen +``` + +Expected: + +```txt +blocked +``` + +--- + +Scenario 5 + +Marketing User + +Expected: + +```txt +see outcome +cannot see PO amount +``` + +--- + +# Documentation + +Create: + +```txt +docs/adr/0016-won-lost-lifecycle-governance.md +docs/implementation/task-d4-won-lost-lifecycle-governance.md +``` + +Freeze: + +```txt +PO = Won +Lost Reason required +Won cannot reopen +Lost can reopen +Revenue attribution rule +Win/Loss KPI rule +``` + +--- + +# Explicit Non-Scope + +Do NOT implement: + +```txt +Sales Order +Project Handover +Invoice +Delivery Note +ERP Integration +Forecasting +Probability Scoring +``` + +--- + +# Deliverables + +1. Won Lifecycle +2. Lost Lifecycle +3. PO Tracking +4. Lost Reason Governance +5. Outcome Analytics Foundation +6. Dashboard KPI Migration +7. Security Rules +8. Audit Coverage +9. ADR 0016 + +--- + +# Definition of Done + +Task D.4 is complete when: + +* PO creates Closed Won +* Lost requires Lost Reason +* Won/Lost KPIs use pipelineStage +* Lost analytics exist +* Revenue rules are frozen +* Marketing visibility restrictions are enforced +* Audit logs exist +* ADR 0016 exists + +Result: + +```txt +Lead +→ Enquiry +→ Quotation +→ Closed Won (PO) + +Lead +→ Enquiry +→ Quotation +→ Closed Lost +``` + +becomes the official CRM business outcome lifecycle. diff --git a/src/app/api/crm/enquiries/[id]/assign/route.ts b/src/app/api/crm/enquiries/[id]/assign/route.ts index 21ce562..6b0eae5 100644 --- a/src/app/api/crm/enquiries/[id]/assign/route.ts +++ b/src/app/api/crm/enquiries/[id]/assign/route.ts @@ -22,6 +22,7 @@ export async function POST(request: NextRequest, { params }: Params) { userId: session.user.id, membershipRole: membership.role, businessRole: membership.businessRole, + permissions: membership.permissions ?? [], branchScopeIds: membership.branchScopeIds ?? [], productTypeScopeIds: membership.productTypeScopeIds ?? [], ownershipScope: membership.ownershipScope ?? 'organization', diff --git a/src/app/api/crm/enquiries/[id]/mark-lost/route.ts b/src/app/api/crm/enquiries/[id]/mark-lost/route.ts new file mode 100644 index 0000000..b65f402 --- /dev/null +++ b/src/app/api/crm/enquiries/[id]/mark-lost/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { + enquiryMarkLostSchema +} from '@/features/crm/enquiries/schemas/enquiry.schema'; +import { getEnquiryDetail, markEnquiryAsLost } from '@/features/crm/enquiries/server/service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +type Params = { + params: Promise<{ id: string }>; +}; + +export async function POST(request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session, membership } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmEnquiryMarkLost + }); + const payload = enquiryMarkLostSchema.parse(await request.json()); + const accessContext = { + organizationId: organization.id, + userId: session.user.id, + membershipRole: membership.role, + businessRole: membership.businessRole, + permissions: membership.permissions ?? [], + branchScopeIds: membership.branchScopeIds ?? [], + productTypeScopeIds: membership.productTypeScopeIds ?? [], + ownershipScope: membership.ownershipScope ?? 'organization', + branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all', + productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all' + }; + const before = await getEnquiryDetail(id, organization.id, accessContext); + const updated = await markEnquiryAsLost(id, organization.id, session.user.id, payload, accessContext); + const after = await getEnquiryDetail(id, organization.id, accessContext); + + return NextResponse.json({ + success: true, + message: 'Enquiry marked as lost successfully', + before, + enquiry: updated, + after + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof z.ZodError) { + return NextResponse.json( + { message: error.issues[0]?.message ?? 'Invalid payload' }, + { status: 400 } + ); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json({ message: 'Unable to mark enquiry as lost' }, { status: 500 }); + } +} diff --git a/src/app/api/crm/enquiries/[id]/mark-won/route.ts b/src/app/api/crm/enquiries/[id]/mark-won/route.ts new file mode 100644 index 0000000..94be523 --- /dev/null +++ b/src/app/api/crm/enquiries/[id]/mark-won/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { + enquiryMarkWonSchema +} from '@/features/crm/enquiries/schemas/enquiry.schema'; +import { getEnquiryDetail, markEnquiryAsWon } from '@/features/crm/enquiries/server/service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +type Params = { + params: Promise<{ id: string }>; +}; + +export async function POST(request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session, membership } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmEnquiryMarkWon + }); + const payload = enquiryMarkWonSchema.parse(await request.json()); + const accessContext = { + organizationId: organization.id, + userId: session.user.id, + membershipRole: membership.role, + businessRole: membership.businessRole, + permissions: membership.permissions ?? [], + branchScopeIds: membership.branchScopeIds ?? [], + productTypeScopeIds: membership.productTypeScopeIds ?? [], + ownershipScope: membership.ownershipScope ?? 'organization', + branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all', + productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all' + }; + const before = await getEnquiryDetail(id, organization.id, accessContext); + const updated = await markEnquiryAsWon(id, organization.id, session.user.id, payload, accessContext); + const after = await getEnquiryDetail(id, organization.id, accessContext); + + return NextResponse.json({ + success: true, + message: 'Enquiry marked as won successfully', + before, + enquiry: updated, + after + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof z.ZodError) { + return NextResponse.json( + { message: error.issues[0]?.message ?? 'Invalid payload' }, + { status: 400 } + ); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json({ message: 'Unable to mark enquiry as won' }, { status: 500 }); + } +} diff --git a/src/app/api/crm/enquiries/[id]/po-attachments/[attachmentId]/route.ts b/src/app/api/crm/enquiries/[id]/po-attachments/[attachmentId]/route.ts new file mode 100644 index 0000000..dbd6d03 --- /dev/null +++ b/src/app/api/crm/enquiries/[id]/po-attachments/[attachmentId]/route.ts @@ -0,0 +1,83 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getEnquiryAttachment } from '@/features/crm/enquiries/server/service'; +import { getStorageProvider } from '@/features/foundation/storage/service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +type Params = { + params: Promise<{ id: string; attachmentId: string }>; +}; + +function buildAccessContext(input: { + organizationId: string; + userId: string; + membership: { + role: string; + businessRole: string; + permissions?: string[] | null; + branchScopeIds?: string[] | null; + productTypeScopeIds?: string[] | null; + ownershipScope?: string | null; + branchScopeMode?: string | null; + productScopeMode?: string | null; + }; +}) { + return { + organizationId: input.organizationId, + userId: input.userId, + membershipRole: input.membership.role, + businessRole: input.membership.businessRole, + permissions: input.membership.permissions ?? [], + branchScopeIds: input.membership.branchScopeIds ?? [], + productTypeScopeIds: input.membership.productTypeScopeIds ?? [], + ownershipScope: input.membership.ownershipScope ?? 'organization', + branchScopeMode: input.membership.branchScopeMode === 'assigned' ? 'assigned' : 'all', + productScopeMode: input.membership.productScopeMode === 'assigned' ? 'assigned' : 'all' + }; +} + +export async function GET(request: NextRequest, { params }: Params) { + try { + const { id, attachmentId } = await params; + const { organization, session, membership } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmEnquiryRead + }); + + if ( + membership.role !== 'admin' && + !(membership.permissions ?? []).includes(PERMISSIONS.crmQuotationPricingRead) + ) { + return NextResponse.json({ message: 'Forbidden' }, { status: 403 }); + } + + const attachment = await getEnquiryAttachment( + id, + attachmentId, + organization.id, + buildAccessContext({ + organizationId: organization.id, + userId: session.user.id, + membership + }) + ); + const object = await getStorageProvider().getObject({ key: attachment.storageKey }); + const download = request.nextUrl.searchParams.get('download') === '1'; + + return new NextResponse(object.body, { + headers: { + 'Content-Type': attachment.fileType ?? object.contentType, + 'Content-Disposition': `${download ? 'attachment' : 'inline'}; filename="${attachment.originalFileName}"` + } + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json({ message: 'Unable to load PO attachment' }, { status: 500 }); + } +} diff --git a/src/app/api/crm/enquiries/[id]/po-attachments/route.ts b/src/app/api/crm/enquiries/[id]/po-attachments/route.ts new file mode 100644 index 0000000..e6ee660 --- /dev/null +++ b/src/app/api/crm/enquiries/[id]/po-attachments/route.ts @@ -0,0 +1,141 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { + listEnquiryAttachments, + uploadPurchaseOrderAttachment +} from '@/features/crm/enquiries/server/service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +type Params = { + params: Promise<{ id: string }>; +}; + +function buildAccessContext(input: { + organizationId: string; + userId: string; + membership: { + role: string; + businessRole: string; + permissions?: string[] | null; + branchScopeIds?: string[] | null; + productTypeScopeIds?: string[] | null; + ownershipScope?: string | null; + branchScopeMode?: string | null; + productScopeMode?: string | null; + }; +}) { + return { + organizationId: input.organizationId, + userId: input.userId, + membershipRole: input.membership.role, + businessRole: input.membership.businessRole, + permissions: input.membership.permissions ?? [], + branchScopeIds: input.membership.branchScopeIds ?? [], + productTypeScopeIds: input.membership.productTypeScopeIds ?? [], + ownershipScope: input.membership.ownershipScope ?? 'organization', + branchScopeMode: input.membership.branchScopeMode === 'assigned' ? 'assigned' : 'all', + productScopeMode: input.membership.productScopeMode === 'assigned' ? 'assigned' : 'all' + }; +} + +function canViewCommercialData(membership: { role: string; permissions?: string[] | null }) { + return ( + membership.role === 'admin' || + (membership.permissions ?? []).includes(PERMISSIONS.crmQuotationPricingRead) + ); +} + +export async function GET(_request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session, membership } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmEnquiryRead + }); + + if (!canViewCommercialData(membership)) { + return NextResponse.json({ message: 'Forbidden' }, { status: 403 }); + } + + const items = await listEnquiryAttachments( + id, + organization.id, + buildAccessContext({ + organizationId: organization.id, + userId: session.user.id, + membership + }) + ); + + return NextResponse.json({ + success: true, + time: new Date().toISOString(), + message: 'Enquiry purchase order attachments loaded successfully', + items + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json({ message: 'Unable to load PO attachments' }, { status: 500 }); + } +} + +export async function POST(request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session, membership } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmEnquiryMarkWon + }); + + if (!canViewCommercialData(membership)) { + return NextResponse.json({ message: 'Forbidden' }, { status: 403 }); + } + + const formData = await request.formData(); + const file = formData.get('file'); + + if (!(file instanceof File)) { + return NextResponse.json({ message: 'PO attachment file is required' }, { status: 400 }); + } + + const created = await uploadPurchaseOrderAttachment({ + enquiryId: id, + organizationId: organization.id, + userId: session.user.id, + description: + typeof formData.get('description') === 'string' ? (formData.get('description') as string) : null, + file: { + name: file.name, + type: file.type, + size: file.size, + buffer: Buffer.from(await file.arrayBuffer()) + }, + accessContext: buildAccessContext({ + organizationId: organization.id, + userId: session.user.id, + membership + }) + }); + + return NextResponse.json({ + success: true, + message: 'PO attachment uploaded successfully', + attachment: created + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json({ message: 'Unable to upload PO attachment' }, { status: 500 }); + } +} diff --git a/src/app/api/crm/enquiries/[id]/reassign/route.ts b/src/app/api/crm/enquiries/[id]/reassign/route.ts index 5f006a9..7b7d714 100644 --- a/src/app/api/crm/enquiries/[id]/reassign/route.ts +++ b/src/app/api/crm/enquiries/[id]/reassign/route.ts @@ -22,6 +22,7 @@ export async function POST(request: NextRequest, { params }: Params) { userId: session.user.id, membershipRole: membership.role, businessRole: membership.businessRole, + permissions: membership.permissions ?? [], branchScopeIds: membership.branchScopeIds ?? [], productTypeScopeIds: membership.productTypeScopeIds ?? [], ownershipScope: membership.ownershipScope ?? 'organization', diff --git a/src/app/api/crm/enquiries/[id]/reopen/route.ts b/src/app/api/crm/enquiries/[id]/reopen/route.ts new file mode 100644 index 0000000..c8818ff --- /dev/null +++ b/src/app/api/crm/enquiries/[id]/reopen/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { enquiryReopenSchema } from '@/features/crm/enquiries/schemas/enquiry.schema'; +import { getEnquiryDetail, reopenLostEnquiry } from '@/features/crm/enquiries/server/service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +type Params = { + params: Promise<{ id: string }>; +}; + +export async function POST(request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session, membership } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmEnquiryReopen + }); + const payload = enquiryReopenSchema.parse(await request.json()); + const accessContext = { + organizationId: organization.id, + userId: session.user.id, + membershipRole: membership.role, + businessRole: membership.businessRole, + permissions: membership.permissions ?? [], + branchScopeIds: membership.branchScopeIds ?? [], + productTypeScopeIds: membership.productTypeScopeIds ?? [], + ownershipScope: membership.ownershipScope ?? 'organization', + branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all', + productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all' + }; + const before = await getEnquiryDetail(id, organization.id, accessContext); + const updated = await reopenLostEnquiry(id, organization.id, session.user.id, payload, accessContext); + const after = await getEnquiryDetail(id, organization.id, accessContext); + + return NextResponse.json({ + success: true, + message: 'Enquiry reopened successfully', + before, + enquiry: updated, + after + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof z.ZodError) { + return NextResponse.json( + { message: error.issues[0]?.message ?? 'Invalid payload' }, + { status: 400 } + ); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json({ message: 'Unable to reopen enquiry' }, { status: 500 }); + } +} diff --git a/src/app/api/crm/enquiries/[id]/route.ts b/src/app/api/crm/enquiries/[id]/route.ts index dd17bc3..0e37412 100644 --- a/src/app/api/crm/enquiries/[id]/route.ts +++ b/src/app/api/crm/enquiries/[id]/route.ts @@ -33,6 +33,7 @@ export async function GET(_request: NextRequest, { params }: Params) { userId: session.user.id, membershipRole: membership.role, businessRole: membership.businessRole, + permissions: membership.permissions ?? [], branchScopeIds: membership.branchScopeIds ?? [], productTypeScopeIds: membership.productTypeScopeIds ?? [], ownershipScope: membership.ownershipScope ?? 'organization', @@ -76,6 +77,7 @@ export async function PATCH(request: NextRequest, { params }: Params) { userId: session.user.id, membershipRole: membership.role, businessRole: membership.businessRole, + permissions: membership.permissions ?? [], branchScopeIds: membership.branchScopeIds ?? [], productTypeScopeIds: membership.productTypeScopeIds ?? [], ownershipScope: membership.ownershipScope ?? 'organization', @@ -131,6 +133,7 @@ export async function DELETE(_request: NextRequest, { params }: Params) { userId: session.user.id, membershipRole: membership.role, businessRole: membership.businessRole, + permissions: membership.permissions ?? [], branchScopeIds: membership.branchScopeIds ?? [], productTypeScopeIds: membership.productTypeScopeIds ?? [], ownershipScope: membership.ownershipScope ?? 'organization', diff --git a/src/app/dashboard/crm/enquiries/[id]/page.tsx b/src/app/dashboard/crm/enquiries/[id]/page.tsx index 28148ec..4c99b6d 100644 --- a/src/app/dashboard/crm/enquiries/[id]/page.tsx +++ b/src/app/dashboard/crm/enquiries/[id]/page.tsx @@ -59,6 +59,26 @@ export default async function EnquiryDetailRoute({ params }: PageProps) { (!!session?.user?.activeOrganizationId && (session.user.activeMembershipRole === 'admin' || session.user.activePermissions.includes(PERMISSIONS.crmQuotationRead))); + const canViewOutcomeCommercialData = + session?.user?.systemRole === 'super_admin' || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === 'admin' || + session.user.activePermissions.includes(PERMISSIONS.crmQuotationPricingRead))); + const canMarkWon = + session?.user?.systemRole === 'super_admin' || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === 'admin' || + session.user.activePermissions.includes(PERMISSIONS.crmEnquiryMarkWon))); + const canMarkLost = + session?.user?.systemRole === 'super_admin' || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === 'admin' || + session.user.activePermissions.includes(PERMISSIONS.crmEnquiryMarkLost))); + const canReopen = + session?.user?.systemRole === 'super_admin' || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === 'admin' || + session.user.activePermissions.includes(PERMISSIONS.crmEnquiryReopen))); const queryClient = getQueryClient(); if (canRead) { @@ -98,6 +118,10 @@ export default async function EnquiryDetailRoute({ params }: PageProps) { canAssign={canAssign} canReassign={canReassign} canViewRelatedQuotations={canReadQuotation} + canViewOutcomeCommercialData={canViewOutcomeCommercialData} + canMarkWon={canMarkWon} + canMarkLost={canMarkLost} + canReopen={canReopen} canManageFollowups={{ create: canFollowupCreate, update: canFollowupUpdate, diff --git a/src/app/dashboard/crm/leads/[id]/page.tsx b/src/app/dashboard/crm/leads/[id]/page.tsx index 0e92b70..ced0db1 100644 --- a/src/app/dashboard/crm/leads/[id]/page.tsx +++ b/src/app/dashboard/crm/leads/[id]/page.tsx @@ -46,6 +46,7 @@ export default async function LeadDetailRoute({ params }: PageProps) { userId: session.user.id, membershipRole: session.user.activeMembershipRole ?? 'user', businessRole: session.user.activeBusinessRole ?? 'sales_support', + permissions: session.user.activePermissions ?? [], branchScopeIds: session.user.activeBranchScopeIds ?? [], productTypeScopeIds: session.user.activeProductTypeScopeIds ?? [], ownershipScope: session.user.activeOwnershipScope ?? 'organization', @@ -91,6 +92,10 @@ export default async function LeadDetailRoute({ params }: PageProps) { canAssign={canAssign} canReassign={false} canViewRelatedQuotations={false} + canViewOutcomeCommercialData={false} + canMarkWon={false} + canMarkLost={false} + canReopen={false} canManageFollowups={{ create: false, update: false, diff --git a/src/db/schema.ts b/src/db/schema.ts index 29a9e1f..65b2af1 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -317,6 +317,16 @@ export const crmEnquiries = pgTable( isHotProject: boolean('is_hot_project').default(false).notNull(), isActive: boolean('is_active').default(true).notNull(), pipelineStage: text('pipeline_stage').default('lead').notNull(), + closedWonAt: timestamp('closed_won_at', { withTimezone: true }), + closedLostAt: timestamp('closed_lost_at', { withTimezone: true }), + closedByUserId: text('closed_by_user_id'), + poNumber: text('po_number'), + poDate: timestamp('po_date', { withTimezone: true }), + poAmount: doublePrecision('po_amount'), + poCurrency: text('po_currency'), + lostReason: text('lost_reason'), + lostCompetitor: text('lost_competitor'), + lostRemark: text('lost_remark'), assignedToUserId: text('assigned_to_user_id'), assignedAt: timestamp('assigned_at', { withTimezone: true }), assignedBy: text('assigned_by'), @@ -365,6 +375,25 @@ export const crmEnquiryCustomers = pgTable('crm_enquiry_customers', { deletedAt: timestamp('deleted_at', { withTimezone: true }) }); +export const crmEnquiryAttachments = pgTable('crm_enquiry_attachments', { + id: text('id').primaryKey(), + organizationId: text('organization_id').notNull(), + enquiryId: text('enquiry_id').notNull(), + category: text('category').notNull(), + fileName: text('file_name').notNull(), + originalFileName: text('original_file_name').notNull(), + storageProvider: text('storage_provider').notNull(), + storageKey: text('storage_key').notNull(), + fileSize: integer('file_size'), + fileType: text('file_type'), + description: text('description'), + uploadedAt: timestamp('uploaded_at', { withTimezone: true }).defaultNow().notNull(), + uploadedBy: text('uploaded_by').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + deletedAt: timestamp('deleted_at', { withTimezone: true }) +}); + export const crmQuotations = pgTable( 'crm_quotations', { diff --git a/src/db/seeds/foundation.seed.ts b/src/db/seeds/foundation.seed.ts index e1bf59e..2e04373 100644 --- a/src/db/seeds/foundation.seed.ts +++ b/src/db/seeds/foundation.seed.ts @@ -285,6 +285,36 @@ const FOUNDATION_OPTIONS = { { code: 'email', label: 'Email', value: 'email', sortOrder: 4 }, { code: 'line', label: 'LINE', value: 'line', sortOrder: 5 } ], + crm_lost_reason: [ + { code: 'competitor_won', label: 'Competitor Won', value: 'competitor_won', sortOrder: 1 }, + { + code: 'budget_not_approved', + label: 'Budget Not Approved', + value: 'budget_not_approved', + sortOrder: 2 + }, + { + code: 'project_cancelled', + label: 'Project Cancelled', + value: 'project_cancelled', + sortOrder: 3 + }, + { + code: 'customer_no_response', + label: 'Customer No Response', + value: 'customer_no_response', + sortOrder: 4 + }, + { + code: 'technical_requirement_mismatch', + label: 'Technical Requirement Mismatch', + value: 'technical_requirement_mismatch', + sortOrder: 5 + }, + { code: 'price_too_high', label: 'Price Too High', value: 'price_too_high', sortOrder: 6 }, + { code: 'timeline_not_fit', label: 'Timeline Not Fit', value: 'timeline_not_fit', sortOrder: 7 }, + { code: 'other', label: 'Other', value: 'other', sortOrder: 8 } + ], crm_job_title: [ { code: 'sales', label: 'Sales Engineer', value: 'sales', sortOrder: 1 }, { code: 'sales_support', label: 'Sales Support', value: 'sales_support', sortOrder: 2 }, diff --git a/src/features/crm/dashboard/api/types.ts b/src/features/crm/dashboard/api/types.ts index 3508045..d19855a 100644 --- a/src/features/crm/dashboard/api/types.ts +++ b/src/features/crm/dashboard/api/types.ts @@ -37,6 +37,7 @@ export interface CrmDashboardSummary { activeEnquiries: number; wonCount: number; lostCount: number; + winRate: number; hotEnquiries: number; enquiryValue: number; }; @@ -150,6 +151,11 @@ export interface CrmDashboardResponse { myPendingApprovals: CrmDashboardApprovalRow[]; }; hotProjects: CrmDashboardHotProjectRow[]; + outcomeAnalytics: { + winRate: number; + lostByReason: Array<{ key: string; label: string; count: number; revenue: number }>; + lostByCompetitor: Array<{ key: string; label: string; count: number; revenue: number }>; + }; visibility: { canViewCommercialData: boolean; canViewApprovalData: boolean; diff --git a/src/features/crm/dashboard/components/dashboard-summary-cards.tsx b/src/features/crm/dashboard/components/dashboard-summary-cards.tsx index 836333b..71bce9c 100644 --- a/src/features/crm/dashboard/components/dashboard-summary-cards.tsx +++ b/src/features/crm/dashboard/components/dashboard-summary-cards.tsx @@ -70,7 +70,7 @@ export function DashboardSummaryCards({ { label: 'จำนวนโอกาสขาย', value: summary.enquiry.enquiryCount }, { label: 'โอกาสขายที่กำลังดำเนินการ', value: summary.enquiry.activeEnquiries }, { label: 'จำนวนงานที่ชนะ', value: summary.enquiry.wonCount }, - { label: 'จำนวนงานที่แพ้', value: summary.enquiry.lostCount } + { label: 'Win Rate', value: summary.enquiry.winRate, suffix: '%' } ]} /> {canViewCommercialData ? ( diff --git a/src/features/crm/dashboard/server/service.ts b/src/features/crm/dashboard/server/service.ts index 30778d5..2267cda 100644 --- a/src/features/crm/dashboard/server/service.ts +++ b/src/features/crm/dashboard/server/service.ts @@ -30,7 +30,12 @@ import { getRevenueByBillingCustomer, getRevenueByConsultant, getRevenueByContractor, - getRevenueByEndCustomer + getRevenueByEndCustomer, + getLostByCompetitor, + getLostByReason, + getLostRevenue, + getWinRate, + getWonRevenue } from '@/features/crm/reporting/server/service'; import { getPipelineStageThaiLabel, getProjectPartyRoleThaiLabel } from '@/features/crm/shared/terminology'; import type { @@ -281,7 +286,8 @@ async function loadScopedRows( function buildSummary( scopedEnquiries: Array, scopedQuotations: Array, - statusMaps: StatusMaps + statusMaps: StatusMaps, + outcomeMetrics: { wonValue: number; lostValue: number; winRate: number } ): CrmDashboardSummary { const now = new Date(); const leadRows = scopedEnquiries.filter((row) => isPipelineStage(row, 'lead')); @@ -304,6 +310,7 @@ function buildSummary( activeEnquiries: enquiryRows.length, wonCount: wonRows.length, lostCount: lostRows.length, + winRate: outcomeMetrics.winRate, hotEnquiries: enquiryRows.filter((row) => row.isHotProject).length, enquiryValue: round2(enquiryRows.reduce((sum, row) => sum + (row.estimatedValue ?? 0), 0)) }, @@ -319,18 +326,8 @@ function buildSummary( .filter((row) => (statusMaps.quotationStatusById.get(row.status) ?? '') !== 'cancelled') .reduce((sum, row) => sum + row.totalAmount, 0) ), - wonValue: round2( - scopedQuotations - .filter((row) => (statusMaps.quotationStatusById.get(row.status) ?? '') === 'accepted') - .reduce((sum, row) => sum + row.totalAmount, 0) - ), - lostValue: round2( - scopedQuotations - .filter((row) => - ['lost', 'rejected'].includes(statusMaps.quotationStatusById.get(row.status) ?? '') - ) - .reduce((sum, row) => sum + row.totalAmount, 0) - ) + wonValue: round2(outcomeMetrics.wonValue), + lostValue: round2(outcomeMetrics.lostValue) } }; } @@ -387,9 +384,7 @@ function buildFunnel( key: 'closed_won', label: getPipelineStageThaiLabel('closed_won'), count: summary.enquiry.wonCount, - value: scopedQuotations - .filter((row) => (statusMaps.quotationStatusById.get(row.status) ?? '') === 'accepted') - .reduce((sum, row) => sum + row.totalAmount, 0) + value: summary.revenue.wonValue } ]; @@ -463,7 +458,8 @@ async function buildRevenueAnalytics( async function buildSalesRanking( scopedEnquiries: Array, scopedQuotations: Array, - statusMaps: StatusMaps + statusMaps: StatusMaps, + wonRevenueRows: Array<{ assignedToUserId: string | null; revenue: number }> ): Promise { const userIds = [ ...new Set( @@ -529,11 +525,29 @@ async function buildSalesRanking( current.approvedQuotations += 1; } - if (statusCode === 'accepted') { - current.wonRevenue += row.totalAmount; + rankingMap.set(row.salesmanId, current); + } + + for (const row of wonRevenueRows) { + if (!row.assignedToUserId) { + continue; } - rankingMap.set(row.salesmanId, current); + const current = + rankingMap.get(row.assignedToUserId) ?? + { + salesPersonId: row.assignedToUserId, + salesPersonName: userMap.get(row.assignedToUserId) ?? 'Unknown', + leadCount: 0, + enquiryCount: 0, + quotationCount: 0, + approvedQuotations: 0, + wonRevenue: 0, + conversionRate: 0 + }; + + current.wonRevenue += row.revenue; + rankingMap.set(row.assignedToUserId, current); } return [...rankingMap.values()] @@ -776,12 +790,33 @@ export async function getCrmDashboardData( const filters = normalizeDashboardFilters(rawFilters); const allowCommercialData = canViewCommercialData(context); const allowApprovalData = canViewApprovalData(context); + const outcomeFilters = { + dateFrom: filters.dateFrom, + dateTo: filters.dateTo, + branch: filters.branch, + salesman: filters.salesman, + productType: filters.productType + }; const [referenceData, statusMaps, scoped] = await Promise.all([ getReferenceData(context.organizationId), getStatusMaps(context.organizationId), loadScopedRows(context, filters) ]); - const summary = buildSummary(scoped.enquiries, scoped.quotations, statusMaps); + const [wonRevenueRows, lostRevenueRows, winRate, lostByReasonRows, lostByCompetitorRows] = + allowCommercialData + ? await Promise.all([ + getWonRevenue(context.organizationId, outcomeFilters), + getLostRevenue(context.organizationId, outcomeFilters), + getWinRate(context.organizationId, outcomeFilters), + getLostByReason(context.organizationId, outcomeFilters), + getLostByCompetitor(context.organizationId, outcomeFilters) + ]) + : [[], [], 0, [], []]; + const summary = buildSummary(scoped.enquiries, scoped.quotations, statusMaps, { + wonValue: wonRevenueRows.reduce((sum, row) => sum + row.revenue, 0), + lostValue: lostRevenueRows.reduce((sum, row) => sum + row.revenue, 0), + winRate + }); const [revenueAnalytics, salesRanking, followups, approvals, hotProjects] = await Promise.all([ allowCommercialData ? buildRevenueAnalytics(context.organizationId, filters) @@ -792,7 +827,7 @@ export async function getCrmDashboardData( topConsultants: [] }), allowCommercialData - ? buildSalesRanking(scoped.enquiries, scoped.quotations, statusMaps) + ? buildSalesRanking(scoped.enquiries, scoped.quotations, statusMaps, wonRevenueRows) : Promise.resolve([]), buildFollowups(context, filters), allowApprovalData @@ -825,6 +860,11 @@ export async function getCrmDashboardData( followups, approvals, hotProjects, + outcomeAnalytics: { + winRate, + lostByReason: lostByReasonRows, + lostByCompetitor: lostByCompetitorRows + }, visibility: { canViewCommercialData: allowCommercialData, canViewApprovalData: allowApprovalData diff --git a/src/features/crm/enquiries/api/mutations.ts b/src/features/crm/enquiries/api/mutations.ts index bfe22e7..e44c133 100644 --- a/src/features/crm/enquiries/api/mutations.ts +++ b/src/features/crm/enquiries/api/mutations.ts @@ -6,7 +6,10 @@ import { createEnquiryFollowup, deleteEnquiry, deleteEnquiryFollowup, + markEnquiryLost, + markEnquiryWon, reassignEnquiry, + reopenEnquiry, updateEnquiry, updateEnquiryFollowup } from './service'; @@ -14,6 +17,8 @@ import { enquiryKeys } from './queries'; import type { EnquiryAssignmentMutationPayload, EnquiryFollowupMutationPayload, + EnquiryMarkLostPayload, + EnquiryMarkWonPayload, EnquiryMutationPayload } from './types'; import { crmDashboardKeys } from '@/features/crm/dashboard/api/queries'; @@ -38,11 +43,16 @@ async function invalidateEnquiryFollowups(id: string) { await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.followups(id) }); } +async function invalidateEnquiryAttachments(id: string) { + await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.attachments(id) }); +} + export async function invalidateEnquiryMutationQueries(id: string) { await Promise.all([ invalidateEnquiryLists(), invalidateEnquiryDetail(id), invalidateEnquiryProjectParties(id), + invalidateEnquiryAttachments(id), invalidateDashboardQueries() ]); } @@ -148,3 +158,33 @@ export const deleteEnquiryFollowupMutation = mutationOptions({ } } }); + +export const markEnquiryWonMutation = mutationOptions({ + mutationFn: ({ id, values }: { id: string; values: EnquiryMarkWonPayload }) => + markEnquiryWon(id, values), + onSettled: async (_data, error, variables) => { + if (!error) { + await invalidateEnquiryMutationQueries(variables.id); + } + } +}); + +export const markEnquiryLostMutation = mutationOptions({ + mutationFn: ({ id, values }: { id: string; values: EnquiryMarkLostPayload }) => + markEnquiryLost(id, values), + onSettled: async (_data, error, variables) => { + if (!error) { + await invalidateEnquiryMutationQueries(variables.id); + } + } +}); + +export const reopenEnquiryMutation = mutationOptions({ + mutationFn: ({ id, reason }: { id: string; reason: string }) => + reopenEnquiry(id, { reopenReason: reason }), + onSettled: async (_data, error, variables) => { + if (!error) { + await invalidateEnquiryMutationQueries(variables.id); + } + } +}); diff --git a/src/features/crm/enquiries/api/queries.ts b/src/features/crm/enquiries/api/queries.ts index 171dd2e..4d294d0 100644 --- a/src/features/crm/enquiries/api/queries.ts +++ b/src/features/crm/enquiries/api/queries.ts @@ -1,5 +1,6 @@ import { queryOptions } from '@tanstack/react-query'; import { + getEnquiryAttachments, getEnquiries, getEnquiryById, getEnquiryFollowups, @@ -16,7 +17,9 @@ export const enquiryKeys = { projectPartiesRoot: () => [...enquiryKeys.all, 'project-parties'] as const, projectParties: (id: string) => [...enquiryKeys.projectPartiesRoot(), id] as const, followupsRoot: () => [...enquiryKeys.all, 'followups'] as const, - followups: (id: string) => [...enquiryKeys.followupsRoot(), id] as const + followups: (id: string) => [...enquiryKeys.followupsRoot(), id] as const, + attachmentsRoot: () => [...enquiryKeys.all, 'attachments'] as const, + attachments: (id: string) => [...enquiryKeys.attachmentsRoot(), id] as const }; export const enquiriesQueryOptions = (filters: EnquiryFilters) => @@ -42,3 +45,9 @@ export const enquiryFollowupsOptions = (id: string) => queryKey: enquiryKeys.followups(id), queryFn: () => getEnquiryFollowups(id) }); + +export const enquiryAttachmentsOptions = (id: string) => + queryOptions({ + queryKey: enquiryKeys.attachments(id), + queryFn: () => getEnquiryAttachments(id) + }); diff --git a/src/features/crm/enquiries/api/service.ts b/src/features/crm/enquiries/api/service.ts index cf21616..13ac25f 100644 --- a/src/features/crm/enquiries/api/service.ts +++ b/src/features/crm/enquiries/api/service.ts @@ -1,13 +1,17 @@ import { apiClient } from '@/lib/api-client'; import type { + EnquiryAttachmentsResponse, EnquiryAssignmentMutationPayload, EnquiryDetailResponse, EnquiryFilters, EnquiryFollowupMutationPayload, EnquiryFollowupsResponse, EnquiryListResponse, + EnquiryMarkLostPayload, + EnquiryMarkWonPayload, EnquiryMutationPayload, EnquiryProjectPartiesResponse, + EnquiryReopenPayload, MutationSuccessResponse } from './types'; @@ -76,6 +80,10 @@ export async function getEnquiryFollowups(id: string): Promise(`/crm/enquiries/${id}/followups`); } +export async function getEnquiryAttachments(id: string): Promise { + return apiClient(`/crm/enquiries/${id}/po-attachments`); +} + export async function createEnquiryFollowup( enquiryId: string, data: EnquiryFollowupMutationPayload @@ -102,3 +110,24 @@ export async function deleteEnquiryFollowup(enquiryId: string, followupId: strin method: 'DELETE' }); } + +export async function markEnquiryWon(id: string, data: EnquiryMarkWonPayload) { + return apiClient(`/crm/enquiries/${id}/mark-won`, { + method: 'POST', + body: JSON.stringify(data) + }); +} + +export async function markEnquiryLost(id: string, data: EnquiryMarkLostPayload) { + return apiClient(`/crm/enquiries/${id}/mark-lost`, { + method: 'POST', + body: JSON.stringify(data) + }); +} + +export async function reopenEnquiry(id: string, data: EnquiryReopenPayload) { + return apiClient(`/crm/enquiries/${id}/reopen`, { + method: 'POST', + body: JSON.stringify(data) + }); +} diff --git a/src/features/crm/enquiries/api/types.ts b/src/features/crm/enquiries/api/types.ts index 22a26cb..dc74832 100644 --- a/src/features/crm/enquiries/api/types.ts +++ b/src/features/crm/enquiries/api/types.ts @@ -56,6 +56,25 @@ export interface EnquiryAssignableUserLookup { businessRole: string; } +export interface EnquiryAttachmentRecord { + id: string; + organizationId: string; + enquiryId: string; + category: string; + fileName: string; + originalFileName: string; + storageProvider: string; + storageKey: string; + fileSize: number | null; + fileType: string | null; + description: string | null; + uploadedAt: string; + uploadedBy: string; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + export type EnquiryPipelineStage = 'lead' | 'enquiry' | 'closed_won' | 'closed_lost'; export interface EnquiryRecord { @@ -83,6 +102,19 @@ export interface EnquiryRecord { isHotProject: boolean; isActive: boolean; pipelineStage: EnquiryPipelineStage; + closedWonAt: string | null; + closedLostAt: string | null; + closedByUserId: string | null; + closedByName: string | null; + poNumber: string | null; + poDate: string | null; + poAmount: number | null; + poCurrency: string | null; + lostReason: string | null; + lostReasonCode: string | null; + lostReasonLabel: string | null; + lostCompetitor: string | null; + lostRemark: string | null; assignedToUserId: string | null; assignedToName: string | null; assignedAt: string | null; @@ -140,6 +172,7 @@ export interface EnquiryReferenceData { priorities: EnquiryOption[]; leadChannels: EnquiryOption[]; followupTypes: EnquiryOption[]; + lostReasons: EnquiryOption[]; projectPartyRoles: EnquiryOption[]; customers: EnquiryCustomerLookup[]; contacts: EnquiryContactLookup[]; @@ -191,6 +224,13 @@ export interface EnquiryFollowupsResponse { items: EnquiryFollowupRecord[]; } +export interface EnquiryAttachmentsResponse { + success: boolean; + time: string; + message: string; + items: EnquiryAttachmentRecord[]; +} + export interface EnquiryProjectPartyMutationPayload { customerId: string; role: string; @@ -237,6 +277,24 @@ export interface EnquiryAssignmentMutationPayload { remark?: string; } +export interface EnquiryMarkWonPayload { + poNumber: string; + poDate: string; + poAmount?: number | null; + poCurrency?: string | null; + remark?: string | null; +} + +export interface EnquiryMarkLostPayload { + lostReason: string; + lostCompetitor?: string | null; + lostRemark?: string | null; +} + +export interface EnquiryReopenPayload { + reopenReason: string; +} + export interface EnquiryCustomerRelationItem { id: string; code: string; diff --git a/src/features/crm/enquiries/components/enquiry-detail.tsx b/src/features/crm/enquiries/components/enquiry-detail.tsx index 7db1654..3eb9aae 100644 --- a/src/features/crm/enquiries/components/enquiry-detail.tsx +++ b/src/features/crm/enquiries/components/enquiry-detail.tsx @@ -16,6 +16,7 @@ import type { EnquiryRecord, EnquiryReferenceData } from '../api/types'; import { EnquiryAssignmentDialog } from './enquiry-assignment-dialog'; import { EnquiryFollowupsTab } from './enquiry-followups-tab'; import { EnquiryFormSheet } from './enquiry-form-sheet'; +import { EnquiryOutcomeCard } from './enquiry-outcome-card'; import { EnquiryStatusBadge } from './enquiry-status-badge'; function FieldItem({ label, value }: { label: string; value: string | null | undefined }) { @@ -40,6 +41,10 @@ interface EnquiryDetailProps { canAssign: boolean; canReassign: boolean; canViewRelatedQuotations: boolean; + canViewOutcomeCommercialData: boolean; + canMarkWon: boolean; + canMarkLost: boolean; + canReopen: boolean; canManageFollowups: { create: boolean; update: boolean; @@ -56,6 +61,10 @@ export function EnquiryDetail({ canAssign, canReassign, canViewRelatedQuotations, + canViewOutcomeCommercialData, + canMarkWon, + canMarkLost, + canReopen, canManageFollowups }: EnquiryDetailProps) { const { data } = useSuspenseQuery(enquiryByIdOptions(enquiryId)); @@ -374,6 +383,14 @@ export function EnquiryDetail({
+ ข้อมูลระบบ diff --git a/src/features/crm/enquiries/components/enquiry-outcome-card.tsx b/src/features/crm/enquiries/components/enquiry-outcome-card.tsx new file mode 100644 index 0000000..3aeaa6e --- /dev/null +++ b/src/features/crm/enquiries/components/enquiry-outcome-card.tsx @@ -0,0 +1,465 @@ +'use client'; + +import Link from 'next/link'; +import { useMemo, useState } from 'react'; +import { useMutation, useQuery, useSuspenseQuery } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { Icons } from '@/components/icons'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select'; +import { Textarea } from '@/components/ui/textarea'; +import { + invalidateEnquiryMutationQueries, + markEnquiryLostMutation, + markEnquiryWonMutation, + reopenEnquiryMutation +} from '../api/mutations'; +import { enquiryAttachmentsOptions, enquiryByIdOptions } from '../api/queries'; +import type { EnquiryReferenceData } from '../api/types'; + +function FieldItem({ label, value }: { label: string; value: string | null | undefined }) { + return ( +
+
{label}
+
{value || '-'}
+
+ ); +} + +function getOutcomeLabel(stage: string) { + if (stage === 'closed_won') return 'Won'; + if (stage === 'closed_lost') return 'Lost'; + return 'Open'; +} + +type EnquiryOutcomeCardProps = { + enquiryId: string; + referenceData: EnquiryReferenceData; + canViewCommercialData: boolean; + canMarkWon: boolean; + canMarkLost: boolean; + canReopen: boolean; +}; + +export function EnquiryOutcomeCard({ + enquiryId, + referenceData, + canViewCommercialData, + canMarkWon, + canMarkLost, + canReopen +}: EnquiryOutcomeCardProps) { + const { data } = useSuspenseQuery(enquiryByIdOptions(enquiryId)); + const attachmentsQuery = useQuery({ + ...enquiryAttachmentsOptions(enquiryId), + enabled: canViewCommercialData + }); + const enquiry = data.enquiry; + const [wonOpen, setWonOpen] = useState(false); + const [lostOpen, setLostOpen] = useState(false); + const [reopenOpen, setReopenOpen] = useState(false); + const [poNumber, setPoNumber] = useState(''); + const [poDate, setPoDate] = useState(''); + const [poAmount, setPoAmount] = useState(''); + const [poCurrency, setPoCurrency] = useState('THB'); + const [wonRemark, setWonRemark] = useState(''); + const [lostReason, setLostReason] = useState(''); + const [lostCompetitor, setLostCompetitor] = useState(''); + const [lostRemark, setLostRemark] = useState(''); + const [reopenReason, setReopenReason] = useState(''); + const [poFile, setPoFile] = useState(null); + const [poFileDescription, setPoFileDescription] = useState(''); + + const wonMutation = useMutation({ + ...markEnquiryWonMutation, + onSuccess: async () => { + toast.success('Marked enquiry as won'); + setWonOpen(false); + await invalidateEnquiryMutationQueries(enquiryId); + }, + onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to mark won') + }); + const lostMutation = useMutation({ + ...markEnquiryLostMutation, + onSuccess: async () => { + toast.success('Marked enquiry as lost'); + setLostOpen(false); + await invalidateEnquiryMutationQueries(enquiryId); + }, + onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to mark lost') + }); + const reopenMutation = useMutation({ + ...reopenEnquiryMutation, + onSuccess: async () => { + toast.success('Reopened enquiry'); + setReopenOpen(false); + await invalidateEnquiryMutationQueries(enquiryId); + }, + onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to reopen enquiry') + }); + const uploadMutation = useMutation({ + mutationFn: async () => { + if (!poFile) { + throw new Error('Please choose a PO attachment file'); + } + + const formData = new FormData(); + formData.append('file', poFile); + formData.append('description', poFileDescription); + const response = await fetch(`/api/crm/enquiries/${enquiryId}/po-attachments`, { + method: 'POST', + body: formData + }); + + if (!response.ok) { + const payload = (await response.json().catch(() => null)) as { message?: string } | null; + throw new Error(payload?.message ?? 'Failed to upload PO attachment'); + } + + return response.json(); + }, + onSuccess: async () => { + toast.success('PO attachment uploaded'); + setPoFile(null); + setPoFileDescription(''); + await invalidateEnquiryMutationQueries(enquiryId); + }, + onError: (error) => + toast.error(error instanceof Error ? error.message : 'Failed to upload PO attachment') + }); + + const closedDate = enquiry.closedWonAt ?? enquiry.closedLostAt; + const lostReasonLabel = useMemo(() => { + if (!enquiry.lostReason) { + return null; + } + + return ( + enquiry.lostReasonLabel ?? + referenceData.lostReasons.find( + (item) => item.id === enquiry.lostReason || item.code === enquiry.lostReason + )?.label ?? + enquiry.lostReason + ); + }, [enquiry.lostReason, enquiry.lostReasonLabel, referenceData.lostReasons]); + const attachmentItems = attachmentsQuery.data?.items ?? []; + + return ( + <> + + + Outcome + Official won/lost lifecycle and purchase order tracking. + + +
+ + {getOutcomeLabel(enquiry.pipelineStage)} + +
+ {enquiry.pipelineStage === 'enquiry' && canMarkWon ? ( + + ) : null} + {enquiry.pipelineStage === 'enquiry' && canMarkLost ? ( + + ) : null} + {enquiry.pipelineStage === 'closed_lost' && canReopen ? ( + + ) : null} +
+
+ +
+ + + + + + + + +
+ + {canViewCommercialData && enquiry.pipelineStage === 'closed_won' ? ( +
+
+
Purchase Order Attachment
+
+ Upload PDF, image, or Excel files for the received PO. +
+
+
+
+ + setPoFile(event.target.files?.[0] ?? null)} + /> +
+
+ + setPoFileDescription(event.target.value)} + placeholder='Optional note for this file' + /> +
+
+ + {!attachmentItems.length ? ( +
+ No PO attachments uploaded yet. +
+ ) : ( +
+ {attachmentItems.map((attachment) => ( +
+
+
{attachment.originalFileName}
+
+ {attachment.fileType ?? 'Unknown type'} + {attachment.fileSize ? ` • ${attachment.fileSize.toLocaleString()} bytes` : ''} +
+
+
+ + +
+
+ ))} +
+ )} +
+ ) : null} +
+
+ + + + + Mark Won + PO received is the official trigger for closed won. + +
+
+ + setPoNumber(e.target.value)} /> +
+
+ + setPoDate(e.target.value)} /> +
+
+
+ + setPoAmount(e.target.value)} /> +
+
+ + +
+
+
+ +