From 5be6c54272c4f94180048557cdabf49d69563c19 Mon Sep 17 00:00:00 2001 From: phaichayon Date: Wed, 17 Jun 2026 14:46:52 +0700 Subject: [PATCH] taks-d.2.1 --- .../0010-revenue-attribution-governance.md | 36 + ...ask-d2-billing-customer-project-parties.md | 39 + ...task-d21-revenue-attribution-governance.md | 76 + .../task-j0-kpi-definition-freeze.md | 139 + docs/implementation/technical-debt.md | 55 + drizzle/0010_calm_wendell_rand.sql | 13 + drizzle/meta/0010_snapshot.json | 3267 +++++++++++++++++ drizzle/meta/_journal.json | 7 + plans/task-d.2.1.md | 370 ++ plans/task-d.2.md | 219 ++ plans/task-j.0.md | 421 +++ plans/task-j.md | 0 .../api/crm/enquiries/[id]/customers/route.ts | 38 + src/app/dashboard/crm/enquiries/[id]/page.tsx | 7 +- src/app/dashboard/crm/enquiries/page.tsx | 52 +- src/config/nav-config.ts | 125 +- src/db/schema.ts | 13 + src/db/seeds/foundation.seed.ts | 17 +- .../crm/components/project-parties-editor.tsx | 153 + src/features/crm/enquiries/api/mutations.ts | 11 +- src/features/crm/enquiries/api/queries.ts | 15 +- src/features/crm/enquiries/api/service.ts | 5 + src/features/crm/enquiries/api/types.ts | 34 + .../enquiries/components/enquiry-detail.tsx | 27 +- .../components/enquiry-form-sheet.tsx | 43 +- .../crm/enquiries/schemas/enquiry.schema.ts | 11 +- src/features/crm/enquiries/server/service.ts | 333 +- src/features/crm/quotations/api/types.ts | 7 +- .../components/quotation-detail.tsx | 78 +- .../components/quotation-document-preview.tsx | 2 +- .../components/quotation-form-sheet.tsx | 46 +- .../crm/quotations/document/server/service.ts | 6 +- .../quotations/schemas/quotation.schema.ts | 15 +- src/features/crm/quotations/server/service.ts | 447 ++- src/features/crm/reporting/server/service.ts | 341 ++ src/features/crm/reporting/server/types.ts | 22 + src/features/crm/shared/project-party.ts | 78 + .../foundation/master-options/types.ts | 2 +- ...4a5e043db-QT-TASK-H1-APPROVED-approved.pdf | Bin 0 -> 120859 bytes 39 files changed, 6254 insertions(+), 316 deletions(-) create mode 100644 docs/adr/0010-revenue-attribution-governance.md create mode 100644 docs/implementation/task-d2-billing-customer-project-parties.md create mode 100644 docs/implementation/task-d21-revenue-attribution-governance.md create mode 100644 docs/implementation/task-j0-kpi-definition-freeze.md create mode 100644 drizzle/0010_calm_wendell_rand.sql create mode 100644 drizzle/meta/0010_snapshot.json create mode 100644 plans/task-d.2.1.md create mode 100644 plans/task-d.2.md create mode 100644 plans/task-j.0.md create mode 100644 plans/task-j.md create mode 100644 src/app/api/crm/enquiries/[id]/customers/route.ts create mode 100644 src/features/crm/components/project-parties-editor.tsx create mode 100644 src/features/crm/reporting/server/service.ts create mode 100644 src/features/crm/reporting/server/types.ts create mode 100644 src/features/crm/shared/project-party.ts create mode 100644 storage/organizations/2330c067-2737-4443-bd6e-ccb8a6cf856b/quotations/approved/47efd8cb-4aca-486d-9370-3b44a5e043db-QT-TASK-H1-APPROVED-approved.pdf diff --git a/docs/adr/0010-revenue-attribution-governance.md b/docs/adr/0010-revenue-attribution-governance.md new file mode 100644 index 0000000..0daa3c5 --- /dev/null +++ b/docs/adr/0010-revenue-attribution-governance.md @@ -0,0 +1,36 @@ +Status: +accepted + +Context: +Task D.2 introduced separate `Billing Customer` and `Project Parties` data entry in CRM enquiries and quotations. +Task J.0 froze KPI definitions and confirmed that dashboard/reporting behavior must not drift without an explicit governance decision. +Task D.2.1 freezes how revenue attribution and relationship reporting work across dashboard, report center, exports, and future analytics features. + +Decision: + +- Freeze `Revenue Owner = End Customer`. +- Determine `Revenue Owner` from project-party role code `end_customer`. +- If a record has no `end_customer`, fallback to `billing_customer` for revenue-owner attribution only. +- Freeze `Billing Revenue` as quotation revenue grouped by project-party role `billing_customer`. +- Freeze `Contractor Revenue` as quotation revenue grouped by project-party role `contractor`. +- Freeze `Consultant Revenue` as quotation revenue grouped by project-party role `consultant`. +- Use project-party relationships as the reporting authority, not bare `crm_customers` references alone. +- Prefer quotation project parties for reporting. If quotation project parties are absent, fallback to enquiry project parties for the linked enquiry. +- When multiple parties share the same reporting role on one quotation, each party receives full quotation attribution. +- Do not prorate relationship analytics across multiple parties because this model is for CRM relationship reporting, not accounting allocation. +- Exclude cancelled quotations from revenue attribution by default unless an explicit reporting filter intentionally includes them. + +Consequences: + +- Dashboard KPI, report center, exports, and ad hoc analytics can share one attribution rule set. +- `Top End Customers`, `Top Contractors`, `Top Consultants`, and `Top Billing Customers` can be built from the same reusable service layer. +- A single quotation may contribute full value to more than one customer in relationship analytics when multiple parties share a role. +- Revenue attribution and accounting allocation remain intentionally separate concerns. +- Legacy or incomplete quotations without project-party rows may not attribute revenue until project-party data is synchronized or backfilled. + +Future: + +- add a unified reporting view or materialized projection if revenue analytics queries become heavy +- add explicit lifecycle timestamps for won/lost stage reporting precision +- decide whether approved snapshots should become the long-term reporting source for historically locked documents +- add backfill tooling if legacy quotations are found without authoritative project-party rows diff --git a/docs/implementation/task-d2-billing-customer-project-parties.md b/docs/implementation/task-d2-billing-customer-project-parties.md new file mode 100644 index 0000000..acc9bda --- /dev/null +++ b/docs/implementation/task-d2-billing-customer-project-parties.md @@ -0,0 +1,39 @@ +# Task D.2 Billing Customer + Project Parties + +## Scope delivered + +- Kept the main CRM field named `Billing Customer` on enquiry and quotation forms/detail pages. +- Added a separate `Project Parties` model for enquiries and aligned quotation related customers to the same business concept. +- Added `remark` support on quotation project parties and introduced enquiry project-party persistence. +- Switched role sourcing to the shared master-option category `crm_project_party_role`. +- Added compatibility mapping so legacy quotation roles such as `owner` and `billing` still render correctly. + +## Data model + +- Added `crm_enquiry_customers` for enquiry project parties. +- Added `remark` column to `crm_quotation_customers`. +- Generated migration `drizzle/0010_calm_wendell_rand.sql`. + +## Server behavior + +- Enquiry create/update now sync `Project Parties` transactionally. +- Quotation create/update now sync `Project Parties` transactionally. +- Billing customer is auto-added as a `billing_customer` project party during sync. +- Duplicate `customerId + roleCode` combinations are de-duplicated during main form sync and rejected in quotation detail CRUD. +- Quotation project-party reads normalize legacy role codes to current shared role options. + +## UI changes + +- Enquiry form now has `Billing Customer` plus a `Project Parties` editor. +- Enquiry detail now shows `Billing Customer` and `Project Parties` separately. +- Quotation form now has `Billing Customer` plus a `Project Parties` editor. +- Quotation detail overview separates `Billing Customer` and `Project Parties`. +- Quotation customer tab is relabeled to `Project Parties`, shows role clearly, and supports remark editing. + +## Verification + +- `npx tsc --noEmit` passes. +- `npm run lint` still fails because of pre-existing repo issues outside this task, including: + - `src/components/ui/input-group.tsx` + - `src/features/chat/components/message-composer.tsx` + - `src/components/forms/demo-form.tsx` diff --git a/docs/implementation/task-d21-revenue-attribution-governance.md b/docs/implementation/task-d21-revenue-attribution-governance.md new file mode 100644 index 0000000..ef8059d --- /dev/null +++ b/docs/implementation/task-d21-revenue-attribution-governance.md @@ -0,0 +1,76 @@ +# Task D.2.1: Revenue Attribution and Party Governance + +## Scope delivered + +- Froze the revenue attribution rules for CRM reporting. +- Froze project-party reporting authority so dashboard and reports use the same source of truth. +- Added reusable server-side helpers for grouped revenue analytics by party role. +- Added ADR governance so future reporting changes require an explicit decision trail. + +## Frozen rules + +### Revenue owner + +- `Revenue Owner = End Customer` +- Authority: project-party role code `end_customer` +- Fallback: if no `end_customer` exists, use `billing_customer` + +### Relationship revenue dimensions + +- `Billing Revenue` = quotation revenue grouped by project-party role `billing_customer` +- `Contractor Revenue` = quotation revenue grouped by project-party role `contractor` +- `Consultant Revenue` = quotation revenue grouped by project-party role `consultant` + +### Multiple party behavior + +- When multiple parties share the same role on a quotation, each party receives full quotation attribution. +- This is intentional CRM relationship analytics behavior. +- No proration is applied. + +### Reporting authority + +- Reporting must use project-party relationships, not raw customer references by themselves. +- Service logic prefers `crm_quotation_customers`. +- If quotation parties are missing, service logic falls back to `crm_enquiry_customers` on the linked enquiry. +- Cancelled quotations are excluded by default from revenue attribution. + +## Service layer added + +Added server-only reporting helpers under `src/features/crm/reporting/server/`: + +- `getRevenueByEndCustomer()` +- `getRevenueByBillingCustomer()` +- `getRevenueByContractor()` +- `getRevenueByConsultant()` + +Shared behavior: + +- organization scoped +- supports active quotation/report filters +- groups revenue by customer +- counts quotation coverage per attributed customer +- respects end-customer fallback to billing-customer for revenue-owner attribution + +## Governance added + +- Added ADR [0010-revenue-attribution-governance.md](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/docs/adr/0010-revenue-attribution-governance.md) +- This ADR freezes: + - revenue owner rule + - project-party reporting rule + - fallback rule + - multiple-party rule + +## Remaining risks + +- quotations and enquiries still use separate party tables, so analytics currently depends on a precedence rule rather than a unified reporting projection +- legacy records without synchronized project-party rows may not attribute revenue until backfill or edit/save normalization happens +- historical analytics still reflects current project-party state, not an effective-dated party snapshot + +## Task J readiness + +- revenue attribution rules frozen +- revenue owner frozen +- project-party reporting rules frozen +- KPI dimensions frozen for reporting by end customer, billing customer, contractor, and consultant +- reusable service layer added for dashboard/report reuse +- ADR added diff --git a/docs/implementation/task-j0-kpi-definition-freeze.md b/docs/implementation/task-j0-kpi-definition-freeze.md new file mode 100644 index 0000000..a0e5c2f --- /dev/null +++ b/docs/implementation/task-j0-kpi-definition-freeze.md @@ -0,0 +1,139 @@ +# Task J.0: KPI Definition Freeze + +## Scope delivered + +- Froze the CRM sales pipeline definitions that Dashboard KPI must use. +- Froze customer party terminology so enquiry, quotation, and dashboard reporting use the same business language. +- Froze KPI and revenue formulas before Task J dashboard work begins. +- Declared that KPI definition changes after this point require a new ADR or equivalent decision record. + +## Governance rule + +- After Task J.0, KPI definitions, pipeline meanings, and dashboard counting rules must not change silently. +- Any future change to these definitions requires a new ADR so reporting behavior stays auditable. + +## Frozen sales pipeline + +### Lead + +- Definition: enquiry is not assigned to sales yet. +- Rule: `assignedToUserId = null` +- Rule: `pipelineStage = lead` +- KPI impact: `Lead Count`, `Lead Aging`, `Lead Source`, `New Leads`, `Unassigned Leads` + +### Opportunity + +- Definition: enquiry has already been assigned to sales. +- Rule: `assignedToUserId != null` +- Rule: `pipelineStage = opportunity` +- Auto transition: assigning sales moves the record from `Lead` to `Opportunity` +- KPI impact: `Opportunity Count`, `Opportunity Value`, `Hot Opportunities`, `Opportunity Aging`, `Open Opportunities` + +### Closed Won + +- Definition: customer confirms the award, or the project is officially awarded. +- Rule: `pipelineStage = closed_won` + +### Closed Lost + +- Definition: competitor wins, budget is not approved, customer cancels, or project does not proceed. +- Rule: `pipelineStage = closed_lost` + +## Frozen opportunity lifecycle + +```txt +Lead +-> Assign Sales +Opportunity +-> Create Quotation +Opportunity +-> Approved Quotation +Opportunity +-> Award +Closed Won +``` + +## Frozen customer party definitions + +- `Billing Customer`: the company used for quotation issue and billing. +- `End Customer`: the real project owner. +- `Contractor`: the construction contractor or main contractor. +- `Consultant`: the project consultant. +- `Customer`: a general commercial counterparty or related party when a more specific role is not applicable. + +These terms are frozen as the shared language for CRM forms, detail views, and future dashboard/reporting logic. + +## Frozen KPI definitions + +### Lead metrics + +- `Lead Count` = enquiries where `pipelineStage = lead` +- `New Leads` = lead-stage enquiries created within the selected reporting period +- `Unassigned Leads` = lead-stage enquiries where `assignedToUserId = null` +- `Lead Aging` = age of lead-stage enquiries based on creation date until current/report date + +### Opportunity metrics + +- `Opportunity Count` = enquiries where `pipelineStage = opportunity` +- `Open Opportunities` = opportunities still active and not moved to `closed_won` or `closed_lost` +- `Hot Opportunities` = opportunity-stage enquiries flagged by the future dashboard/business rule layer +- `Opportunity Aging` = age of opportunity-stage enquiries based on entry into active opportunity lifecycle until current/report date + +### Conversion metrics + +- `Lead -> Opportunity` = `Opportunity Created / Total Leads` +- `Opportunity -> Quotation` = `Opportunity With Quotation / Total Opportunities` +- `Quotation -> Won` = `Closed Won / Total Opportunities With Quotation` + +## Frozen revenue rules + +### Opportunity value + +- Source: `crm_enquiries.estimatedValue` +- Condition: only count rows where `pipelineStage = opportunity` + +### Quotation value + +- Source: `crm_quotations.totalAmount` +- Condition: count quotations where `status != cancelled` + +### Won value + +- Source: `crm_quotations.totalAmount` +- Condition: count quotation-linked business only when parent enquiry `pipelineStage = closed_won` + +### Lost value + +- Source: `crm_quotations.totalAmount` +- Condition: count quotation-linked business only when parent enquiry `pipelineStage = closed_lost` + +## Frozen ownership rules for reporting + +- Dashboard ownership for `Lead` and `Opportunity` must use `assignedToUserId`. +- Dashboard ownership for `Quotation` must use `salesmanId`. + +This prevents mixing enquiry ownership with quotation ownership in the same KPI bucket. + +## Explicit non-scope items + +Task J.0 intentionally does not add lifecycle timestamp fields or new master-data defaults. + +Deferred fields: + +- `convertedToOpportunityAt` +- `closedWonAt` +- `closedLostAt` +- `lostReason` +- `defaultPartyRole` + +These remain technical debt and should be added only when downstream reporting or workflow requirements justify schema changes. + +## Dashboard readiness + +Task J can now build CRM dashboard KPIs on top of these frozen definitions: + +- KPI definitions frozen +- sales pipeline frozen +- customer party definitions frozen +- revenue rules frozen +- dashboard semantics ready for implementation diff --git a/docs/implementation/technical-debt.md b/docs/implementation/technical-debt.md index 466c9f4..73d9145 100644 --- a/docs/implementation/technical-debt.md +++ b/docs/implementation/technical-debt.md @@ -242,3 +242,58 @@ Future: - decide when direct signed URLs are acceptable versus mandatory server streaming - add response caching strategy for large artifact downloads - evaluate optional download-audit sampling if volume grows + +## After Task J.0 + +### Missing lifecycle timestamps for KPI precision + +Task J.0 freezes KPI rules around `pipelineStage`, but the schema still lacks explicit lifecycle timestamps for stage conversion and closure events. + +Future: + +- add `convertedToOpportunityAt` for precise lead-to-opportunity timing +- add `closedWonAt` for won-date reporting +- add `closedLostAt` for lost-date reporting +- backfill reporting logic to prefer explicit lifecycle timestamps over inference when available + +### Lost reason analytics + +Closed-lost semantics are frozen, but there is still no structured `lostReason` field for dashboard grouping and sales analysis. + +Future: + +- add `lostReason` to the CRM enquiry lifecycle model +- define a controlled vocabulary or master-option category for loss analysis +- expose lost-reason reporting only after the field is reliably captured + +### Default customer party role behavior + +Customer party definitions are frozen, but there is still no `defaultPartyRole` behavior to help normalize customer selection and reduce operator ambiguity. + +Future: + +- decide whether customers should carry a suggested default project-party role +- add `defaultPartyRole` only if it improves data-entry quality without creating hidden automation +- keep dashboard logic independent from this field until the model is formally introduced + +## After Task D.2.1 + +### Unified project-party reporting projection + +Revenue attribution is now frozen, but the schema still stores enquiry and quotation parties in separate tables rather than a dedicated reporting projection. + +Future: + +- add a unified reporting view or materialized projection if dashboard/report queries become heavy +- keep quotation-party precedence and enquiry fallback centralized in one service until that projection exists +- avoid duplicating attribution logic in dashboard-specific code + +### Historical party snapshot fidelity + +Current revenue attribution uses live project-party relationships with quotation-first fallback behavior, but it does not yet provide effective-dated historical party snapshots for reporting. + +Future: + +- decide whether approved quotation snapshots or another immutable reporting snapshot should become the historical source of truth +- define how reporting should behave if project parties change after quotation approval or after a won/lost outcome +- add backfill or reconciliation tooling if historical party drift becomes a business issue diff --git a/drizzle/0010_calm_wendell_rand.sql b/drizzle/0010_calm_wendell_rand.sql new file mode 100644 index 0000000..e0ddad3 --- /dev/null +++ b/drizzle/0010_calm_wendell_rand.sql @@ -0,0 +1,13 @@ +CREATE TABLE "crm_enquiry_customers" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "enquiry_id" text NOT NULL, + "customer_id" text NOT NULL, + "role" text 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 +ALTER TABLE "crm_quotation_customers" ADD COLUMN "remark" text; \ No newline at end of file diff --git a/drizzle/meta/0010_snapshot.json b/drizzle/meta/0010_snapshot.json new file mode 100644 index 0000000..fb702b0 --- /dev/null +++ b/drizzle/meta/0010_snapshot.json @@ -0,0 +1,3267 @@ +{ + "id": "2528d9c4-a324-4654-866e-e1e1fef68be4", + "prevId": "ef6e9c14-9f8a-42aa-bbd3-4638dccea8a4", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.crm_approval_actions": { + "name": "crm_approval_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approval_request_id": { + "name": "approval_request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "step_number": { + "name": "step_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acted_by": { + "name": "acted_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "acted_at": { + "name": "acted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_approval_requests": { + "name": "crm_approval_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_step": { + "name": "current_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_approval_steps": { + "name": "crm_approval_steps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "step_number": { + "name": "step_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role_code": { + "name": "role_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role_name": { + "name": "role_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_required": { + "name": "is_required", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "crm_approval_steps_workflow_step_idx": { + "name": "crm_approval_steps_workflow_step_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "step_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_approval_workflows": { + "name": "crm_approval_workflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "crm_approval_workflows_org_code_idx": { + "name": "crm_approval_workflows_org_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_customer_contacts": { + "name": "crm_customer_contacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "department": { + "name": "department", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mobile": { + "name": "mobile", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_customers": { + "name": "crm_customers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "abbr": { + "name": "abbr", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tax_id": { + "name": "tax_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_type": { + "name": "customer_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_status": { + "name": "customer_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "province": { + "name": "province", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "district": { + "name": "district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sub_district": { + "name": "sub_district", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "postal_code": { + "name": "postal_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fax": { + "name": "fax", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lead_channel": { + "name": "lead_channel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_group": { + "name": "customer_group", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "assigned_to_user_id": { + "name": "assigned_to_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignment_remark": { + "name": "assignment_remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "crm_enquiries_org_code_idx": { + "name": "crm_enquiries_org_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_enquiry_customers": { + "name": "crm_enquiry_customers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enquiry_id": { + "name": "enquiry_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_enquiry_followups": { + "name": "crm_enquiry_followups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enquiry_id": { + "name": "enquiry_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "followup_date": { + "name": "followup_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "followup_type": { + "name": "followup_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_followup_date": { + "name": "next_followup_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action": { + "name": "next_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotation_attachments": { + "name": "crm_quotation_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quotation_id": { + "name": "quotation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_file_name": { + "name": "original_file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "file_type": { + "name": "file_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotation_customers": { + "name": "crm_quotation_customers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quotation_id": { + "name": "quotation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotation_followups": { + "name": "crm_quotation_followups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quotation_id": { + "name": "quotation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "followup_date": { + "name": "followup_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "followup_type": { + "name": "followup_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_followup_date": { + "name": "next_followup_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_action": { + "name": "next_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotation_items": { + "name": "crm_quotation_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quotation_id": { + "name": "quotation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_number": { + "name": "item_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "product_type": { + "name": "product_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unit_price": { + "name": "unit_price", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "discount": { + "name": "discount", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "discount_type": { + "name": "discount_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tax_rate": { + "name": "tax_rate", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_price": { + "name": "total_price", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotation_topic_items": { + "name": "crm_quotation_topic_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "topic_id": { + "name": "topic_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotation_topics": { + "name": "crm_quotation_topics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quotation_id": { + "name": "quotation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "topic_type": { + "name": "topic_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.crm_quotations": { + "name": "crm_quotations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enquiry_id": { + "name": "enquiry_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quotation_date": { + "name": "quotation_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "valid_until": { + "name": "valid_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "quotation_type": { + "name": "quotation_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_name": { + "name": "project_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_location": { + "name": "project_location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attention": { + "name": "attention", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference": { + "name": "reference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "parent_quotation_id": { + "name": "parent_quotation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision_remark": { + "name": "revision_remark", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "exchange_rate": { + "name": "exchange_rate", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "subtotal": { + "name": "subtotal", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "discount": { + "name": "discount", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "discount_type": { + "name": "discount_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tax_rate": { + "name": "tax_rate", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tax_amount": { + "name": "tax_amount", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_amount": { + "name": "total_amount", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "chance_percent": { + "name": "chance_percent", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_hot_project": { + "name": "is_hot_project", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "competitor": { + "name": "competitor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "salesman_id": { + "name": "salesman_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_sent": { + "name": "is_sent", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sent_via": { + "name": "sent_via", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "approved_artifact_id": { + "name": "approved_artifact_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_pdf_url": { + "name": "approved_pdf_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_snapshot": { + "name": "approved_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "approved_template_version_id": { + "name": "approved_template_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rejected_at": { + "name": "rejected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "crm_quotations_org_code_idx": { + "name": "crm_quotations_org_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_sequences": { + "name": "document_sequences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "document_type": { + "name": "document_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period": { + "name": "period", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_number": { + "name": "current_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "padding_length": { + "name": "padding_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "document_sequences_org_doc_period_branch_idx": { + "name": "document_sequences_org_doc_period_branch_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "branch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "business_role": { + "name": "business_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'sales_support'" + }, + "permissions": { + "name": "permissions", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ms_options": { + "name": "ms_options", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ms_options_org_category_code_idx": { + "name": "ms_options_org_category_code_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organizations_slug_idx": { + "name": "organizations_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.products": { + "name": "products", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "products_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "photo_url": { + "name": "photo_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "price": { + "name": "price", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tr_audit_logs": { + "name": "tr_audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_id": { + "name": "branch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "before_data": { + "name": "before_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after_data": { + "name": "after_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "system_role": { + "name": "system_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index a5ab9d0..a399140 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1781603421415, "tag": "0009_lazy_shard", "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1781675409847, + "tag": "0010_calm_wendell_rand", + "breakpoints": true } ] } \ No newline at end of file diff --git a/plans/task-d.2.1.md b/plans/task-d.2.1.md new file mode 100644 index 0000000..9b636db --- /dev/null +++ b/plans/task-d.2.1.md @@ -0,0 +1,370 @@ +# Task D.2.1: Revenue Attribution & Party Governance + +## Goal + +Freeze Revenue Attribution Rules และ Project Party Reporting Rules สำหรับ ALLA OS CRM + +หลัง Task D.2.1 ผ่านแล้ว: + +* Dashboard KPI +* Report Center +* Export +* Sales Analytics + +ต้องใช้กติกาชุดเดียวกัน + +ห้ามแต่ละ module ตีความ Revenue Owner เอง + +--- + +# Business Problem + +ตัวอย่าง: + +Billing Customer: + +```txt +Italian Thai +``` + +Project Parties: + +```txt +PTT -> End Customer +Italian Thai -> Contractor +AEC -> Consultant +ALLA -> Billing Customer +``` + +Quotation: + +```txt +5,000,000 THB +``` + +คำถาม: + +```txt +Revenue นี้เป็นของใคร? +``` + +ถ้าไม่ Freeze ตอนนี้ + +Dashboard และ Report จะให้คำตอบไม่ตรงกัน + +--- + +# Scope D2.1.1 Revenue Attribution Rule + +## Revenue Owner + +Freeze: + +```txt +Revenue Owner = End Customer +``` + +Rules: + +* ใช้ Project Party role = `end_customer` +* ถ้าไม่มี end_customer ให้ใช้ billing_customer fallback +* Dashboard ต้องใช้ Revenue Owner ในการจัดอันดับลูกค้า + +Example: + +```txt +Quotation = 5,000,000 + +Billing Customer: +Italian Thai + +End Customer: +PTT +``` + +Revenue Attribution: + +```txt +PTT = 5,000,000 +``` + +--- + +# Scope D2.1.2 Billing Customer Revenue + +Freeze: + +```txt +Billing Revenue +``` + +Definition: + +```txt +Sum quotation value +where party role = billing_customer +``` + +Purpose: + +```txt +Commercial relationship reporting +``` + +Example: + +```txt +Italian Thai +``` + +ได้รับ Billing Revenue + +--- + +# Scope D2.1.3 Contractor Revenue + +Freeze: + +```txt +Contractor Revenue +``` + +Definition: + +```txt +Sum quotation value +where party role = contractor +``` + +Purpose: + +```txt +Contractor relationship reporting +``` + +--- + +# Scope D2.1.4 Consultant Revenue + +Freeze: + +```txt +Consultant Revenue +``` + +Definition: + +```txt +Sum quotation value +where party role = consultant +``` + +Purpose: + +```txt +Consultant relationship reporting +``` + +--- + +# Scope D2.1.5 Multiple Party Rule + +Example: + +```txt +Contractor A +Contractor B +``` + +Rules: + +```txt +Both receive full attribution +``` + +Meaning: + +```txt +Revenue = 5,000,000 + +Contractor A = 5,000,000 +Contractor B = 5,000,000 +``` + +Reason: + +```txt +CRM relationship analytics +Not accounting allocation +``` + +Do NOT prorate. + +--- + +# Scope D2.1.6 Dashboard Reporting Rule + +Dashboard must use: + +```txt +crm_project_parties +crm_enquiry_customers +crm_quotation_customers +``` + +Dashboard must NOT use: + +```txt +crm_customers only +``` + +as revenue source. + +Project Party role is the reporting authority. + +--- + +# Scope D2.1.7 KPI Extension + +Freeze new KPI dimensions: + +## End Customer + +```txt +Top End Customers +Revenue by End Customer +Won Revenue by End Customer +Lost Revenue by End Customer +``` + +--- + +## Contractor + +```txt +Top Contractors +Revenue by Contractor +Won Revenue by Contractor +``` + +--- + +## Consultant + +```txt +Top Consultants +Revenue by Consultant +Won Revenue by Consultant +``` + +--- + +## Billing Customer + +```txt +Top Billing Customers +Revenue by Billing Customer +``` + +--- + +# Scope D2.1.8 Service Layer + +Create reporting helpers: + +```ts +getRevenueByEndCustomer() +getRevenueByBillingCustomer() +getRevenueByContractor() +getRevenueByConsultant() +``` + +Rules: + +* organization scoped +* respect active filters +* reusable by Dashboard and Reports + +No UI required. + +--- + +# Scope D2.1.9 Governance Documentation + +Create ADR: + +```txt +Revenue Attribution Governance +``` + +Document: + +```txt +Revenue Owner Rule +Project Party Reporting Rule +Fallback Rule +Multiple Party Rule +``` + +Update: + +```txt +docs/implementation/technical-debt.md +``` + +if any unresolved reporting edge cases are discovered. + +--- + +# Explicit Non-Scope + +Do NOT implement: + +```txt +Dashboard UI +Charts +Export +Forecasting +Commission calculation +Accounting allocation +Profitability reporting +``` + +This task is governance only. + +--- + +# Output + +1. Revenue Attribution Rules Frozen +2. Revenue Owner Frozen +3. Project Party Reporting Rules Frozen +4. KPI Dimensions Frozen +5. Service Layer Added +6. ADR Added +7. Remaining Risks +8. Task J Readiness + +--- + +# Definition of Done + +✔ Revenue Owner frozen + +✔ End Customer attribution frozen + +✔ Billing attribution frozen + +✔ Contractor attribution frozen + +✔ Consultant attribution frozen + +✔ Multiple party rule frozen + +✔ Dashboard reporting rule frozen + +✔ KPI dimensions frozen + +✔ ADR created + +✔ Ready for Task J Dashboard KPI diff --git a/plans/task-d.2.md b/plans/task-d.2.md new file mode 100644 index 0000000..51d835b --- /dev/null +++ b/plans/task-d.2.md @@ -0,0 +1,219 @@ +# Addendum for Task D.2: Customer Role Selection UI + Separate Project Party Field + +## Context + +ตอนนี้การเลือก Customer ใน Lead / Opportunity / Quotation ใช้งานได้แล้ว แต่ยังไม่ชัดว่า Customer ที่เลือกมีบทบาทอะไรในโครงการ + +Business requirement ใหม่: + +* Customer หลักในฟอร์มยังใช้เป็น Billing Customer ได้ +* แต่ต้องมี Project Party แยกอีกชุดหนึ่ง +* Project Party ใช้บอกว่าในโครงการนี้มีบริษัทไหนทำ role อะไร +* บริษัทเดียวกันอาจมี role ต่างกันในแต่ละโครงการ + +--- + +# UI Requirement 1: Billing Customer Field + +ใน Lead / Opportunity / Quotation form ให้คง field หลัก: + +```txt +Billing Customer +``` + +Data source: + +```txt +crm_customers +``` + +Meaning: + +```txt +ลูกค้าที่ใช้เป็นผู้ติดต่อ/ผู้ออกเอกสาร/ผู้วางบิลหลัก +``` + +Do not rename this field to generic Customer. + +--- + +# UI Requirement 2: Project Party Section + +เพิ่ม section แยกชื่อ: + +```txt +Project Parties +``` + +หรือภาษาไทย: + +```txt +บริษัทที่เกี่ยวข้องในโครงการ +``` + +ใน form/detail ของ: + +```txt +Lead / Opportunity +Quotation +``` + +Project Party row ต้องมี field: + +```txt +Customer +Role +Remark +``` + +Role ดึงจาก master option: + +```txt +crm_project_party_role +``` + +Role values: + +```txt +billing_customer +customer +consultant +contractor +end_customer +other +``` + +--- + +# UI Requirement 3: Add Party Flow + +ใน Project Parties section ต้องมีปุ่ม: + +```txt +Add Party +``` + +เมื่อกดให้เลือก: + +```txt +Customer +Role +Remark optional +``` + +ถ้า customer ยังไม่มีใน master ให้ยังไม่ต้องสร้าง inline customer ใน Task นี้ +ให้ใช้ customer master ที่มีอยู่ก่อน + +--- + +# UI Requirement 4: Display Role Clearly + +ทุกที่ที่แสดง Project Party ต้องแสดง role ชัดเจน เช่น: + +```txt +PTT Public Company Limited +Role: End Customer + +Italian Thai +Role: Contractor + +AEC Consultant +Role: Consultant +``` + +ห้ามแสดงแค่ชื่อบริษัทโดยไม่มี role + +--- + +# UI Requirement 5: Billing Customer Auto Add Option + +เมื่อสร้าง Lead / Opportunity / Quotation แล้วเลือก Billing Customer + +ให้ระบบสามารถ auto-add ลง Project Parties เป็น role: + +```txt +billing_customer +``` + +ถ้าทำได้โดยไม่ซับซ้อน + +Rules: + +* ห้าม duplicate customerId + roleCode ใน entity เดียวกัน +* ถ้า billing customer มีอยู่แล้วใน Project Parties ไม่ต้องเพิ่มซ้ำ +* ถ้า user เปลี่ยน Billing Customer ให้ถาม/หรือ update ตาม business-safe behavior + +ถ้า logic นี้ใหญ่เกินไป ให้ทำเป็น TODO และไม่ block Task D.2 + +--- + +# UI Requirement 6: Detail Page + +ใน detail page ให้แสดงแยกชัดเจน: + +```txt +Billing Customer +``` + +และ + +```txt +Project Parties +``` + +ตัวอย่าง: + +```txt +Billing Customer: +ABC Engineering Co., Ltd. + +Project Parties: +- ABC Engineering Co., Ltd. / Billing Customer +- PTT / End Customer +- Italian Thai / Contractor +- AEC / Consultant +``` + +--- + +# UI Requirement 7: Quotation Compatibility + +Quotation เดิมมี: + +```txt +crm_quotation_customers +``` + +ให้ refactor/align กับ Project Parties + +แต่ UI ต้องแสดงแบบเดียวกัน: + +```txt +Billing Customer +Project Parties +``` + +อย่าใช้คำว่า Owner ถ้า business ไม่ใช้แล้ว + +Mapping เก่า: + +```txt +owner -> customer +billing -> billing_customer +consultant -> consultant +contractor -> contractor +``` + +--- + +# Definition of Done Addendum + +Task D.2 จะถือว่าครบเมื่อ: + +* Billing Customer เป็น field หลักแยกจาก Project Parties +* Project Parties เป็น section/table แยก +* แต่ละ Project Party มี Customer + Role + Remark +* Role ดึงจาก master option +* Detail page แสดง Billing Customer และ Project Parties แยกกัน +* Quotation compatibility mapping ไม่ทำให้ข้อมูลเก่าหาย +* ไม่มี UI ที่แสดงบริษัทเกี่ยวข้องโดยไม่บอก role diff --git a/plans/task-j.0.md b/plans/task-j.0.md new file mode 100644 index 0000000..530e80f --- /dev/null +++ b/plans/task-j.0.md @@ -0,0 +1,421 @@ +# Task J.0: KPI Definition Freeze + +## Goal + +Freeze KPI Definitions และ Sales Pipeline Definitions ก่อนเริ่มสร้าง CRM Dashboard KPI + +หลัง Task J.0 ผ่านแล้ว ห้ามเปลี่ยนนิยาม KPI โดยไม่มี ADR ใหม่ + +--- + +# Sales Pipeline Definition + +## Lead + +Definition: + +```txt +ยังไม่ได้ Assign Sales +``` + +Rules: + +```txt +assignedToUserId = null +pipelineStage = lead +``` + +KPI Impact: + +```txt +Lead Count +Lead Aging +Lead Source +New Leads +``` + +--- + +## Opportunity + +Definition: + +```txt +Assign Sales แล้ว +``` + +Rules: + +```txt +assignedToUserId != null +pipelineStage = opportunity +``` + +Auto Transition: + +```txt +Lead +↓ Assign Sales +Opportunity +``` + +KPI Impact: + +```txt +Opportunity Count +Opportunity Value +Hot Opportunities +Opportunity Aging +``` + +--- + +## Closed Won + +Definition: + +```txt +ลูกค้ายืนยันรับงาน +หรือโครงการได้รับ Award +``` + +Rules: + +```txt +pipelineStage = closed_won +``` + +Recommendation: + +Add future field: + +```txt +closedWonAt +``` + +--- + +## Closed Lost + +Definition: + +```txt +แพ้คู่แข่ง +งบประมาณไม่ผ่าน +ลูกค้ายกเลิก +โครงการไม่เดินต่อ +``` + +Rules: + +```txt +pipelineStage = closed_lost +``` + +Recommendation: + +Add future field: + +```txt +closedLostAt +lostReason +``` + +--- + +# Opportunity Lifecycle + +```txt +Lead +↓ Assign Sales +Opportunity +↓ Create Quotation +Opportunity +↓ Approved Quotation +Opportunity +↓ Award +Closed Won +``` + +--- + +# Customer Party Definition + +## Billing Customer + +Definition: + +```txt +บริษัทที่ใช้ในการออกใบเสนอราคา +หรือวางบิล +``` + +Examples: + +```txt +ALLA +Italian Thai +SCG +``` + +--- + +## End Customer + +Definition: + +```txt +เจ้าของโครงการตัวจริง +``` + +Examples: + +```txt +PTT +CP +SCG Chemicals +``` + +--- + +## Contractor + +Definition: + +```txt +ผู้รับเหมาก่อสร้าง +``` + +--- + +## Consultant + +Definition: + +```txt +ที่ปรึกษาโครงการ +``` + +--- + +## Customer + +Definition: + +```txt +คู่ค้าหรือผู้เกี่ยวข้องทั่วไป +``` + +--- + +# KPI Definitions + +## Lead Metrics + +```txt +Lead Count +New Leads +Unassigned Leads +Lead Aging +``` + +Lead Count: + +```txt +pipelineStage = lead +``` + +--- + +## Opportunity Metrics + +```txt +Opportunity Count +Open Opportunities +Hot Opportunities +``` + +Opportunity Count: + +```txt +pipelineStage = opportunity +``` + +--- + +## Conversion Metrics + +### Lead → Opportunity + +Formula: + +```txt +Opportunity Created +/ +Total Leads +``` + +--- + +### Opportunity → Quotation + +Formula: + +```txt +Opportunity With Quotation +/ +Total Opportunities +``` + +--- + +### Quotation → Won + +Formula: + +```txt +Closed Won +/ +Total Opportunities With Quotation +``` + +--- + +# Revenue Metrics + +## Opportunity Value + +Source: + +```txt +crm_enquiries.estimatedValue +``` + +Condition: + +```txt +pipelineStage = opportunity +``` + +--- + +## Quotation Value + +Source: + +```txt +crm_quotations.totalAmount +``` + +Condition: + +```txt +quotation status != cancelled +``` + +--- + +## Won Value + +Source: + +```txt +crm_quotations.totalAmount +``` + +Condition: + +```txt +pipelineStage = closed_won +``` + +--- + +## Lost Value + +Source: + +```txt +crm_quotations.totalAmount +``` + +Condition: + +```txt +pipelineStage = closed_lost +``` + +--- + +# Sales Ownership + +Dashboard must use: + +```txt +assignedToUserId +``` + +for: + +```txt +Lead +Opportunity +``` + +and + +```txt +salesmanId +``` + +for: + +```txt +Quotation +``` + +--- + +# Future Fields (Not In Scope) + +Do NOT implement now. + +Track as technical debt: + +```txt +convertedToOpportunityAt +closedWonAt +closedLostAt +lostReason +defaultPartyRole +``` + +--- + +# Output + +1. KPI Definitions Frozen +2. Sales Pipeline Frozen +3. Customer Party Definitions Frozen +4. Revenue Rules Frozen +5. Dashboard Ready + +--- + +# Definition of Done + +✔ Lead definition frozen + +✔ Opportunity definition frozen + +✔ Won/Lost definition frozen + +✔ Billing Customer definition frozen + +✔ Project Party role definition frozen + +✔ KPI formulas frozen + +✔ Revenue formulas frozen + +✔ Ready for Task J diff --git a/plans/task-j.md b/plans/task-j.md new file mode 100644 index 0000000..e69de29 diff --git a/src/app/api/crm/enquiries/[id]/customers/route.ts b/src/app/api/crm/enquiries/[id]/customers/route.ts new file mode 100644 index 0000000..f3b82de --- /dev/null +++ b/src/app/api/crm/enquiries/[id]/customers/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { listEnquiryProjectParties } 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 GET(_request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmEnquiryRead + }); + const items = await listEnquiryProjectParties(id, organization.id); + + return NextResponse.json({ + success: true, + time: new Date().toISOString(), + message: 'Enquiry project parties 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 enquiry project parties' }, + { status: 500 } + ); + } +} diff --git a/src/app/dashboard/crm/enquiries/[id]/page.tsx b/src/app/dashboard/crm/enquiries/[id]/page.tsx index 2c62b62..12ab477 100644 --- a/src/app/dashboard/crm/enquiries/[id]/page.tsx +++ b/src/app/dashboard/crm/enquiries/[id]/page.tsx @@ -1,7 +1,11 @@ import { auth } from '@/auth'; import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; import PageContainer from '@/components/layout/page-container'; -import { enquiryByIdOptions, enquiryFollowupsOptions } from '@/features/crm/enquiries/api/queries'; +import { + enquiryByIdOptions, + enquiryFollowupsOptions, + enquiryProjectPartiesOptions +} from '@/features/crm/enquiries/api/queries'; import { EnquiryDetail } from '@/features/crm/enquiries/components/enquiry-detail'; import { getEnquiryReferenceData } from '@/features/crm/enquiries/server/service'; import { listEnquiryQuotationRelations } from '@/features/crm/quotations/server/service'; @@ -54,6 +58,7 @@ export default async function EnquiryDetailRoute({ params }: PageProps) { if (canRead) { void queryClient.prefetchQuery(enquiryByIdOptions(id)); + void queryClient.prefetchQuery(enquiryProjectPartiesOptions(id)); void queryClient.prefetchQuery(enquiryFollowupsOptions(id)); } diff --git a/src/app/dashboard/crm/enquiries/page.tsx b/src/app/dashboard/crm/enquiries/page.tsx index d2a238c..076929a 100644 --- a/src/app/dashboard/crm/enquiries/page.tsx +++ b/src/app/dashboard/crm/enquiries/page.tsx @@ -1,14 +1,14 @@ -import { auth } from '@/auth'; -import PageContainer from '@/components/layout/page-container'; -import EnquiryListing from '@/features/crm/enquiries/components/enquiry-listing'; -import { EnquiryFormSheetTrigger } from '@/features/crm/enquiries/components/enquiry-form-sheet'; -import { getEnquiryReferenceData } from '@/features/crm/enquiries/server/service'; -import { PERMISSIONS } from '@/lib/auth/rbac'; -import { searchParamsCache } from '@/lib/searchparams'; -import type { SearchParams } from 'nuqs/server'; +import { auth } from "@/auth"; +import PageContainer from "@/components/layout/page-container"; +import EnquiryListing from "@/features/crm/enquiries/components/enquiry-listing"; +import { EnquiryFormSheetTrigger } from "@/features/crm/enquiries/components/enquiry-form-sheet"; +import { getEnquiryReferenceData } from "@/features/crm/enquiries/server/service"; +import { PERMISSIONS } from "@/lib/auth/rbac"; +import { searchParamsCache } from "@/lib/searchparams"; +import type { SearchParams } from "nuqs/server"; export const metadata = { - title: 'Dashboard: CRM Enquiries' + title: "Dashboard: CRM Enquiries", }; type PageProps = { @@ -19,35 +19,37 @@ export default async function EnquiriesRoute(props: PageProps) { const searchParams = await props.searchParams; const session = await auth(); const canRead = - session?.user?.systemRole === 'super_admin' || + session?.user?.systemRole === "super_admin" || (!!session?.user?.activeOrganizationId && - (session.user.activeMembershipRole === 'admin' || + (session.user.activeMembershipRole === "admin" || session.user.activePermissions.includes(PERMISSIONS.crmEnquiryRead))); const canCreate = - session?.user?.systemRole === 'super_admin' || + session?.user?.systemRole === "super_admin" || (!!session?.user?.activeOrganizationId && - (session.user.activeMembershipRole === 'admin' || + (session.user.activeMembershipRole === "admin" || session.user.activePermissions.includes(PERMISSIONS.crmEnquiryCreate))); const canUpdate = - session?.user?.systemRole === 'super_admin' || + session?.user?.systemRole === "super_admin" || (!!session?.user?.activeOrganizationId && - (session.user.activeMembershipRole === 'admin' || + (session.user.activeMembershipRole === "admin" || session.user.activePermissions.includes(PERMISSIONS.crmEnquiryUpdate))); const canDelete = - session?.user?.systemRole === 'super_admin' || + session?.user?.systemRole === "super_admin" || (!!session?.user?.activeOrganizationId && - (session.user.activeMembershipRole === 'admin' || + (session.user.activeMembershipRole === "admin" || session.user.activePermissions.includes(PERMISSIONS.crmEnquiryDelete))); const canAssign = - session?.user?.systemRole === 'super_admin' || + session?.user?.systemRole === "super_admin" || (!!session?.user?.activeOrganizationId && - (session.user.activeMembershipRole === 'admin' || + (session.user.activeMembershipRole === "admin" || session.user.activePermissions.includes(PERMISSIONS.crmEnquiryAssign))); const canReassign = - session?.user?.systemRole === 'super_admin' || + session?.user?.systemRole === "super_admin" || (!!session?.user?.activeOrganizationId && - (session.user.activeMembershipRole === 'admin' || - session.user.activePermissions.includes(PERMISSIONS.crmEnquiryReassign))); + (session.user.activeMembershipRole === "admin" || + session.user.activePermissions.includes( + PERMISSIONS.crmEnquiryReassign, + ))); searchParamsCache.parse(searchParams); @@ -58,11 +60,11 @@ export default async function EnquiriesRoute(props: PageProps) { return ( +
You do not have access to CRM enquiries.
} diff --git a/src/config/nav-config.ts b/src/config/nav-config.ts index 6c97f8e..ecfde5c 100644 --- a/src/config/nav-config.ts +++ b/src/config/nav-config.ts @@ -35,110 +35,113 @@ import { NavGroup } from "@/types"; */ export const navGroups: NavGroup[] = [ { - label: 'Overview', + label: "Overview", items: [ { - title: 'Dashboard', - url: '/dashboard', - icon: 'dashboard', + title: "Dashboard", + url: "/dashboard", + icon: "dashboard", isActive: false, - shortcut: ['d', 'd'], - items: [] + shortcut: ["d", "d"], + items: [], }, { - title: 'Workspaces', - url: '/dashboard/workspaces', - icon: 'workspace', + title: "Workspaces", + url: "/dashboard/workspaces", + icon: "workspace", isActive: false, items: [], - access: { systemRole: 'super_admin' } + access: { systemRole: "super_admin" }, }, { - title: 'Teams', - url: '/dashboard/workspaces/team', - icon: 'teams', + title: "Teams", + url: "/dashboard/workspaces/team", + icon: "teams", isActive: false, items: [], - access: { requireOrg: true, role: 'admin' } + access: { requireOrg: true, role: "admin" }, }, { - title: 'Users', - url: '/dashboard/users', - icon: 'teams', - shortcut: ['u', 'u'], + title: "Users", + url: "/dashboard/users", + icon: "teams", + shortcut: ["u", "u"], isActive: false, items: [], - access: { requireOrg: true, permission: 'users:manage' } + access: { requireOrg: true, permission: "users:manage" }, }, { - title: 'Kanban', - url: '/dashboard/kanban', - icon: 'kanban', - shortcut: ['k', 'k'], + title: "Kanban", + url: "/dashboard/kanban", + icon: "kanban", + shortcut: ["k", "k"], isActive: false, - items: [] - } - ] + items: [], + }, + ], }, { - label: 'CRM', + label: "CRM", items: [ { - title: 'Customers', - url: '/dashboard/crm/customers', - icon: 'teams', + title: "Customers", + url: "/dashboard/crm/customers", + icon: "teams", isActive: false, items: [], - access: { requireOrg: true, permission: 'crm.customer.read' } + access: { requireOrg: true, permission: "crm.customer.read" }, }, { - title: 'Enquiries', - url: '/dashboard/crm/enquiries', - icon: 'forms', + title: "Enquiries (Lead)", + url: "/dashboard/crm/enquiries", + icon: "forms", isActive: false, items: [], - access: { requireOrg: true, permission: 'crm.enquiry.read' } + access: { requireOrg: true, permission: "crm.enquiry.read" }, }, { - title: 'Quotations', - url: '/dashboard/crm/quotations', - icon: 'post', + title: "Quotations", + url: "/dashboard/crm/quotations", + icon: "post", isActive: false, items: [], - access: { requireOrg: true, role: 'admin' } + access: { requireOrg: true, role: "admin" }, }, { - title: 'Approvals', - url: '/dashboard/crm/approvals', - icon: 'checks', + title: "Approvals", + url: "/dashboard/crm/approvals", + icon: "checks", isActive: false, items: [], - access: { requireOrg: true, permission: 'crm.approval.read' } + access: { requireOrg: true, permission: "crm.approval.read" }, }, { - title: 'CRM Settings', - url: '/dashboard/crm/settings/master-options', - icon: 'settings', + title: "CRM Settings", + url: "/dashboard/crm/settings/master-options", + icon: "settings", isActive: false, - access: { requireOrg: true, permission: 'crm.document_template.read' }, + access: { requireOrg: true, permission: "crm.document_template.read" }, items: [ { - title: 'Master Options', - url: '/dashboard/crm/settings/master-options', - access: { requireOrg: true, permission: 'organization:manage' } + title: "Master Options", + url: "/dashboard/crm/settings/master-options", + access: { requireOrg: true, permission: "organization:manage" }, }, { - title: 'Document Sequences', - url: '/dashboard/crm/settings/document-sequences', - access: { requireOrg: true, permission: 'organization:manage' } + title: "Document Sequences", + url: "/dashboard/crm/settings/document-sequences", + access: { requireOrg: true, permission: "organization:manage" }, }, { - title: 'Templates', - url: '/dashboard/crm/settings/templates', - access: { requireOrg: true, permission: 'crm.document_template.read' } - } - ] - } - ] - } + title: "Templates", + url: "/dashboard/crm/settings/templates", + access: { + requireOrg: true, + permission: "crm.document_template.read", + }, + }, + ], + }, + ], + }, ]; diff --git a/src/db/schema.ts b/src/db/schema.ts index 70c5e31..939935e 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -251,6 +251,18 @@ export const crmEnquiryFollowups = pgTable('crm_enquiry_followups', { updatedBy: text('updated_by').notNull() }); +export const crmEnquiryCustomers = pgTable('crm_enquiry_customers', { + id: text('id').primaryKey(), + organizationId: text('organization_id').notNull(), + enquiryId: text('enquiry_id').notNull(), + customerId: text('customer_id').notNull(), + role: text('role').notNull(), + remark: text('remark'), + 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', { @@ -339,6 +351,7 @@ export const crmQuotationCustomers = pgTable('crm_quotation_customers', { customerId: text('customer_id').notNull(), role: text('role').notNull(), isPrimary: boolean('is_primary').default(false).notNull(), + remark: text('remark'), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), deletedAt: timestamp('deleted_at', { withTimezone: true }) diff --git a/src/db/seeds/foundation.seed.ts b/src/db/seeds/foundation.seed.ts index 604d3af..b46daea 100644 --- a/src/db/seeds/foundation.seed.ts +++ b/src/db/seeds/foundation.seed.ts @@ -171,11 +171,18 @@ const FOUNDATION_OPTIONS = { { code: 'manual', label: 'Manual', value: 'manual', sortOrder: 2 }, { code: 'system', label: 'System', value: 'system', sortOrder: 3 } ], - crm_quotation_customer_role: [ - { code: 'owner', label: 'Owner', value: 'owner', sortOrder: 1 }, - { code: 'consultant', label: 'Consultant', value: 'consultant', sortOrder: 2 }, - { code: 'contractor', label: 'Contractor', value: 'contractor', sortOrder: 3 }, - { code: 'billing', label: 'Billing', value: 'billing', sortOrder: 4 } + crm_project_party_role: [ + { + code: 'billing_customer', + label: 'Billing Customer', + value: 'billing_customer', + sortOrder: 1 + }, + { code: 'customer', label: 'Customer', value: 'customer', sortOrder: 2 }, + { code: 'consultant', label: 'Consultant', value: 'consultant', sortOrder: 3 }, + { code: 'contractor', label: 'Contractor', value: 'contractor', sortOrder: 4 }, + { code: 'end_customer', label: 'End Customer', value: 'end_customer', sortOrder: 5 }, + { code: 'other', label: 'Other', value: 'other', sortOrder: 6 } ], crm_quotation_topic_type: [ { code: 'scope', label: 'Scope', value: 'scope', sortOrder: 1 }, diff --git a/src/features/crm/components/project-parties-editor.tsx b/src/features/crm/components/project-parties-editor.tsx new file mode 100644 index 0000000..1dd3261 --- /dev/null +++ b/src/features/crm/components/project-parties-editor.tsx @@ -0,0 +1,153 @@ +'use client'; + +import { Icons } from '@/components/icons'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select'; + +export interface ProjectPartyEditorCustomer { + id: string; + code: string; + name: string; +} + +export interface ProjectPartyEditorRole { + id: string; + code: string; + label: string; +} + +export interface ProjectPartyEditorItem { + key: string; + customerId: string; + role: string; + remark: string; +} + +export function createEmptyProjectPartyItem( + roles: ProjectPartyEditorRole[], + initial?: Partial> +): ProjectPartyEditorItem { + return { + key: crypto.randomUUID(), + customerId: initial?.customerId ?? '', + role: initial?.role ?? roles[0]?.id ?? '', + remark: initial?.remark ?? '' + }; +} + +export function ProjectPartiesEditor({ + customers, + roles, + items, + onChange +}: { + customers: ProjectPartyEditorCustomer[]; + roles: ProjectPartyEditorRole[]; + items: ProjectPartyEditorItem[]; + onChange: (items: ProjectPartyEditorItem[]) => void; +}) { + function updateItem( + key: string, + field: keyof Omit, + value: string + ) { + onChange( + items.map((item) => (item.key === key ? { ...item, [field]: value } : item)) + ); + } + + function removeItem(key: string) { + onChange(items.filter((item) => item.key !== key)); + } + + function addItem() { + onChange([...items, createEmptyProjectPartyItem(roles)]); + } + + return ( +
+
+
+
Project Parties
+

+ Add related companies with their role and optional remark. +

+
+ +
+ + {!items.length ? ( +
+ No project parties yet. Billing customer will still stay separate from this list. +
+ ) : null} + +
+ {items.map((item, index) => ( +
+
+
Party {index + 1}
+ +
+
+ + + + + updateItem(item.key, 'remark', event.target.value)} + placeholder='Remark' + /> +
+
+ ))} +
+
+ ); +} diff --git a/src/features/crm/enquiries/api/mutations.ts b/src/features/crm/enquiries/api/mutations.ts index a546d77..b7926b6 100644 --- a/src/features/crm/enquiries/api/mutations.ts +++ b/src/features/crm/enquiries/api/mutations.ts @@ -25,12 +25,20 @@ async function invalidateEnquiryDetail(id: string) { await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.detail(id) }); } +async function invalidateEnquiryProjectParties(id: string) { + await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.projectParties(id) }); +} + async function invalidateEnquiryFollowups(id: string) { await getQueryClient().invalidateQueries({ queryKey: enquiryKeys.followups(id) }); } export async function invalidateEnquiryMutationQueries(id: string) { - await Promise.all([invalidateEnquiryLists(), invalidateEnquiryDetail(id)]); + await Promise.all([ + invalidateEnquiryLists(), + invalidateEnquiryDetail(id), + invalidateEnquiryProjectParties(id) + ]); } export async function invalidateEnquiryFollowupMutationQueries(enquiryId: string) { @@ -65,6 +73,7 @@ export const deleteEnquiryMutation = mutationOptions({ onSettled: async (_data, error, id) => { if (!error) { getQueryClient().removeQueries({ queryKey: enquiryKeys.detail(id) }); + getQueryClient().removeQueries({ queryKey: enquiryKeys.projectParties(id) }); getQueryClient().removeQueries({ queryKey: enquiryKeys.followups(id) }); await invalidateEnquiryLists(); } diff --git a/src/features/crm/enquiries/api/queries.ts b/src/features/crm/enquiries/api/queries.ts index 5d99391..171dd2e 100644 --- a/src/features/crm/enquiries/api/queries.ts +++ b/src/features/crm/enquiries/api/queries.ts @@ -1,5 +1,10 @@ import { queryOptions } from '@tanstack/react-query'; -import { getEnquiries, getEnquiryById, getEnquiryFollowups } from './service'; +import { + getEnquiries, + getEnquiryById, + getEnquiryFollowups, + getEnquiryProjectParties +} from './service'; import type { EnquiryFilters } from './types'; export const enquiryKeys = { @@ -8,6 +13,8 @@ export const enquiryKeys = { list: (filters: EnquiryFilters) => [...enquiryKeys.lists(), filters] as const, details: () => [...enquiryKeys.all, 'detail'] as const, detail: (id: string) => [...enquiryKeys.details(), id] as const, + 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 }; @@ -24,6 +31,12 @@ export const enquiryByIdOptions = (id: string) => queryFn: () => getEnquiryById(id) }); +export const enquiryProjectPartiesOptions = (id: string) => + queryOptions({ + queryKey: enquiryKeys.projectParties(id), + queryFn: () => getEnquiryProjectParties(id) + }); + export const enquiryFollowupsOptions = (id: string) => queryOptions({ queryKey: enquiryKeys.followups(id), diff --git a/src/features/crm/enquiries/api/service.ts b/src/features/crm/enquiries/api/service.ts index 3041a21..1c3aea4 100644 --- a/src/features/crm/enquiries/api/service.ts +++ b/src/features/crm/enquiries/api/service.ts @@ -7,6 +7,7 @@ import type { EnquiryFollowupsResponse, EnquiryListResponse, EnquiryMutationPayload, + EnquiryProjectPartiesResponse, MutationSuccessResponse } from './types'; @@ -32,6 +33,10 @@ export async function getEnquiryById(id: string): Promise return apiClient(`/crm/enquiries/${id}`); } +export async function getEnquiryProjectParties(id: string): Promise { + return apiClient(`/crm/enquiries/${id}/customers`); +} + export async function createEnquiry(data: EnquiryMutationPayload) { return apiClient('/crm/enquiries', { method: 'POST', diff --git a/src/features/crm/enquiries/api/types.ts b/src/features/crm/enquiries/api/types.ts index 479d77d..3d1748a 100644 --- a/src/features/crm/enquiries/api/types.ts +++ b/src/features/crm/enquiries/api/types.ts @@ -5,6 +5,25 @@ export interface EnquiryOption { value: string | null; } +export interface EnquiryProjectPartyRecord { + id: string; + organizationId: string; + enquiryId: string; + customerId: string; + role: string; + roleCode: string; + roleLabel: string | null; + remark: string | null; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +export interface EnquiryProjectPartyListItem extends EnquiryProjectPartyRecord { + customerName: string; + customerCode: string; +} + export interface EnquiryBranchOption { id: string; code: string; @@ -113,6 +132,7 @@ export interface EnquiryReferenceData { priorities: EnquiryOption[]; leadChannels: EnquiryOption[]; followupTypes: EnquiryOption[]; + projectPartyRoles: EnquiryOption[]; customers: EnquiryCustomerLookup[]; contacts: EnquiryContactLookup[]; assignableUsers: EnquiryAssignableUserLookup[]; @@ -148,6 +168,13 @@ export interface EnquiryDetailResponse { activity: EnquiryActivityRecord[]; } +export interface EnquiryProjectPartiesResponse { + success: boolean; + time: string; + message: string; + items: EnquiryProjectPartyListItem[]; +} + export interface EnquiryFollowupsResponse { success: boolean; time: string; @@ -155,6 +182,12 @@ export interface EnquiryFollowupsResponse { items: EnquiryFollowupRecord[]; } +export interface EnquiryProjectPartyMutationPayload { + customerId: string; + role: string; + remark?: string | null; +} + export interface EnquiryMutationPayload { customerId: string; contactId?: string | null; @@ -176,6 +209,7 @@ export interface EnquiryMutationPayload { isHotProject?: boolean; notes?: string; isActive?: boolean; + projectParties?: EnquiryProjectPartyMutationPayload[]; } export interface EnquiryFollowupMutationPayload { diff --git a/src/features/crm/enquiries/components/enquiry-detail.tsx b/src/features/crm/enquiries/components/enquiry-detail.tsx index eaa3670..ecbdb30 100644 --- a/src/features/crm/enquiries/components/enquiry-detail.tsx +++ b/src/features/crm/enquiries/components/enquiry-detail.tsx @@ -9,7 +9,7 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Separator } from '@/components/ui/separator'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { enquiryByIdOptions } from '../api/queries'; +import { enquiryByIdOptions, enquiryProjectPartiesOptions } from '../api/queries'; import type { EnquiryReferenceData } from '../api/types'; import type { QuotationRelationItem } from '@/features/crm/quotations/api/types'; import { EnquiryAssignmentDialog } from './enquiry-assignment-dialog'; @@ -48,6 +48,7 @@ export function EnquiryDetail({ }; }) { const { data } = useSuspenseQuery(enquiryByIdOptions(enquiryId)); + const { data: projectPartiesData } = useSuspenseQuery(enquiryProjectPartiesOptions(enquiryId)); const [editOpen, setEditOpen] = useState(false); const [assignmentOpen, setAssignmentOpen] = useState(false); const enquiry = data.enquiry; @@ -142,7 +143,7 @@ export function EnquiryDetail({ - + +
+
+
Project Parties
+ {!projectPartiesData.items.length ? ( +
+ No project parties have been added yet. +
+ ) : ( +
+ {projectPartiesData.items.map((item) => ( +
+
{item.customerName}
+
+ Role: {item.roleLabel ?? item.roleCode} +
+ {item.remark ?
{item.remark}
: null} +
+ ))} +
+ )} +
+
([]); const { FormTextField, FormTextareaField, FormSelectField, FormSwitchField } = useFormFields(); + const projectPartiesQuery = useQuery({ + ...enquiryProjectPartiesOptions(enquiry?.id ?? ''), + enabled: open && !!enquiry?.id + }); const createMutation = useMutation({ ...createEnquiryMutation, @@ -127,7 +137,14 @@ export function EnquiryFormSheet({ source: value.source, isHotProject: value.isHotProject ?? false, notes: value.notes, - isActive: value.isActive ?? true + isActive: value.isActive ?? true, + projectParties: projectParties + .filter((item) => item.customerId && item.role) + .map((item) => ({ + customerId: item.customerId, + role: item.role, + remark: item.remark || null + })) }; if (isEdit && enquiry) { @@ -146,7 +163,17 @@ export function EnquiryFormSheet({ setSelectedCustomerId(defaultValues.customerId); form.reset(defaultValues); - }, [defaultValues, form, open]); + setProjectParties( + enquiry + ? (projectPartiesQuery.data?.items ?? []).map((item) => ({ + key: item.id, + customerId: item.customerId, + role: item.role, + remark: item.remark ?? '' + })) + : [] + ); + }, [defaultValues, enquiry, form, open, projectPartiesQuery.data?.items]); const isPending = createMutation.isPending || updateMutation.isPending; @@ -168,7 +195,7 @@ export function EnquiryFormSheet({ children={(field) => ( - Customer * + Billing Customer * - + setRemark(event.target.value)} placeholder='Remark' />
) : null} {!customersData.items.length ? ( - + ) : ( customersData.items.map((item) => (
{item.customerName}
- {item.role} - {item.isPrimary ? ' - Primary' : ''} + Role: {item.roleLabel ?? item.roleCode}
+ {item.remark ? ( +
{item.remark}
+ ) : null}
{canManageCustomers ? (
diff --git a/src/features/crm/quotations/components/quotation-document-preview.tsx b/src/features/crm/quotations/components/quotation-document-preview.tsx index e5ae827..1a47d32 100644 --- a/src/features/crm/quotations/components/quotation-document-preview.tsx +++ b/src/features/crm/quotations/components/quotation-document-preview.tsx @@ -73,7 +73,7 @@ export function QuotationDocumentPreview({ quotationId }: { quotationId: string Bill-to and attention context for the document. - + diff --git a/src/features/crm/quotations/components/quotation-form-sheet.tsx b/src/features/crm/quotations/components/quotation-form-sheet.tsx index cb171cf..6f404a9 100644 --- a/src/features/crm/quotations/components/quotation-form-sheet.tsx +++ b/src/features/crm/quotations/components/quotation-form-sheet.tsx @@ -2,10 +2,14 @@ import * as React from 'react'; import { useEffect, useMemo, useState } from 'react'; -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useQuery } from '@tanstack/react-query'; import { toast } from 'sonner'; import { Icons } from '@/components/icons'; import { Button } from '@/components/ui/button'; +import { + ProjectPartiesEditor, + type ProjectPartyEditorItem +} from '@/features/crm/components/project-parties-editor'; import { Input } from '@/components/ui/input'; import { Select, @@ -24,6 +28,7 @@ import { } from '@/components/ui/sheet'; import { Switch } from '@/components/ui/switch'; import { Textarea } from '@/components/ui/textarea'; +import { quotationCustomersOptions } from '../api/queries'; import { createQuotationMutation, updateQuotationMutation } from '../api/mutations'; import type { QuotationMutationPayload, QuotationRecord, QuotationReferenceData } from '../api/types'; @@ -123,6 +128,11 @@ export function QuotationFormSheet({ const isEdit = !!quotation; const defaultState = useMemo(() => toFormState(quotation, referenceData), [quotation, referenceData]); const [state, setState] = useState(defaultState); + const [projectParties, setProjectParties] = useState([]); + const projectPartiesQuery = useQuery({ + ...quotationCustomersOptions(quotation?.id ?? ''), + enabled: open && !!quotation?.id + }); const createMutation = useMutation({ ...createQuotationMutation, @@ -147,8 +157,18 @@ export function QuotationFormSheet({ useEffect(() => { if (open) { setState(defaultState); + setProjectParties( + quotation + ? (projectPartiesQuery.data?.items ?? []).map((item) => ({ + key: item.id, + customerId: item.customerId, + role: item.role, + remark: item.remark ?? '' + })) + : [] + ); } - }, [defaultState, open]); + }, [defaultState, open, projectPartiesQuery.data?.items, quotation]); const contacts = useMemo( () => referenceData.contacts.filter((item) => item.customerId === state.customerId), @@ -200,7 +220,14 @@ export function QuotationFormSheet({ taxRate: state.taxRate ? Number(state.taxRate) : 0, sentVia: state.sentVia || null, revisionRemark: state.revisionRemark || null, - isActive: state.isActive + isActive: state.isActive, + projectParties: projectParties + .filter((item) => item.customerId && item.role) + .map((item) => ({ + customerId: item.customerId, + role: item.role, + remark: item.remark || null + })) }; if (isEdit && quotation) { @@ -219,7 +246,7 @@ export function QuotationFormSheet({ {isEdit ? 'Edit Quotation' : 'New Quotation'} - Capture quotation header data, customer linkage, and commercial context. + Capture quotation header data, billing customer, project parties, and commercial context. @@ -241,7 +268,7 @@ export function QuotationFormSheet({ - + setField('competitor', e.target.value)} placeholder='Competitor' /> +
+ +
+