diff --git a/docs/implementation/task-f-approval-production.md b/docs/implementation/task-f-approval-production.md new file mode 100644 index 0000000..8d24407 --- /dev/null +++ b/docs/implementation/task-f-approval-production.md @@ -0,0 +1,45 @@ +# Task F - Approval Production + +## Summary + +Task F delivered the first production-ready approval workflow on top of the CRM foundation, starting with quotation approval and keeping the service layer generic enough for future enquiry, PR, PO, and document flows. + +## Delivered Scope + +- Added approval persistence tables in [src/db/schema.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/db/schema.ts). +- Seeded the `quotation_standard_approval` workflow with sequential `sales_manager -> department_manager -> top_manager` steps in [src/db/seeds/foundation.seed.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/db/seeds/foundation.seed.ts). +- Added generic approval feature APIs in: + - [src/features/foundation/approval/types.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/approval/types.ts) + - [src/features/foundation/approval/service.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/approval/service.ts) + - [src/features/foundation/approval/queries.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/approval/queries.ts) + - [src/features/foundation/approval/mutations.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/approval/mutations.ts) + - [src/features/foundation/approval/server/service.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/approval/server/service.ts) +- Added production routes: + - [src/app/api/crm/approvals/route.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/approvals/route.ts) + - [src/app/api/crm/approvals/[id]/route.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/approvals/[id]/route.ts) + - [src/app/api/crm/approvals/[id]/approve/route.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/approvals/[id]/approve/route.ts) + - [src/app/api/crm/approvals/[id]/reject/route.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/approvals/[id]/reject/route.ts) + - [src/app/api/crm/approvals/[id]/return/route.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/approvals/[id]/return/route.ts) + - [src/app/api/crm/quotations/[id]/submit-approval/route.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/quotations/[id]/submit-approval/route.ts) +- Added production approvals UI: + - list page [src/app/dashboard/crm/approvals/page.tsx](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/dashboard/crm/approvals/page.tsx) + - detail page [src/app/dashboard/crm/approvals/[id]/page.tsx](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/dashboard/crm/approvals/[id]/page.tsx) + - reusable approval panels in [src/features/foundation/approval/components/](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/approval/components) +- Replaced the quotation approval placeholder with a real approval tab in [src/features/crm/quotations/components/quotation-detail.tsx](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/components/quotation-detail.tsx) and [src/features/crm/quotations/components/quotation-approval-tab.tsx](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/components/quotation-approval-tab.tsx). +- Added approval permissions and business roles in [src/lib/auth/rbac.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/lib/auth/rbac.ts). +- Updated side navigation and search-param plumbing in: + - [src/config/nav-config.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/config/nav-config.ts) + - [src/lib/searchparams.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/lib/searchparams.ts) + +## Behavioral Notes + +- Quotation submission validates draft or revised status, active customer linkage, and at least one quotation item before opening an approval request. +- Final approval promotes quotation status to `approved`. +- Reject moves quotation status to `rejected`. +- Return or cancel sends quotation back to `draft`. +- Approval actions are audited under `crm_approval_request` and `crm_approval_action`. + +## Follow-up + +- Run schema generation and migration creation after reviewing the new tables. +- Seed or sync membership permission arrays if non-admin approvers need the new approval permissions immediately in existing environments. diff --git a/drizzle/0005_numerous_blob.sql b/drizzle/0005_numerous_blob.sql new file mode 100644 index 0000000..a3282d3 --- /dev/null +++ b/drizzle/0005_numerous_blob.sql @@ -0,0 +1,57 @@ +CREATE TABLE "crm_approval_actions" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "approval_request_id" text NOT NULL, + "step_number" integer NOT NULL, + "action" text NOT NULL, + "remark" text, + "acted_by" text NOT NULL, + "acted_at" timestamp with time zone DEFAULT now() NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "deleted_at" timestamp with time zone +); +--> statement-breakpoint +CREATE TABLE "crm_approval_requests" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "workflow_id" text NOT NULL, + "entity_type" text NOT NULL, + "entity_id" text NOT NULL, + "current_step" integer DEFAULT 1 NOT NULL, + "status" text NOT NULL, + "requested_by" text NOT NULL, + "requested_at" timestamp with time zone DEFAULT now() NOT NULL, + "completed_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "deleted_at" timestamp with time zone +); +--> statement-breakpoint +CREATE TABLE "crm_approval_steps" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "workflow_id" text NOT NULL, + "step_number" integer NOT NULL, + "role_code" text NOT NULL, + "role_name" text NOT NULL, + "is_required" boolean DEFAULT true NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "deleted_at" timestamp with time zone +); +--> statement-breakpoint +CREATE TABLE "crm_approval_workflows" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "code" text NOT NULL, + "name" text NOT NULL, + "entity_type" text NOT NULL, + "is_active" boolean DEFAULT true NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "deleted_at" timestamp with time zone +); +--> statement-breakpoint +CREATE UNIQUE INDEX "crm_approval_steps_workflow_step_idx" ON "crm_approval_steps" USING btree ("workflow_id","step_number");--> statement-breakpoint +CREATE UNIQUE INDEX "crm_approval_workflows_org_code_idx" ON "crm_approval_workflows" USING btree ("organization_id","code"); \ No newline at end of file diff --git a/drizzle/meta/0005_snapshot.json b/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..1ce094c --- /dev/null +++ b/drizzle/meta/0005_snapshot.json @@ -0,0 +1,2500 @@ +{ + "id": "784d3c1e-a756-471c-8400-38db2f394041", + "prevId": "2d07652f-ec50-4bc0-970e-b2e59784a958", + "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_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 + }, + "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_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 + }, + "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 + }, + "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": "'viewer'" + }, + "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 394648d..7e5305e 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1781511534267, "tag": "0004_worthless_ender_wiggin", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1781575976324, + "tag": "0005_numerous_blob", + "breakpoints": true } ] } \ No newline at end of file diff --git a/plans/task-f.md b/plans/task-f.md new file mode 100644 index 0000000..1115535 --- /dev/null +++ b/plans/task-f.md @@ -0,0 +1,551 @@ +# Task F: Approval Production Module + +## ALLA OS CRM vNext + +คุณคือ Senior Full-stack Engineer + +ให้ implement Approval Production Module โดยต่อยอดจาก: + +```txt +Task A +Task B +Task B.1 +Task C +Task D +Task E +Task E.1 +``` + +Approval Module ต้องเป็น Generic Foundation ที่สามารถใช้กับ: + +```txt +Quotation +Enquiry (future) +Purchase Request (future) +Purchase Order (future) +Document Approval (future) +``` + +ห้ามผูกกับ Quotation เพียงอย่างเดียว + +--- + +# ต้องอ่านก่อน + +```txt +docs/implementation/task-a-template-audit.md +docs/implementation/task-b-template-audit.md +docs/implementation/task-b1-foundation-stabilization.md +docs/implementation/technical-debt.md + +docs/adr/0007-quotation-revision-strategy.md +docs/adr/0008-attachment-storage-strategy.md + +Layout.md +AGENTS.md +.agents/skills/kiranism-shadcn-dashboard + +src/features/foundation/** +src/features/crm/customers/** +src/features/crm/enquiries/** +src/features/crm/quotations/** +src/db/schema.ts +``` + +--- + +# Goal + +สร้าง Approval Engine ที่ reusable ได้ + +รองรับ: + +```txt +Submit For Approval +Approve +Reject +Request Change +Approval Timeline +Approval History +Current Approval Status +``` + +เริ่มใช้กับ Quotation ก่อน + +--- + +# Approval Workflow Version 1 + +รองรับ sequential approval + +ตัวอย่าง: + +```txt +Step 1 +Sales Manager + +Step 2 +Department Manager + +Step 3 +Top Manager +``` + +Approval ต้องเดินทีละ step + +--- + +# Scope F1: Approval Schema + +เพิ่ม tables + +```txt +crm_approval_workflows +crm_approval_steps +crm_approval_requests +crm_approval_actions +``` + +--- + +## crm_approval_workflows + +```txt +id +organizationId + +code +name +entityType + +isActive + +createdAt +updatedAt +deletedAt +``` + +entityType ตัวอย่าง + +```txt +quotation +enquiry +purchase_request +``` + +--- + +## crm_approval_steps + +```txt +id +organizationId + +workflowId + +stepNumber + +roleCode +roleName + +isRequired + +createdAt +updatedAt +deletedAt +``` + +ตัวอย่าง + +```txt +sales_manager +department_manager +top_manager +``` + +--- + +## crm_approval_requests + +```txt +id +organizationId + +workflowId + +entityType +entityId + +currentStep + +status + +requestedBy +requestedAt + +completedAt + +createdAt +updatedAt +deletedAt +``` + +status + +```txt +pending +approved +rejected +returned +cancelled +``` + +--- + +## crm_approval_actions + +```txt +id +organizationId + +approvalRequestId + +stepNumber + +action + +remark + +actedBy +actedAt + +createdAt +updatedAt +deletedAt +``` + +action + +```txt +approve +reject +return +submit +``` + +--- + +# Scope F2: Approval Service + +สร้าง: + +```txt +src/features/foundation/approval/** +``` + +ประกอบด้วย + +```txt +types.ts +service.ts +queries.ts +mutations.ts +``` + +Functions + +```ts +submitForApproval() +approveApproval() +rejectApproval() +returnApproval() +cancelApproval() + +getApprovalRequest() +getApprovalTimeline() +getCurrentApprovalStep() +``` + +--- + +# Scope F3: Quotation Integration + +เชื่อม Approval เข้ากับ Quotation + +--- + +## Submit For Approval + +จาก: + +```txt +draft +revised +``` + +ไป: + +```txt +pending_approval +``` + +Rules + +```txt +ต้องมี item อย่างน้อย 1 รายการ +ต้องมี customer +ต้องไม่ deleted +``` + +--- + +## Approval Success + +เมื่อทุก step approve + +เปลี่ยน quotation status + +```txt +approved +``` + +--- + +## Reject + +เปลี่ยน quotation status + +```txt +rejected +``` + +--- + +## Return + +เปลี่ยน quotation status + +```txt +draft +``` + +และเก็บ remark + +--- + +# Scope F4: API Routes + +สร้าง: + +```txt +src/app/api/crm/approvals/route.ts +src/app/api/crm/approvals/[id]/route.ts + +src/app/api/crm/approvals/[id]/approve/route.ts +src/app/api/crm/approvals/[id]/reject/route.ts +src/app/api/crm/approvals/[id]/return/route.ts + +src/app/api/crm/quotations/[id]/submit-approval/route.ts +``` + +--- + +# Scope F5: Permission Model + +เพิ่ม permissions + +```txt +crm.approval.read +crm.approval.submit + +crm.approval.approve +crm.approval.reject +crm.approval.return +``` + +--- + +# Scope F6: Approval UI + +แทน placeholder route + +```txt +/ dashboard / crm / approvals +``` + +--- + +Approval List + +```txt +Pending +Approved +Rejected +Returned +``` + +ใช้ + +```txt +PageContainer +DataTable +``` + +--- + +Approval Detail + +แสดง + +```txt +Request Information +Workflow +Current Step +Timeline +Actions +``` + +--- + +# Scope F7: Quotation Detail Integration + +ใน + +```txt +/ dashboard / crm / quotations / [id] +``` + +เพิ่ม + +```txt +Approval Tab +``` + +แสดง + +```txt +Current Status +Current Step +Timeline +``` + +Actions + +```txt +Submit For Approval +Approve +Reject +Return +``` + +ตาม permission + +--- + +# Scope F8: Audit Integration + +ทุก action ต้อง audit + +entityType + +```txt +crm_approval_request +crm_approval_action +``` + +--- + +# Scope F9: Seed Data + +เพิ่มใน + +```txt +foundation.seed.ts +``` + +Workflow เริ่มต้น + +```txt +Quotation Standard Approval +``` + +Steps + +```txt +1 sales_manager +2 department_manager +3 top_manager +``` + +Seed แบบ idempotent + +--- + +# ห้ามทำ + +```txt +Email Notification +Line Notification +Mobile Push +PDF Generation +Report +Dashboard KPI +Parallel Approval +Conditional Approval +Escalation +Delegation +``` + +--- + +# Output + +สรุป: + +1. Files Added +2. Files Modified +3. Schema Added +4. Approval APIs Added +5. Approval UI Added +6. Quotation Integration +7. Permission Added +8. Audit Integration +9. Seed Added +10. Remaining Risks +11. Task G Readiness + +--- + +# Definition of Done + +Task F ผ่านเมื่อ: + +✔ Approval workflow schema พร้อม + +✔ Approval request พร้อม + +✔ Approval actions พร้อม + +✔ Approval timeline พร้อม + +✔ Submit For Approval ใช้งานได้ + +✔ Approve ใช้งานได้ + +✔ Reject ใช้งานได้ + +✔ Return ใช้งานได้ + +✔ Quotation เชื่อม approval แล้ว + +✔ Audit ครบทุก approval action + +✔ Approval UI ใช้งานได้ + +✔ ไม่ทำ notification + +✔ ไม่ทำ PDF + +✔ ไม่ทำ report + +✔ พร้อมเริ่ม Task G diff --git a/src/app/api/crm/approvals/[id]/approve/route.ts b/src/app/api/crm/approvals/[id]/approve/route.ts new file mode 100644 index 0000000..d5361ae --- /dev/null +++ b/src/app/api/crm/approvals/[id]/approve/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { approveApproval } from '@/features/foundation/approval/server/service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +type Params = { + params: Promise<{ id: string }>; +}; + +const actionSchema = z.object({ + remark: z.string().optional() +}); + +export async function POST(request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmApprovalApprove + }); + const payload = actionSchema.parse(await request.json()); + await approveApproval(id, organization.id, session.user.id, payload.remark); + + return NextResponse.json({ + success: true, + message: 'Approval completed for current step' + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + if (error instanceof z.ZodError) { + return NextResponse.json( + { message: error.issues[0]?.message ?? 'Invalid payload' }, + { status: 400 } + ); + } + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + return NextResponse.json({ message: 'Unable to approve request' }, { status: 500 }); + } +} diff --git a/src/app/api/crm/approvals/[id]/reject/route.ts b/src/app/api/crm/approvals/[id]/reject/route.ts new file mode 100644 index 0000000..5f92a47 --- /dev/null +++ b/src/app/api/crm/approvals/[id]/reject/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { rejectApproval } from '@/features/foundation/approval/server/service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +type Params = { + params: Promise<{ id: string }>; +}; + +const actionSchema = z.object({ + remark: z.string().optional() +}); + +export async function POST(request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmApprovalReject + }); + const payload = actionSchema.parse(await request.json()); + await rejectApproval(id, organization.id, session.user.id, payload.remark); + + return NextResponse.json({ + success: true, + message: 'Approval request rejected' + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + if (error instanceof z.ZodError) { + return NextResponse.json( + { message: error.issues[0]?.message ?? 'Invalid payload' }, + { status: 400 } + ); + } + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + return NextResponse.json({ message: 'Unable to reject approval request' }, { status: 500 }); + } +} diff --git a/src/app/api/crm/approvals/[id]/return/route.ts b/src/app/api/crm/approvals/[id]/return/route.ts new file mode 100644 index 0000000..7204d49 --- /dev/null +++ b/src/app/api/crm/approvals/[id]/return/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { returnApproval } from '@/features/foundation/approval/server/service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +type Params = { + params: Promise<{ id: string }>; +}; + +const actionSchema = z.object({ + remark: z.string().optional() +}); + +export async function POST(request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmApprovalReturn + }); + const payload = actionSchema.parse(await request.json()); + await returnApproval(id, organization.id, session.user.id, payload.remark); + + return NextResponse.json({ + success: true, + message: 'Approval request returned for change' + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + if (error instanceof z.ZodError) { + return NextResponse.json( + { message: error.issues[0]?.message ?? 'Invalid payload' }, + { status: 400 } + ); + } + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + return NextResponse.json({ message: 'Unable to return approval request' }, { status: 500 }); + } +} diff --git a/src/app/api/crm/approvals/[id]/route.ts b/src/app/api/crm/approvals/[id]/route.ts new file mode 100644 index 0000000..f9ab1fe --- /dev/null +++ b/src/app/api/crm/approvals/[id]/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cancelApproval, getApprovalRequest } from '@/features/foundation/approval/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.crmApprovalRead + }); + const approval = await getApprovalRequest(id, organization.id); + + return NextResponse.json({ + success: true, + time: new Date().toISOString(), + message: 'Approval detail loaded successfully', + approval + }); + } 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 approval detail' }, { status: 500 }); + } +} + +export async function DELETE(_request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmApprovalSubmit + }); + await cancelApproval(id, organization.id, session.user.id); + + return NextResponse.json({ + success: true, + message: 'Approval request cancelled successfully' + }); + } 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 cancel approval request' }, { status: 500 }); + } +} diff --git a/src/app/api/crm/approvals/route.ts b/src/app/api/crm/approvals/route.ts new file mode 100644 index 0000000..a5f5d7d --- /dev/null +++ b/src/app/api/crm/approvals/route.ts @@ -0,0 +1,88 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { listApprovalRequests, submitForApproval } from '@/features/foundation/approval/server/service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +const submitApprovalSchema = z.object({ + workflowCode: z.string().optional(), + entityType: z.string().min(1, 'Entity type is required'), + entityId: z.string().min(1, 'Entity id is required'), + remark: z.string().optional() +}); + +export async function GET(request: NextRequest) { + try { + const { organization } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmApprovalRead + }); + const { searchParams } = request.nextUrl; + const page = Number(searchParams.get('page') ?? 1); + const limit = Number(searchParams.get('limit') ?? 10); + const search = searchParams.get('search') ?? undefined; + const status = searchParams.get('status') ?? undefined; + const entityType = searchParams.get('entityType') ?? undefined; + const entityId = searchParams.get('entityId') ?? undefined; + const sort = searchParams.get('sort') ?? undefined; + const result = await listApprovalRequests(organization.id, { + page, + limit, + search, + status, + entityType, + entityId, + sort + }); + + return NextResponse.json({ + success: true, + time: new Date().toISOString(), + message: 'Approvals loaded successfully', + totalItems: result.totalItems, + offset: (page - 1) * limit, + limit, + items: result.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 approvals' }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmApprovalSubmit + }); + const payload = submitApprovalSchema.parse(await request.json()); + const created = await submitForApproval(organization.id, session.user.id, payload); + + return NextResponse.json( + { + success: true, + message: 'Approval request submitted successfully', + approvalRequest: created + }, + { status: 201 } + ); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + if (error instanceof z.ZodError) { + return NextResponse.json( + { message: error.issues[0]?.message ?? 'Invalid payload' }, + { status: 400 } + ); + } + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + return NextResponse.json({ message: 'Unable to submit approval request' }, { status: 500 }); + } +} diff --git a/src/app/api/crm/quotations/[id]/submit-approval/route.ts b/src/app/api/crm/quotations/[id]/submit-approval/route.ts new file mode 100644 index 0000000..aecfe91 --- /dev/null +++ b/src/app/api/crm/quotations/[id]/submit-approval/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { submitForApproval } from '@/features/foundation/approval/server/service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +type Params = { + params: Promise<{ id: string }>; +}; + +const submitSchema = z.object({ + remark: z.string().optional() +}); + +export async function POST(request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmApprovalSubmit + }); + const payload = submitSchema.parse(await request.json()); + await submitForApproval(organization.id, session.user.id, { + workflowCode: 'quotation_standard_approval', + entityType: 'quotation', + entityId: id, + remark: payload.remark + }); + + return NextResponse.json({ + success: true, + message: 'Quotation submitted for approval successfully' + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + if (error instanceof z.ZodError) { + return NextResponse.json( + { message: error.issues[0]?.message ?? 'Invalid payload' }, + { status: 400 } + ); + } + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + return NextResponse.json({ message: 'Unable to submit quotation for approval' }, { status: 500 }); + } +} diff --git a/src/app/dashboard/crm/approvals/[id]/page.tsx b/src/app/dashboard/crm/approvals/[id]/page.tsx new file mode 100644 index 0000000..326f2c9 --- /dev/null +++ b/src/app/dashboard/crm/approvals/[id]/page.tsx @@ -0,0 +1,76 @@ +import { auth } from '@/auth'; +import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; +import PageContainer from '@/components/layout/page-container'; +import { ApprovalDetail } from '@/features/foundation/approval/components/approval-detail'; +import { approvalByIdOptions } from '@/features/foundation/approval/queries'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { getQueryClient } from '@/lib/query-client'; + +type PageProps = { + params: Promise<{ id: string }>; +}; + +export default async function ApprovalDetailRoute({ params }: PageProps) { + const { id } = await params; + const session = await auth(); + const canRead = + session?.user?.systemRole === 'super_admin' || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === 'admin' || + session.user.activePermissions.includes(PERMISSIONS.crmApprovalRead))); + const canApprove = + session?.user?.systemRole === 'super_admin' || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === 'admin' || + session.user.activePermissions.includes(PERMISSIONS.crmApprovalApprove))); + const canReject = + session?.user?.systemRole === 'super_admin' || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === 'admin' || + session.user.activePermissions.includes(PERMISSIONS.crmApprovalReject))); + const canReturn = + session?.user?.systemRole === 'super_admin' || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === 'admin' || + session.user.activePermissions.includes(PERMISSIONS.crmApprovalReturn))); + const canCancel = + session?.user?.systemRole === 'super_admin' || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === 'admin' || + session.user.activePermissions.includes(PERMISSIONS.crmApprovalSubmit))); + const isOrgAdmin = + session?.user?.systemRole === 'super_admin' || session?.user?.activeMembershipRole === 'admin'; + const queryClient = getQueryClient(); + + if (canRead) { + void queryClient.prefetchQuery(approvalByIdOptions(id)); + } + + return ( + + You do not have access to this approval request. + + } + > + {canRead && session?.user ? ( + + + + ) : null} + + ); +} diff --git a/src/app/dashboard/crm/approvals/page.tsx b/src/app/dashboard/crm/approvals/page.tsx index 6a3ceca..976451d 100644 --- a/src/app/dashboard/crm/approvals/page.tsx +++ b/src/app/dashboard/crm/approvals/page.tsx @@ -1,28 +1,41 @@ +import { auth } from '@/auth'; import PageContainer from '@/components/layout/page-container'; -import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder'; +import ApprovalListing from '@/features/foundation/approval/components/approval-listing'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { searchParamsCache } from '@/lib/searchparams'; +import type { SearchParams } from 'nuqs/server'; export const metadata = { title: 'Dashboard: CRM Approvals' }; -export default function ApprovalsRoute() { +type PageProps = { + searchParams: Promise; +}; + +export default async function ApprovalsRoute(props: PageProps) { + const searchParams = await props.searchParams; + const session = await auth(); + const canRead = + session?.user?.systemRole === 'super_admin' || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === 'admin' || + session.user.activePermissions.includes(PERMISSIONS.crmApprovalRead))); + + searchParamsCache.parse(searchParams); + return ( + You do not have access to CRM approvals. + + } > - + {canRead ? : null} ); } diff --git a/src/app/dashboard/crm/quotations/[id]/page.tsx b/src/app/dashboard/crm/quotations/[id]/page.tsx index b79111b..0f7e3f6 100644 --- a/src/app/dashboard/crm/quotations/[id]/page.tsx +++ b/src/app/dashboard/crm/quotations/[id]/page.tsx @@ -12,6 +12,7 @@ import { } from '@/features/crm/quotations/api/queries'; import { QuotationDetail } from '@/features/crm/quotations/components/quotation-detail'; import { getQuotationReferenceData } from '@/features/crm/quotations/server/service'; +import { approvalsQueryOptions } from '@/features/foundation/approval/queries'; import { PERMISSIONS } from '@/lib/auth/rbac'; import { getQueryClient } from '@/lib/query-client'; @@ -62,6 +63,28 @@ export default async function QuotationDetailRoute({ params }: PageProps) { (!!session?.user?.activeOrganizationId && (session.user.activeMembershipRole === 'admin' || session.user.activePermissions.includes(PERMISSIONS.crmQuotationRevisionCreate))); + const canSubmitApproval = + session?.user?.systemRole === 'super_admin' || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === 'admin' || + session.user.activePermissions.includes(PERMISSIONS.crmApprovalSubmit))); + const canApproveApproval = + session?.user?.systemRole === 'super_admin' || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === 'admin' || + session.user.activePermissions.includes(PERMISSIONS.crmApprovalApprove))); + const canRejectApproval = + session?.user?.systemRole === 'super_admin' || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === 'admin' || + session.user.activePermissions.includes(PERMISSIONS.crmApprovalReject))); + const canReturnApproval = + session?.user?.systemRole === 'super_admin' || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === 'admin' || + session.user.activePermissions.includes(PERMISSIONS.crmApprovalReturn))); + const isOrgAdmin = + session?.user?.systemRole === 'super_admin' || session?.user?.activeMembershipRole === 'admin'; const queryClient = getQueryClient(); if (canRead) { @@ -72,6 +95,13 @@ export default async function QuotationDetailRoute({ params }: PageProps) { void queryClient.prefetchQuery(quotationFollowupsOptions(id)); void queryClient.prefetchQuery(quotationAttachmentsOptions(id)); void queryClient.prefetchQuery(quotationRevisionsOptions(id)); + void queryClient.prefetchQuery( + approvalsQueryOptions({ + entityType: 'quotation', + entityId: id, + limit: 20 + }) + ); } const referenceData = @@ -102,6 +132,13 @@ export default async function QuotationDetailRoute({ params }: PageProps) { canManageFollowups={canManageFollowups} canManageAttachments={canManageAttachments} canCreateRevision={canCreateRevision} + canSubmitApproval={canSubmitApproval} + canApproveApproval={canApproveApproval} + canRejectApproval={canRejectApproval} + canReturnApproval={canReturnApproval} + activeBusinessRole={session?.user?.activeBusinessRole ?? null} + isOrgAdmin={isOrgAdmin} + currentUserId={session?.user?.id ?? ''} /> ) : null} diff --git a/src/config/nav-config.ts b/src/config/nav-config.ts index b454482..cef17d3 100644 --- a/src/config/nav-config.ts +++ b/src/config/nav-config.ts @@ -113,7 +113,7 @@ export const navGroups: NavGroup[] = [ icon: 'checks', isActive: false, items: [], - access: { requireOrg: true, role: 'admin' } + access: { requireOrg: true, permission: 'crm.approval.read' } }, { title: 'CRM Settings', diff --git a/src/db/schema.ts b/src/db/schema.ts index 65edb4f..473ccdd 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -392,3 +392,76 @@ export const crmQuotationAttachments = pgTable('crm_quotation_attachments', { updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), deletedAt: timestamp('deleted_at', { withTimezone: true }) }); + +export const crmApprovalWorkflows = pgTable( + 'crm_approval_workflows', + { + id: text('id').primaryKey(), + organizationId: text('organization_id').notNull(), + code: text('code').notNull(), + name: text('name').notNull(), + entityType: text('entity_type').notNull(), + isActive: boolean('is_active').default(true).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + deletedAt: timestamp('deleted_at', { withTimezone: true }) + }, + (table) => ({ + organizationCodeIdx: uniqueIndex('crm_approval_workflows_org_code_idx').on( + table.organizationId, + table.code + ) + }) +); + +export const crmApprovalSteps = pgTable( + 'crm_approval_steps', + { + id: text('id').primaryKey(), + organizationId: text('organization_id').notNull(), + workflowId: text('workflow_id').notNull(), + stepNumber: integer('step_number').notNull(), + roleCode: text('role_code').notNull(), + roleName: text('role_name').notNull(), + isRequired: boolean('is_required').default(true).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + deletedAt: timestamp('deleted_at', { withTimezone: true }) + }, + (table) => ({ + workflowStepIdx: uniqueIndex('crm_approval_steps_workflow_step_idx').on( + table.workflowId, + table.stepNumber + ) + }) +); + +export const crmApprovalRequests = pgTable('crm_approval_requests', { + id: text('id').primaryKey(), + organizationId: text('organization_id').notNull(), + workflowId: text('workflow_id').notNull(), + entityType: text('entity_type').notNull(), + entityId: text('entity_id').notNull(), + currentStep: integer('current_step').default(1).notNull(), + status: text('status').notNull(), + requestedBy: text('requested_by').notNull(), + requestedAt: timestamp('requested_at', { withTimezone: true }).defaultNow().notNull(), + completedAt: timestamp('completed_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + deletedAt: timestamp('deleted_at', { withTimezone: true }) +}); + +export const crmApprovalActions = pgTable('crm_approval_actions', { + id: text('id').primaryKey(), + organizationId: text('organization_id').notNull(), + approvalRequestId: text('approval_request_id').notNull(), + stepNumber: integer('step_number').notNull(), + action: text('action').notNull(), + remark: text('remark'), + actedBy: text('acted_by').notNull(), + actedAt: timestamp('acted_at', { withTimezone: true }).defaultNow().notNull(), + 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 a04b7ac..6d76446 100644 --- a/src/db/seeds/foundation.seed.ts +++ b/src/db/seeds/foundation.seed.ts @@ -200,6 +200,19 @@ const DOCUMENT_SEQUENCES = [ { documentType: 'approval', prefix: 'APV' } ]; +const APPROVAL_WORKFLOWS = [ + { + code: 'quotation_standard_approval', + name: 'Quotation Standard Approval', + entityType: 'quotation', + steps: [ + { stepNumber: 1, roleCode: 'sales_manager', roleName: 'Sales Manager' }, + { stepNumber: 2, roleCode: 'department_manager', roleName: 'Department Manager' }, + { stepNumber: 3, roleCode: 'top_manager', roleName: 'Top Manager' } + ] + } +]; + async function upsertMasterOptionsForOrganization( sql: SqlClient, organizationId: string @@ -306,6 +319,69 @@ async function upsertDocumentSequencesForOrganization( } } +async function upsertApprovalWorkflowsForOrganization(sql: SqlClient, organizationId: string) { + for (const workflow of APPROVAL_WORKFLOWS) { + const [workflowRow] = await sql` + insert into crm_approval_workflows ( + id, + organization_id, + code, + name, + entity_type, + is_active, + deleted_at + ) values ( + ${crypto.randomUUID()}, + ${organizationId}, + ${workflow.code}, + ${workflow.name}, + ${workflow.entityType}, + ${true}, + ${null} + ) + on conflict (organization_id, code) do update + set + name = excluded.name, + entity_type = excluded.entity_type, + is_active = excluded.is_active, + deleted_at = excluded.deleted_at, + updated_at = now() + returning id + `; + + for (const step of workflow.steps) { + await sql` + insert into crm_approval_steps ( + id, + organization_id, + workflow_id, + step_number, + role_code, + role_name, + is_required, + deleted_at + ) values ( + ${crypto.randomUUID()}, + ${organizationId}, + ${workflowRow.id}, + ${step.stepNumber}, + ${step.roleCode}, + ${step.roleName}, + ${true}, + ${null} + ) + on conflict (workflow_id, step_number) do update + set + role_code = excluded.role_code, + role_name = excluded.role_name, + is_required = excluded.is_required, + deleted_at = excluded.deleted_at, + updated_at = now() + `; + } + } +} + async function main() { loadLocalEnv(); @@ -328,6 +404,7 @@ async function main() { for (const organization of organizations) { const branchRows = await upsertMasterOptionsForOrganization(sql, organization.id); await upsertDocumentSequencesForOrganization(sql, organization.id, branchRows); + await upsertApprovalWorkflowsForOrganization(sql, organization.id); console.log(`[seed-foundation] Seeded foundation data for ${organization.name}`); } } finally { diff --git a/src/features/crm/quotations/components/quotation-approval-tab.tsx b/src/features/crm/quotations/components/quotation-approval-tab.tsx new file mode 100644 index 0000000..010e5d6 --- /dev/null +++ b/src/features/crm/quotations/components/quotation-approval-tab.tsx @@ -0,0 +1,127 @@ +'use client'; + +import { useState } from 'react'; +import { useMutation, useQuery, useSuspenseQuery } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { Icons } from '@/components/icons'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Textarea } from '@/components/ui/textarea'; +import { + approvalByIdOptions, + approvalsQueryOptions +} from '@/features/foundation/approval/queries'; +import { submitQuotationApprovalMutation } from '@/features/foundation/approval/mutations'; +import { ApprovalRequestPanel } from '@/features/foundation/approval/components/approval-request-panel'; +import { ApprovalStatusBadge } from '@/features/foundation/approval/components/approval-status-badge'; + +export function QuotationApprovalTab({ + quotationId, + quotationStatusCode, + canSubmitApproval, + canApproveApproval, + canRejectApproval, + canReturnApproval, + activeBusinessRole, + isOrgAdmin, + currentUserId +}: { + quotationId: string; + quotationStatusCode: string | null; + canSubmitApproval: boolean; + canApproveApproval: boolean; + canRejectApproval: boolean; + canReturnApproval: boolean; + activeBusinessRole: string | null; + isOrgAdmin: boolean; + currentUserId: string; +}) { + const [remark, setRemark] = useState(''); + const { data: requestsData } = useSuspenseQuery( + approvalsQueryOptions({ + entityType: 'quotation', + entityId: quotationId, + limit: 20 + }) + ); + const latestRequest = requestsData.items[0] ?? null; + const { data: approvalDetailData } = useQuery({ + ...approvalByIdOptions(latestRequest?.id ?? ''), + enabled: !!latestRequest + }); + + const submitApproval = useMutation({ + ...submitQuotationApprovalMutation, + onSuccess: () => { + toast.success('Quotation submitted for approval'); + setRemark(''); + }, + onError: (error) => + toast.error(error instanceof Error ? error.message : 'Unable to submit quotation') + }); + const canSubmitNow = canSubmitApproval && ['draft', 'revised'].includes(quotationStatusCode ?? ''); + + return ( + + + + Current Status + Submission readiness and latest approval request state. + + + + + + {latestRequest + ? `Latest request: ${latestRequest.workflowName}` + : 'No approval request has been submitted yet.'} + + + {canSubmitNow ? ( + + Submit quotation into approval flow + setRemark(event.target.value)} + placeholder='Optional remark for approvers' + rows={3} + /> + submitApproval.mutate({ id: quotationId, remark })} + > + + Submit for Approval + + + ) : null} + + + + {approvalDetailData?.approval ? ( + + ) : ( + + + Approval Timeline + Submission and approver history will appear here. + + + + No approval activity recorded yet. + + + + )} + + ); +} diff --git a/src/features/crm/quotations/components/quotation-detail.tsx b/src/features/crm/quotations/components/quotation-detail.tsx index c1c0485..347a535 100644 --- a/src/features/crm/quotations/components/quotation-detail.tsx +++ b/src/features/crm/quotations/components/quotation-detail.tsx @@ -40,7 +40,6 @@ import { deleteQuotationFollowupMutation, deleteQuotationItemMutation, deleteQuotationTopicMutation, - updateQuotationMutation, updateQuotationAttachmentMutation, updateQuotationCustomerMutation, updateQuotationFollowupMutation, @@ -69,7 +68,9 @@ import type { QuotationTopicMutationPayload, QuotationTopicRecord } from '../api/types'; +import { submitQuotationApprovalMutation } from '@/features/foundation/approval/mutations'; import { QuotationFormSheet } from './quotation-form-sheet'; +import { QuotationApprovalTab } from './quotation-approval-tab'; import { QuotationStatusBadge } from './quotation-status-badge'; function FieldItem({ label, value }: { label: string; value: string | null | undefined }) { @@ -473,7 +474,14 @@ export function QuotationDetail({ canManageTopics, canManageFollowups, canManageAttachments, - canCreateRevision + canCreateRevision, + canSubmitApproval, + canApproveApproval, + canRejectApproval, + canReturnApproval, + activeBusinessRole, + isOrgAdmin, + currentUserId }: { quotationId: string; referenceData: QuotationReferenceData; @@ -484,6 +492,13 @@ export function QuotationDetail({ canManageFollowups: boolean; canManageAttachments: boolean; canCreateRevision: boolean; + canSubmitApproval: boolean; + canApproveApproval: boolean; + canRejectApproval: boolean; + canReturnApproval: boolean; + activeBusinessRole: string | null; + isOrgAdmin: boolean; + currentUserId: string; }) { const { data } = useSuspenseQuery(quotationByIdOptions(quotationId)); const { data: itemsData } = useSuspenseQuery(quotationItemsOptions(quotationId)); @@ -611,7 +626,7 @@ export function QuotationDetail({ onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to create revision') }); const submitForApproval = useMutation({ - ...updateQuotationMutation, + ...submitQuotationApprovalMutation, onSuccess: () => toast.success('Quotation moved to pending approval'), onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to submit for approval') @@ -655,7 +670,8 @@ export function QuotationDetail({ const canReviseByStatus = ['accepted', 'rejected', 'sent', 'approved'].includes( status?.code ?? '' ); - const canSubmitApproval = ['draft', 'revised'].includes(status?.code ?? ''); + const canSubmitCurrentQuotation = + canSubmitApproval && ['draft', 'revised'].includes(status?.code ?? ''); return ( @@ -814,44 +830,11 @@ export function QuotationDetail({ - {canUpdate && canSubmitApproval ? ( + {canSubmitCurrentQuotation ? ( - submitForApproval.mutate({ - id: quotationId, - values: { - enquiryId: quotation.enquiryId, - customerId: quotation.customerId, - contactId: quotation.contactId, - quotationDate: quotation.quotationDate, - validUntil: quotation.validUntil, - quotationType: quotation.quotationType, - projectName: quotation.projectName ?? '', - projectLocation: quotation.projectLocation ?? '', - attention: quotation.attention ?? '', - branchId: quotation.branchId, - currency: quotation.currency, - exchangeRate: quotation.exchangeRate, - status: - referenceData.statuses.find((item) => item.code === 'pending_approval')?.id ?? - quotation.status, - chancePercent: quotation.chancePercent, - isHotProject: quotation.isHotProject, - competitor: quotation.competitor ?? '', - reference: quotation.reference ?? '', - notes: quotation.notes ?? '', - salesmanId: quotation.salesmanId, - discount: quotation.discount, - discountType: quotation.discountType, - taxRate: quotation.taxRate, - sentVia: quotation.sentVia, - revisionRemark: quotation.revisionRemark, - isActive: quotation.isActive - } - }) - } + onClick={() => submitForApproval.mutate({ id: quotationId })} > Submit for approval @@ -1191,15 +1174,17 @@ export function QuotationDetail({ - - - Approval Placeholder - Approval workflow is deferred beyond Task E. - - - - - + diff --git a/src/features/foundation/approval/components/approval-columns.tsx b/src/features/foundation/approval/components/approval-columns.tsx new file mode 100644 index 0000000..5b0da5f --- /dev/null +++ b/src/features/foundation/approval/components/approval-columns.tsx @@ -0,0 +1,103 @@ +'use client'; + +import Link from 'next/link'; +import type { Column, ColumnDef } from '@tanstack/react-table'; +import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header'; +import { Icons } from '@/components/icons'; +import type { ApprovalListItem } from '../types'; +import { ApprovalStatusBadge } from './approval-status-badge'; + +export function getApprovalColumns(): ColumnDef[] { + return [ + { + id: 'entityCode', + accessorFn: (row) => row.entityCode ?? row.entityId, + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => ( + + + {row.original.entityCode ?? row.original.entityId} + + + {row.original.workflowName} + {row.original.entityTitle ? ` - ${row.original.entityTitle}` : ''} + + + ), + meta: { + label: 'Request', + placeholder: 'Search approvals...', + variant: 'text' as const, + icon: Icons.search + }, + enableColumnFilter: true + }, + { + id: 'status', + accessorKey: 'status', + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => , + meta: { + label: 'Status', + variant: 'multiSelect' as const, + options: [ + { value: 'pending', label: 'Pending' }, + { value: 'approved', label: 'Approved' }, + { value: 'rejected', label: 'Rejected' }, + { value: 'returned', label: 'Returned' }, + { value: 'cancelled', label: 'Cancelled' } + ] + }, + enableColumnFilter: true + }, + { + id: 'entityType', + accessorKey: 'entityType', + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => row.original.entityType.replaceAll('_', ' '), + meta: { + label: 'Entity', + variant: 'multiSelect' as const, + options: [{ value: 'quotation', label: 'Quotation' }] + }, + enableColumnFilter: true + }, + { + id: 'currentStep', + accessorKey: 'currentStep', + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => ( + + Step {row.original.currentStep} + + {row.original.currentStepRoleName ?? '-'} + + + ) + }, + { + id: 'requestedBy', + accessorFn: (row) => row.requestedByName ?? row.requestedBy, + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => row.original.requestedByName ?? row.original.requestedBy + }, + { + id: 'requestedAt', + accessorKey: 'requestedAt', + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => new Date(row.original.requestedAt).toLocaleString() + } + ]; +} diff --git a/src/features/foundation/approval/components/approval-detail.tsx b/src/features/foundation/approval/components/approval-detail.tsx new file mode 100644 index 0000000..129fdaa --- /dev/null +++ b/src/features/foundation/approval/components/approval-detail.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { useSuspenseQuery } from '@tanstack/react-query'; +import { approvalByIdOptions } from '../queries'; +import { ApprovalRequestPanel } from './approval-request-panel'; + +export function ApprovalDetail({ + approvalId, + canApprove, + canReject, + canReturn, + canCancel, + activeBusinessRole, + isOrgAdmin, + currentUserId +}: { + approvalId: string; + canApprove: boolean; + canReject: boolean; + canReturn: boolean; + canCancel: boolean; + activeBusinessRole: string | null; + isOrgAdmin: boolean; + currentUserId: string; +}) { + const { data } = useSuspenseQuery(approvalByIdOptions(approvalId)); + const entityHref = + data.approval.request.entityType === 'quotation' + ? `/dashboard/crm/quotations/${data.approval.request.entityId}` + : undefined; + + return ( + + ); +} diff --git a/src/features/foundation/approval/components/approval-listing.tsx b/src/features/foundation/approval/components/approval-listing.tsx new file mode 100644 index 0000000..a93a214 --- /dev/null +++ b/src/features/foundation/approval/components/approval-listing.tsx @@ -0,0 +1,31 @@ +import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; +import { getQueryClient } from '@/lib/query-client'; +import { searchParamsCache } from '@/lib/searchparams'; +import { approvalsQueryOptions } from '../queries'; +import { ApprovalsTable } from './approvals-table'; + +export default function ApprovalListing() { + const page = searchParamsCache.get('page'); + const limit = searchParamsCache.get('perPage'); + const search = searchParamsCache.get('name'); + const status = searchParamsCache.get('status'); + const entityType = searchParamsCache.get('entityType'); + const sort = searchParamsCache.get('sort'); + const filters = { + page, + limit, + ...(search && { search }), + ...(status && { status }), + ...(entityType && { entityType }), + ...(sort && { sort }) + }; + const queryClient = getQueryClient(); + + void queryClient.prefetchQuery(approvalsQueryOptions(filters)); + + return ( + + + + ); +} diff --git a/src/features/foundation/approval/components/approval-request-panel.tsx b/src/features/foundation/approval/components/approval-request-panel.tsx new file mode 100644 index 0000000..f8735b8 --- /dev/null +++ b/src/features/foundation/approval/components/approval-request-panel.tsx @@ -0,0 +1,292 @@ +'use client'; + +import Link from 'next/link'; +import { useState } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { Icons } from '@/components/icons'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Separator } from '@/components/ui/separator'; +import { Textarea } from '@/components/ui/textarea'; +import { + approveApprovalMutation, + cancelApprovalMutation, + rejectApprovalMutation, + returnApprovalMutation +} from '../mutations'; +import type { ApprovalDetailRecord } from '../types'; +import { ApprovalStatusBadge } from './approval-status-badge'; + +function FieldItem({ label, value }: { label: string; value: string | null | undefined }) { + return ( + + {label} + {value || '-'} + + ); +} + +export function ApprovalRequestPanel({ + approval, + canApprove, + canReject, + canReturn, + canCancel, + activeBusinessRole, + isOrgAdmin, + currentUserId, + entityHref +}: { + approval: ApprovalDetailRecord; + canApprove: boolean; + canReject: boolean; + canReturn: boolean; + canCancel: boolean; + activeBusinessRole: string | null; + isOrgAdmin: boolean; + currentUserId: string; + entityHref?: string; +}) { + const [remark, setRemark] = useState(''); + const isPending = approval.request.status === 'pending'; + const canHandleCurrentStep = + !!approval.currentStep && + (isOrgAdmin || activeBusinessRole === approval.currentStep.roleCode); + const canCancelCurrentRequest = + canCancel && isPending && (isOrgAdmin || approval.request.requestedBy === currentUserId); + + const approveAction = useMutation({ + ...approveApprovalMutation, + onSuccess: () => { + toast.success('Approval step completed'); + setRemark(''); + }, + onError: (error) => + toast.error(error instanceof Error ? error.message : 'Unable to approve request') + }); + const rejectAction = useMutation({ + ...rejectApprovalMutation, + onSuccess: () => { + toast.success('Approval request rejected'); + setRemark(''); + }, + onError: (error) => + toast.error(error instanceof Error ? error.message : 'Unable to reject request') + }); + const returnAction = useMutation({ + ...returnApprovalMutation, + onSuccess: () => { + toast.success('Approval request returned to draft'); + setRemark(''); + }, + onError: (error) => + toast.error(error instanceof Error ? error.message : 'Unable to return request') + }); + const cancelAction = useMutation({ + ...cancelApprovalMutation, + onSuccess: () => toast.success('Approval request cancelled'), + onError: (error) => + toast.error(error instanceof Error ? error.message : 'Unable to cancel request') + }); + const isActing = + approveAction.isPending || + rejectAction.isPending || + returnAction.isPending || + cancelAction.isPending; + + return ( + + + + Request Information + Current workflow state for this approval request. + + + + + Status + + + + + + + + + {entityHref ? ( + + + + + Open Related Document + + + + ) : null} + + + + + + Workflow + Sequential approver chain configured for this document. + + + {approval.steps.map((step) => { + const isCurrent = approval.currentStep?.id === step.id && isPending; + const isCompleted = approval.request.currentStep > step.stepNumber || approval.request.status === 'approved'; + + return ( + + + + Step {step.stepNumber} - {step.roleName} + + {step.roleCode} + + + {isCurrent ? 'Current' : isCompleted ? 'Completed' : 'Pending'} + + + ); + })} + + + + + + Actions + + Approvers can approve, reject, or return on their assigned step. + + + + setRemark(event.target.value)} + placeholder='Optional remark for approval history' + rows={4} + /> + + {canApprove && canHandleCurrentStep && isPending ? ( + + approveAction.mutate({ id: approval.request.id, values: { remark } }) + } + > + + Approve + + ) : null} + {canReject && canHandleCurrentStep && isPending ? ( + + rejectAction.mutate({ id: approval.request.id, values: { remark } }) + } + > + + Reject + + ) : null} + {canReturn && canHandleCurrentStep && isPending ? ( + + returnAction.mutate({ id: approval.request.id, values: { remark } }) + } + > + + Return + + ) : null} + {canCancelCurrentRequest ? ( + cancelAction.mutate(approval.request.id)} + > + + Cancel Request + + ) : null} + + {!canHandleCurrentStep && isPending ? ( + + Current step is assigned to{' '} + {approval.currentStep?.roleName ?? 'another role'}. + + ) : null} + {!isPending ? ( + + This request is already {approval.request.status.replaceAll('_', ' ')}. + + ) : null} + {isActing ? ( + Saving approval action... + ) : null} + + + + + + Timeline + Full audit trail of request submission and approval actions. + + + {!approval.timeline.length ? ( + + No approval actions recorded yet. + + ) : ( + approval.timeline.map((item, index) => ( + + + + + + + {item.action} + + + {item.actorName ?? item.actedBy} + + + Step {item.stepNumber} + + + + {new Date(item.actedAt).toLocaleString()} + + {item.remark ? {item.remark} : null} + + + {index < approval.timeline.length - 1 ? : null} + + )) + )} + + + + ); +} diff --git a/src/features/foundation/approval/components/approval-status-badge.tsx b/src/features/foundation/approval/components/approval-status-badge.tsx new file mode 100644 index 0000000..b167523 --- /dev/null +++ b/src/features/foundation/approval/components/approval-status-badge.tsx @@ -0,0 +1,31 @@ +'use client'; + +import { Badge } from '@/components/ui/badge'; +import { Icons } from '@/components/icons'; + +export function ApprovalStatusBadge({ status }: { status: string }) { + const normalized = status.toLowerCase(); + const variant = + normalized === 'approved' + ? 'default' + : normalized === 'pending' + ? 'secondary' + : normalized === 'rejected' + ? 'destructive' + : 'outline'; + const Icon = + normalized === 'approved' + ? Icons.circleCheck + : normalized === 'rejected' + ? Icons.warning + : normalized === 'returned' + ? Icons.chevronLeft + : Icons.clock; + + return ( + + + {status.replaceAll('_', ' ')} + + ); +} diff --git a/src/features/foundation/approval/components/approvals-table.tsx b/src/features/foundation/approval/components/approvals-table.tsx new file mode 100644 index 0000000..33cece4 --- /dev/null +++ b/src/features/foundation/approval/components/approvals-table.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { useMemo } from 'react'; +import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs'; +import { useSuspenseQuery } from '@tanstack/react-query'; +import { DataTable } from '@/components/ui/table/data-table'; +import { DataTableToolbar } from '@/components/ui/table/data-table-toolbar'; +import { useDataTable } from '@/hooks/use-data-table'; +import { getSortingStateParser } from '@/lib/parsers'; +import { approvalsQueryOptions } from '../queries'; +import { getApprovalColumns } from './approval-columns'; + +export function ApprovalsTable() { + const columns = useMemo(() => getApprovalColumns(), []); + const columnIds = columns.map((column) => column.id).filter(Boolean) as string[]; + const [params] = useQueryStates({ + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + name: parseAsString, + status: parseAsString, + entityType: parseAsString, + sort: getSortingStateParser(columnIds).withDefault([]) + }); + + const filters = { + page: params.page, + limit: params.perPage, + ...(params.name && { search: params.name }), + ...(params.status && { status: params.status }), + ...(params.entityType && { entityType: params.entityType }), + ...(params.sort.length > 0 && { sort: JSON.stringify(params.sort) }) + }; + const { data } = useSuspenseQuery(approvalsQueryOptions(filters)); + const pageCount = Math.ceil(data.totalItems / params.perPage); + const { table } = useDataTable({ + data: data.items, + columns, + pageCount, + shallow: true, + debounceMs: 500 + }); + + return ( + + + + ); +} diff --git a/src/features/foundation/approval/mutations.ts b/src/features/foundation/approval/mutations.ts new file mode 100644 index 0000000..c389736 --- /dev/null +++ b/src/features/foundation/approval/mutations.ts @@ -0,0 +1,84 @@ +import { mutationOptions } from '@tanstack/react-query'; +import { getQueryClient } from '@/lib/query-client'; +import { + approveApproval, + cancelApproval, + rejectApproval, + returnApproval, + submitApproval, + submitQuotationForApproval +} from './service'; +import { approvalKeys } from './queries'; +import { quotationKeys } from '@/features/crm/quotations/api/queries'; +import type { ApprovalActionPayload, SubmitApprovalPayload } from './types'; + +function invalidateQuotationQueries(quotationId: string) { + const queryClient = getQueryClient(); + queryClient.invalidateQueries({ queryKey: quotationKeys.all }); + queryClient.invalidateQueries({ queryKey: quotationKeys.detail(quotationId) }); +} + +function invalidateRelatedEntityQueries(approvalId: string) { + const queryClient = getQueryClient(); + const cachedDetail = queryClient.getQueryData<{ approval: { request: { entityType: string; entityId: string } } }>( + approvalKeys.detail(approvalId) + ); + + if (cachedDetail?.approval.request.entityType === 'quotation') { + invalidateQuotationQueries(cachedDetail.approval.request.entityId); + } +} + +export const submitApprovalMutation = mutationOptions({ + mutationFn: (data: SubmitApprovalPayload) => submitApproval(data), + onSuccess: () => { + getQueryClient().invalidateQueries({ queryKey: approvalKeys.all }); + } +}); + +export const submitQuotationApprovalMutation = mutationOptions({ + mutationFn: ({ id, remark }: { id: string; remark?: string }) => submitQuotationForApproval(id, remark), + onSuccess: (_data, variables) => { + getQueryClient().invalidateQueries({ queryKey: approvalKeys.all }); + invalidateQuotationQueries(variables.id); + } +}); + +export const cancelApprovalMutation = mutationOptions({ + mutationFn: (id: string) => cancelApproval(id), + onSuccess: (_data, id) => { + getQueryClient().invalidateQueries({ queryKey: approvalKeys.all }); + getQueryClient().invalidateQueries({ queryKey: approvalKeys.detail(id) }); + invalidateRelatedEntityQueries(id); + } +}); + +export const approveApprovalMutation = mutationOptions({ + mutationFn: ({ id, values }: { id: string; values: ApprovalActionPayload }) => + approveApproval(id, values), + onSuccess: (_data, variables) => { + getQueryClient().invalidateQueries({ queryKey: approvalKeys.all }); + getQueryClient().invalidateQueries({ queryKey: approvalKeys.detail(variables.id) }); + invalidateRelatedEntityQueries(variables.id); + } +}); + +export const rejectApprovalMutation = mutationOptions({ + mutationFn: ({ id, values }: { id: string; values: ApprovalActionPayload }) => + rejectApproval(id, values), + onSuccess: (_data, variables) => { + getQueryClient().invalidateQueries({ queryKey: approvalKeys.all }); + getQueryClient().invalidateQueries({ queryKey: approvalKeys.detail(variables.id) }); + invalidateRelatedEntityQueries(variables.id); + } +}); + +export const returnApprovalMutation = mutationOptions({ + mutationFn: ({ id, values }: { id: string; values: ApprovalActionPayload }) => + returnApproval(id, values), + onSuccess: (_data, variables) => { + getQueryClient().invalidateQueries({ queryKey: approvalKeys.all }); + getQueryClient().invalidateQueries({ queryKey: approvalKeys.detail(variables.id) }); + invalidateRelatedEntityQueries(variables.id); + } +}); diff --git a/src/features/foundation/approval/queries.ts b/src/features/foundation/approval/queries.ts new file mode 100644 index 0000000..daac569 --- /dev/null +++ b/src/features/foundation/approval/queries.ts @@ -0,0 +1,21 @@ +import { queryOptions } from '@tanstack/react-query'; +import { getApprovalById, getApprovals } from './service'; +import type { ApprovalFilters } from './types'; + +export const approvalKeys = { + all: ['crm-approvals'] as const, + list: (filters: ApprovalFilters) => [...approvalKeys.all, 'list', filters] as const, + detail: (id: string) => [...approvalKeys.all, 'detail', id] as const +}; + +export const approvalsQueryOptions = (filters: ApprovalFilters) => + queryOptions({ + queryKey: approvalKeys.list(filters), + queryFn: () => getApprovals(filters) + }); + +export const approvalByIdOptions = (id: string) => + queryOptions({ + queryKey: approvalKeys.detail(id), + queryFn: () => getApprovalById(id) + }); diff --git a/src/features/foundation/approval/server/service.ts b/src/features/foundation/approval/server/service.ts new file mode 100644 index 0000000..5b65156 --- /dev/null +++ b/src/features/foundation/approval/server/service.ts @@ -0,0 +1,986 @@ +import { + and, + asc, + count, + desc, + eq, + ilike, + inArray, + isNull, + or, + type SQL +} from 'drizzle-orm'; +import { + crmApprovalActions, + crmApprovalRequests, + crmApprovalSteps, + crmApprovalWorkflows, + crmCustomers, + crmQuotationItems, + crmQuotations, + memberships, + users +} from '@/db/schema'; +import { db } from '@/lib/db'; +import { AuthError } from '@/lib/auth/session'; +import { auditAction } from '@/features/foundation/audit-log/service'; +import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service'; +import type { + ApprovalActionRecord, + ApprovalDetailRecord, + ApprovalFilters, + ApprovalListItem, + ApprovalRequestRecord, + ApprovalStepRecord, + ApprovalTimelineItem, + ApprovalWorkflowRecord, + SubmitApprovalPayload +} from '../types'; + +const APPROVAL_REQUEST_STATUSES = { + pending: 'pending', + approved: 'approved', + rejected: 'rejected', + returned: 'returned', + cancelled: 'cancelled' +} as const; + +const APPROVAL_ACTIONS = { + submit: 'submit', + approve: 'approve', + reject: 'reject', + return: 'return', + cancel: 'cancel' +} as const; + +const QUOTATION_STATUS_CATEGORY = 'crm_quotation_status'; + +function mapWorkflowRecord(row: typeof crmApprovalWorkflows.$inferSelect): ApprovalWorkflowRecord { + return { + id: row.id, + organizationId: row.organizationId, + code: row.code, + name: row.name, + entityType: row.entityType, + isActive: row.isActive, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + deletedAt: row.deletedAt?.toISOString() ?? null + }; +} + +function mapStepRecord(row: typeof crmApprovalSteps.$inferSelect): ApprovalStepRecord { + return { + id: row.id, + organizationId: row.organizationId, + workflowId: row.workflowId, + stepNumber: row.stepNumber, + roleCode: row.roleCode, + roleName: row.roleName, + isRequired: row.isRequired, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + deletedAt: row.deletedAt?.toISOString() ?? null + }; +} + +function mapRequestRecord(row: typeof crmApprovalRequests.$inferSelect): ApprovalRequestRecord { + return { + id: row.id, + organizationId: row.organizationId, + workflowId: row.workflowId, + entityType: row.entityType, + entityId: row.entityId, + currentStep: row.currentStep, + status: row.status, + requestedBy: row.requestedBy, + requestedAt: row.requestedAt.toISOString(), + completedAt: row.completedAt?.toISOString() ?? null, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + deletedAt: row.deletedAt?.toISOString() ?? null + }; +} + +function mapActionRecord(row: typeof crmApprovalActions.$inferSelect): ApprovalActionRecord { + return { + id: row.id, + organizationId: row.organizationId, + approvalRequestId: row.approvalRequestId, + stepNumber: row.stepNumber, + action: row.action, + remark: row.remark, + actedBy: row.actedBy, + actedAt: row.actedAt.toISOString(), + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + deletedAt: row.deletedAt?.toISOString() ?? null + }; +} + +function parseSort(sort?: string) { + if (!sort) { + return desc(crmApprovalRequests.updatedAt); + } + + try { + const parsed = JSON.parse(sort) as Array<{ id: string; desc?: boolean }>; + const [rule] = parsed; + + if (!rule) { + return desc(crmApprovalRequests.updatedAt); + } + + const sortMap = { + status: crmApprovalRequests.status, + currentStep: crmApprovalRequests.currentStep, + requestedAt: crmApprovalRequests.requestedAt, + updatedAt: crmApprovalRequests.updatedAt + } as const; + + const column = sortMap[rule.id as keyof typeof sortMap]; + if (!column) { + return desc(crmApprovalRequests.updatedAt); + } + + return rule.desc ? desc(column) : asc(column); + } catch { + return desc(crmApprovalRequests.updatedAt); + } +} + +async function resolveQuotationStatusIdByCode(organizationId: string, code: string) { + const options = await getActiveOptionsByCategory(QUOTATION_STATUS_CATEGORY, { organizationId }); + return options.find((option) => option.code === code)?.id ?? null; +} + +async function resolveQuotationStatusCodeById(organizationId: string, id: string) { + const options = await getActiveOptionsByCategory(QUOTATION_STATUS_CATEGORY, { organizationId }); + return options.find((option) => option.id === id)?.code ?? null; +} + +async function assertWorkflowByCode( + organizationId: string, + code: string, + entityType?: string +) { + const [workflow] = await db + .select() + .from(crmApprovalWorkflows) + .where( + and( + eq(crmApprovalWorkflows.organizationId, organizationId), + eq(crmApprovalWorkflows.code, code), + eq(crmApprovalWorkflows.isActive, true), + isNull(crmApprovalWorkflows.deletedAt), + ...(entityType ? [eq(crmApprovalWorkflows.entityType, entityType)] : []) + ) + ) + .limit(1); + + if (!workflow) { + throw new AuthError('Approval workflow not found', 404); + } + + return workflow; +} + +async function assertApprovalRequest(id: string, organizationId: string) { + const [request] = await db + .select() + .from(crmApprovalRequests) + .where( + and( + eq(crmApprovalRequests.id, id), + eq(crmApprovalRequests.organizationId, organizationId), + isNull(crmApprovalRequests.deletedAt) + ) + ) + .limit(1); + + if (!request) { + throw new AuthError('Approval request not found', 404); + } + + return request; +} + +async function listWorkflowSteps( + workflowId: string, + organizationId: string +): Promise { + const rows = await db + .select() + .from(crmApprovalSteps) + .where( + and( + eq(crmApprovalSteps.workflowId, workflowId), + eq(crmApprovalSteps.organizationId, organizationId), + isNull(crmApprovalSteps.deletedAt) + ) + ) + .orderBy(asc(crmApprovalSteps.stepNumber)); + + return rows.map(mapStepRecord); +} + +async function assertActivePendingRequestForEntity( + organizationId: string, + entityType: string, + entityId: string +) { + const [request] = await db + .select() + .from(crmApprovalRequests) + .where( + and( + eq(crmApprovalRequests.organizationId, organizationId), + eq(crmApprovalRequests.entityType, entityType), + eq(crmApprovalRequests.entityId, entityId), + eq(crmApprovalRequests.status, APPROVAL_REQUEST_STATUSES.pending), + isNull(crmApprovalRequests.deletedAt) + ) + ) + .limit(1); + + return request ?? null; +} + +async function assertQuotationForApprovalReadiness(entityId: string, organizationId: string) { + const [quotation] = await db + .select() + .from(crmQuotations) + .where( + and( + eq(crmQuotations.id, entityId), + eq(crmQuotations.organizationId, organizationId), + isNull(crmQuotations.deletedAt) + ) + ) + .limit(1); + + if (!quotation) { + throw new AuthError('Quotation not found', 404); + } + + const statusCode = await resolveQuotationStatusCodeById(organizationId, quotation.status); + if (!statusCode || !['draft', 'revised'].includes(statusCode)) { + throw new AuthError('Only draft or revised quotations can be submitted for approval', 400); + } + + const [itemCount] = await db + .select({ value: count() }) + .from(crmQuotationItems) + .where( + and( + eq(crmQuotationItems.organizationId, organizationId), + eq(crmQuotationItems.quotationId, entityId), + isNull(crmQuotationItems.deletedAt) + ) + ); + + if (!itemCount?.value) { + throw new AuthError('Quotation must contain at least one item before approval', 400); + } + + const [customer] = await db + .select() + .from(crmCustomers) + .where( + and( + eq(crmCustomers.id, quotation.customerId), + eq(crmCustomers.organizationId, organizationId), + isNull(crmCustomers.deletedAt) + ) + ) + .limit(1); + + if (!customer) { + throw new AuthError('Quotation must be linked to an active customer', 400); + } + + return quotation; +} + +async function syncQuotationStatusFromApproval( + quotationId: string, + organizationId: string, + userId: string, + nextStatusCode: string +) { + const statusId = await resolveQuotationStatusIdByCode(organizationId, nextStatusCode); + + if (!statusId) { + throw new AuthError(`Quotation status ${nextStatusCode} is not configured`, 400); + } + + await db + .update(crmQuotations) + .set({ + status: statusId, + updatedAt: new Date(), + updatedBy: userId + }) + .where(eq(crmQuotations.id, quotationId)); +} + +async function syncEntityStatusFromApproval( + organizationId: string, + entityType: string, + entityId: string, + userId: string, + approvalStatus: string +) { + if (entityType !== 'quotation') { + return; + } + + if (approvalStatus === APPROVAL_REQUEST_STATUSES.pending) { + await syncQuotationStatusFromApproval(entityId, organizationId, userId, 'pending_approval'); + return; + } + + if (approvalStatus === APPROVAL_REQUEST_STATUSES.approved) { + await syncQuotationStatusFromApproval(entityId, organizationId, userId, 'approved'); + return; + } + + if (approvalStatus === APPROVAL_REQUEST_STATUSES.rejected) { + await syncQuotationStatusFromApproval(entityId, organizationId, userId, 'rejected'); + return; + } + + if (approvalStatus === APPROVAL_REQUEST_STATUSES.returned) { + await syncQuotationStatusFromApproval(entityId, organizationId, userId, 'draft'); + } +} + +async function assertActorCanHandleStep( + organizationId: string, + userId: string, + roleCode: string +) { + const [userRow] = await db.select().from(users).where(eq(users.id, userId)).limit(1); + + if (!userRow) { + throw new AuthError('User not found', 404); + } + + if (userRow.systemRole === 'super_admin') { + return; + } + + const [membership] = await db + .select() + .from(memberships) + .where(and(eq(memberships.organizationId, organizationId), eq(memberships.userId, userId))) + .limit(1); + + if (!membership) { + throw new AuthError('Organization membership required', 403); + } + + if (membership.role === 'admin' || membership.businessRole === roleCode) { + return; + } + + throw new AuthError('You are not allowed to act on this approval step', 403); +} + +async function resolveEntitySummaries( + organizationId: string, + items: Array<{ entityType: string; entityId: string }> +) { + const quotationIds = items + .filter((item) => item.entityType === 'quotation') + .map((item) => item.entityId); + const quotations = quotationIds.length + ? await db + .select({ + id: crmQuotations.id, + code: crmQuotations.code, + projectName: crmQuotations.projectName + }) + .from(crmQuotations) + .where( + and( + eq(crmQuotations.organizationId, organizationId), + inArray(crmQuotations.id, quotationIds), + isNull(crmQuotations.deletedAt) + ) + ) + : []; + + return new Map( + quotations.map((quotation) => [ + `quotation:${quotation.id}`, + { + entityCode: quotation.code, + entityTitle: quotation.projectName ?? quotation.code + } + ]) + ); +} + +function buildApprovalFilters(organizationId: string, filters: ApprovalFilters): SQL[] { + return [ + eq(crmApprovalRequests.organizationId, organizationId), + isNull(crmApprovalRequests.deletedAt), + ...(filters.status ? [eq(crmApprovalRequests.status, filters.status)] : []), + ...(filters.entityType ? [eq(crmApprovalRequests.entityType, filters.entityType)] : []), + ...(filters.entityId ? [eq(crmApprovalRequests.entityId, filters.entityId)] : []), + ...(filters.search + ? [ + or( + ilike(crmApprovalRequests.entityId, `%${filters.search}%`), + ilike(crmApprovalRequests.status, `%${filters.search}%`) + )! + ] + : []) + ]; +} + +export async function listApprovalRequests( + organizationId: string, + filters: ApprovalFilters +): Promise<{ items: ApprovalListItem[]; totalItems: number }> { + const page = filters.page ?? 1; + const limit = filters.limit ?? 10; + const whereFilters = buildApprovalFilters(organizationId, filters); + const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters); + const offset = (page - 1) * limit; + + const [totalResult] = await db.select({ value: count() }).from(crmApprovalRequests).where(where); + const rows = await db + .select() + .from(crmApprovalRequests) + .where(where) + .orderBy(parseSort(filters.sort)) + .limit(limit) + .offset(offset); + + const workflowIds = [...new Set(rows.map((row) => row.workflowId))]; + const requesterIds = [...new Set(rows.map((row) => row.requestedBy))]; + const [workflows, requesters, entitySummaries] = await Promise.all([ + workflowIds.length + ? db + .select() + .from(crmApprovalWorkflows) + .where(inArray(crmApprovalWorkflows.id, workflowIds)) + : [], + requesterIds.length ? db.select({ id: users.id, name: users.name }).from(users).where(inArray(users.id, requesterIds)) : [], + resolveEntitySummaries( + organizationId, + rows.map((row) => ({ entityType: row.entityType, entityId: row.entityId })) + ) + ]); + const workflowMap = new Map(workflows.map((row) => [row.id, row])); + const requesterMap = new Map(requesters.map((row) => [row.id, row.name])); + const stepMap = new Map(); + + if (workflowIds.length) { + const steps = await db + .select() + .from(crmApprovalSteps) + .where( + and( + eq(crmApprovalSteps.organizationId, organizationId), + inArray(crmApprovalSteps.workflowId, workflowIds), + isNull(crmApprovalSteps.deletedAt) + ) + ); + + for (const step of steps) { + stepMap.set(`${step.workflowId}:${step.stepNumber}`, mapStepRecord(step)); + } + } + + return { + items: rows.map((row) => { + const workflow = workflowMap.get(row.workflowId); + const currentStep = stepMap.get(`${row.workflowId}:${row.currentStep}`) ?? null; + const entitySummary = entitySummaries.get(`${row.entityType}:${row.entityId}`) ?? null; + + return { + ...mapRequestRecord(row), + workflowCode: workflow?.code ?? '', + workflowName: workflow?.name ?? '', + currentStepRoleCode: currentStep?.roleCode ?? null, + currentStepRoleName: currentStep?.roleName ?? null, + requestedByName: requesterMap.get(row.requestedBy) ?? null, + entityCode: entitySummary?.entityCode ?? null, + entityTitle: entitySummary?.entityTitle ?? null + }; + }), + totalItems: totalResult?.value ?? 0 + }; +} + +export async function getApprovalTimeline( + approvalRequestId: string, + organizationId: string +): Promise { + await assertApprovalRequest(approvalRequestId, organizationId); + const rows = await db + .select() + .from(crmApprovalActions) + .where( + and( + eq(crmApprovalActions.approvalRequestId, approvalRequestId), + eq(crmApprovalActions.organizationId, organizationId), + isNull(crmApprovalActions.deletedAt) + ) + ) + .orderBy(asc(crmApprovalActions.actedAt), asc(crmApprovalActions.createdAt)); + const actorIds = [...new Set(rows.map((row) => row.actedBy))]; + const actors = actorIds.length + ? await db.select({ id: users.id, name: users.name }).from(users).where(inArray(users.id, actorIds)) + : []; + const actorMap = new Map(actors.map((row) => [row.id, row.name])); + + return rows.map((row) => ({ + ...mapActionRecord(row), + actorName: actorMap.get(row.actedBy) ?? null + })); +} + +export async function getCurrentApprovalStep( + approvalRequestId: string, + organizationId: string +): Promise { + const request = await assertApprovalRequest(approvalRequestId, organizationId); + const [row] = await db + .select() + .from(crmApprovalSteps) + .where( + and( + eq(crmApprovalSteps.workflowId, request.workflowId), + eq(crmApprovalSteps.organizationId, organizationId), + eq(crmApprovalSteps.stepNumber, request.currentStep), + isNull(crmApprovalSteps.deletedAt) + ) + ) + .limit(1); + + return row ? mapStepRecord(row) : null; +} + +export async function getApprovalRequest( + id: string, + organizationId: string +): Promise { + const requestRow = await assertApprovalRequest(id, organizationId); + const [workflowRow] = await db + .select() + .from(crmApprovalWorkflows) + .where(eq(crmApprovalWorkflows.id, requestRow.workflowId)) + .limit(1); + + if (!workflowRow) { + throw new AuthError('Approval workflow not found', 404); + } + + const [steps, timeline, entitySummaries] = await Promise.all([ + listWorkflowSteps(requestRow.workflowId, organizationId), + getApprovalTimeline(id, organizationId), + resolveEntitySummaries(organizationId, [ + { entityType: requestRow.entityType, entityId: requestRow.entityId } + ]) + ]); + const currentStep = steps.find((step) => step.stepNumber === requestRow.currentStep) ?? null; + const entitySummary = + entitySummaries.get(`${requestRow.entityType}:${requestRow.entityId}`) ?? null; + + return { + request: mapRequestRecord(requestRow), + workflow: mapWorkflowRecord(workflowRow), + steps, + currentStep, + timeline, + entityCode: entitySummary?.entityCode ?? null, + entityTitle: entitySummary?.entityTitle ?? null + }; +} + +export async function submitForApproval( + organizationId: string, + userId: string, + payload: SubmitApprovalPayload +) { + const workflow = await assertWorkflowByCode( + organizationId, + payload.workflowCode ?? `${payload.entityType}_standard_approval`, + payload.entityType + ); + const steps = await listWorkflowSteps(workflow.id, organizationId); + + if (!steps.length) { + throw new AuthError('Approval workflow has no steps', 400); + } + + const existingRequest = await assertActivePendingRequestForEntity( + organizationId, + payload.entityType, + payload.entityId + ); + + if (existingRequest) { + throw new AuthError('This document already has a pending approval request', 400); + } + + if (payload.entityType === 'quotation') { + await assertQuotationForApprovalReadiness(payload.entityId, organizationId); + } + + const [createdRequest] = await db + .insert(crmApprovalRequests) + .values({ + id: crypto.randomUUID(), + organizationId, + workflowId: workflow.id, + entityType: payload.entityType, + entityId: payload.entityId, + currentStep: steps[0].stepNumber, + status: APPROVAL_REQUEST_STATUSES.pending, + requestedBy: userId + }) + .returning(); + + const [createdAction] = await db + .insert(crmApprovalActions) + .values({ + id: crypto.randomUUID(), + organizationId, + approvalRequestId: createdRequest.id, + stepNumber: 0, + action: APPROVAL_ACTIONS.submit, + remark: payload.remark?.trim() || null, + actedBy: userId + }) + .returning(); + + await syncEntityStatusFromApproval( + organizationId, + payload.entityType, + payload.entityId, + userId, + APPROVAL_REQUEST_STATUSES.pending + ); + + await Promise.all([ + auditAction({ + organizationId, + userId, + entityType: 'crm_approval_request', + entityId: createdRequest.id, + action: APPROVAL_ACTIONS.submit, + afterData: createdRequest + }), + auditAction({ + organizationId, + userId, + entityType: 'crm_approval_action', + entityId: createdAction.id, + action: APPROVAL_ACTIONS.submit, + afterData: createdAction + }) + ]); + + return createdRequest; +} + +export async function approveApproval( + approvalRequestId: string, + organizationId: string, + userId: string, + remark?: string +) { + const request = await assertApprovalRequest(approvalRequestId, organizationId); + + if (request.status !== APPROVAL_REQUEST_STATUSES.pending) { + throw new AuthError('Only pending approvals can be approved', 400); + } + + const steps = await listWorkflowSteps(request.workflowId, organizationId); + const currentStep = steps.find((step) => step.stepNumber === request.currentStep); + + if (!currentStep) { + throw new AuthError('Current approval step not found', 404); + } + + await assertActorCanHandleStep(organizationId, userId, currentStep.roleCode); + const nextStep = steps.find((step) => step.stepNumber > currentStep.stepNumber) ?? null; + + const [createdAction] = await db + .insert(crmApprovalActions) + .values({ + id: crypto.randomUUID(), + organizationId, + approvalRequestId, + stepNumber: currentStep.stepNumber, + action: APPROVAL_ACTIONS.approve, + remark: remark?.trim() || null, + actedBy: userId + }) + .returning(); + + const [updatedRequest] = await db + .update(crmApprovalRequests) + .set({ + currentStep: nextStep?.stepNumber ?? currentStep.stepNumber, + status: nextStep ? APPROVAL_REQUEST_STATUSES.pending : APPROVAL_REQUEST_STATUSES.approved, + completedAt: nextStep ? null : new Date(), + updatedAt: new Date() + }) + .where(eq(crmApprovalRequests.id, approvalRequestId)) + .returning(); + + if (!nextStep) { + await syncEntityStatusFromApproval( + organizationId, + request.entityType, + request.entityId, + userId, + APPROVAL_REQUEST_STATUSES.approved + ); + } + + await Promise.all([ + auditAction({ + organizationId, + userId, + entityType: 'crm_approval_request', + entityId: approvalRequestId, + action: APPROVAL_ACTIONS.approve, + beforeData: request, + afterData: updatedRequest + }), + auditAction({ + organizationId, + userId, + entityType: 'crm_approval_action', + entityId: createdAction.id, + action: APPROVAL_ACTIONS.approve, + afterData: createdAction + }) + ]); + + return updatedRequest; +} + +export async function rejectApproval( + approvalRequestId: string, + organizationId: string, + userId: string, + remark?: string +) { + const request = await assertApprovalRequest(approvalRequestId, organizationId); + + if (request.status !== APPROVAL_REQUEST_STATUSES.pending) { + throw new AuthError('Only pending approvals can be rejected', 400); + } + + const currentStep = await getCurrentApprovalStep(approvalRequestId, organizationId); + + if (!currentStep) { + throw new AuthError('Current approval step not found', 404); + } + + await assertActorCanHandleStep(organizationId, userId, currentStep.roleCode); + + const [createdAction] = await db + .insert(crmApprovalActions) + .values({ + id: crypto.randomUUID(), + organizationId, + approvalRequestId, + stepNumber: currentStep.stepNumber, + action: APPROVAL_ACTIONS.reject, + remark: remark?.trim() || null, + actedBy: userId + }) + .returning(); + + const [updatedRequest] = await db + .update(crmApprovalRequests) + .set({ + status: APPROVAL_REQUEST_STATUSES.rejected, + completedAt: new Date(), + updatedAt: new Date() + }) + .where(eq(crmApprovalRequests.id, approvalRequestId)) + .returning(); + + await syncEntityStatusFromApproval( + organizationId, + request.entityType, + request.entityId, + userId, + APPROVAL_REQUEST_STATUSES.rejected + ); + + await Promise.all([ + auditAction({ + organizationId, + userId, + entityType: 'crm_approval_request', + entityId: approvalRequestId, + action: APPROVAL_ACTIONS.reject, + beforeData: request, + afterData: updatedRequest + }), + auditAction({ + organizationId, + userId, + entityType: 'crm_approval_action', + entityId: createdAction.id, + action: APPROVAL_ACTIONS.reject, + afterData: createdAction + }) + ]); + + return updatedRequest; +} + +export async function returnApproval( + approvalRequestId: string, + organizationId: string, + userId: string, + remark?: string +) { + const request = await assertApprovalRequest(approvalRequestId, organizationId); + + if (request.status !== APPROVAL_REQUEST_STATUSES.pending) { + throw new AuthError('Only pending approvals can be returned', 400); + } + + const currentStep = await getCurrentApprovalStep(approvalRequestId, organizationId); + + if (!currentStep) { + throw new AuthError('Current approval step not found', 404); + } + + await assertActorCanHandleStep(organizationId, userId, currentStep.roleCode); + + const [createdAction] = await db + .insert(crmApprovalActions) + .values({ + id: crypto.randomUUID(), + organizationId, + approvalRequestId, + stepNumber: currentStep.stepNumber, + action: APPROVAL_ACTIONS.return, + remark: remark?.trim() || null, + actedBy: userId + }) + .returning(); + + const [updatedRequest] = await db + .update(crmApprovalRequests) + .set({ + status: APPROVAL_REQUEST_STATUSES.returned, + completedAt: new Date(), + updatedAt: new Date() + }) + .where(eq(crmApprovalRequests.id, approvalRequestId)) + .returning(); + + await syncEntityStatusFromApproval( + organizationId, + request.entityType, + request.entityId, + userId, + APPROVAL_REQUEST_STATUSES.returned + ); + + await Promise.all([ + auditAction({ + organizationId, + userId, + entityType: 'crm_approval_request', + entityId: approvalRequestId, + action: APPROVAL_ACTIONS.return, + beforeData: request, + afterData: updatedRequest + }), + auditAction({ + organizationId, + userId, + entityType: 'crm_approval_action', + entityId: createdAction.id, + action: APPROVAL_ACTIONS.return, + afterData: createdAction + }) + ]); + + return updatedRequest; +} + +export async function cancelApproval( + approvalRequestId: string, + organizationId: string, + userId: string +) { + const request = await assertApprovalRequest(approvalRequestId, organizationId); + + if (request.status !== APPROVAL_REQUEST_STATUSES.pending) { + throw new AuthError('Only pending approvals can be cancelled', 400); + } + + if (request.requestedBy !== userId) { + await assertActorCanHandleStep(organizationId, userId, 'sales_manager'); + } + + const [createdAction] = await db + .insert(crmApprovalActions) + .values({ + id: crypto.randomUUID(), + organizationId, + approvalRequestId, + stepNumber: request.currentStep, + action: APPROVAL_ACTIONS.cancel, + remark: null, + actedBy: userId + }) + .returning(); + + const [updatedRequest] = await db + .update(crmApprovalRequests) + .set({ + status: APPROVAL_REQUEST_STATUSES.cancelled, + completedAt: new Date(), + updatedAt: new Date() + }) + .where(eq(crmApprovalRequests.id, approvalRequestId)) + .returning(); + + await syncEntityStatusFromApproval( + organizationId, + request.entityType, + request.entityId, + userId, + APPROVAL_REQUEST_STATUSES.returned + ); + + await Promise.all([ + auditAction({ + organizationId, + userId, + entityType: 'crm_approval_request', + entityId: approvalRequestId, + action: APPROVAL_ACTIONS.cancel, + beforeData: request, + afterData: updatedRequest + }), + auditAction({ + organizationId, + userId, + entityType: 'crm_approval_action', + entityId: createdAction.id, + action: APPROVAL_ACTIONS.cancel, + afterData: createdAction + }) + ]); + + return updatedRequest; +} diff --git a/src/features/foundation/approval/service.ts b/src/features/foundation/approval/service.ts new file mode 100644 index 0000000..cba5239 --- /dev/null +++ b/src/features/foundation/approval/service.ts @@ -0,0 +1,69 @@ +import { apiClient } from '@/lib/api-client'; +import type { + ApprovalActionPayload, + ApprovalDetailResponse, + ApprovalFilters, + ApprovalListResponse, + MutationSuccessResponse, + SubmitApprovalPayload +} from './types'; + +export async function getApprovals(filters: ApprovalFilters): Promise { + const searchParams = new URLSearchParams(); + + if (filters.page) searchParams.set('page', String(filters.page)); + if (filters.limit) searchParams.set('limit', String(filters.limit)); + if (filters.search) searchParams.set('search', filters.search); + if (filters.status) searchParams.set('status', filters.status); + if (filters.entityType) searchParams.set('entityType', filters.entityType); + if (filters.entityId) searchParams.set('entityId', filters.entityId); + if (filters.sort) searchParams.set('sort', filters.sort); + + const query = searchParams.toString(); + return apiClient(`/crm/approvals${query ? `?${query}` : ''}`); +} + +export async function getApprovalById(id: string): Promise { + return apiClient(`/crm/approvals/${id}`); +} + +export async function submitApproval(data: SubmitApprovalPayload) { + return apiClient('/crm/approvals', { + method: 'POST', + body: JSON.stringify(data) + }); +} + +export async function cancelApproval(id: string) { + return apiClient(`/crm/approvals/${id}`, { + method: 'DELETE' + }); +} + +export async function approveApproval(id: string, data: ApprovalActionPayload) { + return apiClient(`/crm/approvals/${id}/approve`, { + method: 'POST', + body: JSON.stringify(data) + }); +} + +export async function rejectApproval(id: string, data: ApprovalActionPayload) { + return apiClient(`/crm/approvals/${id}/reject`, { + method: 'POST', + body: JSON.stringify(data) + }); +} + +export async function returnApproval(id: string, data: ApprovalActionPayload) { + return apiClient(`/crm/approvals/${id}/return`, { + method: 'POST', + body: JSON.stringify(data) + }); +} + +export async function submitQuotationForApproval(id: string, remark?: string) { + return apiClient(`/crm/quotations/${id}/submit-approval`, { + method: 'POST', + body: JSON.stringify({ remark }) + }); +} diff --git a/src/features/foundation/approval/types.ts b/src/features/foundation/approval/types.ts new file mode 100644 index 0000000..633c7ae --- /dev/null +++ b/src/features/foundation/approval/types.ts @@ -0,0 +1,121 @@ +export interface ApprovalWorkflowRecord { + id: string; + organizationId: string; + code: string; + name: string; + entityType: string; + isActive: boolean; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +export interface ApprovalStepRecord { + id: string; + organizationId: string; + workflowId: string; + stepNumber: number; + roleCode: string; + roleName: string; + isRequired: boolean; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +export interface ApprovalRequestRecord { + id: string; + organizationId: string; + workflowId: string; + entityType: string; + entityId: string; + currentStep: number; + status: string; + requestedBy: string; + requestedAt: string; + completedAt: string | null; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +export interface ApprovalActionRecord { + id: string; + organizationId: string; + approvalRequestId: string; + stepNumber: number; + action: string; + remark: string | null; + actedBy: string; + actedAt: string; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +export interface ApprovalListItem extends ApprovalRequestRecord { + workflowCode: string; + workflowName: string; + currentStepRoleCode: string | null; + currentStepRoleName: string | null; + requestedByName: string | null; + entityCode: string | null; + entityTitle: string | null; +} + +export interface ApprovalTimelineItem extends ApprovalActionRecord { + actorName: string | null; +} + +export interface ApprovalDetailRecord { + request: ApprovalRequestRecord; + workflow: ApprovalWorkflowRecord; + steps: ApprovalStepRecord[]; + currentStep: ApprovalStepRecord | null; + timeline: ApprovalTimelineItem[]; + entityCode: string | null; + entityTitle: string | null; +} + +export interface ApprovalFilters { + page?: number; + limit?: number; + search?: string; + status?: string; + entityType?: string; + entityId?: string; + sort?: string; +} + +export interface SubmitApprovalPayload { + workflowCode?: string; + entityType: string; + entityId: string; + remark?: string; +} + +export interface ApprovalActionPayload { + remark?: string; +} + +export interface ApprovalListResponse { + success: boolean; + time: string; + message: string; + totalItems: number; + offset: number; + limit: number; + items: ApprovalListItem[]; +} + +export interface ApprovalDetailResponse { + success: boolean; + time: string; + message: string; + approval: ApprovalDetailRecord; +} + +export interface MutationSuccessResponse { + success: boolean; + message: string; +} diff --git a/src/lib/auth/rbac.ts b/src/lib/auth/rbac.ts index 45d24f6..253be8d 100644 --- a/src/lib/auth/rbac.ts +++ b/src/lib/auth/rbac.ts @@ -6,7 +6,10 @@ export const BUSINESS_ROLES = [ 'infrastructure', 'application', 'auditor', - 'viewer' + 'viewer', + 'sales_manager', + 'department_manager', + 'top_manager' ] as const; export type SystemRole = (typeof SYSTEM_ROLES)[number]; @@ -44,7 +47,12 @@ export const PERMISSIONS = { crmQuotationTopicManage: 'crm.quotation.topic.manage', crmQuotationFollowupManage: 'crm.quotation.followup.manage', crmQuotationAttachmentManage: 'crm.quotation.attachment.manage', - crmQuotationRevisionCreate: 'crm.quotation.revision.create' + crmQuotationRevisionCreate: 'crm.quotation.revision.create', + crmApprovalRead: 'crm.approval.read', + crmApprovalSubmit: 'crm.approval.submit', + crmApprovalApprove: 'crm.approval.approve', + crmApprovalReject: 'crm.approval.reject', + crmApprovalReturn: 'crm.approval.return' } as const; export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS]; @@ -81,7 +89,12 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] { PERMISSIONS.crmQuotationTopicManage, PERMISSIONS.crmQuotationFollowupManage, PERMISSIONS.crmQuotationAttachmentManage, - PERMISSIONS.crmQuotationRevisionCreate + PERMISSIONS.crmQuotationRevisionCreate, + PERMISSIONS.crmApprovalRead, + PERMISSIONS.crmApprovalSubmit, + PERMISSIONS.crmApprovalApprove, + PERMISSIONS.crmApprovalReject, + PERMISSIONS.crmApprovalReturn ]; } @@ -91,7 +104,8 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] { PERMISSIONS.crmContactRead, PERMISSIONS.crmEnquiryRead, PERMISSIONS.crmEnquiryFollowupRead, - PERMISSIONS.crmQuotationRead + PERMISSIONS.crmQuotationRead, + PERMISSIONS.crmApprovalRead ]; } @@ -108,6 +122,9 @@ export function getBusinessRolePermissions(role: BusinessRole): Permission[] { case 'auditor': return [PERMISSIONS.reportRead]; case 'viewer': + case 'sales_manager': + case 'department_manager': + case 'top_manager': default: return []; } diff --git a/src/lib/searchparams.ts b/src/lib/searchparams.ts index 54b441e..0cdb16b 100644 --- a/src/lib/searchparams.ts +++ b/src/lib/searchparams.ts @@ -25,6 +25,7 @@ export const searchParams = { hot: parseAsString, view: parseAsString, status: parseAsString, + entityType: parseAsString, assetType: parseAsString, role: parseAsString, sort: parseAsString