diff --git a/docs/implementation/task-e-quotation-production.md b/docs/implementation/task-e-quotation-production.md new file mode 100644 index 0000000..923d18d --- /dev/null +++ b/docs/implementation/task-e-quotation-production.md @@ -0,0 +1,153 @@ +# Task E: Quotation Production Module + +## 1. Files Added + +- `src/app/api/crm/quotations/route.ts` +- `src/app/api/crm/quotations/[id]/route.ts` +- `src/app/api/crm/quotations/[id]/items/route.ts` +- `src/app/api/crm/quotations/[id]/items/[itemId]/route.ts` +- `src/app/api/crm/quotations/[id]/customers/route.ts` +- `src/app/api/crm/quotations/[id]/topics/route.ts` +- `src/app/api/crm/quotations/[id]/followups/route.ts` +- `src/app/api/crm/quotations/[id]/attachments/route.ts` +- `src/app/api/crm/quotations/[id]/revisions/route.ts` +- `src/features/crm/quotations/api/types.ts` +- `src/features/crm/quotations/api/service.ts` +- `src/features/crm/quotations/api/queries.ts` +- `src/features/crm/quotations/api/mutations.ts` +- `src/features/crm/quotations/schemas/quotation.schema.ts` +- `src/features/crm/quotations/server/service.ts` +- `src/features/crm/quotations/components/quotation-status-badge.tsx` +- `src/features/crm/quotations/components/quotation-form-sheet.tsx` +- `src/features/crm/quotations/components/quotation-cell-action.tsx` +- `src/features/crm/quotations/components/quotation-columns.tsx` +- `src/features/crm/quotations/components/quotations-table.tsx` +- `src/features/crm/quotations/components/quotation-listing.tsx` +- `src/features/crm/quotations/components/quotation-detail.tsx` +- `drizzle/0004_worthless_ender_wiggin.sql` +- `drizzle/meta/0004_snapshot.json` + +## 2. Files Modified + +- `src/app/dashboard/crm/quotations/page.tsx` +- `src/app/dashboard/crm/quotations/[id]/page.tsx` +- `src/app/dashboard/crm/enquiries/[id]/page.tsx` +- `src/app/dashboard/crm/customers/[id]/page.tsx` +- `src/features/crm/enquiries/components/enquiry-detail.tsx` +- `src/features/crm/customers/components/customer-detail.tsx` +- `src/db/schema.ts` +- `src/db/seeds/foundation.seed.ts` +- `src/lib/auth/rbac.ts` +- `src/lib/searchparams.ts` +- `drizzle/meta/_journal.json` + +## 3. Schema Added + +- `crm_quotations` + - organization-scoped quotation master with document code, customer/contact/enquiry linkage, pricing header, status, revision metadata, and soft-delete columns +- `crm_quotation_items` + - server-calculated line items under quotation +- `crm_quotation_customers` + - related parties such as owner, consultant, contractor, and billing +- `crm_quotation_topics` + - quotation document sections such as scope, exclusions, and payment +- `crm_quotation_topic_items` + - topic bullet items stored under each section +- `crm_quotation_followups` + - post-send follow-up timeline under quotation +- `crm_quotation_attachments` + - metadata-only attachment registry for future file-storage integration + +## 4. API Routes Added + +- `GET /api/crm/quotations` +- `POST /api/crm/quotations` +- `GET /api/crm/quotations/[id]` +- `PATCH /api/crm/quotations/[id]` +- `DELETE /api/crm/quotations/[id]` +- `GET /api/crm/quotations/[id]/items` +- `POST /api/crm/quotations/[id]/items` +- `PATCH /api/crm/quotations/[id]/items/[itemId]` +- `DELETE /api/crm/quotations/[id]/items/[itemId]` +- `GET /api/crm/quotations/[id]/customers` +- `POST /api/crm/quotations/[id]/customers` +- `PATCH /api/crm/quotations/[id]/customers` +- `DELETE /api/crm/quotations/[id]/customers` +- `GET /api/crm/quotations/[id]/topics` +- `POST /api/crm/quotations/[id]/topics` +- `PATCH /api/crm/quotations/[id]/topics` +- `DELETE /api/crm/quotations/[id]/topics` +- `GET /api/crm/quotations/[id]/followups` +- `POST /api/crm/quotations/[id]/followups` +- `PATCH /api/crm/quotations/[id]/followups` +- `DELETE /api/crm/quotations/[id]/followups` +- `GET /api/crm/quotations/[id]/attachments` +- `POST /api/crm/quotations/[id]/attachments` +- `PATCH /api/crm/quotations/[id]/attachments` +- `DELETE /api/crm/quotations/[id]/attachments` +- `GET /api/crm/quotations/[id]/revisions` +- `POST /api/crm/quotations/[id]/revisions` + +## 5. UI Routes Completed + +- `/dashboard/crm/quotations` + - production list with React Query, nuqs filters, create button, edit/view/delete actions + - filters: search, status, quotation type, branch, customer, enquiry, hot project +- `/dashboard/crm/quotations/[id]` + - detail page with tabs: Overview, Items, Customers, Topics, Follow-ups, Attachments, Activity, Approval Placeholder, Document Preview Placeholder + - revision chain card with create-revision action +- `/dashboard/crm/enquiries/[id]` + - related quotations tab now shows real quotation links for the enquiry +- `/dashboard/crm/customers/[id]` + - related documents tab now shows both enquiries and quotations for the customer + +## 6. Permissions Used + +- `crm.quotation.read` +- `crm.quotation.create` +- `crm.quotation.update` +- `crm.quotation.delete` +- `crm.quotation.item.manage` +- `crm.quotation.customer.manage` +- `crm.quotation.topic.manage` +- `crm.quotation.followup.manage` +- `crm.quotation.attachment.manage` +- `crm.quotation.revision.create` + +Admin defaults include the full quotation permission set. Regular users inherit read access only unless their membership permissions are extended. + +## 7. Audit Integration + +- quotation create/update/delete writes audit entries with `entityType = crm_quotation` +- item create/update/delete writes audit entries with `entityType = crm_quotation_item` +- related customer create/update/delete writes audit entries with `entityType = crm_quotation_customer` +- topic create/update/delete writes audit entries with `entityType = crm_quotation_topic` +- follow-up create/update/delete writes audit entries with `entityType = crm_quotation_followup` +- attachment metadata create/update/delete writes audit entries with `entityType = crm_quotation_attachment` +- quotation detail activity tab now surfaces all quotation-side audit mutations together + +## 8. Document Sequence And Totals + +- quotation create uses `generateNextDocumentCode({ documentType: 'quotation' })` +- revisions also consume the same document sequence and keep `parentQuotationId` linkage +- item totals are calculated on the server +- quotation subtotal, tax, and total are refreshed from current line items after item mutations + +## 9. Enquiry And Customer Integration + +- quotation create/update validates customer, contact, enquiry, branch, and salesman membership against the active organization +- selecting an enquiry in the quotation form auto-fills customer/contact/project context where available +- enquiry detail now links to real quotations instead of the old Task D placeholder +- customer detail now links to both related enquiries and related quotations + +## 10. Remaining Risks + +- quotation item `taxRate` is stored per line, but current aggregate total refresh still uses quotation-header tax for the final quote summary rather than a mixed per-line tax rollup +- attachment handling is metadata-only; there is still no binary upload/storage pipeline +- revision creation copies header, items, customers, and topics, but does not snapshot follow-ups or attachments +- there are still no database foreign keys between quotation tables and the linked CRM entities; organization scoping is enforced in application logic + +## 11. Verification + +- `npx tsc --noEmit` +- `npm run gen` diff --git a/docs/implementation/technical-debt.md b/docs/implementation/technical-debt.md new file mode 100644 index 0000000..8994721 --- /dev/null +++ b/docs/implementation/technical-debt.md @@ -0,0 +1,117 @@ +# Technical Debt + +## After Task D + +### 1. Database FK + +Customer / Contact / Enquiry ยัง enforce relationship ด้วย application logic + +Future: + +* เพิ่ม FK หลัง schema stable + +### 2. Date Input + +Form ยังใช้ text input แบบ YYYY-MM-DD + +Future: + +* เปลี่ยนเป็น Date Picker กลางของระบบ + +### 3. Customer Related Enquiries Pagination + +หน้า customer detail แสดง related enquiries แล้ว แต่ยังไม่มี pagination + +Future: + +* เพิ่มเมื่อข้อมูลเริ่มเยอะ + +### 4. Follow-up Activity + +Activity timeline ยังพึ่ง audit payload + +Future: + +* ทำ combined activity view/service + +--- + +## After Task E + +### 5. Mixed Tax Rollup + +Quotation item รองรับ taxRate ต่อรายการแล้ว + +Current: + +* quotation summary ยังใช้ header tax calculation + +Future: + +* รองรับ mixed tax per line +* aggregate tax จาก item จริง + +Priority: + +* Medium + +### 6. Attachment Storage + +Attachment ยังเป็น metadata-only + +Current: + +* เก็บเฉพาะ metadata + +Future: + +* storage provider abstraction +* upload service +* download endpoint +* permission check + +Priority: + +* High + +### 7. Revision Snapshot + +Revision copy: + +* header +* items +* customers +* topics + +Current: + +* ไม่ copy follow-ups +* ไม่ copy attachments + +Future: + +* กำหนด revision snapshot strategy ให้ชัดเจน + +Priority: + +* Medium + +### 8. CRM Foreign Keys + +Customer +Contact +Enquiry +Quotation + +Current: + +* ใช้ organization validation ใน application layer + +Future: + +* เพิ่ม FK เมื่อ schema stable +* review cascade strategy + +Priority: + +* High diff --git a/drizzle/0004_worthless_ender_wiggin.sql b/drizzle/0004_worthless_ender_wiggin.sql new file mode 100644 index 0000000..262d52e --- /dev/null +++ b/drizzle/0004_worthless_ender_wiggin.sql @@ -0,0 +1,138 @@ +CREATE TABLE "crm_quotation_attachments" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "quotation_id" text NOT NULL, + "file_name" text NOT NULL, + "original_file_name" text NOT NULL, + "file_path" text NOT NULL, + "file_size" integer, + "file_type" text, + "description" text, + "uploaded_at" timestamp with time zone DEFAULT now() NOT NULL, + "uploaded_by" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "deleted_at" timestamp with time zone +); +--> statement-breakpoint +CREATE TABLE "crm_quotation_customers" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "quotation_id" text NOT NULL, + "customer_id" text NOT NULL, + "role" text NOT NULL, + "is_primary" boolean DEFAULT false 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_quotation_followups" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "quotation_id" text NOT NULL, + "followup_date" timestamp with time zone NOT NULL, + "followup_type" text NOT NULL, + "contact_id" text, + "outcome" text, + "notes" text, + "next_followup_date" timestamp with time zone, + "next_action" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "deleted_at" timestamp with time zone, + "created_by" text NOT NULL, + "updated_by" text NOT NULL +); +--> statement-breakpoint +CREATE TABLE "crm_quotation_items" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "quotation_id" text NOT NULL, + "item_number" integer NOT NULL, + "product_type" text NOT NULL, + "description" text NOT NULL, + "quantity" double precision DEFAULT 0 NOT NULL, + "unit" text, + "unit_price" double precision DEFAULT 0 NOT NULL, + "discount" double precision DEFAULT 0 NOT NULL, + "discount_type" text, + "tax_rate" double precision DEFAULT 0 NOT NULL, + "total_price" double precision DEFAULT 0 NOT NULL, + "notes" text, + "sort_order" integer DEFAULT 0 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_quotation_topic_items" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "topic_id" text NOT NULL, + "content" text NOT NULL, + "sort_order" integer DEFAULT 0 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_quotation_topics" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "quotation_id" text NOT NULL, + "topic_type" text NOT NULL, + "title" text NOT NULL, + "sort_order" integer DEFAULT 0 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_quotations" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "branch_id" text, + "code" text NOT NULL, + "enquiry_id" text, + "customer_id" text NOT NULL, + "contact_id" text, + "quotation_date" timestamp with time zone NOT NULL, + "valid_until" timestamp with time zone, + "quotation_type" text NOT NULL, + "project_name" text, + "project_location" text, + "attention" text, + "reference" text, + "notes" text, + "status" text NOT NULL, + "revision" integer DEFAULT 0 NOT NULL, + "parent_quotation_id" text, + "revision_remark" text, + "currency" text NOT NULL, + "exchange_rate" double precision DEFAULT 1 NOT NULL, + "subtotal" double precision DEFAULT 0 NOT NULL, + "discount" double precision DEFAULT 0 NOT NULL, + "discount_type" text, + "tax_rate" double precision DEFAULT 0 NOT NULL, + "tax_amount" double precision DEFAULT 0 NOT NULL, + "total_amount" double precision DEFAULT 0 NOT NULL, + "chance_percent" integer, + "is_hot_project" boolean DEFAULT false NOT NULL, + "competitor" text, + "salesman_id" text, + "is_sent" boolean DEFAULT false NOT NULL, + "sent_at" timestamp with time zone, + "sent_via" text, + "accepted_at" timestamp with time zone, + "rejected_at" timestamp with time zone, + "rejection_reason" text, + "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, + "created_by" text NOT NULL, + "updated_by" text NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX "crm_quotations_org_code_idx" ON "crm_quotations" USING btree ("organization_id","code"); \ No newline at end of file diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..56063a1 --- /dev/null +++ b/drizzle/meta/0004_snapshot.json @@ -0,0 +1,2133 @@ +{ + "id": "2d07652f-ec50-4bc0-970e-b2e59784a958", + "prevId": "7f3502c2-ddb7-47cd-bc78-0510a67ceded", + "version": "7", + "dialect": "postgresql", + "tables": { + "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 88f0f6c..394648d 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1781505431077, "tag": "0003_blushing_bruce_banner", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1781511534267, + "tag": "0004_worthless_ender_wiggin", + "breakpoints": true } ] } \ No newline at end of file diff --git a/plans/task-e.md b/plans/task-e.md new file mode 100644 index 0000000..058103d --- /dev/null +++ b/plans/task-e.md @@ -0,0 +1,537 @@ +# Task E: Quotation Production Module + +## ALLA OS CRM vNext + +คุณคือ Senior Full-stack Engineer +ให้ implement Quotation production module โดยต่อยอดจาก Task A, B, B.1, C, D + +## ต้องอ่านก่อน + +```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 + +Layout.md +AGENTS.md +.agents/skills/kiranism-shadcn-dashboard + +src/db/schema.ts +src/features/foundation/** +src/features/crm/customers/** +src/features/crm/enquiries/** +src/app/dashboard/crm/** +``` + +## เป้าหมาย + +สร้าง production module สำหรับ: + +```txt +Quotation +Quotation Items +Quotation Customers +Quotation Topics +Quotation Follow-ups +Quotation Attachments metadata +Quotation Revision +Enquiry → Quotation linkage +``` + +ยังไม่ทำ: + +```txt +Approval workflow เต็ม +PDF generation จริง +Report +Dashboard KPI จริง +Notification +``` + +## Core Rules + +```txt +organizationId = tenant boundary +branchId = business sub-scope +``` + +ทุก quotation ต้องมี `organizationId` + +Quotation ควรอ้างอิง: + +```txt +customerId +contactId +enquiryId optional +``` + +ห้ามใช้ mock CRM service +ห้าม import จาก: + +```txt +src/features/crm-demo/** +src/constants/mock-api* +``` + +## Scope E1: Quotation Schema + +เพิ่ม production schema ใน `src/db/schema.ts` + +ตารางหลัก: + +```txt +crm_quotations +``` + +Fields ขั้นต่ำ: + +```txt +id +organizationId +branchId +code + +enquiryId +customerId +contactId + +quotationDate +validUntil +quotationType + +projectName +projectLocation +attention +reference +notes + +status +revision +parentQuotationId +revisionRemark + +currency +exchangeRate + +subtotal +discount +discountType +taxRate +taxAmount +totalAmount + +chancePercent +isHotProject +competitor +salesmanId + +isSent +sentAt +sentVia +acceptedAt +rejectedAt +rejectionReason + +isActive +createdAt +updatedAt +deletedAt +createdBy +updatedBy +``` + +Rules: + +* `organizationId` required +* `customerId` required +* `code` unique ภายใน organization +* `status`, `quotationType`, `currency`, `discountType` ใช้ master options ถ้ามี +* code ใช้ document sequence `quotation -> QT` +* create/update/delete ต้อง audit log +* ยังไม่ทำ approval table +* status `pending_approval` ทำเป็น placeholder ได้ + +## Scope E2: Quotation Items Schema + +เพิ่ม table: + +```txt +crm_quotation_items +``` + +Fields ขั้นต่ำ: + +```txt +id +organizationId +quotationId +itemNumber +productType +description +quantity +unit +unitPrice +discount +discountType +taxRate +totalPrice +notes +sortOrder +createdAt +updatedAt +deletedAt +``` + +Rules: + +* item ต้องอยู่ organization เดียวกับ quotation +* totalPrice คำนวณฝั่ง server +* quotation total ต้องคำนวณจาก items +* ห้ามเชื่อ total จาก client โดยตรง + +## Scope E3: Quotation Customers Schema + +เพิ่ม table: + +```txt +crm_quotation_customers +``` + +Fields: + +```txt +id +organizationId +quotationId +customerId +role +isPrimary +createdAt +updatedAt +deletedAt +``` + +Roles: + +```txt +owner +consultant +contractor +billing +``` + +Rules: + +* customer หลักใน quotation ต้องอยู่ใน organization เดียวกัน +* contractor สำคัญต่อ reporting ภายหลัง +* ยังไม่ทำ report ใน Task E + +## Scope E4: Quotation Topics Schema + +เพิ่ม tables: + +```txt +crm_quotation_topics +crm_quotation_topic_items +``` + +Topic types: + +```txt +scope +exclusion +payment +``` + +ใช้สำหรับเอกสารใบเสนอราคาในอนาคต + +## Scope E5: Follow-ups + Attachments Metadata + +เพิ่ม tables: + +```txt +crm_quotation_followups +crm_quotation_attachments +``` + +Attachment ใน Task E เก็บ metadata ก่อน: + +```txt +fileName +originalFileName +filePath +fileSize +fileType +description +uploadedAt +uploadedBy +``` + +ยังไม่ต้องทำ upload storage จริง ถ้า scope ใหญ่เกินไป +ทำ placeholder service ได้ แต่ schema ต้องพร้อม + +## Scope E6: API Routes + +สร้าง route handlers: + +```txt +src/app/api/crm/quotations/route.ts +src/app/api/crm/quotations/[id]/route.ts +src/app/api/crm/quotations/[id]/items/route.ts +src/app/api/crm/quotations/[id]/items/[itemId]/route.ts +src/app/api/crm/quotations/[id]/customers/route.ts +src/app/api/crm/quotations/[id]/topics/route.ts +src/app/api/crm/quotations/[id]/followups/route.ts +src/app/api/crm/quotations/[id]/attachments/route.ts +src/app/api/crm/quotations/[id]/revisions/route.ts +``` + +รองรับ: + +```txt +GET list +POST create +GET detail +PATCH update +DELETE soft delete + +CRUD items +CRUD customers +CRUD topics +CRUD followups +list/create attachment metadata +create revision +``` + +Rules: + +* ใช้ `requireOrganizationAccess` +* ใช้ permission helper +* filter ด้วย organizationId เสมอ +* customer/contact/enquiry ต้องอยู่ organization เดียวกัน +* create quotation ต้อง generate code +* revision ต้องสร้าง code suffix หรือ revision record ตาม design +* ทุก mutation ต้อง audit + +Permissions แนะนำ: + +```txt +crm.quotation.read +crm.quotation.create +crm.quotation.update +crm.quotation.delete +crm.quotation.item.manage +crm.quotation.customer.manage +crm.quotation.topic.manage +crm.quotation.followup.manage +crm.quotation.attachment.manage +crm.quotation.revision.create +``` + +## Scope E7: Feature Layer + +สร้าง: + +```txt +src/features/crm/quotations/api/types.ts +src/features/crm/quotations/api/service.ts +src/features/crm/quotations/api/queries.ts +src/features/crm/quotations/api/mutations.ts +src/features/crm/quotations/schemas/quotation.schema.ts +src/features/crm/quotations/server/service.ts +src/features/crm/quotations/components/** +``` + +ใช้ pattern จาก: + +```txt +src/features/products/** +src/features/users/** +src/features/crm/customers/** +src/features/crm/enquiries/** +``` + +## Scope E8: Quotation UI + +แทน placeholder routes: + +```txt +/dashboard/crm/quotations +/dashboard/crm/quotations/[id] +``` + +List page ต้องมี: + +```txt +PageContainer +DataTable +search +status filter +quotation type filter +branch filter +customer filter +enquiry filter +hot project filter +create button +edit action +view action +soft delete action +``` + +Detail page ใช้ Layout.md Template A + +Tabs: + +```txt +Overview +Items +Customers +Topics +Follow-ups +Attachments +Activity +Approval Placeholder +Document Preview Placeholder +``` + +## Scope E9: Quotation Form + +ใช้: + +```txt +useAppForm +Zod +Sheet/Dialog pattern +React Query mutation +``` + +Create/Edit fields: + +```txt +enquiryId +customerId +contactId +quotationDate +validUntil +quotationType +projectName +projectLocation +branchId +currency +exchangeRate +status +chancePercent +isHotProject +competitor +reference +notes +``` + +Rules: + +* enquiry dropdown จาก production enquiry API +* ถ้าเลือก enquiry ให้ auto-fill customer/contact/project ถ้าทำได้แบบไม่ซับซ้อน +* customer dropdown จาก production customer API +* contact dropdown filter ตาม customer +* status/quotationType/currency ดึงจาก master options +* ห้าม hardcode option ใน form + +## Scope E10: Item UI + +ใน Quotation Detail tab: Items + +ต้องมี: + +```txt +item list +create item +edit item +delete item +subtotal/tax/total summary +``` + +Rules: + +* คำนวณ total ฝั่ง server +* UI แสดง readonly total summary จาก API + +## Scope E11: Revision + +รองรับ create revision เบื้องต้น + +Rules: + +* revision สร้างจาก quotation เดิม +* copy quotation header + items + customers + topics +* parentQuotationId ชี้กลับ quotation เดิม +* revision เริ่ม R01, R02 +* ห้าม revise quotation ที่ draft/cancelled ถ้า business rule ต้องการ +* mutation ต้อง audit + +## Scope E12: Integration + +### Enquiry Detail + +เพิ่ม related quotations tab/list จริงใน: + +```txt +/dashboard/crm/enquiries/[id] +``` + +### Customer Detail + +เพิ่ม related quotations list placeholder หรือจริงถ้าทำไม่ซับซ้อน + +## ห้ามทำใน Task E + +```txt +Approval table +Approval matrix +Approval workflow เต็ม +PDF generation จริง +Excel generation จริง +Dashboard KPI จริง +Report +Notification +``` + +## Output หลังทำเสร็จ + +สรุป: + +1. Files Added +2. Files Modified +3. Schema Added +4. API Routes Added +5. UI Routes Completed +6. Permissions Used +7. Audit Integration +8. Document Sequence Integration +9. Enquiry/Customer Integration +10. Revision Support +11. Remaining Risks +12. Task F Readiness + +## Definition of Done + +Task E ผ่านเมื่อ: + +* Quotation schema พร้อม +* Quotation items พร้อม +* Quotation customers พร้อม +* Quotation topics พร้อม +* Quotation follow-ups พร้อม +* Attachment metadata พร้อม +* Quotation list ใช้ production data +* Quotation detail ใช้ production data +* Quotation create/edit/delete ใช้งานได้ +* Item create/edit/delete ใช้งานได้ +* Revision create ใช้งานได้เบื้องต้น +* ใช้ organizationId ทุก query +* customer/contact/enquiry ต้องอยู่ organization เดียวกัน +* ใช้ master options ใน status/type/currency +* ใช้ document sequence ตอนสร้าง quotation +* ใช้ audit log ทุก mutation +* ไม่ import mock CRM +* ไม่ทำ Approval workflow เต็ม diff --git a/src/app/api/crm/quotations/[id]/attachments/route.ts b/src/app/api/crm/quotations/[id]/attachments/route.ts new file mode 100644 index 0000000..7ddd3fe --- /dev/null +++ b/src/app/api/crm/quotations/[id]/attachments/route.ts @@ -0,0 +1,204 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { auditCreate, auditDelete, auditUpdate } from '@/features/foundation/audit-log/service'; +import { + createQuotationAttachment, + getQuotationDetail, + listQuotationAttachments, + softDeleteQuotationAttachment, + updateQuotationAttachment +} from '@/features/crm/quotations/server/service'; +import { quotationAttachmentSchema } from '@/features/crm/quotations/schemas/quotation.schema'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +type Params = { + params: Promise<{ id: string }>; +}; + +const updateSchema = quotationAttachmentSchema.extend({ + id: z.string().min(1, 'Attachment id is required') +}); + +const deleteSchema = z.object({ + id: z.string().min(1, 'Attachment id is required') +}); + +export async function GET(_request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmQuotationRead + }); + const items = await listQuotationAttachments(id, organization.id); + + return NextResponse.json({ + success: true, + time: new Date().toISOString(), + message: 'Quotation attachments loaded successfully', + items + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json({ message: 'Unable to load quotation attachments' }, { status: 500 }); + } +} + +export async function POST(request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmQuotationAttachmentManage + }); + const payload = quotationAttachmentSchema.parse(await request.json()); + const quotation = await getQuotationDetail(id, organization.id); + const created = await createQuotationAttachment(id, organization.id, session.user.id, payload); + + await auditCreate({ + organizationId: organization.id, + branchId: quotation.branchId, + userId: session.user.id, + entityType: 'crm_quotation_attachment', + entityId: created.id, + afterData: created + }); + + return NextResponse.json( + { + success: true, + message: 'Quotation attachment created successfully', + attachment: 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 create quotation attachment' }, { status: 500 }); + } +} + +export async function PATCH(request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmQuotationAttachmentManage + }); + const payload = updateSchema.parse(await request.json()); + const quotation = await getQuotationDetail(id, organization.id); + const before = (await listQuotationAttachments(id, organization.id)).find( + (item) => item.id === payload.id + ); + + if (!before) { + return NextResponse.json({ message: 'Quotation attachment not found' }, { status: 404 }); + } + + const updated = await updateQuotationAttachment(id, payload.id, organization.id, payload); + + await auditUpdate({ + organizationId: organization.id, + branchId: quotation.branchId, + userId: session.user.id, + entityType: 'crm_quotation_attachment', + entityId: payload.id, + beforeData: before, + afterData: updated + }); + + return NextResponse.json({ + success: true, + message: 'Quotation attachment updated successfully', + attachment: updated + }); + } 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 update quotation attachment' }, { status: 500 }); + } +} + +export async function DELETE(request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmQuotationAttachmentManage + }); + const payload = deleteSchema.parse(await request.json()); + const quotation = await getQuotationDetail(id, organization.id); + const before = (await listQuotationAttachments(id, organization.id)).find( + (item) => item.id === payload.id + ); + + if (!before) { + return NextResponse.json({ message: 'Quotation attachment not found' }, { status: 404 }); + } + + const updated = await softDeleteQuotationAttachment(id, payload.id, organization.id); + + await auditDelete({ + organizationId: organization.id, + branchId: quotation.branchId, + userId: session.user.id, + entityType: 'crm_quotation_attachment', + entityId: payload.id, + beforeData: before, + afterData: updated + }); + + return NextResponse.json({ + success: true, + message: 'Quotation attachment deleted 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 delete quotation attachment' }, { status: 500 }); + } +} diff --git a/src/app/api/crm/quotations/[id]/customers/route.ts b/src/app/api/crm/quotations/[id]/customers/route.ts new file mode 100644 index 0000000..50858f5 --- /dev/null +++ b/src/app/api/crm/quotations/[id]/customers/route.ts @@ -0,0 +1,204 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { auditCreate, auditDelete, auditUpdate } from '@/features/foundation/audit-log/service'; +import { + createQuotationCustomer, + getQuotationDetail, + listQuotationCustomers, + softDeleteQuotationCustomer, + updateQuotationCustomer +} from '@/features/crm/quotations/server/service'; +import { quotationCustomerSchema } from '@/features/crm/quotations/schemas/quotation.schema'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +type Params = { + params: Promise<{ id: string }>; +}; + +const updateSchema = quotationCustomerSchema.extend({ + id: z.string().min(1, 'Relation id is required') +}); + +const deleteSchema = z.object({ + id: z.string().min(1, 'Relation id is required') +}); + +export async function GET(_request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmQuotationRead + }); + const items = await listQuotationCustomers(id, organization.id); + + return NextResponse.json({ + success: true, + time: new Date().toISOString(), + message: 'Quotation customers loaded successfully', + items + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json({ message: 'Unable to load quotation customers' }, { status: 500 }); + } +} + +export async function POST(request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmQuotationCustomerManage + }); + const payload = quotationCustomerSchema.parse(await request.json()); + const quotation = await getQuotationDetail(id, organization.id); + const created = await createQuotationCustomer(id, organization.id, payload); + + await auditCreate({ + organizationId: organization.id, + branchId: quotation.branchId, + userId: session.user.id, + entityType: 'crm_quotation_customer', + entityId: created.id, + afterData: created + }); + + return NextResponse.json( + { + success: true, + message: 'Quotation customer created successfully', + relation: 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 create quotation customer' }, { status: 500 }); + } +} + +export async function PATCH(request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmQuotationCustomerManage + }); + const payload = updateSchema.parse(await request.json()); + const quotation = await getQuotationDetail(id, organization.id); + const before = (await listQuotationCustomers(id, organization.id)).find( + (item) => item.id === payload.id + ); + + if (!before) { + return NextResponse.json({ message: 'Quotation customer relation not found' }, { status: 404 }); + } + + const updated = await updateQuotationCustomer(id, payload.id, organization.id, payload); + + await auditUpdate({ + organizationId: organization.id, + branchId: quotation.branchId, + userId: session.user.id, + entityType: 'crm_quotation_customer', + entityId: payload.id, + beforeData: before, + afterData: updated + }); + + return NextResponse.json({ + success: true, + message: 'Quotation customer updated successfully', + relation: updated + }); + } 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 update quotation customer' }, { status: 500 }); + } +} + +export async function DELETE(request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmQuotationCustomerManage + }); + const payload = deleteSchema.parse(await request.json()); + const quotation = await getQuotationDetail(id, organization.id); + const before = (await listQuotationCustomers(id, organization.id)).find( + (item) => item.id === payload.id + ); + + if (!before) { + return NextResponse.json({ message: 'Quotation customer relation not found' }, { status: 404 }); + } + + const updated = await softDeleteQuotationCustomer(id, payload.id, organization.id); + + await auditDelete({ + organizationId: organization.id, + branchId: quotation.branchId, + userId: session.user.id, + entityType: 'crm_quotation_customer', + entityId: payload.id, + beforeData: before, + afterData: updated + }); + + return NextResponse.json({ + success: true, + message: 'Quotation customer deleted 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 delete quotation customer' }, { status: 500 }); + } +} diff --git a/src/app/api/crm/quotations/[id]/followups/route.ts b/src/app/api/crm/quotations/[id]/followups/route.ts new file mode 100644 index 0000000..c265ef6 --- /dev/null +++ b/src/app/api/crm/quotations/[id]/followups/route.ts @@ -0,0 +1,215 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { auditCreate, auditDelete, auditUpdate } from '@/features/foundation/audit-log/service'; +import { + createQuotationFollowup, + getQuotationDetail, + listQuotationFollowups, + softDeleteQuotationFollowup, + updateQuotationFollowup +} from '@/features/crm/quotations/server/service'; +import { quotationFollowupSchema } from '@/features/crm/quotations/schemas/quotation.schema'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +type Params = { + params: Promise<{ id: string }>; +}; + +const updateSchema = quotationFollowupSchema.extend({ + id: z.string().min(1, 'Follow-up id is required') +}); + +const deleteSchema = z.object({ + id: z.string().min(1, 'Follow-up id is required') +}); + +export async function GET(_request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmQuotationRead + }); + const items = await listQuotationFollowups(id, organization.id); + + return NextResponse.json({ + success: true, + time: new Date().toISOString(), + message: 'Quotation follow-ups loaded successfully', + items + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json({ message: 'Unable to load quotation follow-ups' }, { status: 500 }); + } +} + +export async function POST(request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmQuotationFollowupManage + }); + const payload = quotationFollowupSchema.parse(await request.json()); + const quotation = await getQuotationDetail(id, organization.id); + const created = await createQuotationFollowup(id, organization.id, session.user.id, payload); + + await auditCreate({ + organizationId: organization.id, + branchId: quotation.branchId, + userId: session.user.id, + entityType: 'crm_quotation_followup', + entityId: created.id, + afterData: created + }); + + return NextResponse.json( + { + success: true, + message: 'Quotation follow-up created successfully', + followup: 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 create quotation follow-up' }, { status: 500 }); + } +} + +export async function PATCH(request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmQuotationFollowupManage + }); + const payload = updateSchema.parse(await request.json()); + const quotation = await getQuotationDetail(id, organization.id); + const before = (await listQuotationFollowups(id, organization.id)).find( + (item) => item.id === payload.id + ); + + if (!before) { + return NextResponse.json({ message: 'Quotation follow-up not found' }, { status: 404 }); + } + + const updated = await updateQuotationFollowup( + id, + payload.id, + organization.id, + session.user.id, + payload + ); + + await auditUpdate({ + organizationId: organization.id, + branchId: quotation.branchId, + userId: session.user.id, + entityType: 'crm_quotation_followup', + entityId: payload.id, + beforeData: before, + afterData: updated + }); + + return NextResponse.json({ + success: true, + message: 'Quotation follow-up updated successfully', + followup: updated + }); + } 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 update quotation follow-up' }, { status: 500 }); + } +} + +export async function DELETE(request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmQuotationFollowupManage + }); + const payload = deleteSchema.parse(await request.json()); + const quotation = await getQuotationDetail(id, organization.id); + const before = (await listQuotationFollowups(id, organization.id)).find( + (item) => item.id === payload.id + ); + + if (!before) { + return NextResponse.json({ message: 'Quotation follow-up not found' }, { status: 404 }); + } + + const updated = await softDeleteQuotationFollowup( + id, + payload.id, + organization.id, + session.user.id + ); + + await auditDelete({ + organizationId: organization.id, + branchId: quotation.branchId, + userId: session.user.id, + entityType: 'crm_quotation_followup', + entityId: payload.id, + beforeData: before, + afterData: updated + }); + + return NextResponse.json({ + success: true, + message: 'Quotation follow-up deleted 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 delete quotation follow-up' }, { status: 500 }); + } +} diff --git a/src/app/api/crm/quotations/[id]/items/[itemId]/route.ts b/src/app/api/crm/quotations/[id]/items/[itemId]/route.ts new file mode 100644 index 0000000..e736a0b --- /dev/null +++ b/src/app/api/crm/quotations/[id]/items/[itemId]/route.ts @@ -0,0 +1,109 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service'; +import { + getQuotationDetail, + listQuotationItems, + softDeleteQuotationItem, + updateQuotationItem +} from '@/features/crm/quotations/server/service'; +import { quotationItemSchema } from '@/features/crm/quotations/schemas/quotation.schema'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +type Params = { + params: Promise<{ id: string; itemId: string }>; +}; + +export async function PATCH(request: NextRequest, { params }: Params) { + try { + const { id, itemId } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmQuotationItemManage + }); + const payload = quotationItemSchema.parse(await request.json()); + const quotation = await getQuotationDetail(id, organization.id); + const before = (await listQuotationItems(id, organization.id)).find((item) => item.id === itemId); + + if (!before) { + return NextResponse.json({ message: 'Quotation item not found' }, { status: 404 }); + } + + const updated = await updateQuotationItem(id, itemId, organization.id, session.user.id, payload); + + await auditUpdate({ + organizationId: organization.id, + branchId: quotation.branchId, + userId: session.user.id, + entityType: 'crm_quotation_item', + entityId: itemId, + beforeData: before, + afterData: updated + }); + + return NextResponse.json({ + success: true, + message: 'Quotation item updated successfully', + item: updated + }); + } 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 update quotation item' }, { status: 500 }); + } +} + +export async function DELETE(_request: NextRequest, { params }: Params) { + try { + const { id, itemId } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmQuotationItemManage + }); + const quotation = await getQuotationDetail(id, organization.id); + const before = (await listQuotationItems(id, organization.id)).find((item) => item.id === itemId); + + if (!before) { + return NextResponse.json({ message: 'Quotation item not found' }, { status: 404 }); + } + + const updated = await softDeleteQuotationItem(id, itemId, organization.id, session.user.id); + + await auditDelete({ + organizationId: organization.id, + branchId: quotation.branchId, + userId: session.user.id, + entityType: 'crm_quotation_item', + entityId: itemId, + beforeData: before, + afterData: updated + }); + + return NextResponse.json({ + success: true, + message: 'Quotation item deleted 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 delete quotation item' }, { status: 500 }); + } +} diff --git a/src/app/api/crm/quotations/[id]/items/route.ts b/src/app/api/crm/quotations/[id]/items/route.ts new file mode 100644 index 0000000..4bedd6a --- /dev/null +++ b/src/app/api/crm/quotations/[id]/items/route.ts @@ -0,0 +1,89 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { auditCreate } from '@/features/foundation/audit-log/service'; +import { + createQuotationItem, + getQuotationDetail, + listQuotationItems +} from '@/features/crm/quotations/server/service'; +import { quotationItemSchema } from '@/features/crm/quotations/schemas/quotation.schema'; +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.crmQuotationRead + }); + const items = await listQuotationItems(id, organization.id); + + return NextResponse.json({ + success: true, + time: new Date().toISOString(), + message: 'Quotation items loaded successfully', + items + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json({ message: 'Unable to load quotation items' }, { status: 500 }); + } +} + +export async function POST(request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmQuotationItemManage + }); + const payload = quotationItemSchema.parse(await request.json()); + const quotation = await getQuotationDetail(id, organization.id); + const created = await createQuotationItem(id, organization.id, session.user.id, payload); + + await auditCreate({ + organizationId: organization.id, + branchId: quotation.branchId, + userId: session.user.id, + entityType: 'crm_quotation_item', + entityId: created.id, + afterData: created + }); + + return NextResponse.json( + { + success: true, + message: 'Quotation item created successfully', + item: 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 create quotation item' }, { status: 500 }); + } +} diff --git a/src/app/api/crm/quotations/[id]/revisions/route.ts b/src/app/api/crm/quotations/[id]/revisions/route.ts new file mode 100644 index 0000000..93c5a41 --- /dev/null +++ b/src/app/api/crm/quotations/[id]/revisions/route.ts @@ -0,0 +1,94 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { auditCreate } from '@/features/foundation/audit-log/service'; +import { + createQuotationRevision, + getQuotationDetail, + listQuotationRevisions +} from '@/features/crm/quotations/server/service'; +import { quotationRevisionSchema } from '@/features/crm/quotations/schemas/quotation.schema'; +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.crmQuotationRead + }); + const items = await listQuotationRevisions(id, organization.id); + + return NextResponse.json({ + success: true, + time: new Date().toISOString(), + message: 'Quotation revisions loaded successfully', + items + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json({ message: 'Unable to load quotation revisions' }, { status: 500 }); + } +} + +export async function POST(request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmQuotationRevisionCreate + }); + const payload = quotationRevisionSchema.parse(await request.json()); + const quotation = await getQuotationDetail(id, organization.id); + const created = await createQuotationRevision( + id, + organization.id, + session.user.id, + payload.revisionRemark + ); + + await auditCreate({ + organizationId: organization.id, + branchId: quotation.branchId, + userId: session.user.id, + entityType: 'crm_quotation', + entityId: created.id, + afterData: created + }); + + return NextResponse.json( + { + success: true, + message: 'Quotation revision created successfully', + quotation: 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 create quotation revision' }, { status: 500 }); + } +} diff --git a/src/app/api/crm/quotations/[id]/route.ts b/src/app/api/crm/quotations/[id]/route.ts new file mode 100644 index 0000000..bbe417b --- /dev/null +++ b/src/app/api/crm/quotations/[id]/route.ts @@ -0,0 +1,128 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service'; +import { + getQuotationActivity, + getQuotationDetail, + softDeleteQuotation, + updateQuotation +} from '@/features/crm/quotations/server/service'; +import { quotationSchema } from '@/features/crm/quotations/schemas/quotation.schema'; +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.crmQuotationRead + }); + const [quotation, activity] = await Promise.all([ + getQuotationDetail(id, organization.id), + getQuotationActivity(id, organization.id) + ]); + + return NextResponse.json({ + success: true, + time: new Date().toISOString(), + message: 'Quotation loaded successfully', + quotation, + activity + }); + } 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 quotation' }, { status: 500 }); + } +} + +export async function PATCH(request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmQuotationUpdate + }); + const payload = quotationSchema.parse(await request.json()); + const before = await getQuotationDetail(id, organization.id); + const updated = await updateQuotation(id, organization.id, session.user.id, payload); + + await auditUpdate({ + organizationId: organization.id, + branchId: updated.branchId, + userId: session.user.id, + entityType: 'crm_quotation', + entityId: id, + beforeData: before, + afterData: updated + }); + + return NextResponse.json({ + success: true, + message: 'Quotation updated successfully', + quotation: updated + }); + } 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 update quotation' }, { status: 500 }); + } +} + +export async function DELETE(_request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmQuotationDelete + }); + const before = await getQuotationDetail(id, organization.id); + const updated = await softDeleteQuotation(id, organization.id, session.user.id); + + await auditDelete({ + organizationId: organization.id, + branchId: updated.branchId, + userId: session.user.id, + entityType: 'crm_quotation', + entityId: id, + beforeData: before, + afterData: updated + }); + + return NextResponse.json({ + success: true, + message: 'Quotation deleted 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 delete quotation' }, { status: 500 }); + } +} diff --git a/src/app/api/crm/quotations/[id]/topics/route.ts b/src/app/api/crm/quotations/[id]/topics/route.ts new file mode 100644 index 0000000..32d7cc4 --- /dev/null +++ b/src/app/api/crm/quotations/[id]/topics/route.ts @@ -0,0 +1,202 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { auditCreate, auditDelete, auditUpdate } from '@/features/foundation/audit-log/service'; +import { + createQuotationTopic, + getQuotationDetail, + listQuotationTopics, + softDeleteQuotationTopic, + updateQuotationTopic +} from '@/features/crm/quotations/server/service'; +import { quotationTopicSchema } from '@/features/crm/quotations/schemas/quotation.schema'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +type Params = { + params: Promise<{ id: string }>; +}; + +const deleteSchema = z.object({ + id: z.string().min(1, 'Topic id is required') +}); + +export async function GET(_request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmQuotationRead + }); + const items = await listQuotationTopics(id, organization.id); + + return NextResponse.json({ + success: true, + time: new Date().toISOString(), + message: 'Quotation topics loaded successfully', + items + }); + } catch (error) { + if (error instanceof AuthError) { + return NextResponse.json({ message: error.message }, { status: error.status }); + } + + if (error instanceof Error) { + return NextResponse.json({ message: error.message }, { status: 400 }); + } + + return NextResponse.json({ message: 'Unable to load quotation topics' }, { status: 500 }); + } +} + +export async function POST(request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmQuotationTopicManage + }); + const payload = quotationTopicSchema.parse(await request.json()); + const quotation = await getQuotationDetail(id, organization.id); + const created = await createQuotationTopic(id, organization.id, payload); + + await auditCreate({ + organizationId: organization.id, + branchId: quotation.branchId, + userId: session.user.id, + entityType: 'crm_quotation_topic', + entityId: created.id, + afterData: created + }); + + return NextResponse.json( + { + success: true, + message: 'Quotation topic created successfully', + topic: 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 create quotation topic' }, { status: 500 }); + } +} + +export async function PATCH(request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmQuotationTopicManage + }); + const payload = quotationTopicSchema.extend({ + id: z.string().min(1, 'Topic id is required') + }).parse(await request.json()); + const quotation = await getQuotationDetail(id, organization.id); + const before = (await listQuotationTopics(id, organization.id)).find( + (item) => item.id === payload.id + ); + + if (!before) { + return NextResponse.json({ message: 'Quotation topic not found' }, { status: 404 }); + } + + const updated = await updateQuotationTopic(id, payload.id, organization.id, payload); + + await auditUpdate({ + organizationId: organization.id, + branchId: quotation.branchId, + userId: session.user.id, + entityType: 'crm_quotation_topic', + entityId: payload.id, + beforeData: before, + afterData: updated + }); + + return NextResponse.json({ + success: true, + message: 'Quotation topic updated successfully', + topic: updated + }); + } 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 update quotation topic' }, { status: 500 }); + } +} + +export async function DELETE(request: NextRequest, { params }: Params) { + try { + const { id } = await params; + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmQuotationTopicManage + }); + const payload = deleteSchema.parse(await request.json()); + const quotation = await getQuotationDetail(id, organization.id); + const before = (await listQuotationTopics(id, organization.id)).find( + (item) => item.id === payload.id + ); + + if (!before) { + return NextResponse.json({ message: 'Quotation topic not found' }, { status: 404 }); + } + + const updated = await softDeleteQuotationTopic(id, payload.id, organization.id); + + await auditDelete({ + organizationId: organization.id, + branchId: quotation.branchId, + userId: session.user.id, + entityType: 'crm_quotation_topic', + entityId: payload.id, + beforeData: before, + afterData: updated + }); + + return NextResponse.json({ + success: true, + message: 'Quotation topic deleted 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 delete quotation topic' }, { status: 500 }); + } +} diff --git a/src/app/api/crm/quotations/route.ts b/src/app/api/crm/quotations/route.ts new file mode 100644 index 0000000..68f8408 --- /dev/null +++ b/src/app/api/crm/quotations/route.ts @@ -0,0 +1,104 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { auditCreate } from '@/features/foundation/audit-log/service'; +import { createQuotation, listQuotations } from '@/features/crm/quotations/server/service'; +import { quotationSchema } from '@/features/crm/quotations/schemas/quotation.schema'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; + +export async function GET(request: NextRequest) { + try { + const { organization } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmQuotationRead + }); + 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 quotationType = searchParams.get('quotationType') ?? undefined; + const branch = searchParams.get('branch') ?? undefined; + const customer = searchParams.get('customer') ?? undefined; + const enquiry = searchParams.get('enquiry') ?? undefined; + const hot = searchParams.get('hot') ?? undefined; + const sort = searchParams.get('sort') ?? undefined; + const result = await listQuotations(organization.id, { + page, + limit, + search, + status, + quotationType, + branch, + customer, + enquiry, + hot, + sort + }); + const offset = (page - 1) * limit; + + return NextResponse.json({ + success: true, + time: new Date().toISOString(), + message: 'Quotations loaded successfully', + totalItems: result.totalItems, + offset, + 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 quotations' }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const { organization, session } = await requireOrganizationAccess({ + permission: PERMISSIONS.crmQuotationCreate + }); + const payload = quotationSchema.parse(await request.json()); + const created = await createQuotation(organization.id, session.user.id, payload); + + await auditCreate({ + organizationId: organization.id, + branchId: created.branchId, + userId: session.user.id, + entityType: 'crm_quotation', + entityId: created.id, + afterData: created + }); + + return NextResponse.json( + { + success: true, + message: 'Quotation created successfully', + quotation: 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 create quotation' }, { status: 500 }); + } +} diff --git a/src/app/dashboard/crm/customers/[id]/page.tsx b/src/app/dashboard/crm/customers/[id]/page.tsx index 9f9c996..8f6c660 100644 --- a/src/app/dashboard/crm/customers/[id]/page.tsx +++ b/src/app/dashboard/crm/customers/[id]/page.tsx @@ -5,6 +5,7 @@ import { CustomerDetail } from '@/features/crm/customers/components/customer-det import { customerByIdOptions, customerContactsOptions } from '@/features/crm/customers/api/queries'; import { getCustomerReferenceData } from '@/features/crm/customers/server/service'; import { listCustomerEnquiryRelations } from '@/features/crm/enquiries/server/service'; +import { listCustomerQuotationRelations } from '@/features/crm/quotations/server/service'; import { PERMISSIONS } from '@/lib/auth/rbac'; import { getQueryClient } from '@/lib/query-client'; @@ -55,6 +56,10 @@ export default async function CustomerDetailRoute({ params }: PageProps) { canRead && session?.user?.activeOrganizationId ? await listCustomerEnquiryRelations(id, session.user.activeOrganizationId) : []; + const relatedQuotations = + canRead && session?.user?.activeOrganizationId + ? await listCustomerQuotationRelations(id, session.user.activeOrganizationId) + : []; return ( ) : null} diff --git a/src/app/dashboard/crm/enquiries/[id]/page.tsx b/src/app/dashboard/crm/enquiries/[id]/page.tsx index afd6a05..3f1ea7b 100644 --- a/src/app/dashboard/crm/enquiries/[id]/page.tsx +++ b/src/app/dashboard/crm/enquiries/[id]/page.tsx @@ -4,6 +4,7 @@ import PageContainer from '@/components/layout/page-container'; import { enquiryByIdOptions, enquiryFollowupsOptions } from '@/features/crm/enquiries/api/queries'; import { EnquiryDetail } from '@/features/crm/enquiries/components/enquiry-detail'; import { getEnquiryReferenceData } from '@/features/crm/enquiries/server/service'; +import { listEnquiryQuotationRelations } from '@/features/crm/quotations/server/service'; import { PERMISSIONS } from '@/lib/auth/rbac'; import { getQueryClient } from '@/lib/query-client'; @@ -50,6 +51,10 @@ export default async function EnquiryDetailRoute({ params }: PageProps) { canRead && session?.user?.activeOrganizationId ? await getEnquiryReferenceData(session.user.activeOrganizationId) : null; + const relatedQuotations = + canRead && session?.user?.activeOrganizationId + ? await listEnquiryQuotationRelations(id, session.user.activeOrganizationId) + : []; return ( ; @@ -7,24 +21,90 @@ type PageProps = { export default async function QuotationDetailRoute({ 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.crmQuotationRead))); + const canUpdate = + session?.user?.systemRole === 'super_admin' || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === 'admin' || + session.user.activePermissions.includes(PERMISSIONS.crmQuotationUpdate))); + const canManageItems = + session?.user?.systemRole === 'super_admin' || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === 'admin' || + session.user.activePermissions.includes(PERMISSIONS.crmQuotationItemManage))); + const canManageCustomers = + session?.user?.systemRole === 'super_admin' || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === 'admin' || + session.user.activePermissions.includes(PERMISSIONS.crmQuotationCustomerManage))); + const canManageTopics = + session?.user?.systemRole === 'super_admin' || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === 'admin' || + session.user.activePermissions.includes(PERMISSIONS.crmQuotationTopicManage))); + const canManageFollowups = + session?.user?.systemRole === 'super_admin' || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === 'admin' || + session.user.activePermissions.includes(PERMISSIONS.crmQuotationFollowupManage))); + const canManageAttachments = + session?.user?.systemRole === 'super_admin' || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === 'admin' || + session.user.activePermissions.includes(PERMISSIONS.crmQuotationAttachmentManage))); + const canCreateRevision = + session?.user?.systemRole === 'super_admin' || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === 'admin' || + session.user.activePermissions.includes(PERMISSIONS.crmQuotationRevisionCreate))); + const queryClient = getQueryClient(); + + if (canRead) { + void queryClient.prefetchQuery(quotationByIdOptions(id)); + void queryClient.prefetchQuery(quotationItemsOptions(id)); + void queryClient.prefetchQuery(quotationCustomersOptions(id)); + void queryClient.prefetchQuery(quotationTopicsOptions(id)); + void queryClient.prefetchQuery(quotationFollowupsOptions(id)); + void queryClient.prefetchQuery(quotationAttachmentsOptions(id)); + void queryClient.prefetchQuery(quotationRevisionsOptions(id)); + } + + const referenceData = + canRead && session?.user?.activeOrganizationId + ? await getQuotationReferenceData(session.user.activeOrganizationId) + : null; return ( + You do not have access to this quotation. + + } > - + {referenceData ? ( + + + + ) : null} ); } diff --git a/src/app/dashboard/crm/quotations/page.tsx b/src/app/dashboard/crm/quotations/page.tsx index 059579e..92bb54a 100644 --- a/src/app/dashboard/crm/quotations/page.tsx +++ b/src/app/dashboard/crm/quotations/page.tsx @@ -1,28 +1,68 @@ +import { auth } from '@/auth'; import PageContainer from '@/components/layout/page-container'; -import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder'; +import QuotationListing from '@/features/crm/quotations/components/quotation-listing'; +import { QuotationFormSheetTrigger } from '@/features/crm/quotations/components/quotation-form-sheet'; +import { getQuotationReferenceData } from '@/features/crm/quotations/server/service'; +import { PERMISSIONS } from '@/lib/auth/rbac'; +import { searchParamsCache } from '@/lib/searchparams'; +import type { SearchParams } from 'nuqs/server'; export const metadata = { title: 'Dashboard: CRM Quotations' }; -export default function QuotationsRoute() { +type PageProps = { + searchParams: Promise; +}; + +export default async function QuotationsRoute(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.crmQuotationRead))); + const canCreate = + session?.user?.systemRole === 'super_admin' || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === 'admin' || + session.user.activePermissions.includes(PERMISSIONS.crmQuotationCreate))); + const canUpdate = + session?.user?.systemRole === 'super_admin' || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === 'admin' || + session.user.activePermissions.includes(PERMISSIONS.crmQuotationUpdate))); + const canDelete = + session?.user?.systemRole === 'super_admin' || + (!!session?.user?.activeOrganizationId && + (session.user.activeMembershipRole === 'admin' || + session.user.activePermissions.includes(PERMISSIONS.crmQuotationDelete))); + + searchParamsCache.parse(searchParams); + + const referenceData = + canRead && session?.user?.activeOrganizationId + ? await getQuotationReferenceData(session.user.activeOrganizationId) + : null; + return ( + You do not have access to CRM quotations. + + } + pageHeaderAction={ + canCreate && referenceData ? : null + } > - + {referenceData ? ( + + ) : null} ); } diff --git a/src/config/nav-config.ts b/src/config/nav-config.ts index 568b92d..b454482 100644 --- a/src/config/nav-config.ts +++ b/src/config/nav-config.ts @@ -35,49 +35,110 @@ import { NavGroup } from "@/types"; */ export const navGroups: NavGroup[] = [ { - label: "Overview", + label: 'Overview', items: [ { - title: "Dashboard", - url: "/dashboard", - icon: "dashboard", + title: 'Dashboard', + url: '/dashboard', + icon: 'dashboard', isActive: false, - shortcut: ["d", "d"], - items: [], + shortcut: ['d', 'd'], + items: [] }, { - title: "Workspaces", - url: "/dashboard/workspaces", - icon: "workspace", + title: 'Workspaces', + url: '/dashboard/workspaces', + icon: 'workspace', isActive: false, items: [], - access: { systemRole: "super_admin" }, + access: { systemRole: 'super_admin' } }, { - title: "Teams", - url: "/dashboard/workspaces/team", - icon: "teams", + title: 'Teams', + url: '/dashboard/workspaces/team', + icon: 'teams', isActive: false, items: [], - access: { requireOrg: true, role: "admin" }, + access: { requireOrg: true, role: 'admin' } }, { - title: "Users", - url: "/dashboard/users", - icon: "teams", - shortcut: ["u", "u"], + title: 'Users', + url: '/dashboard/users', + icon: 'teams', + shortcut: ['u', 'u'], isActive: false, items: [], - access: { requireOrg: true, permission: "users:manage" }, + access: { requireOrg: true, permission: 'users:manage' } }, { - title: "Kanban", - url: "/dashboard/kanban", - icon: "kanban", - shortcut: ["k", "k"], + title: 'Kanban', + url: '/dashboard/kanban', + icon: 'kanban', + shortcut: ['k', 'k'], isActive: false, - items: [], - }, - ], + items: [] + } + ] }, + { + label: 'CRM', + items: [ + { + title: 'Customers', + url: '/dashboard/crm/customers', + icon: 'teams', + isActive: false, + items: [], + access: { requireOrg: true, permission: 'crm.customer.read' } + }, + { + title: 'Enquiries', + url: '/dashboard/crm/enquiries', + icon: 'forms', + isActive: false, + items: [], + access: { requireOrg: true, permission: 'crm.enquiry.read' } + }, + { + title: 'Quotations', + url: '/dashboard/crm/quotations', + icon: 'post', + isActive: false, + items: [], + access: { requireOrg: true, role: 'admin' } + }, + { + title: 'Approvals', + url: '/dashboard/crm/approvals', + icon: 'checks', + isActive: false, + items: [], + access: { requireOrg: true, role: 'admin' } + }, + { + title: 'CRM Settings', + url: '/dashboard/crm/settings/master-options', + icon: 'settings', + isActive: false, + access: { requireOrg: true, permission: 'organization:manage' }, + items: [ + { + title: 'Master Options', + url: '/dashboard/crm/settings/master-options', + access: { requireOrg: true, permission: 'organization:manage' } + }, + { + title: 'Document Sequences', + url: '/dashboard/crm/settings/document-sequences', + access: { requireOrg: true, permission: 'organization:manage' } + }, + { + title: 'Templates', + url: '/dashboard/crm/settings/templates', + access: { requireOrg: true, permission: 'organization:manage' } + } + ] + } + ] + } ]; diff --git a/src/db/schema.ts b/src/db/schema.ts index 54d1102..65edb4f 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -246,3 +246,149 @@ export const crmEnquiryFollowups = pgTable('crm_enquiry_followups', { createdBy: text('created_by').notNull(), updatedBy: text('updated_by').notNull() }); + +export const crmQuotations = pgTable( + 'crm_quotations', + { + id: text('id').primaryKey(), + organizationId: text('organization_id').notNull(), + branchId: text('branch_id'), + code: text('code').notNull(), + enquiryId: text('enquiry_id'), + customerId: text('customer_id').notNull(), + contactId: text('contact_id'), + quotationDate: timestamp('quotation_date', { withTimezone: true }).notNull(), + validUntil: timestamp('valid_until', { withTimezone: true }), + quotationType: text('quotation_type').notNull(), + projectName: text('project_name'), + projectLocation: text('project_location'), + attention: text('attention'), + reference: text('reference'), + notes: text('notes'), + status: text('status').notNull(), + revision: integer('revision').default(0).notNull(), + parentQuotationId: text('parent_quotation_id'), + revisionRemark: text('revision_remark'), + currency: text('currency').notNull(), + exchangeRate: doublePrecision('exchange_rate').default(1).notNull(), + subtotal: doublePrecision('subtotal').default(0).notNull(), + discount: doublePrecision('discount').default(0).notNull(), + discountType: text('discount_type'), + taxRate: doublePrecision('tax_rate').default(0).notNull(), + taxAmount: doublePrecision('tax_amount').default(0).notNull(), + totalAmount: doublePrecision('total_amount').default(0).notNull(), + chancePercent: integer('chance_percent'), + isHotProject: boolean('is_hot_project').default(false).notNull(), + competitor: text('competitor'), + salesmanId: text('salesman_id'), + isSent: boolean('is_sent').default(false).notNull(), + sentAt: timestamp('sent_at', { withTimezone: true }), + sentVia: text('sent_via'), + acceptedAt: timestamp('accepted_at', { withTimezone: true }), + rejectedAt: timestamp('rejected_at', { withTimezone: true }), + rejectionReason: text('rejection_reason'), + 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 }), + createdBy: text('created_by').notNull(), + updatedBy: text('updated_by').notNull() + }, + (table) => ({ + organizationCodeIdx: uniqueIndex('crm_quotations_org_code_idx').on( + table.organizationId, + table.code + ) + }) +); + +export const crmQuotationItems = pgTable('crm_quotation_items', { + id: text('id').primaryKey(), + organizationId: text('organization_id').notNull(), + quotationId: text('quotation_id').notNull(), + itemNumber: integer('item_number').notNull(), + productType: text('product_type').notNull(), + description: text('description').notNull(), + quantity: doublePrecision('quantity').default(0).notNull(), + unit: text('unit'), + unitPrice: doublePrecision('unit_price').default(0).notNull(), + discount: doublePrecision('discount').default(0).notNull(), + discountType: text('discount_type'), + taxRate: doublePrecision('tax_rate').default(0).notNull(), + totalPrice: doublePrecision('total_price').default(0).notNull(), + notes: text('notes'), + sortOrder: integer('sort_order').default(0).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + deletedAt: timestamp('deleted_at', { withTimezone: true }) +}); + +export const crmQuotationCustomers = pgTable('crm_quotation_customers', { + id: text('id').primaryKey(), + organizationId: text('organization_id').notNull(), + quotationId: text('quotation_id').notNull(), + customerId: text('customer_id').notNull(), + role: text('role').notNull(), + isPrimary: boolean('is_primary').default(false).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + deletedAt: timestamp('deleted_at', { withTimezone: true }) +}); + +export const crmQuotationTopics = pgTable('crm_quotation_topics', { + id: text('id').primaryKey(), + organizationId: text('organization_id').notNull(), + quotationId: text('quotation_id').notNull(), + topicType: text('topic_type').notNull(), + title: text('title').notNull(), + sortOrder: integer('sort_order').default(0).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + deletedAt: timestamp('deleted_at', { withTimezone: true }) +}); + +export const crmQuotationTopicItems = pgTable('crm_quotation_topic_items', { + id: text('id').primaryKey(), + organizationId: text('organization_id').notNull(), + topicId: text('topic_id').notNull(), + content: text('content').notNull(), + sortOrder: integer('sort_order').default(0).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + deletedAt: timestamp('deleted_at', { withTimezone: true }) +}); + +export const crmQuotationFollowups = pgTable('crm_quotation_followups', { + id: text('id').primaryKey(), + organizationId: text('organization_id').notNull(), + quotationId: text('quotation_id').notNull(), + followupDate: timestamp('followup_date', { withTimezone: true }).notNull(), + followupType: text('followup_type').notNull(), + contactId: text('contact_id'), + outcome: text('outcome'), + notes: text('notes'), + nextFollowupDate: timestamp('next_followup_date', { withTimezone: true }), + nextAction: text('next_action'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + deletedAt: timestamp('deleted_at', { withTimezone: true }), + createdBy: text('created_by').notNull(), + updatedBy: text('updated_by').notNull() +}); + +export const crmQuotationAttachments = pgTable('crm_quotation_attachments', { + id: text('id').primaryKey(), + organizationId: text('organization_id').notNull(), + quotationId: text('quotation_id').notNull(), + fileName: text('file_name').notNull(), + originalFileName: text('original_file_name').notNull(), + filePath: text('file_path').notNull(), + fileSize: integer('file_size'), + fileType: text('file_type'), + description: text('description'), + uploadedAt: timestamp('uploaded_at', { withTimezone: true }).defaultNow().notNull(), + uploadedBy: text('uploaded_by').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + deletedAt: timestamp('deleted_at', { withTimezone: true }) +}); diff --git a/src/db/seeds/foundation.seed.ts b/src/db/seeds/foundation.seed.ts index cae1921..8861de6 100644 --- a/src/db/seeds/foundation.seed.ts +++ b/src/db/seeds/foundation.seed.ts @@ -121,6 +121,11 @@ const FOUNDATION_OPTIONS = { { code: 'cancelled', label: 'Cancelled', value: 'cancelled', sortOrder: 8 }, { code: 'revised', label: 'Revised', value: 'revised', sortOrder: 9 } ], + crm_quotation_type: [ + { code: 'standard', label: 'Standard', value: 'standard', sortOrder: 1 }, + { code: 'budgetary', label: 'Budgetary', value: 'budgetary', sortOrder: 2 }, + { code: 'service', label: 'Service', value: 'service', sortOrder: 3 } + ], crm_product_type: [ { code: 'crane', label: 'Crane', value: 'crane', sortOrder: 1 }, { code: 'dockdoor', label: 'Dock Door', value: 'dockdoor', sortOrder: 2 }, @@ -131,6 +136,15 @@ const FOUNDATION_OPTIONS = { { code: 'USD', label: 'USD', value: 'USD', sortOrder: 2 }, { code: 'EUR', label: 'EUR', value: 'EUR', sortOrder: 3 } ], + crm_discount_type: [ + { code: 'amount', label: 'Amount', value: 'amount', sortOrder: 1 }, + { code: 'percent', label: 'Percent', value: 'percent', sortOrder: 2 } + ], + crm_topic_type: [ + { code: 'scope', label: 'Scope', value: 'scope', sortOrder: 1 }, + { code: 'exclusion', label: 'Exclusion', value: 'exclusion', sortOrder: 2 }, + { code: 'payment', label: 'Payment', value: 'payment', sortOrder: 3 } + ], crm_payment_term: [ { code: 'cash', label: 'Cash', value: 'cash', sortOrder: 1 }, { code: 'credit_30', label: 'Credit 30', value: 'credit_30', sortOrder: 2 }, diff --git a/src/features/crm/customers/components/customer-detail.tsx b/src/features/crm/customers/components/customer-detail.tsx index 531c957..2487486 100644 --- a/src/features/crm/customers/components/customer-detail.tsx +++ b/src/features/crm/customers/components/customer-detail.tsx @@ -15,6 +15,7 @@ import { CustomerFormSheet } from './customer-form-sheet'; import { CustomerContactsTab } from './customer-contacts-tab'; import { CustomerStatusBadge } from './customer-status-badge'; import type { CustomerRelatedEnquiryItem } from '../api/types'; +import type { QuotationRelationItem } from '@/features/crm/quotations/api/types'; function FieldItem({ label, value }: { label: string; value: string | null | undefined }) { return ( @@ -30,7 +31,8 @@ export function CustomerDetail({ referenceData, canUpdate, canManageContacts, - relatedEnquiries + relatedEnquiries, + relatedQuotations }: { customerId: string; referenceData: CustomerReferenceData; @@ -41,6 +43,7 @@ export function CustomerDetail({ delete: boolean; }; relatedEnquiries: CustomerRelatedEnquiryItem[]; + relatedQuotations: QuotationRelationItem[]; }) { const { data } = useSuspenseQuery(customerByIdOptions(customerId)); const [editOpen, setEditOpen] = useState(false); @@ -215,14 +218,13 @@ export function CustomerDetail({ Related Documents - Production enquiry links are now visible here, while quotation and approval - modules stay deferred. + Production enquiry and quotation links for this customer. - {!relatedEnquiries.length ? ( + {!relatedEnquiries.length && !relatedQuotations.length ? ( - No enquiries have been linked to this customer yet. + No enquiries or quotations have been linked to this customer yet. ) : ( @@ -243,6 +245,23 @@ export function CustomerDetail({ ))} + {relatedQuotations.map((quotation) => ( + + + + {quotation.code} + + {quotation.totalAmount.toLocaleString()} + + + + + + ))} )} diff --git a/src/features/crm/enquiries/components/enquiry-detail.tsx b/src/features/crm/enquiries/components/enquiry-detail.tsx index da3a500..a3ac12d 100644 --- a/src/features/crm/enquiries/components/enquiry-detail.tsx +++ b/src/features/crm/enquiries/components/enquiry-detail.tsx @@ -11,6 +11,7 @@ import { Separator } from '@/components/ui/separator'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { enquiryByIdOptions } from '../api/queries'; import type { EnquiryReferenceData } from '../api/types'; +import type { QuotationRelationItem } from '@/features/crm/quotations/api/types'; import { EnquiryFormSheet } from './enquiry-form-sheet'; import { EnquiryFollowupsTab } from './enquiry-followups-tab'; import { EnquiryStatusBadge } from './enquiry-status-badge'; @@ -27,11 +28,13 @@ function FieldItem({ label, value }: { label: string; value: string | null | und export function EnquiryDetail({ enquiryId, referenceData, + relatedQuotations, canUpdate, canManageFollowups }: { enquiryId: string; referenceData: EnquiryReferenceData; + relatedQuotations: QuotationRelationItem[]; canUpdate: boolean; canManageFollowups: { create: boolean; @@ -245,14 +248,35 @@ export function EnquiryDetail({ Related Quotations - Task D ends at enquiry readiness, so quotation production stays - intentionally deferred. + Production quotations already linked to this enquiry. - - Quotation linkage will land here in Task E. - + {!relatedQuotations.length ? ( + + No quotations have been linked to this enquiry yet. + + ) : ( + + {relatedQuotations.map((quotation) => ( + + + + {quotation.code} + + {quotation.totalAmount.toLocaleString()} + + + + + + ))} + + )} diff --git a/src/features/crm/enquiries/server/service.ts b/src/features/crm/enquiries/server/service.ts index b573b88..574e794 100644 --- a/src/features/crm/enquiries/server/service.ts +++ b/src/features/crm/enquiries/server/service.ts @@ -178,13 +178,17 @@ async function assertMasterOptionValue( } export async function assertCustomerBelongsToOrganization(id: string, organizationId: string) { - const customer = await db.query.crmCustomers.findFirst({ - where: and( - eq(crmCustomers.id, id), - eq(crmCustomers.organizationId, organizationId), - isNull(crmCustomers.deletedAt) + const [customer] = await db + .select() + .from(crmCustomers) + .where( + and( + eq(crmCustomers.id, id), + eq(crmCustomers.organizationId, organizationId), + isNull(crmCustomers.deletedAt) + ) ) - }); + .limit(1); if (!customer) { throw new AuthError('Customer not found', 404); @@ -198,14 +202,18 @@ export async function assertContactBelongsToOrganization( organizationId: string, customerId?: string | null ) { - const contact = await db.query.crmCustomerContacts.findFirst({ - where: and( - eq(crmCustomerContacts.id, contactId), - eq(crmCustomerContacts.organizationId, organizationId), - isNull(crmCustomerContacts.deletedAt), - ...(customerId ? [eq(crmCustomerContacts.customerId, customerId)] : []) + const [contact] = await db + .select() + .from(crmCustomerContacts) + .where( + and( + eq(crmCustomerContacts.id, contactId), + eq(crmCustomerContacts.organizationId, organizationId), + isNull(crmCustomerContacts.deletedAt), + ...(customerId ? [eq(crmCustomerContacts.customerId, customerId)] : []) + ) ) - }); + .limit(1); if (!contact) { throw new AuthError('Contact not found', 404); @@ -215,13 +223,17 @@ export async function assertContactBelongsToOrganization( } async function assertEnquiryBelongsToOrganization(id: string, organizationId: string) { - const enquiry = await db.query.crmEnquiries.findFirst({ - where: and( - eq(crmEnquiries.id, id), - eq(crmEnquiries.organizationId, organizationId), - isNull(crmEnquiries.deletedAt) + const [enquiry] = await db + .select() + .from(crmEnquiries) + .where( + and( + eq(crmEnquiries.id, id), + eq(crmEnquiries.organizationId, organizationId), + isNull(crmEnquiries.deletedAt) + ) ) - }); + .limit(1); if (!enquiry) { throw new AuthError('Enquiry not found', 404); @@ -235,14 +247,18 @@ async function assertFollowupBelongsToEnquiry( enquiryId: string, organizationId: string ) { - const followup = await db.query.crmEnquiryFollowups.findFirst({ - where: and( - eq(crmEnquiryFollowups.id, followupId), - eq(crmEnquiryFollowups.enquiryId, enquiryId), - eq(crmEnquiryFollowups.organizationId, organizationId), - isNull(crmEnquiryFollowups.deletedAt) + const [followup] = await db + .select() + .from(crmEnquiryFollowups) + .where( + and( + eq(crmEnquiryFollowups.id, followupId), + eq(crmEnquiryFollowups.enquiryId, enquiryId), + eq(crmEnquiryFollowups.organizationId, organizationId), + isNull(crmEnquiryFollowups.deletedAt) + ) ) - }); + .limit(1); if (!followup) { throw new AuthError('Follow-up not found', 404); @@ -345,17 +361,21 @@ export async function getEnquiryReferenceData( getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.priority, { organizationId }), getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.leadChannel, { organizationId }), getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.followupType, { organizationId }), - db.query.crmCustomers.findMany({ - where: and(eq(crmCustomers.organizationId, organizationId), isNull(crmCustomers.deletedAt)), - orderBy: [asc(crmCustomers.name)] - }), - db.query.crmCustomerContacts.findMany({ - where: and( - eq(crmCustomerContacts.organizationId, organizationId), - isNull(crmCustomerContacts.deletedAt) - ), - orderBy: [desc(crmCustomerContacts.isPrimary), asc(crmCustomerContacts.name)] - }) + db + .select() + .from(crmCustomers) + .where(and(eq(crmCustomers.organizationId, organizationId), isNull(crmCustomers.deletedAt))) + .orderBy(asc(crmCustomers.name)), + db + .select() + .from(crmCustomerContacts) + .where( + and( + eq(crmCustomerContacts.organizationId, organizationId), + isNull(crmCustomerContacts.deletedAt) + ) + ) + .orderBy(desc(crmCustomerContacts.isPrimary), asc(crmCustomerContacts.name)) ]); return { @@ -407,20 +427,26 @@ export async function listEnquiries( const [customers, contacts, followupCounts] = await Promise.all([ customerIds.length - ? db.query.crmCustomers.findMany({ - where: and( - eq(crmCustomers.organizationId, organizationId), - inArray(crmCustomers.id, customerIds) + ? db + .select() + .from(crmCustomers) + .where( + and( + eq(crmCustomers.organizationId, organizationId), + inArray(crmCustomers.id, customerIds) + ) ) - }) : [], contactIds.length - ? db.query.crmCustomerContacts.findMany({ - where: and( - eq(crmCustomerContacts.organizationId, organizationId), - inArray(crmCustomerContacts.id, contactIds) + ? db + .select() + .from(crmCustomerContacts) + .where( + and( + eq(crmCustomerContacts.organizationId, organizationId), + inArray(crmCustomerContacts.id, contactIds) + ) ) - }) : [], enquiryIds.length ? db @@ -618,14 +644,17 @@ export async function softDeleteEnquiry(id: string, organizationId: string, user export async function listEnquiryFollowups(id: string, organizationId: string) { await assertEnquiryBelongsToOrganization(id, organizationId); - const rows = await db.query.crmEnquiryFollowups.findMany({ - where: and( - eq(crmEnquiryFollowups.enquiryId, id), - eq(crmEnquiryFollowups.organizationId, organizationId), - isNull(crmEnquiryFollowups.deletedAt) - ), - orderBy: [desc(crmEnquiryFollowups.followupDate)] - }); + const rows = await db + .select() + .from(crmEnquiryFollowups) + .where( + and( + eq(crmEnquiryFollowups.enquiryId, id), + eq(crmEnquiryFollowups.organizationId, organizationId), + isNull(crmEnquiryFollowups.deletedAt) + ) + ) + .orderBy(desc(crmEnquiryFollowups.followupDate)); return rows.map(mapFollowupRecord); } @@ -713,15 +742,18 @@ export async function listCustomerEnquiryRelations( customerId: string, organizationId: string ): Promise { - const rows = await db.query.crmEnquiries.findMany({ - where: and( - eq(crmEnquiries.customerId, customerId), - eq(crmEnquiries.organizationId, organizationId), - isNull(crmEnquiries.deletedAt) - ), - orderBy: [desc(crmEnquiries.updatedAt)], - limit: 10 - }); + const rows = await db + .select() + .from(crmEnquiries) + .where( + and( + eq(crmEnquiries.customerId, customerId), + eq(crmEnquiries.organizationId, organizationId), + isNull(crmEnquiries.deletedAt) + ) + ) + .orderBy(desc(crmEnquiries.updatedAt)) + .limit(10); return rows.map((row) => ({ id: row.id, diff --git a/src/features/crm/quotations/api/mutations.ts b/src/features/crm/quotations/api/mutations.ts new file mode 100644 index 0000000..4a7f26d --- /dev/null +++ b/src/features/crm/quotations/api/mutations.ts @@ -0,0 +1,258 @@ +import { mutationOptions } from '@tanstack/react-query'; +import { getQueryClient } from '@/lib/query-client'; +import { + createQuotation, + createQuotationAttachment, + createQuotationCustomer, + createQuotationFollowup, + createQuotationItem, + createQuotationRevision, + createQuotationTopic, + deleteQuotation, + deleteQuotationAttachment, + deleteQuotationCustomer, + deleteQuotationFollowup, + deleteQuotationItem, + deleteQuotationTopic, + updateQuotation, + updateQuotationAttachment, + updateQuotationCustomer, + updateQuotationFollowup, + updateQuotationItem, + updateQuotationTopic +} from './service'; +import { quotationKeys } from './queries'; +import type { + QuotationAttachmentMutationPayload, + QuotationCustomerMutationPayload, + QuotationFollowupMutationPayload, + QuotationItemMutationPayload, + QuotationMutationPayload, + QuotationTopicMutationPayload +} from './types'; + +export const createQuotationMutation = mutationOptions({ + mutationFn: (data: QuotationMutationPayload) => createQuotation(data), + onSuccess: () => { + getQueryClient().invalidateQueries({ queryKey: quotationKeys.all }); + } +}); + +export const updateQuotationMutation = mutationOptions({ + mutationFn: ({ id, values }: { id: string; values: QuotationMutationPayload }) => + updateQuotation(id, values), + onSuccess: (_data, variables) => { + getQueryClient().invalidateQueries({ queryKey: quotationKeys.all }); + getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.id) }); + } +}); + +export const deleteQuotationMutation = mutationOptions({ + mutationFn: (id: string) => deleteQuotation(id), + onSuccess: () => { + getQueryClient().invalidateQueries({ queryKey: quotationKeys.all }); + } +}); + +export const createQuotationItemMutation = mutationOptions({ + mutationFn: ({ quotationId, values }: { quotationId: string; values: QuotationItemMutationPayload }) => + createQuotationItem(quotationId, values), + onSuccess: (_data, variables) => { + getQueryClient().invalidateQueries({ queryKey: quotationKeys.items(variables.quotationId) }); + getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.quotationId) }); + getQueryClient().invalidateQueries({ queryKey: quotationKeys.all }); + } +}); + +export const updateQuotationItemMutation = mutationOptions({ + mutationFn: ({ + quotationId, + itemId, + values + }: { + quotationId: string; + itemId: string; + values: QuotationItemMutationPayload; + }) => updateQuotationItem(quotationId, itemId, values), + onSuccess: (_data, variables) => { + getQueryClient().invalidateQueries({ queryKey: quotationKeys.items(variables.quotationId) }); + getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.quotationId) }); + getQueryClient().invalidateQueries({ queryKey: quotationKeys.all }); + } +}); + +export const deleteQuotationItemMutation = mutationOptions({ + mutationFn: ({ quotationId, itemId }: { quotationId: string; itemId: string }) => + deleteQuotationItem(quotationId, itemId), + onSuccess: (_data, variables) => { + getQueryClient().invalidateQueries({ queryKey: quotationKeys.items(variables.quotationId) }); + getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.quotationId) }); + getQueryClient().invalidateQueries({ queryKey: quotationKeys.all }); + } +}); + +export const createQuotationCustomerMutation = mutationOptions({ + mutationFn: ({ + quotationId, + values + }: { + quotationId: string; + values: QuotationCustomerMutationPayload; + }) => createQuotationCustomer(quotationId, values), + onSuccess: (_data, variables) => { + getQueryClient().invalidateQueries({ + queryKey: quotationKeys.customers(variables.quotationId) + }); + getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.quotationId) }); + } +}); + +export const updateQuotationCustomerMutation = mutationOptions({ + mutationFn: ({ + quotationId, + relationId, + values + }: { + quotationId: string; + relationId: string; + values: QuotationCustomerMutationPayload; + }) => updateQuotationCustomer(quotationId, relationId, values), + onSuccess: (_data, variables) => { + getQueryClient().invalidateQueries({ + queryKey: quotationKeys.customers(variables.quotationId) + }); + getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.quotationId) }); + } +}); + +export const deleteQuotationCustomerMutation = mutationOptions({ + mutationFn: ({ quotationId, relationId }: { quotationId: string; relationId: string }) => + deleteQuotationCustomer(quotationId, relationId), + onSuccess: (_data, variables) => { + getQueryClient().invalidateQueries({ + queryKey: quotationKeys.customers(variables.quotationId) + }); + getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.quotationId) }); + } +}); + +export const createQuotationTopicMutation = mutationOptions({ + mutationFn: ({ quotationId, values }: { quotationId: string; values: QuotationTopicMutationPayload }) => + createQuotationTopic(quotationId, values), + onSuccess: (_data, variables) => { + getQueryClient().invalidateQueries({ queryKey: quotationKeys.topics(variables.quotationId) }); + } +}); + +export const updateQuotationTopicMutation = mutationOptions({ + mutationFn: ({ quotationId, values }: { quotationId: string; values: QuotationTopicMutationPayload }) => + updateQuotationTopic(quotationId, values), + onSuccess: (_data, variables) => { + getQueryClient().invalidateQueries({ queryKey: quotationKeys.topics(variables.quotationId) }); + } +}); + +export const deleteQuotationTopicMutation = mutationOptions({ + mutationFn: ({ quotationId, topicId }: { quotationId: string; topicId: string }) => + deleteQuotationTopic(quotationId, topicId), + onSuccess: (_data, variables) => { + getQueryClient().invalidateQueries({ queryKey: quotationKeys.topics(variables.quotationId) }); + } +}); + +export const createQuotationFollowupMutation = mutationOptions({ + mutationFn: ({ + quotationId, + values + }: { + quotationId: string; + values: QuotationFollowupMutationPayload; + }) => createQuotationFollowup(quotationId, values), + onSuccess: (_data, variables) => { + getQueryClient().invalidateQueries({ + queryKey: quotationKeys.followups(variables.quotationId) + }); + getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.quotationId) }); + } +}); + +export const updateQuotationFollowupMutation = mutationOptions({ + mutationFn: ({ + quotationId, + values + }: { + quotationId: string; + values: QuotationFollowupMutationPayload; + }) => updateQuotationFollowup(quotationId, values), + onSuccess: (_data, variables) => { + getQueryClient().invalidateQueries({ + queryKey: quotationKeys.followups(variables.quotationId) + }); + getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.quotationId) }); + } +}); + +export const deleteQuotationFollowupMutation = mutationOptions({ + mutationFn: ({ quotationId, followupId }: { quotationId: string; followupId: string }) => + deleteQuotationFollowup(quotationId, followupId), + onSuccess: (_data, variables) => { + getQueryClient().invalidateQueries({ + queryKey: quotationKeys.followups(variables.quotationId) + }); + getQueryClient().invalidateQueries({ queryKey: quotationKeys.detail(variables.quotationId) }); + } +}); + +export const createQuotationAttachmentMutation = mutationOptions({ + mutationFn: ({ + quotationId, + values + }: { + quotationId: string; + values: QuotationAttachmentMutationPayload; + }) => createQuotationAttachment(quotationId, values), + onSuccess: (_data, variables) => { + getQueryClient().invalidateQueries({ + queryKey: quotationKeys.attachments(variables.quotationId) + }); + } +}); + +export const updateQuotationAttachmentMutation = mutationOptions({ + mutationFn: ({ + quotationId, + values + }: { + quotationId: string; + values: QuotationAttachmentMutationPayload; + }) => updateQuotationAttachment(quotationId, values), + onSuccess: (_data, variables) => { + getQueryClient().invalidateQueries({ + queryKey: quotationKeys.attachments(variables.quotationId) + }); + } +}); + +export const deleteQuotationAttachmentMutation = mutationOptions({ + mutationFn: ({ quotationId, attachmentId }: { quotationId: string; attachmentId: string }) => + deleteQuotationAttachment(quotationId, attachmentId), + onSuccess: (_data, variables) => { + getQueryClient().invalidateQueries({ + queryKey: quotationKeys.attachments(variables.quotationId) + }); + } +}); + +export const createQuotationRevisionMutation = mutationOptions({ + mutationFn: ({ + quotationId, + revisionRemark + }: { + quotationId: string; + revisionRemark?: string; + }) => createQuotationRevision(quotationId, revisionRemark), + onSuccess: (_data, variables) => { + getQueryClient().invalidateQueries({ queryKey: quotationKeys.revisions(variables.quotationId) }); + getQueryClient().invalidateQueries({ queryKey: quotationKeys.all }); + } +}); diff --git a/src/features/crm/quotations/api/queries.ts b/src/features/crm/quotations/api/queries.ts new file mode 100644 index 0000000..bff87db --- /dev/null +++ b/src/features/crm/quotations/api/queries.ts @@ -0,0 +1,72 @@ +import { queryOptions } from '@tanstack/react-query'; +import { + getQuotationAttachments, + getQuotationById, + getQuotationCustomers, + getQuotationFollowups, + getQuotationItems, + getQuotationRevisions, + getQuotations, + getQuotationTopics +} from './service'; +import type { QuotationFilters } from './types'; + +export const quotationKeys = { + all: ['crm-quotations'] as const, + list: (filters: QuotationFilters) => [...quotationKeys.all, 'list', filters] as const, + detail: (id: string) => [...quotationKeys.all, 'detail', id] as const, + items: (id: string) => [...quotationKeys.all, 'items', id] as const, + customers: (id: string) => [...quotationKeys.all, 'customers', id] as const, + topics: (id: string) => [...quotationKeys.all, 'topics', id] as const, + followups: (id: string) => [...quotationKeys.all, 'followups', id] as const, + attachments: (id: string) => [...quotationKeys.all, 'attachments', id] as const, + revisions: (id: string) => [...quotationKeys.all, 'revisions', id] as const +}; + +export const quotationsQueryOptions = (filters: QuotationFilters) => + queryOptions({ + queryKey: quotationKeys.list(filters), + queryFn: () => getQuotations(filters) + }); + +export const quotationByIdOptions = (id: string) => + queryOptions({ + queryKey: quotationKeys.detail(id), + queryFn: () => getQuotationById(id) + }); + +export const quotationItemsOptions = (id: string) => + queryOptions({ + queryKey: quotationKeys.items(id), + queryFn: () => getQuotationItems(id) + }); + +export const quotationCustomersOptions = (id: string) => + queryOptions({ + queryKey: quotationKeys.customers(id), + queryFn: () => getQuotationCustomers(id) + }); + +export const quotationTopicsOptions = (id: string) => + queryOptions({ + queryKey: quotationKeys.topics(id), + queryFn: () => getQuotationTopics(id) + }); + +export const quotationFollowupsOptions = (id: string) => + queryOptions({ + queryKey: quotationKeys.followups(id), + queryFn: () => getQuotationFollowups(id) + }); + +export const quotationAttachmentsOptions = (id: string) => + queryOptions({ + queryKey: quotationKeys.attachments(id), + queryFn: () => getQuotationAttachments(id) + }); + +export const quotationRevisionsOptions = (id: string) => + queryOptions({ + queryKey: quotationKeys.revisions(id), + queryFn: () => getQuotationRevisions(id) + }); diff --git a/src/features/crm/quotations/api/service.ts b/src/features/crm/quotations/api/service.ts new file mode 100644 index 0000000..caff593 --- /dev/null +++ b/src/features/crm/quotations/api/service.ts @@ -0,0 +1,214 @@ +import { apiClient } from '@/lib/api-client'; +import type { + MutationSuccessResponse, + QuotationAttachmentMutationPayload, + QuotationAttachmentsResponse, + QuotationCustomerMutationPayload, + QuotationCustomersResponse, + QuotationDetailResponse, + QuotationFilters, + QuotationFollowupMutationPayload, + QuotationFollowupsResponse, + QuotationItemMutationPayload, + QuotationItemsResponse, + QuotationListResponse, + QuotationMutationPayload, + QuotationRevisionsResponse, + QuotationTopicMutationPayload, + QuotationTopicsResponse +} from './types'; + +export async function getQuotations(filters: QuotationFilters): 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.quotationType) searchParams.set('quotationType', filters.quotationType); + if (filters.branch) searchParams.set('branch', filters.branch); + if (filters.customer) searchParams.set('customer', filters.customer); + if (filters.enquiry) searchParams.set('enquiry', filters.enquiry); + if (filters.hot) searchParams.set('hot', filters.hot); + if (filters.sort) searchParams.set('sort', filters.sort); + + const query = searchParams.toString(); + + return apiClient(`/crm/quotations${query ? `?${query}` : ''}`); +} + +export async function getQuotationById(id: string): Promise { + return apiClient(`/crm/quotations/${id}`); +} + +export async function createQuotation(data: QuotationMutationPayload) { + return apiClient('/crm/quotations', { + method: 'POST', + body: JSON.stringify(data) + }); +} + +export async function updateQuotation(id: string, data: QuotationMutationPayload) { + return apiClient(`/crm/quotations/${id}`, { + method: 'PATCH', + body: JSON.stringify(data) + }); +} + +export async function deleteQuotation(id: string) { + return apiClient(`/crm/quotations/${id}`, { + method: 'DELETE' + }); +} + +export async function getQuotationItems(id: string): Promise { + return apiClient(`/crm/quotations/${id}/items`); +} + +export async function createQuotationItem(id: string, data: QuotationItemMutationPayload) { + return apiClient(`/crm/quotations/${id}/items`, { + method: 'POST', + body: JSON.stringify(data) + }); +} + +export async function updateQuotationItem( + quotationId: string, + itemId: string, + data: QuotationItemMutationPayload +) { + return apiClient(`/crm/quotations/${quotationId}/items/${itemId}`, { + method: 'PATCH', + body: JSON.stringify(data) + }); +} + +export async function deleteQuotationItem(quotationId: string, itemId: string) { + return apiClient(`/crm/quotations/${quotationId}/items/${itemId}`, { + method: 'DELETE' + }); +} + +export async function getQuotationCustomers(id: string): Promise { + return apiClient(`/crm/quotations/${id}/customers`); +} + +export async function createQuotationCustomer(id: string, data: QuotationCustomerMutationPayload) { + return apiClient(`/crm/quotations/${id}/customers`, { + method: 'POST', + body: JSON.stringify(data) + }); +} + +export async function updateQuotationCustomer( + quotationId: string, + relationId: string, + data: QuotationCustomerMutationPayload +) { + return apiClient(`/crm/quotations/${quotationId}/customers`, { + method: 'PATCH', + body: JSON.stringify({ id: relationId, ...data }) + }); +} + +export async function deleteQuotationCustomer(quotationId: string, relationId: string) { + return apiClient(`/crm/quotations/${quotationId}/customers`, { + method: 'DELETE', + body: JSON.stringify({ id: relationId }) + }); +} + +export async function getQuotationTopics(id: string): Promise { + return apiClient(`/crm/quotations/${id}/topics`); +} + +export async function createQuotationTopic(id: string, data: QuotationTopicMutationPayload) { + return apiClient(`/crm/quotations/${id}/topics`, { + method: 'POST', + body: JSON.stringify(data) + }); +} + +export async function updateQuotationTopic(id: string, data: QuotationTopicMutationPayload) { + return apiClient(`/crm/quotations/${id}/topics`, { + method: 'PATCH', + body: JSON.stringify(data) + }); +} + +export async function deleteQuotationTopic(quotationId: string, topicId: string) { + return apiClient(`/crm/quotations/${quotationId}/topics`, { + method: 'DELETE', + body: JSON.stringify({ id: topicId }) + }); +} + +export async function getQuotationFollowups(id: string): Promise { + return apiClient(`/crm/quotations/${id}/followups`); +} + +export async function createQuotationFollowup(id: string, data: QuotationFollowupMutationPayload) { + return apiClient(`/crm/quotations/${id}/followups`, { + method: 'POST', + body: JSON.stringify(data) + }); +} + +export async function updateQuotationFollowup( + quotationId: string, + data: QuotationFollowupMutationPayload +) { + return apiClient(`/crm/quotations/${quotationId}/followups`, { + method: 'PATCH', + body: JSON.stringify(data) + }); +} + +export async function deleteQuotationFollowup(quotationId: string, followupId: string) { + return apiClient(`/crm/quotations/${quotationId}/followups`, { + method: 'DELETE', + body: JSON.stringify({ id: followupId }) + }); +} + +export async function getQuotationAttachments(id: string): Promise { + return apiClient(`/crm/quotations/${id}/attachments`); +} + +export async function createQuotationAttachment( + id: string, + data: QuotationAttachmentMutationPayload +) { + return apiClient(`/crm/quotations/${id}/attachments`, { + method: 'POST', + body: JSON.stringify(data) + }); +} + +export async function updateQuotationAttachment( + quotationId: string, + data: QuotationAttachmentMutationPayload +) { + return apiClient(`/crm/quotations/${quotationId}/attachments`, { + method: 'PATCH', + body: JSON.stringify(data) + }); +} + +export async function deleteQuotationAttachment(quotationId: string, attachmentId: string) { + return apiClient(`/crm/quotations/${quotationId}/attachments`, { + method: 'DELETE', + body: JSON.stringify({ id: attachmentId }) + }); +} + +export async function getQuotationRevisions(id: string): Promise { + return apiClient(`/crm/quotations/${id}/revisions`); +} + +export async function createQuotationRevision(id: string, revisionRemark?: string) { + return apiClient(`/crm/quotations/${id}/revisions`, { + method: 'POST', + body: JSON.stringify({ revisionRemark }) + }); +} diff --git a/src/features/crm/quotations/api/types.ts b/src/features/crm/quotations/api/types.ts new file mode 100644 index 0000000..4b63529 --- /dev/null +++ b/src/features/crm/quotations/api/types.ts @@ -0,0 +1,392 @@ +export interface QuotationOption { + id: string; + code: string; + label: string; + value: string | null; +} + +export interface QuotationBranchOption { + id: string; + code: string; + name: string; + value: string | null; +} + +export interface QuotationCustomerLookup { + id: string; + code: string; + name: string; + branchId: string | null; +} + +export interface QuotationContactLookup { + id: string; + customerId: string; + name: string; + email: string | null; + mobile: string | null; + isPrimary: boolean; +} + +export interface QuotationEnquiryLookup { + id: string; + code: string; + customerId: string; + contactId: string | null; + title: string; + projectName: string | null; + projectLocation: string | null; + branchId: string | null; +} + +export interface QuotationSalesmanLookup { + id: string; + name: string; +} + +export interface QuotationRecord { + id: string; + organizationId: string; + branchId: string | null; + code: string; + enquiryId: string | null; + customerId: string; + contactId: string | null; + quotationDate: string; + validUntil: string | null; + quotationType: string; + projectName: string | null; + projectLocation: string | null; + attention: string | null; + reference: string | null; + notes: string | null; + status: string; + revision: number; + parentQuotationId: string | null; + revisionRemark: string | null; + currency: string; + exchangeRate: number; + subtotal: number; + discount: number; + discountType: string | null; + taxRate: number; + taxAmount: number; + totalAmount: number; + chancePercent: number | null; + isHotProject: boolean; + competitor: string | null; + salesmanId: string | null; + isSent: boolean; + sentAt: string | null; + sentVia: string | null; + acceptedAt: string | null; + rejectedAt: string | null; + rejectionReason: string | null; + isActive: boolean; + createdAt: string; + updatedAt: string; + deletedAt: string | null; + createdBy: string; + updatedBy: string; +} + +export interface QuotationListItem extends QuotationRecord { + customerName: string; + contactName: string | null; + enquiryCode: string | null; + itemCount: number; + revisionLabel: string; +} + +export interface QuotationItemRecord { + id: string; + organizationId: string; + quotationId: string; + itemNumber: number; + productType: string; + description: string; + quantity: number; + unit: string | null; + unitPrice: number; + discount: number; + discountType: string | null; + taxRate: number; + totalPrice: number; + notes: string | null; + sortOrder: number; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +export interface QuotationCustomerRecord { + id: string; + organizationId: string; + quotationId: string; + customerId: string; + role: string; + isPrimary: boolean; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +export interface QuotationCustomerListItem extends QuotationCustomerRecord { + customerName: string; + customerCode: string; +} + +export interface QuotationTopicItemRecord { + id: string; + organizationId: string; + topicId: string; + content: string; + sortOrder: number; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +export interface QuotationTopicRecord { + id: string; + organizationId: string; + quotationId: string; + topicType: string; + title: string; + sortOrder: number; + createdAt: string; + updatedAt: string; + deletedAt: string | null; + items: QuotationTopicItemRecord[]; +} + +export interface QuotationFollowupRecord { + id: string; + organizationId: string; + quotationId: string; + followupDate: string; + followupType: string; + contactId: string | null; + outcome: string | null; + notes: string | null; + nextFollowupDate: string | null; + nextAction: string | null; + createdAt: string; + updatedAt: string; + deletedAt: string | null; + createdBy: string; + updatedBy: string; +} + +export interface QuotationAttachmentRecord { + id: string; + organizationId: string; + quotationId: string; + fileName: string; + originalFileName: string; + filePath: string; + fileSize: number | null; + fileType: string | null; + description: string | null; + uploadedAt: string; + uploadedBy: string; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +export interface QuotationActivityRecord { + id: string; + action: string; + entityType: string; + entityId: string; + createdAt: string; + userId: string; + actorName: string | null; +} + +export interface QuotationRelationItem { + id: string; + code: string; + status: string; + quotationType: string; + totalAmount: number; + updatedAt: string; +} + +export interface QuotationReferenceData { + branches: QuotationBranchOption[]; + statuses: QuotationOption[]; + quotationTypes: QuotationOption[]; + currencies: QuotationOption[]; + discountTypes: QuotationOption[]; + topicTypes: QuotationOption[]; + followupTypes: QuotationOption[]; + productTypes: QuotationOption[]; + customers: QuotationCustomerLookup[]; + contacts: QuotationContactLookup[]; + enquiries: QuotationEnquiryLookup[]; + salesmen: QuotationSalesmanLookup[]; +} + +export interface QuotationFilters { + page?: number; + limit?: number; + search?: string; + status?: string; + quotationType?: string; + branch?: string; + customer?: string; + enquiry?: string; + hot?: string; + sort?: string; +} + +export interface QuotationListResponse { + success: boolean; + time: string; + message: string; + totalItems: number; + offset: number; + limit: number; + items: QuotationListItem[]; +} + +export interface QuotationDetailResponse { + success: boolean; + time: string; + message: string; + quotation: QuotationRecord; + activity: QuotationActivityRecord[]; +} + +export interface QuotationItemsResponse { + success: boolean; + time: string; + message: string; + items: QuotationItemRecord[]; +} + +export interface QuotationCustomersResponse { + success: boolean; + time: string; + message: string; + items: QuotationCustomerListItem[]; +} + +export interface QuotationTopicsResponse { + success: boolean; + time: string; + message: string; + items: QuotationTopicRecord[]; +} + +export interface QuotationFollowupsResponse { + success: boolean; + time: string; + message: string; + items: QuotationFollowupRecord[]; +} + +export interface QuotationAttachmentsResponse { + success: boolean; + time: string; + message: string; + items: QuotationAttachmentRecord[]; +} + +export interface QuotationRevisionsResponse { + success: boolean; + time: string; + message: string; + items: QuotationRelationItem[]; +} + +export interface QuotationMutationPayload { + enquiryId?: string | null; + customerId: string; + contactId?: string | null; + quotationDate: string; + validUntil?: string | null; + quotationType: string; + projectName?: string; + projectLocation?: string; + attention?: string; + branchId?: string | null; + currency: string; + exchangeRate?: number | null; + status: string; + chancePercent?: number | null; + isHotProject?: boolean; + competitor?: string; + reference?: string; + notes?: string; + salesmanId?: string | null; + discount?: number | null; + discountType?: string | null; + taxRate?: number | null; + isActive?: boolean; + sentVia?: string | null; + revisionRemark?: string | null; +} + +export interface QuotationItemMutationPayload { + productType: string; + description: string; + quantity: number; + unit?: string; + unitPrice: number; + discount?: number | null; + discountType?: string | null; + taxRate?: number | null; + notes?: string; + sortOrder?: number | null; +} + +export interface QuotationCustomerMutationPayload { + customerId: string; + role: string; + isPrimary?: boolean; +} + +export interface QuotationTopicMutationPayload { + id?: string; + topicType: string; + title: string; + sortOrder?: number | null; + items: Array<{ + id?: string; + content: string; + sortOrder?: number | null; + }>; +} + +export interface QuotationFollowupMutationPayload { + id?: string; + followupDate: string; + followupType: string; + contactId?: string | null; + outcome?: string; + notes?: string; + nextFollowupDate?: string | null; + nextAction?: string; +} + +export interface QuotationAttachmentMutationPayload { + id?: string; + fileName: string; + originalFileName: string; + filePath: string; + fileSize?: number | null; + fileType?: string | null; + description?: string; +} + +export interface QuotationRevisionMutationPayload { + revisionRemark?: string; +} + +export interface MutationSuccessResponse { + success: boolean; + message: string; +} diff --git a/src/features/crm/quotations/components/quotation-cell-action.tsx b/src/features/crm/quotations/components/quotation-cell-action.tsx new file mode 100644 index 0000000..dcd33e4 --- /dev/null +++ b/src/features/crm/quotations/components/quotation-cell-action.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useMutation } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { AlertModal } from '@/components/modal/alert-modal'; +import { Icons } from '@/components/icons'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu'; +import { deleteQuotationMutation } from '../api/mutations'; +import type { QuotationListItem, QuotationReferenceData } from '../api/types'; +import { QuotationFormSheet } from './quotation-form-sheet'; + +export function QuotationCellAction({ + data, + referenceData, + canUpdate, + canDelete +}: { + data: QuotationListItem; + referenceData: QuotationReferenceData; + canUpdate: boolean; + canDelete: boolean; +}) { + const router = useRouter(); + const [deleteOpen, setDeleteOpen] = useState(false); + const [editOpen, setEditOpen] = useState(false); + + const deleteMutation = useMutation({ + ...deleteQuotationMutation, + onSuccess: () => { + toast.success('Quotation deleted successfully'); + setDeleteOpen(false); + }, + onError: (error) => + toast.error(error instanceof Error ? error.message : 'Failed to delete quotation') + }); + + return ( + <> + setDeleteOpen(false)} + onConfirm={() => deleteMutation.mutate(data.id)} + loading={deleteMutation.isPending} + /> + + + + + Open actions + + + + + Actions + router.push(`/dashboard/crm/quotations/${data.id}`)}> + View + + setEditOpen(true)} disabled={!canUpdate}> + Edit + + setDeleteOpen(true)} disabled={!canDelete}> + Delete + + + + > + ); +} diff --git a/src/features/crm/quotations/components/quotation-columns.tsx b/src/features/crm/quotations/components/quotation-columns.tsx new file mode 100644 index 0000000..c3c02fb --- /dev/null +++ b/src/features/crm/quotations/components/quotation-columns.tsx @@ -0,0 +1,167 @@ +'use client'; + +import Link from 'next/link'; +import type { Column, ColumnDef } from '@tanstack/react-table'; +import { Badge } from '@/components/ui/badge'; +import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header'; +import { Icons } from '@/components/icons'; +import type { QuotationListItem, QuotationReferenceData } from '../api/types'; +import { QuotationCellAction } from './quotation-cell-action'; +import { QuotationStatusBadge } from './quotation-status-badge'; + +export function getQuotationColumns({ + referenceData, + canUpdate, + canDelete +}: { + referenceData: QuotationReferenceData; + canUpdate: boolean; + canDelete: boolean; +}): ColumnDef[] { + const statusMap = new Map(referenceData.statuses.map((item) => [item.id, item])); + const typeMap = new Map(referenceData.quotationTypes.map((item) => [item.id, item])); + const branchMap = new Map(referenceData.branches.map((item) => [item.id, item])); + + return [ + { + id: 'code', + accessorKey: 'code', + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => ( + + + {row.original.code} + + + {row.original.customerName} + {row.original.projectName ? ` - ${row.original.projectName}` : ''} + + + ), + meta: { + label: 'Quotation', + placeholder: 'Search quotations...', + variant: 'text' as const, + icon: Icons.search + }, + enableColumnFilter: true + }, + { + id: 'status', + accessorKey: 'status', + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => { + const status = statusMap.get(row.original.status); + return ; + }, + meta: { + label: 'Status', + variant: 'multiSelect' as const, + options: referenceData.statuses.map((item) => ({ value: item.id, label: item.label })) + }, + enableColumnFilter: true + }, + { + id: 'quotationType', + accessorKey: 'quotationType', + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => ( + + {typeMap.get(row.original.quotationType)?.label ?? 'Unknown'} + + ), + meta: { + label: 'Type', + variant: 'multiSelect' as const, + options: referenceData.quotationTypes.map((item) => ({ value: item.id, label: item.label })) + }, + enableColumnFilter: true + }, + { + id: 'branch', + accessorFn: (row) => row.branchId ?? '', + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => ( + {row.original.branchId ? (branchMap.get(row.original.branchId)?.name ?? 'Unknown') : 'Unassigned'} + ), + meta: { + label: 'Branch', + variant: 'multiSelect' as const, + options: referenceData.branches.map((item) => ({ value: item.id, label: item.name })) + }, + enableColumnFilter: true + }, + { + id: 'customer', + accessorFn: (row) => row.customerId, + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => row.original.customerName, + meta: { + label: 'Customer', + variant: 'multiSelect' as const, + options: referenceData.customers.map((item) => ({ value: item.id, label: item.name })) + }, + enableColumnFilter: true + }, + { + id: 'enquiry', + accessorFn: (row) => row.enquiryId ?? '', + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => row.original.enquiryCode ?? '-', + meta: { + label: 'Enquiry', + variant: 'multiSelect' as const, + options: referenceData.enquiries.map((item) => ({ value: item.id, label: `${item.code} - ${item.title}` })) + }, + enableColumnFilter: true + }, + { + id: 'isHotProject', + accessorFn: (row) => String(row.isHotProject), + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => (row.original.isHotProject ? Hot : -), + meta: { + label: 'Hot Project', + variant: 'multiSelect' as const, + options: [ + { value: 'true', label: 'Hot' }, + { value: 'false', label: 'Normal' } + ] + }, + enableColumnFilter: true + }, + { + id: 'totalAmount', + accessorKey: 'totalAmount', + header: ({ column }: { column: Column }) => ( + + ), + cell: ({ row }) => row.original.totalAmount.toLocaleString() + }, + { + id: 'actions', + cell: ({ row }) => ( + + ) + } + ]; +} diff --git a/src/features/crm/quotations/components/quotation-detail.tsx b/src/features/crm/quotations/components/quotation-detail.tsx new file mode 100644 index 0000000..0e34c01 --- /dev/null +++ b/src/features/crm/quotations/components/quotation-detail.tsx @@ -0,0 +1,1202 @@ +'use client'; + +import Link from 'next/link'; +import { useMemo, useState } from 'react'; +import { useMutation, useSuspenseQuery } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { AlertModal } from '@/components/modal/alert-modal'; +import { Icons } from '@/components/icons'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Separator } from '@/components/ui/separator'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Textarea } from '@/components/ui/textarea'; +import { + createQuotationAttachmentMutation, + createQuotationCustomerMutation, + createQuotationFollowupMutation, + createQuotationItemMutation, + createQuotationRevisionMutation, + createQuotationTopicMutation, + deleteQuotationAttachmentMutation, + deleteQuotationCustomerMutation, + deleteQuotationFollowupMutation, + deleteQuotationItemMutation, + deleteQuotationTopicMutation, + updateQuotationAttachmentMutation, + updateQuotationCustomerMutation, + updateQuotationFollowupMutation, + updateQuotationItemMutation, + updateQuotationTopicMutation +} from '../api/mutations'; +import { + quotationAttachmentsOptions, + quotationByIdOptions, + quotationCustomersOptions, + quotationFollowupsOptions, + quotationItemsOptions, + quotationRevisionsOptions, + quotationTopicsOptions +} from '../api/queries'; +import type { + QuotationAttachmentMutationPayload, + QuotationAttachmentRecord, + QuotationCustomerListItem, + QuotationCustomerMutationPayload, + QuotationFollowupMutationPayload, + QuotationFollowupRecord, + QuotationItemMutationPayload, + QuotationItemRecord, + QuotationReferenceData, + QuotationTopicMutationPayload, + QuotationTopicRecord +} from '../api/types'; +import { QuotationFormSheet } from './quotation-form-sheet'; +import { QuotationStatusBadge } from './quotation-status-badge'; + +function FieldItem({ label, value }: { label: string; value: string | null | undefined }) { + return ( + + {label} + {value || '-'} + + ); +} + +function EmptyState({ message }: { message: string }) { + return ( + + {message} + + ); +} + +function ItemDialog({ + open, + onOpenChange, + onSubmit, + pending, + referenceData, + item +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (values: QuotationItemMutationPayload) => Promise; + pending: boolean; + referenceData: QuotationReferenceData; + item?: QuotationItemRecord; +}) { + const [values, setValues] = useState({ + productType: item?.productType ?? referenceData.productTypes[0]?.id ?? '', + description: item?.description ?? '', + quantity: String(item?.quantity ?? 1), + unit: item?.unit ?? '', + unitPrice: String(item?.unitPrice ?? 0), + discount: String(item?.discount ?? 0), + discountType: item?.discountType ?? '', + taxRate: String(item?.taxRate ?? 0), + notes: item?.notes ?? '' + }); + + return ( + + + + {item ? 'Edit Item' : 'Add Item'} + Line items drive quotation totals from the server. + + + setValues((s) => ({ ...s, productType: value }))}> + + + {referenceData.productTypes.map((option) => ( + {option.label} + ))} + + + setValues((s) => ({ ...s, description: e.target.value }))} placeholder='Description' /> + + setValues((s) => ({ ...s, quantity: e.target.value }))} type='number' placeholder='Quantity' /> + setValues((s) => ({ ...s, unit: e.target.value }))} placeholder='Unit' /> + + + setValues((s) => ({ ...s, unitPrice: e.target.value }))} type='number' placeholder='Unit price' /> + setValues((s) => ({ ...s, discount: e.target.value }))} type='number' placeholder='Discount' /> + + + setValues((s) => ({ ...s, discountType: value === '__none__' ? '' : value }))}> + + + No discount type + {referenceData.discountTypes.map((option) => ( + {option.label} + ))} + + + setValues((s) => ({ ...s, taxRate: e.target.value }))} type='number' placeholder='Tax rate %' /> + + setValues((s) => ({ ...s, notes: e.target.value }))} placeholder='Notes' /> + + + onOpenChange(false)}>Cancel + { + await onSubmit({ + productType: values.productType, + description: values.description, + quantity: Number(values.quantity), + unit: values.unit, + unitPrice: Number(values.unitPrice), + discount: Number(values.discount), + discountType: values.discountType || null, + taxRate: Number(values.taxRate), + notes: values.notes + }); + onOpenChange(false); + }} + > + Save + + + + + ); +} + +function CustomerDialog({ + open, + onOpenChange, + onSubmit, + pending, + referenceData, + relation +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (values: QuotationCustomerMutationPayload) => Promise; + pending: boolean; + referenceData: QuotationReferenceData; + relation?: QuotationCustomerListItem; +}) { + const [customerId, setCustomerId] = useState(relation?.customerId ?? referenceData.customers[0]?.id ?? ''); + const [role, setRole] = useState(relation?.role ?? 'owner'); + const [isPrimary, setIsPrimary] = useState(relation?.isPrimary ?? false); + + return ( + + + + {relation ? 'Edit Related Customer' : 'Add Related Customer'} + Keep owner, consultant, contractor, and billing parties visible on the quote. + + + + + + {referenceData.customers.map((item) => ( + {item.name} + ))} + + + + + + {['owner', 'consultant', 'contractor', 'billing'].map((item) => ( + {item} + ))} + + + + Primary relation + setIsPrimary(e.target.checked)} + /> + + + + onOpenChange(false)}>Cancel + { await onSubmit({ customerId, role, isPrimary }); onOpenChange(false); }}>Save + + + + ); +} + +function TopicDialog({ + open, + onOpenChange, + onSubmit, + pending, + referenceData, + topic +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (values: QuotationTopicMutationPayload) => Promise; + pending: boolean; + referenceData: QuotationReferenceData; + topic?: QuotationTopicRecord; +}) { + const [topicType, setTopicType] = useState(topic?.topicType ?? referenceData.topicTypes[0]?.id ?? ''); + const [title, setTitle] = useState(topic?.title ?? ''); + const [itemsText, setItemsText] = useState(topic?.items.map((item) => item.content).join('\n') ?? ''); + + return ( + + + + {topic ? 'Edit Topic' : 'Add Topic'} + Each line becomes a topic item in the generated quotation document. + + + + + + {referenceData.topicTypes.map((item) => ( + {item.label} + ))} + + + setTitle(e.target.value)} placeholder='Topic title' /> + setItemsText(e.target.value)} placeholder='One line per topic item' rows={6} /> + + + onOpenChange(false)}>Cancel + { + await onSubmit({ + id: topic?.id, + topicType, + title, + items: itemsText + .split('\n') + .map((item, index) => ({ content: item.trim(), sortOrder: index })) + .filter((item) => item.content.length > 0) + }); + onOpenChange(false); + }} + > + Save + + + + + ); +} + +function FollowupDialog({ + open, + onOpenChange, + onSubmit, + pending, + referenceData, + customerId, + followup +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (values: QuotationFollowupMutationPayload) => Promise; + pending: boolean; + referenceData: QuotationReferenceData; + customerId: string; + followup?: QuotationFollowupRecord; +}) { + const contacts = referenceData.contacts.filter((item) => item.customerId === customerId); + const [values, setValues] = useState({ + followupDate: followup?.followupDate.slice(0, 10) ?? new Date().toISOString().slice(0, 10), + followupType: followup?.followupType ?? referenceData.followupTypes[0]?.id ?? '', + contactId: followup?.contactId ?? '', + outcome: followup?.outcome ?? '', + notes: followup?.notes ?? '', + nextFollowupDate: followup?.nextFollowupDate?.slice(0, 10) ?? '', + nextAction: followup?.nextAction ?? '' + }); + + return ( + + + + {followup ? 'Edit Follow-up' : 'Add Follow-up'} + Keep quotation chasing and commercial communication visible. + + + setValues((s) => ({ ...s, followupDate: e.target.value }))} /> + setValues((s) => ({ ...s, followupType: value }))}> + + + {referenceData.followupTypes.map((item) => ( + {item.label} + ))} + + + setValues((s) => ({ ...s, contactId: value === '__none__' ? '' : value }))}> + + + No contact + {contacts.map((item) => ( + {item.name} + ))} + + + setValues((s) => ({ ...s, outcome: e.target.value }))} placeholder='Outcome' /> + setValues((s) => ({ ...s, nextFollowupDate: e.target.value }))} /> + setValues((s) => ({ ...s, nextAction: e.target.value }))} placeholder='Next action' /> + setValues((s) => ({ ...s, notes: e.target.value }))} placeholder='Notes' /> + + + onOpenChange(false)}>Cancel + { + await onSubmit({ + id: followup?.id, + followupDate: values.followupDate, + followupType: values.followupType, + contactId: values.contactId || null, + outcome: values.outcome, + notes: values.notes, + nextFollowupDate: values.nextFollowupDate || null, + nextAction: values.nextAction + }); + onOpenChange(false); + }} + > + Save + + + + + ); +} + +function AttachmentDialog({ + open, + onOpenChange, + onSubmit, + pending, + attachment +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (values: QuotationAttachmentMutationPayload) => Promise; + pending: boolean; + attachment?: QuotationAttachmentRecord; +}) { + const [values, setValues] = useState({ + fileName: attachment?.fileName ?? '', + originalFileName: attachment?.originalFileName ?? '', + filePath: attachment?.filePath ?? '', + fileSize: String(attachment?.fileSize ?? 0), + fileType: attachment?.fileType ?? '', + description: attachment?.description ?? '' + }); + + return ( + + + + {attachment ? 'Edit Attachment Metadata' : 'Add Attachment Metadata'} + Task E stores attachment metadata only, without file upload plumbing. + + + setValues((s) => ({ ...s, fileName: e.target.value }))} placeholder='Stored file name' /> + setValues((s) => ({ ...s, originalFileName: e.target.value }))} placeholder='Original file name' /> + setValues((s) => ({ ...s, filePath: e.target.value }))} placeholder='/crm/quotations/...' /> + + setValues((s) => ({ ...s, fileType: e.target.value }))} placeholder='application/pdf' /> + setValues((s) => ({ ...s, fileSize: e.target.value }))} type='number' placeholder='File size bytes' /> + + setValues((s) => ({ ...s, description: e.target.value }))} placeholder='Description' /> + + + onOpenChange(false)}>Cancel + { + await onSubmit({ + id: attachment?.id, + fileName: values.fileName, + originalFileName: values.originalFileName, + filePath: values.filePath, + fileSize: Number(values.fileSize), + fileType: values.fileType || null, + description: values.description + }); + onOpenChange(false); + }} + > + Save + + + + + ); +} + +export function QuotationDetail({ + quotationId, + referenceData, + canUpdate, + canManageItems, + canManageCustomers, + canManageTopics, + canManageFollowups, + canManageAttachments, + canCreateRevision +}: { + quotationId: string; + referenceData: QuotationReferenceData; + canUpdate: boolean; + canManageItems: boolean; + canManageCustomers: boolean; + canManageTopics: boolean; + canManageFollowups: boolean; + canManageAttachments: boolean; + canCreateRevision: boolean; +}) { + const { data } = useSuspenseQuery(quotationByIdOptions(quotationId)); + const { data: itemsData } = useSuspenseQuery(quotationItemsOptions(quotationId)); + const { data: customersData } = useSuspenseQuery(quotationCustomersOptions(quotationId)); + const { data: topicsData } = useSuspenseQuery(quotationTopicsOptions(quotationId)); + const { data: followupsData } = useSuspenseQuery(quotationFollowupsOptions(quotationId)); + const { data: attachmentsData } = useSuspenseQuery(quotationAttachmentsOptions(quotationId)); + const { data: revisionsData } = useSuspenseQuery(quotationRevisionsOptions(quotationId)); + const quotation = data.quotation; + const [editOpen, setEditOpen] = useState(false); + + const [itemOpen, setItemOpen] = useState(false); + const [editingItem, setEditingItem] = useState(); + const [deleteItemId, setDeleteItemId] = useState(null); + const createItem = useMutation({ + ...createQuotationItemMutation, + onSuccess: () => toast.success('Quotation item saved'), + onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to save item') + }); + const updateItem = useMutation({ + ...updateQuotationItemMutation, + onSuccess: () => toast.success('Quotation item updated'), + onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to update item') + }); + const deleteItem = useMutation({ + ...deleteQuotationItemMutation, + onSuccess: () => { + toast.success('Quotation item deleted'); + setDeleteItemId(null); + }, + onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to delete item') + }); + + const [customerOpen, setCustomerOpen] = useState(false); + const [editingCustomer, setEditingCustomer] = useState(); + const [deleteCustomerId, setDeleteCustomerId] = useState(null); + const createCustomer = useMutation({ + ...createQuotationCustomerMutation, + onSuccess: () => toast.success('Related customer saved'), + onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to save customer') + }); + const updateCustomer = useMutation({ + ...updateQuotationCustomerMutation, + onSuccess: () => toast.success('Related customer updated'), + onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to update customer') + }); + const deleteCustomer = useMutation({ + ...deleteQuotationCustomerMutation, + onSuccess: () => { + toast.success('Related customer deleted'); + setDeleteCustomerId(null); + }, + onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to delete customer') + }); + + const [topicOpen, setTopicOpen] = useState(false); + const [editingTopic, setEditingTopic] = useState(); + const [deleteTopicId, setDeleteTopicId] = useState(null); + const createTopic = useMutation({ + ...createQuotationTopicMutation, + onSuccess: () => toast.success('Topic saved'), + onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to save topic') + }); + const updateTopic = useMutation({ + ...updateQuotationTopicMutation, + onSuccess: () => toast.success('Topic updated'), + onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to update topic') + }); + const deleteTopic = useMutation({ + ...deleteQuotationTopicMutation, + onSuccess: () => { + toast.success('Topic deleted'); + setDeleteTopicId(null); + }, + onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to delete topic') + }); + + const [followupOpen, setFollowupOpen] = useState(false); + const [editingFollowup, setEditingFollowup] = useState(); + const [deleteFollowupId, setDeleteFollowupId] = useState(null); + const createFollowup = useMutation({ + ...createQuotationFollowupMutation, + onSuccess: () => toast.success('Follow-up saved'), + onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to save follow-up') + }); + const updateFollowup = useMutation({ + ...updateQuotationFollowupMutation, + onSuccess: () => toast.success('Follow-up updated'), + onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to update follow-up') + }); + const deleteFollowup = useMutation({ + ...deleteQuotationFollowupMutation, + onSuccess: () => { + toast.success('Follow-up deleted'); + setDeleteFollowupId(null); + }, + onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to delete follow-up') + }); + + const [attachmentOpen, setAttachmentOpen] = useState(false); + const [editingAttachment, setEditingAttachment] = useState(); + const [deleteAttachmentId, setDeleteAttachmentId] = useState(null); + const createAttachment = useMutation({ + ...createQuotationAttachmentMutation, + onSuccess: () => toast.success('Attachment metadata saved'), + onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to save attachment') + }); + const updateAttachment = useMutation({ + ...updateQuotationAttachmentMutation, + onSuccess: () => toast.success('Attachment metadata updated'), + onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to update attachment') + }); + const deleteAttachment = useMutation({ + ...deleteQuotationAttachmentMutation, + onSuccess: () => { + toast.success('Attachment metadata deleted'); + setDeleteAttachmentId(null); + }, + onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to delete attachment') + }); + + const createRevision = useMutation({ + ...createQuotationRevisionMutation, + onSuccess: () => toast.success('Revision created successfully'), + onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to create revision') + }); + + const branchMap = useMemo( + () => new Map(referenceData.branches.map((item) => [item.id, item.name])), + [referenceData.branches] + ); + const customerMap = useMemo( + () => new Map(referenceData.customers.map((item) => [item.id, item])), + [referenceData.customers] + ); + const contactMap = useMemo( + () => new Map(referenceData.contacts.map((item) => [item.id, item])), + [referenceData.contacts] + ); + const statusMap = useMemo( + () => new Map(referenceData.statuses.map((item) => [item.id, item])), + [referenceData.statuses] + ); + const typeMap = useMemo( + () => new Map(referenceData.quotationTypes.map((item) => [item.id, item])), + [referenceData.quotationTypes] + ); + const currencyMap = useMemo( + () => new Map(referenceData.currencies.map((item) => [item.id, item])), + [referenceData.currencies] + ); + const topicTypeMap = useMemo( + () => new Map(referenceData.topicTypes.map((item) => [item.id, item])), + [referenceData.topicTypes] + ); + const followupTypeMap = useMemo( + () => new Map(referenceData.followupTypes.map((item) => [item.id, item])), + [referenceData.followupTypes] + ); + const customer = customerMap.get(quotation.customerId); + const contact = quotation.contactId ? contactMap.get(quotation.contactId) : null; + const status = statusMap.get(quotation.status); + + return ( + + setDeleteItemId(null)} + onConfirm={() => deleteItemId && deleteItem.mutate({ quotationId, itemId: deleteItemId })} + loading={deleteItem.isPending} + /> + setDeleteCustomerId(null)} + onConfirm={() => + deleteCustomerId && deleteCustomer.mutate({ quotationId, relationId: deleteCustomerId }) + } + loading={deleteCustomer.isPending} + /> + setDeleteTopicId(null)} + onConfirm={() => deleteTopicId && deleteTopic.mutate({ quotationId, topicId: deleteTopicId })} + loading={deleteTopic.isPending} + /> + setDeleteFollowupId(null)} + onConfirm={() => + deleteFollowupId && + deleteFollowup.mutate({ quotationId, followupId: deleteFollowupId }) + } + loading={deleteFollowup.isPending} + /> + setDeleteAttachmentId(null)} + onConfirm={() => + deleteAttachmentId && + deleteAttachment.mutate({ quotationId, attachmentId: deleteAttachmentId }) + } + loading={deleteAttachment.isPending} + /> + + { + if (editingItem) { + await updateItem.mutateAsync({ quotationId, itemId: editingItem.id, values }); + return; + } + + await createItem.mutateAsync({ quotationId, values }); + }} + /> + { + if (editingCustomer) { + await updateCustomer.mutateAsync({ + quotationId, + relationId: editingCustomer.id, + values + }); + return; + } + + await createCustomer.mutateAsync({ quotationId, values }); + }} + /> + { + if (editingTopic) { + await updateTopic.mutateAsync({ + quotationId, + values: { ...values, id: editingTopic.id } + }); + return; + } + + await createTopic.mutateAsync({ quotationId, values }); + }} + /> + { + if (editingFollowup) { + await updateFollowup.mutateAsync({ + quotationId, + values: { ...values, id: editingFollowup.id } + }); + return; + } + + await createFollowup.mutateAsync({ quotationId, values }); + }} + /> + { + if (editingAttachment) { + await updateAttachment.mutateAsync({ + quotationId, + values: { ...values, id: editingAttachment.id } + }); + return; + } + + await createAttachment.mutateAsync({ quotationId, values }); + }} + /> + + + + + + + + + + + {quotation.code} + + {quotation.isHotProject ? Hot : null} + + + {quotation.projectName || 'Untitled quotation'} + + {customer?.name ?? 'Unknown customer'} + {contact ? ` - ${contact.name}` : ''} + + + + + {canCreateRevision ? ( + createRevision.mutate({ quotationId })} + > + Create Revision + + ) : null} + {canUpdate ? ( + setEditOpen(true)}> + Edit Quotation + + ) : null} + + + + + + + + + + Overview + Items + Customers + Topics + Follow-ups + Attachments + Activity + Approval + Document Preview + + + + + + Overview + Commercial header data and quotation summary. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Items + Server-calculated line items and pricing inputs. + + {canManageItems ? ( + { + setEditingItem(undefined); + setItemOpen(true); + }} + > + Add Item + + ) : null} + + + {!itemsData.items.length ? ( + + ) : ( + itemsData.items.map((item) => ( + + + + {item.description} + + Qty {item.quantity} {item.unit || ''} x {item.unitPrice.toLocaleString()} + + + Total {item.totalPrice.toLocaleString()} + + + {canManageItems ? ( + + { setEditingItem(item); setItemOpen(true); }}> + Edit + + setDeleteItemId(item.id)}> + Delete + + + ) : null} + + + )) + )} + + + + + + + + + Customers + All parties involved in this quotation. + + {canManageCustomers ? ( + { setEditingCustomer(undefined); setCustomerOpen(true); }}> + Add Customer + + ) : null} + + + {!customersData.items.length ? ( + + ) : ( + customersData.items.map((item) => ( + + + {item.customerName} + + {item.role} + {item.isPrimary ? ' - Primary' : ''} + + + {canManageCustomers ? ( + + { setEditingCustomer(item); setCustomerOpen(true); }}> + Edit + + setDeleteCustomerId(item.id)}> + Delete + + + ) : null} + + )) + )} + + + + + + + + + Topics + Scope, exclusions, payment terms, and other document sections. + + {canManageTopics ? ( + { setEditingTopic(undefined); setTopicOpen(true); }}> + Add Topic + + ) : null} + + + {!topicsData.items.length ? ( + + ) : ( + topicsData.items.map((topic) => ( + + + + {topic.title} + + {topicTypeMap.get(topic.topicType)?.label ?? topic.topicType} + + + {canManageTopics ? ( + + { setEditingTopic(topic); setTopicOpen(true); }}> + Edit + + setDeleteTopicId(topic.id)}> + Delete + + + ) : null} + + + {topic.items.map((item) => ( + {item.content} + ))} + + + )) + )} + + + + + + + + + Follow-ups + Commercial chasing and response history. + + {canManageFollowups ? ( + { setEditingFollowup(undefined); setFollowupOpen(true); }}> + Add Follow-up + + ) : null} + + + {!followupsData.items.length ? ( + + ) : ( + followupsData.items.map((item) => ( + + + + + {followupTypeMap.get(item.followupType)?.label ?? item.followupType} + + + {new Date(item.followupDate).toLocaleDateString()} + {item.outcome ? ` - ${item.outcome}` : ''} + + {item.notes || '-'} + + {canManageFollowups ? ( + + { setEditingFollowup(item); setFollowupOpen(true); }}> + Edit + + setDeleteFollowupId(item.id)}> + Delete + + + ) : null} + + + )) + )} + + + + + + + + + Attachments + Metadata only in Task E, ready for storage integration later. + + {canManageAttachments ? ( + { setEditingAttachment(undefined); setAttachmentOpen(true); }}> + Add Attachment + + ) : null} + + + {!attachmentsData.items.length ? ( + + ) : ( + attachmentsData.items.map((item) => ( + + + {item.originalFileName} + + {item.fileType || 'Unknown type'} + {item.fileSize ? ` - ${item.fileSize.toLocaleString()} bytes` : ''} + + + {canManageAttachments ? ( + + { setEditingAttachment(item); setAttachmentOpen(true); }}> + Edit + + setDeleteAttachmentId(item.id)}> + Delete + + + ) : null} + + )) + )} + + + + + + + + Activity + Audit events for quotation mutations and child records. + + + {!data.activity.length ? ( + + ) : ( + data.activity.map((item, index) => ( + + + + + + {item.action} + {item.entityType.replaceAll('_', ' ')} + {item.actorName ?? item.userId} + + + {new Date(item.createdAt).toLocaleString()} + + + + {index < data.activity.length - 1 ? : null} + + )) + )} + + + + + + + + Approval Placeholder + Approval workflow is deferred beyond Task E. + + + + + + + + + + + Document Preview Placeholder + PDF and rendered output are intentionally postponed. + + + + + + + + + + + + + + + Revision Chain + Family of quotations copied from the same parent record. + + + {!revisionsData.items.length ? ( + + ) : ( + revisionsData.items.map((item) => ( + + + + {item.code} + {item.totalAmount.toLocaleString()} + + + + + )) + )} + + + + + + Record Snapshot + Operational metadata for this quotation row. + + + + + + + + + + + + + ); +} diff --git a/src/features/crm/quotations/components/quotation-form-sheet.tsx b/src/features/crm/quotations/components/quotation-form-sheet.tsx new file mode 100644 index 0000000..537a3e5 --- /dev/null +++ b/src/features/crm/quotations/components/quotation-form-sheet.tsx @@ -0,0 +1,477 @@ +'use client'; + +import * as React from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { Icons } from '@/components/icons'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select'; +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle +} from '@/components/ui/sheet'; +import { Switch } from '@/components/ui/switch'; +import { Textarea } from '@/components/ui/textarea'; +import { createQuotationMutation, updateQuotationMutation } from '../api/mutations'; +import type { QuotationMutationPayload, QuotationRecord, QuotationReferenceData } from '../api/types'; + +type FormState = { + enquiryId: string; + customerId: string; + contactId: string; + quotationDate: string; + validUntil: string; + quotationType: string; + projectName: string; + projectLocation: string; + attention: string; + branchId: string; + currency: string; + exchangeRate: string; + status: string; + chancePercent: string; + competitor: string; + reference: string; + notes: string; + salesmanId: string; + discount: string; + discountType: string; + taxRate: string; + revisionRemark: string; + isHotProject: boolean; + isActive: boolean; +}; + +function toFormState( + quotation: QuotationRecord | undefined, + referenceData: QuotationReferenceData +): FormState { + return { + enquiryId: quotation?.enquiryId ?? '', + customerId: quotation?.customerId ?? referenceData.customers[0]?.id ?? '', + contactId: quotation?.contactId ?? '', + quotationDate: + quotation?.quotationDate?.slice(0, 10) ?? new Date().toISOString().slice(0, 10), + validUntil: quotation?.validUntil?.slice(0, 10) ?? '', + quotationType: quotation?.quotationType ?? referenceData.quotationTypes[0]?.id ?? '', + projectName: quotation?.projectName ?? '', + projectLocation: quotation?.projectLocation ?? '', + attention: quotation?.attention ?? '', + branchId: quotation?.branchId ?? '', + currency: quotation?.currency ?? referenceData.currencies[0]?.id ?? '', + exchangeRate: String(quotation?.exchangeRate ?? 1), + status: quotation?.status ?? referenceData.statuses[0]?.id ?? '', + chancePercent: + quotation?.chancePercent === null || quotation?.chancePercent === undefined + ? '' + : String(quotation.chancePercent), + competitor: quotation?.competitor ?? '', + reference: quotation?.reference ?? '', + notes: quotation?.notes ?? '', + salesmanId: quotation?.salesmanId ?? '', + discount: String(quotation?.discount ?? 0), + discountType: quotation?.discountType ?? '', + taxRate: String(quotation?.taxRate ?? 0), + revisionRemark: quotation?.revisionRemark ?? '', + isHotProject: quotation?.isHotProject ?? false, + isActive: quotation?.isActive ?? true + }; +} + +function Field({ + label, + children, + className +}: { + label: string; + children: React.ReactNode; + className?: string; +}) { + return ( + + {label} + {children} + + ); +} + +export function QuotationFormSheet({ + quotation, + open, + onOpenChange, + referenceData +}: { + quotation?: QuotationRecord; + open: boolean; + onOpenChange: (open: boolean) => void; + referenceData: QuotationReferenceData; +}) { + const isEdit = !!quotation; + const defaultState = useMemo(() => toFormState(quotation, referenceData), [quotation, referenceData]); + const [state, setState] = useState(defaultState); + + const createMutation = useMutation({ + ...createQuotationMutation, + onSuccess: () => { + toast.success('Quotation created successfully'); + onOpenChange(false); + }, + onError: (error) => + toast.error(error instanceof Error ? error.message : 'Failed to create quotation') + }); + + const updateMutation = useMutation({ + ...updateQuotationMutation, + onSuccess: () => { + toast.success('Quotation updated successfully'); + onOpenChange(false); + }, + onError: (error) => + toast.error(error instanceof Error ? error.message : 'Failed to update quotation') + }); + + useEffect(() => { + if (open) { + setState(defaultState); + } + }, [defaultState, open]); + + const contacts = useMemo( + () => referenceData.contacts.filter((item) => item.customerId === state.customerId), + [referenceData.contacts, state.customerId] + ); + + function setField(key: K, value: FormState[K]) { + setState((current) => ({ ...current, [key]: value })); + } + + function handleEnquiryChange(value: string) { + const enquiry = referenceData.enquiries.find((item) => item.id === value); + setState((current) => ({ + ...current, + enquiryId: value === '__none__' ? '' : value, + customerId: enquiry?.customerId ?? current.customerId, + contactId: enquiry?.contactId ?? '', + projectName: enquiry?.projectName ?? current.projectName, + projectLocation: enquiry?.projectLocation ?? current.projectLocation, + branchId: enquiry?.branchId ?? current.branchId + })); + } + + async function onSubmit(event: React.FormEvent) { + event.preventDefault(); + + const payload: QuotationMutationPayload = { + enquiryId: state.enquiryId || null, + customerId: state.customerId, + contactId: state.contactId || null, + quotationDate: state.quotationDate, + validUntil: state.validUntil || null, + quotationType: state.quotationType, + projectName: state.projectName, + projectLocation: state.projectLocation, + attention: state.attention, + branchId: state.branchId || null, + currency: state.currency, + exchangeRate: state.exchangeRate ? Number(state.exchangeRate) : 1, + status: state.status, + chancePercent: state.chancePercent ? Number(state.chancePercent) : null, + isHotProject: state.isHotProject, + competitor: state.competitor, + reference: state.reference, + notes: state.notes, + salesmanId: state.salesmanId || null, + discount: state.discount ? Number(state.discount) : 0, + discountType: state.discountType || null, + taxRate: state.taxRate ? Number(state.taxRate) : 0, + revisionRemark: state.revisionRemark || null, + isActive: state.isActive + }; + + if (isEdit && quotation) { + await updateMutation.mutateAsync({ id: quotation.id, values: payload }); + return; + } + + await createMutation.mutateAsync(payload); + } + + const isPending = createMutation.isPending || updateMutation.isPending; + + return ( + + + + {isEdit ? 'Edit Quotation' : 'New Quotation'} + + Capture quotation header data, customer linkage, and commercial context. + + + + + + + + + + + + No linked enquiry + {referenceData.enquiries.map((item) => ( + + {item.code} - {item.title} + + ))} + + + + + + { + setField('customerId', value); + setField('contactId', ''); + }} + > + + + + + {referenceData.customers.map((item) => ( + + {item.name} ({item.code}) + + ))} + + + + + + setField('contactId', value === '__none__' ? '' : value)} + > + + + + + No contact + {contacts.map((item) => ( + + {item.name} + + ))} + + + + + + setField('quotationType', value)}> + + + + + {referenceData.quotationTypes.map((item) => ( + + {item.label} + + ))} + + + + + + setField('quotationDate', e.target.value)} /> + + + + setField('validUntil', e.target.value)} /> + + + + setField('branchId', value === '__none__' ? '' : value)}> + + + + + Unassigned + {referenceData.branches.map((item) => ( + + {item.name} + + ))} + + + + + + setField('status', value)}> + + + + + {referenceData.statuses.map((item) => ( + + {item.label} + + ))} + + + + + + setField('currency', value)}> + + + + + {referenceData.currencies.map((item) => ( + + {item.label} + + ))} + + + + + + setField('exchangeRate', e.target.value)} type='number' step='0.0001' /> + + + + setField('projectName', e.target.value)} placeholder='Project name' /> + + + + setField('projectLocation', e.target.value)} placeholder='Project location' /> + + + + setField('attention', e.target.value)} placeholder='Attention line' /> + + + + setField('reference', e.target.value)} placeholder='Reference note' /> + + + + setField('salesmanId', value === '__none__' ? '' : value)}> + + + + + Unassigned + {referenceData.salesmen.map((item) => ( + + {item.name} + + ))} + + + + + + setField('chancePercent', e.target.value)} type='number' min='0' max='100' /> + + + + setField('discount', e.target.value)} type='number' step='0.01' /> + + + + setField('discountType', value === '__none__' ? '' : value)}> + + + + + None + {referenceData.discountTypes.map((item) => ( + + {item.label} + + ))} + + + + + + setField('taxRate', e.target.value)} type='number' step='0.01' /> + + + + setField('competitor', e.target.value)} placeholder='Competitor' /> + + + + + setField('notes', e.target.value)} placeholder='Internal notes' /> + + + + + + setField('revisionRemark', e.target.value)} placeholder='Revision note or approval context' /> + + + + + + Hot Project + Highlight urgent or strategic deals. + + setField('isHotProject', checked)} /> + + + + + Active Record + Inactive quotations stay as history only. + + setField('isActive', checked)} /> + + + + + + onOpenChange(false)}> + Cancel + + + + {isEdit ? 'Update Quotation' : 'Create Quotation'} + + + + + ); +} + +export function QuotationFormSheetTrigger({ + referenceData +}: { + referenceData: QuotationReferenceData; +}) { + const [open, setOpen] = React.useState(false); + + return ( + <> + setOpen(true)}> + Add Quotation + + + > + ); +} diff --git a/src/features/crm/quotations/components/quotation-listing.tsx b/src/features/crm/quotations/components/quotation-listing.tsx new file mode 100644 index 0000000..7668a02 --- /dev/null +++ b/src/features/crm/quotations/components/quotation-listing.tsx @@ -0,0 +1,48 @@ +import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; +import { getQueryClient } from '@/lib/query-client'; +import { searchParamsCache } from '@/lib/searchparams'; +import type { QuotationReferenceData } from '../api/types'; +import { quotationsQueryOptions } from '../api/queries'; +import { QuotationsTable } from './quotations-table'; + +export default function QuotationListing({ + referenceData, + canUpdate, + canDelete +}: { + referenceData: QuotationReferenceData; + canUpdate: boolean; + canDelete: boolean; +}) { + const page = searchParamsCache.get('page'); + const limit = searchParamsCache.get('perPage'); + const search = searchParamsCache.get('name'); + const status = searchParamsCache.get('status'); + const quotationType = searchParamsCache.get('quotationType'); + const branch = searchParamsCache.get('branch'); + const customer = searchParamsCache.get('customer'); + const enquiry = searchParamsCache.get('enquiry'); + const hot = searchParamsCache.get('hot'); + const sort = searchParamsCache.get('sort'); + const filters = { + page, + limit, + ...(search && { search }), + ...(status && { status }), + ...(quotationType && { quotationType }), + ...(branch && { branch }), + ...(customer && { customer }), + ...(enquiry && { enquiry }), + ...(hot && { hot }), + ...(sort && { sort }) + }; + const queryClient = getQueryClient(); + + void queryClient.prefetchQuery(quotationsQueryOptions(filters)); + + return ( + + + + ); +} diff --git a/src/features/crm/quotations/components/quotation-status-badge.tsx b/src/features/crm/quotations/components/quotation-status-badge.tsx new file mode 100644 index 0000000..266c5fa --- /dev/null +++ b/src/features/crm/quotations/components/quotation-status-badge.tsx @@ -0,0 +1,29 @@ +'use client'; + +import { Badge } from '@/components/ui/badge'; +import { Icons } from '@/components/icons'; + +export function QuotationStatusBadge({ code, label }: { code?: string | null; label: string }) { + const normalized = code?.toLowerCase() ?? ''; + const variant = + normalized === 'approved' || normalized === 'accepted' || normalized === 'won' + ? 'default' + : normalized === 'draft' || normalized === 'new' + ? 'secondary' + : normalized === 'rejected' || normalized === 'lost' + ? 'destructive' + : 'outline'; + const Icon = + normalized === 'approved' || normalized === 'accepted' || normalized === 'won' + ? Icons.circleCheck + : normalized === 'rejected' || normalized === 'lost' + ? Icons.warning + : Icons.circle; + + return ( + + + {label} + + ); +} diff --git a/src/features/crm/quotations/components/quotations-table.tsx b/src/features/crm/quotations/components/quotations-table.tsx new file mode 100644 index 0000000..0c09a96 --- /dev/null +++ b/src/features/crm/quotations/components/quotations-table.tsx @@ -0,0 +1,72 @@ +'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 { quotationsQueryOptions } from '../api/queries'; +import type { QuotationReferenceData } from '../api/types'; +import { getQuotationColumns } from './quotation-columns'; + +export function QuotationsTable({ + referenceData, + canUpdate, + canDelete +}: { + referenceData: QuotationReferenceData; + canUpdate: boolean; + canDelete: boolean; +}) { + const columns = useMemo( + () => getQuotationColumns({ referenceData, canUpdate, canDelete }), + [referenceData, canUpdate, canDelete] + ); + 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, + quotationType: parseAsString, + branch: parseAsString, + customer: parseAsString, + enquiry: parseAsString, + hot: parseAsString, + sort: getSortingStateParser(columnIds).withDefault([]) + }); + + const filters = { + page: params.page, + limit: params.perPage, + ...(params.name && { search: params.name }), + ...(params.status && { status: params.status }), + ...(params.quotationType && { quotationType: params.quotationType }), + ...(params.branch && { branch: params.branch }), + ...(params.customer && { customer: params.customer }), + ...(params.enquiry && { enquiry: params.enquiry }), + ...(params.hot && { hot: params.hot }), + ...(params.sort.length > 0 && { sort: JSON.stringify(params.sort) }) + }; + + const { data } = useSuspenseQuery(quotationsQueryOptions(filters)); + const pageCount = Math.ceil(data.totalItems / params.perPage); + const { table } = useDataTable({ + data: data.items, + columns, + pageCount, + shallow: true, + debounceMs: 500, + initialState: { + columnPinning: { right: ['actions'] } + } + }); + + return ( + + + + ); +} diff --git a/src/features/crm/quotations/schemas/quotation.schema.ts b/src/features/crm/quotations/schemas/quotation.schema.ts new file mode 100644 index 0000000..42504a1 --- /dev/null +++ b/src/features/crm/quotations/schemas/quotation.schema.ts @@ -0,0 +1,103 @@ +import * as z from 'zod'; + +export const quotationSchema = z.object({ + enquiryId: z.string().optional().nullable(), + customerId: z.string().min(1, 'Please select a customer'), + contactId: z.string().optional().nullable(), + quotationDate: z.string().min(1, 'Quotation date is required'), + validUntil: z.string().optional().nullable(), + quotationType: z.string().min(1, 'Please select a quotation type'), + projectName: z.string().optional(), + projectLocation: z.string().optional(), + attention: z.string().optional(), + branchId: z.string().optional().nullable(), + currency: z.string().min(1, 'Please select a currency'), + exchangeRate: z.number().nullable().optional(), + status: z.string().min(1, 'Please select a status'), + chancePercent: z + .number() + .nullable() + .optional() + .refine( + (value) => value === null || value === undefined || (value >= 0 && value <= 100), + 'Chance percent must be between 0 and 100' + ), + isHotProject: z.boolean().default(false), + competitor: z.string().optional(), + reference: z.string().optional(), + notes: z.string().optional(), + salesmanId: z.string().optional().nullable(), + discount: z.number().nullable().optional(), + discountType: z.string().optional().nullable(), + taxRate: z.number().nullable().optional(), + isActive: z.boolean().default(true), + sentVia: z.string().optional().nullable(), + revisionRemark: z.string().optional().nullable() +}); + +export const quotationItemSchema = z.object({ + productType: z.string().min(1, 'Please select a product type'), + description: z.string().min(2, 'Description must be at least 2 characters'), + quantity: z.number().positive('Quantity must be greater than 0'), + unit: z.string().optional(), + unitPrice: z.number().min(0, 'Unit price must be 0 or greater'), + discount: z.number().min(0).nullable().optional(), + discountType: z.string().nullable().optional(), + taxRate: z.number().min(0).nullable().optional(), + notes: z.string().optional(), + sortOrder: z.number().nullable().optional() +}); + +export const quotationCustomerSchema = z.object({ + customerId: z.string().min(1, 'Please select a customer'), + role: z.string().min(1, 'Please select a role'), + isPrimary: z.boolean().default(false) +}); + +export const quotationTopicSchema = z.object({ + id: z.string().optional(), + topicType: z.string().min(1, 'Please select a topic type'), + title: z.string().min(2, 'Title must be at least 2 characters'), + sortOrder: z.number().nullable().optional(), + items: z + .array( + z.object({ + id: z.string().optional(), + content: z.string().min(1, 'Topic item content is required'), + sortOrder: z.number().nullable().optional() + }) + ) + .min(1, 'At least one topic item is required') +}); + +export const quotationFollowupSchema = z.object({ + id: z.string().optional(), + followupDate: z.string().min(1, 'Follow-up date is required'), + followupType: z.string().min(1, 'Please select a follow-up type'), + contactId: z.string().optional().nullable(), + outcome: z.string().optional(), + notes: z.string().optional(), + nextFollowupDate: z.string().optional().nullable(), + nextAction: z.string().optional() +}); + +export const quotationAttachmentSchema = z.object({ + id: z.string().optional(), + fileName: z.string().min(1, 'Stored file name is required'), + originalFileName: z.string().min(1, 'Original file name is required'), + filePath: z.string().min(1, 'File path is required'), + fileSize: z.number().nullable().optional(), + fileType: z.string().nullable().optional(), + description: z.string().optional() +}); + +export const quotationRevisionSchema = z.object({ + revisionRemark: z.string().optional() +}); + +export type QuotationFormValues = z.input; +export type QuotationItemFormValues = z.input; +export type QuotationCustomerFormValues = z.input; +export type QuotationTopicFormValues = z.input; +export type QuotationFollowupFormValues = z.input; +export type QuotationAttachmentFormValues = z.input; diff --git a/src/features/crm/quotations/server/service.ts b/src/features/crm/quotations/server/service.ts new file mode 100644 index 0000000..0c1e19a --- /dev/null +++ b/src/features/crm/quotations/server/service.ts @@ -0,0 +1,1850 @@ +import { + and, + asc, + count, + desc, + eq, + ilike, + inArray, + isNull, + or, + type SQL +} from 'drizzle-orm'; +import { + crmCustomerContacts, + crmCustomers, + crmEnquiries, + crmQuotationAttachments, + crmQuotationCustomers, + crmQuotationFollowups, + crmQuotationItems, + crmQuotationTopicItems, + crmQuotationTopics, + crmQuotations, + memberships, + users +} from '@/db/schema'; +import { db } from '@/lib/db'; +import { AuthError } from '@/lib/auth/session'; +import { listAuditLogs } from '@/features/foundation/audit-log/service'; +import { getUserBranches, validateBranchAccess } from '@/features/foundation/branch-scope/service'; +import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service'; +import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service'; +import type { + QuotationActivityRecord, + QuotationAttachmentMutationPayload, + QuotationAttachmentRecord, + QuotationBranchOption, + QuotationContactLookup, + QuotationCustomerListItem, + QuotationCustomerLookup, + QuotationCustomerMutationPayload, + QuotationCustomerRecord, + QuotationEnquiryLookup, + QuotationFilters, + QuotationFollowupMutationPayload, + QuotationFollowupRecord, + QuotationItemMutationPayload, + QuotationItemRecord, + QuotationListItem, + QuotationMutationPayload, + QuotationOption, + QuotationRecord, + QuotationReferenceData, + QuotationRelationItem, + QuotationSalesmanLookup, + QuotationTopicItemRecord, + QuotationTopicMutationPayload, + QuotationTopicRecord +} from '../api/types'; + +const QUOTATION_OPTION_CATEGORIES = { + status: 'crm_quotation_status', + quotationType: 'crm_quotation_type', + currency: 'currency', + discountType: 'crm_discount_type', + topicType: 'crm_topic_type', + followupType: 'crm_followup_type', + productType: 'crm_product_type' +} as const; + +const QUOTATION_CUSTOMER_ROLES = ['owner', 'consultant', 'contractor', 'billing'] as const; + +function mapOption( + option: Awaited>[number] +): QuotationOption { + return { + id: option.id, + code: option.code, + label: option.label, + value: option.value + }; +} + +function mapBranch( + branch: Awaited>[number] +): QuotationBranchOption { + return { + id: branch.id, + code: branch.code, + name: branch.name, + value: branch.value + }; +} + +function mapQuotationRecord(row: typeof crmQuotations.$inferSelect): QuotationRecord { + return { + id: row.id, + organizationId: row.organizationId, + branchId: row.branchId, + code: row.code, + enquiryId: row.enquiryId, + customerId: row.customerId, + contactId: row.contactId, + quotationDate: row.quotationDate.toISOString(), + validUntil: row.validUntil?.toISOString() ?? null, + quotationType: row.quotationType, + projectName: row.projectName, + projectLocation: row.projectLocation, + attention: row.attention, + reference: row.reference, + notes: row.notes, + status: row.status, + revision: row.revision, + parentQuotationId: row.parentQuotationId, + revisionRemark: row.revisionRemark, + currency: row.currency, + exchangeRate: row.exchangeRate, + subtotal: row.subtotal, + discount: row.discount, + discountType: row.discountType, + taxRate: row.taxRate, + taxAmount: row.taxAmount, + totalAmount: row.totalAmount, + chancePercent: row.chancePercent, + isHotProject: row.isHotProject, + competitor: row.competitor, + salesmanId: row.salesmanId, + isSent: row.isSent, + sentAt: row.sentAt?.toISOString() ?? null, + sentVia: row.sentVia, + acceptedAt: row.acceptedAt?.toISOString() ?? null, + rejectedAt: row.rejectedAt?.toISOString() ?? null, + rejectionReason: row.rejectionReason, + isActive: row.isActive, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + deletedAt: row.deletedAt?.toISOString() ?? null, + createdBy: row.createdBy, + updatedBy: row.updatedBy + }; +} + +function mapQuotationItemRecord(row: typeof crmQuotationItems.$inferSelect): QuotationItemRecord { + return { + id: row.id, + organizationId: row.organizationId, + quotationId: row.quotationId, + itemNumber: row.itemNumber, + productType: row.productType, + description: row.description, + quantity: row.quantity, + unit: row.unit, + unitPrice: row.unitPrice, + discount: row.discount, + discountType: row.discountType, + taxRate: row.taxRate, + totalPrice: row.totalPrice, + notes: row.notes, + sortOrder: row.sortOrder, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + deletedAt: row.deletedAt?.toISOString() ?? null + }; +} + +function mapQuotationCustomerRecord( + row: typeof crmQuotationCustomers.$inferSelect +): QuotationCustomerRecord { + return { + id: row.id, + organizationId: row.organizationId, + quotationId: row.quotationId, + customerId: row.customerId, + role: row.role, + isPrimary: row.isPrimary, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + deletedAt: row.deletedAt?.toISOString() ?? null + }; +} + +function mapQuotationTopicItemRecord( + row: typeof crmQuotationTopicItems.$inferSelect +): QuotationTopicItemRecord { + return { + id: row.id, + organizationId: row.organizationId, + topicId: row.topicId, + content: row.content, + sortOrder: row.sortOrder, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + deletedAt: row.deletedAt?.toISOString() ?? null + }; +} + +function mapQuotationFollowupRecord( + row: typeof crmQuotationFollowups.$inferSelect +): QuotationFollowupRecord { + return { + id: row.id, + organizationId: row.organizationId, + quotationId: row.quotationId, + followupDate: row.followupDate.toISOString(), + followupType: row.followupType, + contactId: row.contactId, + outcome: row.outcome, + notes: row.notes, + nextFollowupDate: row.nextFollowupDate?.toISOString() ?? null, + nextAction: row.nextAction, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + deletedAt: row.deletedAt?.toISOString() ?? null, + createdBy: row.createdBy, + updatedBy: row.updatedBy + }; +} + +function mapQuotationAttachmentRecord( + row: typeof crmQuotationAttachments.$inferSelect +): QuotationAttachmentRecord { + return { + id: row.id, + organizationId: row.organizationId, + quotationId: row.quotationId, + fileName: row.fileName, + originalFileName: row.originalFileName, + filePath: row.filePath, + fileSize: row.fileSize, + fileType: row.fileType, + description: row.description, + uploadedAt: row.uploadedAt.toISOString(), + uploadedBy: row.uploadedBy, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + deletedAt: row.deletedAt?.toISOString() ?? null + }; +} + +function parseSort(sort?: string) { + if (!sort) { + return desc(crmQuotations.updatedAt); + } + + try { + const parsed = JSON.parse(sort) as Array<{ id: string; desc?: boolean }>; + const [rule] = parsed; + + if (!rule) { + return desc(crmQuotations.updatedAt); + } + + const sortMap = { + code: crmQuotations.code, + quotationDate: crmQuotations.quotationDate, + status: crmQuotations.status, + quotationType: crmQuotations.quotationType, + branch: crmQuotations.branchId, + customer: crmQuotations.customerId, + totalAmount: crmQuotations.totalAmount, + updatedAt: crmQuotations.updatedAt + } as const; + + const column = sortMap[rule.id as keyof typeof sortMap]; + + if (!column) { + return desc(crmQuotations.updatedAt); + } + + return rule.desc ? desc(column) : asc(column); + } catch { + return desc(crmQuotations.updatedAt); + } +} + +function splitFilterValue(value?: string) { + return value + ? value + .split(/[.,]/) + .map((item) => item.trim()) + .filter(Boolean) + : []; +} + +function toRevisionLabel(revision: number) { + return `R${String(revision).padStart(2, '0')}`; +} + +function calculateDiscountAmount(baseAmount: number, discount = 0, discountType?: string | null) { + if (!discount) { + return 0; + } + + if (discountType === 'percent') { + return (baseAmount * discount) / 100; + } + + return discount; +} + +function calculateLineTotal(payload: QuotationItemMutationPayload) { + const baseAmount = payload.quantity * payload.unitPrice; + const discountAmount = calculateDiscountAmount( + baseAmount, + payload.discount ?? 0, + payload.discountType ?? null + ); + + return Math.max(baseAmount - discountAmount, 0); +} + +async function resolveValidOptionIds(category: string, organizationId: string) { + const options = await getActiveOptionsByCategory(category, { organizationId }); + return new Set(options.map((option) => option.id)); +} + +async function assertMasterOptionValue( + organizationId: string, + category: string, + optionId?: string | null +) { + if (!optionId) { + return; + } + + const validIds = await resolveValidOptionIds(category, organizationId); + + if (!validIds.has(optionId)) { + throw new AuthError(`Invalid option for ${category}`, 400); + } +} + +export async function assertCustomerBelongsToOrganization(id: string, organizationId: string) { + const [customer] = await db + .select() + .from(crmCustomers) + .where( + and( + eq(crmCustomers.id, id), + eq(crmCustomers.organizationId, organizationId), + isNull(crmCustomers.deletedAt) + ) + ) + .limit(1); + + if (!customer) { + throw new AuthError('Customer not found', 404); + } + + return customer; +} + +export async function assertContactBelongsToOrganization( + contactId: string, + organizationId: string, + customerId?: string | null +) { + const [contact] = await db + .select() + .from(crmCustomerContacts) + .where( + and( + eq(crmCustomerContacts.id, contactId), + eq(crmCustomerContacts.organizationId, organizationId), + isNull(crmCustomerContacts.deletedAt), + ...(customerId ? [eq(crmCustomerContacts.customerId, customerId)] : []) + ) + ) + .limit(1); + + if (!contact) { + throw new AuthError('Contact not found', 404); + } + + return contact; +} + +async function assertEnquiryBelongsToOrganization(id: string, organizationId: string) { + const [enquiry] = await db + .select() + .from(crmEnquiries) + .where( + and( + eq(crmEnquiries.id, id), + eq(crmEnquiries.organizationId, organizationId), + isNull(crmEnquiries.deletedAt) + ) + ) + .limit(1); + + if (!enquiry) { + throw new AuthError('Enquiry not found', 404); + } + + return enquiry; +} + +async function assertQuotationBelongsToOrganization(id: string, organizationId: string) { + const [quotation] = await db + .select() + .from(crmQuotations) + .where( + and( + eq(crmQuotations.id, id), + eq(crmQuotations.organizationId, organizationId), + isNull(crmQuotations.deletedAt) + ) + ) + .limit(1); + + if (!quotation) { + throw new AuthError('Quotation not found', 404); + } + + return quotation; +} + +async function assertQuotationItemBelongsToQuotation( + quotationId: string, + itemId: string, + organizationId: string +) { + const [item] = await db + .select() + .from(crmQuotationItems) + .where( + and( + eq(crmQuotationItems.id, itemId), + eq(crmQuotationItems.quotationId, quotationId), + eq(crmQuotationItems.organizationId, organizationId), + isNull(crmQuotationItems.deletedAt) + ) + ) + .limit(1); + + if (!item) { + throw new AuthError('Quotation item not found', 404); + } + + return item; +} + +async function assertQuotationCustomerBelongsToQuotation( + quotationId: string, + relationId: string, + organizationId: string +) { + const [relation] = await db + .select() + .from(crmQuotationCustomers) + .where( + and( + eq(crmQuotationCustomers.id, relationId), + eq(crmQuotationCustomers.quotationId, quotationId), + eq(crmQuotationCustomers.organizationId, organizationId), + isNull(crmQuotationCustomers.deletedAt) + ) + ) + .limit(1); + + if (!relation) { + throw new AuthError('Quotation customer relation not found', 404); + } + + return relation; +} + +async function assertQuotationTopicBelongsToQuotation( + quotationId: string, + topicId: string, + organizationId: string +) { + const [topic] = await db + .select() + .from(crmQuotationTopics) + .where( + and( + eq(crmQuotationTopics.id, topicId), + eq(crmQuotationTopics.quotationId, quotationId), + eq(crmQuotationTopics.organizationId, organizationId), + isNull(crmQuotationTopics.deletedAt) + ) + ) + .limit(1); + + if (!topic) { + throw new AuthError('Quotation topic not found', 404); + } + + return topic; +} + +async function assertQuotationFollowupBelongsToQuotation( + quotationId: string, + followupId: string, + organizationId: string +) { + const [followup] = await db + .select() + .from(crmQuotationFollowups) + .where( + and( + eq(crmQuotationFollowups.id, followupId), + eq(crmQuotationFollowups.quotationId, quotationId), + eq(crmQuotationFollowups.organizationId, organizationId), + isNull(crmQuotationFollowups.deletedAt) + ) + ) + .limit(1); + + if (!followup) { + throw new AuthError('Quotation follow-up not found', 404); + } + + return followup; +} + +async function assertQuotationAttachmentBelongsToQuotation( + quotationId: string, + attachmentId: string, + organizationId: string +) { + const [attachment] = await db + .select() + .from(crmQuotationAttachments) + .where( + and( + eq(crmQuotationAttachments.id, attachmentId), + eq(crmQuotationAttachments.quotationId, quotationId), + eq(crmQuotationAttachments.organizationId, organizationId), + isNull(crmQuotationAttachments.deletedAt) + ) + ) + .limit(1); + + if (!attachment) { + throw new AuthError('Quotation attachment not found', 404); + } + + return attachment; +} + +async function assertSalesmanBelongsToOrganization(userId: string, organizationId: string) { + const [membership] = await db + .select({ userId: memberships.userId }) + .from(memberships) + .where(and(eq(memberships.organizationId, organizationId), eq(memberships.userId, userId))) + .limit(1); + + if (!membership) { + throw new AuthError('Salesman not found in organization', 404); + } +} + +async function validateQuotationPayload(organizationId: string, payload: QuotationMutationPayload) { + await assertCustomerBelongsToOrganization(payload.customerId, organizationId); + + if (payload.contactId) { + await assertContactBelongsToOrganization(payload.contactId, organizationId, payload.customerId); + } + + if (payload.enquiryId) { + const enquiry = await assertEnquiryBelongsToOrganization(payload.enquiryId, organizationId); + + if (enquiry.customerId !== payload.customerId) { + throw new AuthError('Selected enquiry belongs to a different customer', 400); + } + } + + if (payload.branchId) { + await validateBranchAccess(payload.branchId); + } + + if (payload.salesmanId) { + await assertSalesmanBelongsToOrganization(payload.salesmanId, organizationId); + } + + await assertMasterOptionValue(organizationId, QUOTATION_OPTION_CATEGORIES.status, payload.status); + await assertMasterOptionValue( + organizationId, + QUOTATION_OPTION_CATEGORIES.quotationType, + payload.quotationType + ); + await assertMasterOptionValue( + organizationId, + QUOTATION_OPTION_CATEGORIES.currency, + payload.currency + ); + await assertMasterOptionValue( + organizationId, + QUOTATION_OPTION_CATEGORIES.discountType, + payload.discountType ?? null + ); +} + +async function validateQuotationItemPayload( + organizationId: string, + payload: QuotationItemMutationPayload +) { + await assertMasterOptionValue( + organizationId, + QUOTATION_OPTION_CATEGORIES.productType, + payload.productType + ); + await assertMasterOptionValue( + organizationId, + QUOTATION_OPTION_CATEGORIES.discountType, + payload.discountType ?? null + ); +} + +async function validateQuotationCustomerPayload( + organizationId: string, + payload: QuotationCustomerMutationPayload +) { + await assertCustomerBelongsToOrganization(payload.customerId, organizationId); + + if (!QUOTATION_CUSTOMER_ROLES.includes(payload.role as (typeof QUOTATION_CUSTOMER_ROLES)[number])) { + throw new AuthError('Invalid quotation customer role', 400); + } +} + +async function validateQuotationTopicPayload( + organizationId: string, + payload: QuotationTopicMutationPayload +) { + await assertMasterOptionValue( + organizationId, + QUOTATION_OPTION_CATEGORIES.topicType, + payload.topicType + ); +} + +async function validateQuotationFollowupPayload( + quotationId: string, + organizationId: string, + payload: QuotationFollowupMutationPayload +) { + const quotation = await assertQuotationBelongsToOrganization(quotationId, organizationId); + + await assertMasterOptionValue( + organizationId, + QUOTATION_OPTION_CATEGORIES.followupType, + payload.followupType + ); + + if (payload.contactId) { + await assertContactBelongsToOrganization( + payload.contactId, + organizationId, + quotation.customerId + ); + } +} + +function buildQuotationFilters(organizationId: string, filters: QuotationFilters): SQL[] { + const statuses = splitFilterValue(filters.status); + const quotationTypes = splitFilterValue(filters.quotationType); + const branches = splitFilterValue(filters.branch); + const customers = splitFilterValue(filters.customer); + const enquiries = splitFilterValue(filters.enquiry); + + return [ + eq(crmQuotations.organizationId, organizationId), + isNull(crmQuotations.deletedAt), + ...(statuses.length ? [inArray(crmQuotations.status, statuses)] : []), + ...(quotationTypes.length ? [inArray(crmQuotations.quotationType, quotationTypes)] : []), + ...(branches.length ? [inArray(crmQuotations.branchId, branches)] : []), + ...(customers.length ? [inArray(crmQuotations.customerId, customers)] : []), + ...(enquiries.length ? [inArray(crmQuotations.enquiryId, enquiries)] : []), + ...(filters.hot === 'true' ? [eq(crmQuotations.isHotProject, true)] : []), + ...(filters.hot === 'false' ? [eq(crmQuotations.isHotProject, false)] : []), + ...(filters.search + ? [ + or( + ilike(crmQuotations.code, `%${filters.search}%`), + ilike(crmQuotations.projectName, `%${filters.search}%`), + ilike(crmQuotations.projectLocation, `%${filters.search}%`), + ilike(crmQuotations.reference, `%${filters.search}%`), + ilike(crmQuotations.competitor, `%${filters.search}%`) + )! + ] + : []) + ]; +} + +async function refreshQuotationTotals(quotationId: string, organizationId: string, userId?: string) { + const quotation = await assertQuotationBelongsToOrganization(quotationId, organizationId); + const items = await listQuotationItems(quotationId, organizationId); + const subtotal = items.reduce((sum, item) => sum + item.totalPrice, 0); + const headerDiscountAmount = calculateDiscountAmount( + subtotal, + quotation.discount, + quotation.discountType + ); + const discountedSubtotal = Math.max(subtotal - headerDiscountAmount, 0); + const taxAmount = discountedSubtotal * (quotation.taxRate / 100); + const totalAmount = discountedSubtotal + taxAmount; + + const [updated] = await db + .update(crmQuotations) + .set({ + subtotal, + taxAmount, + totalAmount, + updatedAt: new Date(), + ...(userId ? { updatedBy: userId } : {}) + }) + .where(eq(crmQuotations.id, quotationId)) + .returning(); + + return updated; +} + +async function listQuotationTopicItemsByTopicIds(topicIds: string[], organizationId: string) { + if (!topicIds.length) { + return []; + } + + return db + .select() + .from(crmQuotationTopicItems) + .where( + and( + eq(crmQuotationTopicItems.organizationId, organizationId), + inArray(crmQuotationTopicItems.topicId, topicIds), + isNull(crmQuotationTopicItems.deletedAt) + ) + ) + .orderBy(asc(crmQuotationTopicItems.sortOrder), asc(crmQuotationTopicItems.createdAt)); +} + +export async function getQuotationReferenceData( + organizationId: string +): Promise { + const [ + branches, + statuses, + quotationTypes, + currencies, + discountTypes, + topicTypes, + followupTypes, + productTypes, + customers, + contacts, + enquiries, + salesmen + ] = await Promise.all([ + getUserBranches(), + getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.status, { organizationId }), + getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.quotationType, { organizationId }), + getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.currency, { organizationId }), + getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.discountType, { organizationId }), + getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.topicType, { organizationId }), + getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.followupType, { organizationId }), + getActiveOptionsByCategory(QUOTATION_OPTION_CATEGORIES.productType, { organizationId }), + db + .select() + .from(crmCustomers) + .where(and(eq(crmCustomers.organizationId, organizationId), isNull(crmCustomers.deletedAt))) + .orderBy(asc(crmCustomers.name)), + db + .select() + .from(crmCustomerContacts) + .where( + and( + eq(crmCustomerContacts.organizationId, organizationId), + isNull(crmCustomerContacts.deletedAt) + ) + ) + .orderBy(desc(crmCustomerContacts.isPrimary), asc(crmCustomerContacts.name)), + db + .select() + .from(crmEnquiries) + .where(and(eq(crmEnquiries.organizationId, organizationId), isNull(crmEnquiries.deletedAt))) + .orderBy(desc(crmEnquiries.updatedAt)), + db + .select({ id: users.id, name: users.name }) + .from(memberships) + .innerJoin(users, eq(memberships.userId, users.id)) + .where(eq(memberships.organizationId, organizationId)) + .orderBy(asc(users.name)) + ]); + + return { + branches: branches.map(mapBranch), + statuses: statuses.map(mapOption), + quotationTypes: quotationTypes.map(mapOption), + currencies: currencies.map(mapOption), + discountTypes: discountTypes.map(mapOption), + topicTypes: topicTypes.map(mapOption), + followupTypes: followupTypes.map(mapOption), + productTypes: productTypes.map(mapOption), + customers: customers.map((customer) => ({ + id: customer.id, + code: customer.code, + name: customer.name, + branchId: customer.branchId + })), + contacts: contacts.map((contact) => ({ + id: contact.id, + customerId: contact.customerId, + name: contact.name, + email: contact.email, + mobile: contact.mobile, + isPrimary: contact.isPrimary + })), + enquiries: enquiries.map((enquiry) => ({ + id: enquiry.id, + code: enquiry.code, + customerId: enquiry.customerId, + contactId: enquiry.contactId, + title: enquiry.title, + projectName: enquiry.projectName, + projectLocation: enquiry.projectLocation, + branchId: enquiry.branchId + })), + salesmen: salesmen.map((salesman) => ({ + id: salesman.id, + name: salesman.name + })) + }; +} + +export async function listQuotations( + organizationId: string, + filters: QuotationFilters +): Promise<{ items: QuotationListItem[]; totalItems: number }> { + const page = filters.page ?? 1; + const limit = filters.limit ?? 10; + const whereFilters = buildQuotationFilters(organizationId, filters); + const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters); + const offset = (page - 1) * limit; + + const [totalResult] = await db.select({ value: count() }).from(crmQuotations).where(where); + const rows = await db + .select() + .from(crmQuotations) + .where(where) + .orderBy(parseSort(filters.sort)) + .limit(limit) + .offset(offset); + + const customerIds = [...new Set(rows.map((row) => row.customerId))]; + const contactIds = [...new Set(rows.map((row) => row.contactId).filter(Boolean))] as string[]; + const enquiryIds = [...new Set(rows.map((row) => row.enquiryId).filter(Boolean))] as string[]; + const quotationIds = rows.map((row) => row.id); + + const [customers, contacts, enquiries, itemCounts] = await Promise.all([ + customerIds.length + ? db.select().from(crmCustomers).where(inArray(crmCustomers.id, customerIds)) + : [], + contactIds.length + ? db.select().from(crmCustomerContacts).where(inArray(crmCustomerContacts.id, contactIds)) + : [], + enquiryIds.length ? db.select().from(crmEnquiries).where(inArray(crmEnquiries.id, enquiryIds)) : [], + quotationIds.length + ? db + .select({ quotationId: crmQuotationItems.quotationId, value: count() }) + .from(crmQuotationItems) + .where( + and( + eq(crmQuotationItems.organizationId, organizationId), + inArray(crmQuotationItems.quotationId, quotationIds), + isNull(crmQuotationItems.deletedAt) + ) + ) + .groupBy(crmQuotationItems.quotationId) + : [] + ]); + + const customerMap = new Map(customers.map((item) => [item.id, item])); + const contactMap = new Map(contacts.map((item) => [item.id, item])); + const enquiryMap = new Map(enquiries.map((item) => [item.id, item])); + const itemCountMap = new Map(itemCounts.map((item) => [item.quotationId, item.value])); + + return { + items: rows.map((row) => ({ + ...mapQuotationRecord(row), + customerName: customerMap.get(row.customerId)?.name ?? 'Unknown customer', + contactName: row.contactId ? (contactMap.get(row.contactId)?.name ?? null) : null, + enquiryCode: row.enquiryId ? (enquiryMap.get(row.enquiryId)?.code ?? null) : null, + itemCount: itemCountMap.get(row.id) ?? 0, + revisionLabel: toRevisionLabel(row.revision) + })), + totalItems: totalResult?.value ?? 0 + }; +} + +export async function getQuotationDetail( + id: string, + organizationId: string +): Promise { + const quotation = await assertQuotationBelongsToOrganization(id, organizationId); + return mapQuotationRecord(quotation); +} + +export async function getQuotationActivity( + id: string, + organizationId: string +): Promise { + const auditLogs = await listAuditLogs({ + organizationId, + entityId: id, + limit: 50 + }); + const filtered = auditLogs.filter( + (log) => + log.entityType === 'crm_quotation' || + log.entityType === 'crm_quotation_item' || + log.entityType === 'crm_quotation_customer' || + log.entityType === 'crm_quotation_topic' || + log.entityType === 'crm_quotation_followup' || + log.entityType === 'crm_quotation_attachment' + ); + const userIds = [...new Set(filtered.map((log) => log.userId))]; + const actorRows = userIds.length + ? await db.select({ id: users.id, name: users.name }).from(users).where(inArray(users.id, userIds)) + : []; + const actorMap = new Map(actorRows.map((row) => [row.id, row.name])); + + return filtered.map((log) => ({ + id: log.id, + action: log.action, + entityType: log.entityType, + entityId: log.entityId, + createdAt: log.createdAt, + userId: log.userId, + actorName: actorMap.get(log.userId) ?? null + })); +} + +export async function createQuotation( + organizationId: string, + userId: string, + payload: QuotationMutationPayload +) { + await validateQuotationPayload(organizationId, payload); + + const enquiry = payload.enquiryId + ? await assertEnquiryBelongsToOrganization(payload.enquiryId, organizationId) + : null; + const documentCode = await generateNextDocumentCode({ + organizationId, + documentType: 'quotation', + branchId: payload.branchId ?? enquiry?.branchId ?? '' + }); + + const [created] = await db + .insert(crmQuotations) + .values({ + id: crypto.randomUUID(), + organizationId, + branchId: payload.branchId ?? enquiry?.branchId ?? null, + code: documentCode.code, + enquiryId: payload.enquiryId ?? null, + customerId: payload.customerId, + contactId: payload.contactId ?? enquiry?.contactId ?? null, + quotationDate: new Date(payload.quotationDate), + validUntil: payload.validUntil ? new Date(payload.validUntil) : null, + quotationType: payload.quotationType, + projectName: payload.projectName?.trim() || enquiry?.projectName || null, + projectLocation: payload.projectLocation?.trim() || enquiry?.projectLocation || null, + attention: payload.attention?.trim() || null, + reference: payload.reference?.trim() || null, + notes: payload.notes?.trim() || null, + status: payload.status, + revision: 0, + revisionRemark: payload.revisionRemark?.trim() || null, + currency: payload.currency, + exchangeRate: payload.exchangeRate ?? 1, + discount: payload.discount ?? 0, + discountType: payload.discountType ?? null, + taxRate: payload.taxRate ?? 0, + chancePercent: payload.chancePercent ?? enquiry?.chancePercent ?? null, + isHotProject: payload.isHotProject ?? enquiry?.isHotProject ?? false, + competitor: payload.competitor?.trim() || enquiry?.competitor || null, + salesmanId: payload.salesmanId ?? null, + isSent: false, + sentVia: payload.sentVia ?? null, + isActive: payload.isActive ?? true, + createdBy: userId, + updatedBy: userId + }) + .returning(); + + await db.insert(crmQuotationCustomers).values({ + id: crypto.randomUUID(), + organizationId, + quotationId: created.id, + customerId: created.customerId, + role: 'owner', + isPrimary: true + }); + + return created; +} + +export async function updateQuotation( + id: string, + organizationId: string, + userId: string, + payload: QuotationMutationPayload +) { + await validateQuotationPayload(organizationId, payload); + const existing = await assertQuotationBelongsToOrganization(id, organizationId); + const enquiry = payload.enquiryId + ? await assertEnquiryBelongsToOrganization(payload.enquiryId, organizationId) + : null; + + const [updated] = await db + .update(crmQuotations) + .set({ + branchId: payload.branchId ?? enquiry?.branchId ?? null, + enquiryId: payload.enquiryId ?? null, + customerId: payload.customerId, + contactId: payload.contactId ?? enquiry?.contactId ?? null, + quotationDate: new Date(payload.quotationDate), + validUntil: payload.validUntil ? new Date(payload.validUntil) : null, + quotationType: payload.quotationType, + projectName: payload.projectName?.trim() || enquiry?.projectName || null, + projectLocation: payload.projectLocation?.trim() || enquiry?.projectLocation || null, + attention: payload.attention?.trim() || null, + reference: payload.reference?.trim() || null, + notes: payload.notes?.trim() || null, + status: payload.status, + revisionRemark: payload.revisionRemark?.trim() || existing.revisionRemark || null, + currency: payload.currency, + exchangeRate: payload.exchangeRate ?? 1, + discount: payload.discount ?? 0, + discountType: payload.discountType ?? null, + taxRate: payload.taxRate ?? 0, + chancePercent: payload.chancePercent ?? enquiry?.chancePercent ?? null, + isHotProject: payload.isHotProject ?? false, + competitor: payload.competitor?.trim() || enquiry?.competitor || null, + salesmanId: payload.salesmanId ?? null, + sentVia: payload.sentVia ?? null, + isActive: payload.isActive ?? true, + updatedAt: new Date(), + updatedBy: userId + }) + .where(eq(crmQuotations.id, id)) + .returning(); + + await refreshQuotationTotals(id, organizationId, userId); + return updated; +} + +export async function softDeleteQuotation(id: string, organizationId: string, userId: string) { + await assertQuotationBelongsToOrganization(id, organizationId); + const now = new Date(); + + const [updated] = await db + .update(crmQuotations) + .set({ + deletedAt: now, + updatedAt: now, + updatedBy: userId, + isActive: false + }) + .where(eq(crmQuotations.id, id)) + .returning(); + + await Promise.all([ + db + .update(crmQuotationItems) + .set({ deletedAt: now, updatedAt: now }) + .where(and(eq(crmQuotationItems.quotationId, id), eq(crmQuotationItems.organizationId, organizationId))), + db + .update(crmQuotationCustomers) + .set({ deletedAt: now, updatedAt: now }) + .where(and(eq(crmQuotationCustomers.quotationId, id), eq(crmQuotationCustomers.organizationId, organizationId))), + db + .update(crmQuotationTopics) + .set({ deletedAt: now, updatedAt: now }) + .where(and(eq(crmQuotationTopics.quotationId, id), eq(crmQuotationTopics.organizationId, organizationId))), + db + .update(crmQuotationFollowups) + .set({ deletedAt: now, updatedAt: now, updatedBy: userId }) + .where(and(eq(crmQuotationFollowups.quotationId, id), eq(crmQuotationFollowups.organizationId, organizationId))), + db + .update(crmQuotationAttachments) + .set({ deletedAt: now, updatedAt: now }) + .where(and(eq(crmQuotationAttachments.quotationId, id), eq(crmQuotationAttachments.organizationId, organizationId))) + ]); + + return updated; +} + +export async function listQuotationItems(id: string, organizationId: string) { + await assertQuotationBelongsToOrganization(id, organizationId); + const rows = await db + .select() + .from(crmQuotationItems) + .where( + and( + eq(crmQuotationItems.quotationId, id), + eq(crmQuotationItems.organizationId, organizationId), + isNull(crmQuotationItems.deletedAt) + ) + ) + .orderBy(asc(crmQuotationItems.sortOrder), asc(crmQuotationItems.itemNumber)); + + return rows.map(mapQuotationItemRecord); +} + +export async function createQuotationItem( + quotationId: string, + organizationId: string, + userId: string, + payload: QuotationItemMutationPayload +) { + await assertQuotationBelongsToOrganization(quotationId, organizationId); + await validateQuotationItemPayload(organizationId, payload); + const existingItems = await listQuotationItems(quotationId, organizationId); + + const [created] = await db + .insert(crmQuotationItems) + .values({ + id: crypto.randomUUID(), + organizationId, + quotationId, + itemNumber: existingItems.length + 1, + productType: payload.productType, + description: payload.description.trim(), + quantity: payload.quantity, + unit: payload.unit?.trim() || null, + unitPrice: payload.unitPrice, + discount: payload.discount ?? 0, + discountType: payload.discountType ?? null, + taxRate: payload.taxRate ?? 0, + totalPrice: calculateLineTotal(payload), + notes: payload.notes?.trim() || null, + sortOrder: payload.sortOrder ?? existingItems.length + }) + .returning(); + + await refreshQuotationTotals(quotationId, organizationId, userId); + return created; +} + +export async function updateQuotationItem( + quotationId: string, + itemId: string, + organizationId: string, + userId: string, + payload: QuotationItemMutationPayload +) { + await validateQuotationItemPayload(organizationId, payload); + await assertQuotationItemBelongsToQuotation(quotationId, itemId, organizationId); + + const [updated] = await db + .update(crmQuotationItems) + .set({ + productType: payload.productType, + description: payload.description.trim(), + quantity: payload.quantity, + unit: payload.unit?.trim() || null, + unitPrice: payload.unitPrice, + discount: payload.discount ?? 0, + discountType: payload.discountType ?? null, + taxRate: payload.taxRate ?? 0, + totalPrice: calculateLineTotal(payload), + notes: payload.notes?.trim() || null, + sortOrder: payload.sortOrder ?? 0, + updatedAt: new Date() + }) + .where(eq(crmQuotationItems.id, itemId)) + .returning(); + + await refreshQuotationTotals(quotationId, organizationId, userId); + return updated; +} + +export async function softDeleteQuotationItem( + quotationId: string, + itemId: string, + organizationId: string, + userId: string +) { + await assertQuotationItemBelongsToQuotation(quotationId, itemId, organizationId); + + const [updated] = await db + .update(crmQuotationItems) + .set({ + deletedAt: new Date(), + updatedAt: new Date() + }) + .where(eq(crmQuotationItems.id, itemId)) + .returning(); + + await refreshQuotationTotals(quotationId, organizationId, userId); + return updated; +} + +export async function listQuotationCustomers(id: string, organizationId: string) { + await assertQuotationBelongsToOrganization(id, organizationId); + const relations = await db + .select() + .from(crmQuotationCustomers) + .where( + and( + eq(crmQuotationCustomers.quotationId, id), + eq(crmQuotationCustomers.organizationId, organizationId), + isNull(crmQuotationCustomers.deletedAt) + ) + ) + .orderBy(desc(crmQuotationCustomers.isPrimary), asc(crmQuotationCustomers.role)); + const customerIds = [...new Set(relations.map((row) => row.customerId))]; + const customers = customerIds.length + ? await db.select().from(crmCustomers).where(inArray(crmCustomers.id, customerIds)) + : []; + const customerMap = new Map(customers.map((item) => [item.id, item])); + + return relations.map((row) => ({ + ...mapQuotationCustomerRecord(row), + customerName: customerMap.get(row.customerId)?.name ?? 'Unknown customer', + customerCode: customerMap.get(row.customerId)?.code ?? '-' + })); +} + +export async function createQuotationCustomer( + quotationId: string, + organizationId: string, + payload: QuotationCustomerMutationPayload +) { + await assertQuotationBelongsToOrganization(quotationId, organizationId); + await validateQuotationCustomerPayload(organizationId, payload); + + return db.transaction(async (tx) => { + if (payload.isPrimary) { + await tx + .update(crmQuotationCustomers) + .set({ isPrimary: false, updatedAt: new Date() }) + .where( + and( + eq(crmQuotationCustomers.quotationId, quotationId), + eq(crmQuotationCustomers.organizationId, organizationId), + isNull(crmQuotationCustomers.deletedAt) + ) + ); + } + + const [created] = await tx + .insert(crmQuotationCustomers) + .values({ + id: crypto.randomUUID(), + organizationId, + quotationId, + customerId: payload.customerId, + role: payload.role, + isPrimary: payload.isPrimary ?? false + }) + .returning(); + + return created; + }); +} + +export async function updateQuotationCustomer( + quotationId: string, + relationId: string, + organizationId: string, + payload: QuotationCustomerMutationPayload +) { + await validateQuotationCustomerPayload(organizationId, payload); + await assertQuotationCustomerBelongsToQuotation(quotationId, relationId, organizationId); + + return db.transaction(async (tx) => { + if (payload.isPrimary) { + await tx + .update(crmQuotationCustomers) + .set({ isPrimary: false, updatedAt: new Date() }) + .where( + and( + eq(crmQuotationCustomers.quotationId, quotationId), + eq(crmQuotationCustomers.organizationId, organizationId), + isNull(crmQuotationCustomers.deletedAt) + ) + ); + } + + const [updated] = await tx + .update(crmQuotationCustomers) + .set({ + customerId: payload.customerId, + role: payload.role, + isPrimary: payload.isPrimary ?? false, + updatedAt: new Date() + }) + .where(eq(crmQuotationCustomers.id, relationId)) + .returning(); + + return updated; + }); +} + +export async function softDeleteQuotationCustomer( + quotationId: string, + relationId: string, + organizationId: string +) { + await assertQuotationCustomerBelongsToQuotation(quotationId, relationId, organizationId); + + const [updated] = await db + .update(crmQuotationCustomers) + .set({ deletedAt: new Date(), updatedAt: new Date() }) + .where(eq(crmQuotationCustomers.id, relationId)) + .returning(); + + return updated; +} + +export async function listQuotationTopics(id: string, organizationId: string) { + await assertQuotationBelongsToOrganization(id, organizationId); + const topics = await db + .select() + .from(crmQuotationTopics) + .where( + and( + eq(crmQuotationTopics.quotationId, id), + eq(crmQuotationTopics.organizationId, organizationId), + isNull(crmQuotationTopics.deletedAt) + ) + ) + .orderBy(asc(crmQuotationTopics.sortOrder), asc(crmQuotationTopics.createdAt)); + const topicItems = await listQuotationTopicItemsByTopicIds( + topics.map((topic) => topic.id), + organizationId + ); + const topicItemMap = new Map(); + + for (const item of topicItems.map(mapQuotationTopicItemRecord)) { + const items = topicItemMap.get(item.topicId) ?? []; + items.push(item); + topicItemMap.set(item.topicId, items); + } + + return topics.map((topic) => ({ + id: topic.id, + organizationId: topic.organizationId, + quotationId: topic.quotationId, + topicType: topic.topicType, + title: topic.title, + sortOrder: topic.sortOrder, + createdAt: topic.createdAt.toISOString(), + updatedAt: topic.updatedAt.toISOString(), + deletedAt: topic.deletedAt?.toISOString() ?? null, + items: topicItemMap.get(topic.id) ?? [] + })); +} + +export async function createQuotationTopic( + quotationId: string, + organizationId: string, + payload: QuotationTopicMutationPayload +) { + await assertQuotationBelongsToOrganization(quotationId, organizationId); + await validateQuotationTopicPayload(organizationId, payload); + + return db.transaction(async (tx) => { + const [createdTopic] = await tx + .insert(crmQuotationTopics) + .values({ + id: crypto.randomUUID(), + organizationId, + quotationId, + topicType: payload.topicType, + title: payload.title.trim(), + sortOrder: payload.sortOrder ?? 0 + }) + .returning(); + + if (payload.items.length) { + await tx.insert(crmQuotationTopicItems).values( + payload.items.map((item, index) => ({ + id: crypto.randomUUID(), + organizationId, + topicId: createdTopic.id, + content: item.content.trim(), + sortOrder: item.sortOrder ?? index + })) + ); + } + + return createdTopic; + }); +} + +export async function updateQuotationTopic( + quotationId: string, + topicId: string, + organizationId: string, + payload: QuotationTopicMutationPayload +) { + await assertQuotationTopicBelongsToQuotation(quotationId, topicId, organizationId); + await validateQuotationTopicPayload(organizationId, payload); + + return db.transaction(async (tx) => { + const [updatedTopic] = await tx + .update(crmQuotationTopics) + .set({ + topicType: payload.topicType, + title: payload.title.trim(), + sortOrder: payload.sortOrder ?? 0, + updatedAt: new Date() + }) + .where(eq(crmQuotationTopics.id, topicId)) + .returning(); + + await tx + .update(crmQuotationTopicItems) + .set({ deletedAt: new Date(), updatedAt: new Date() }) + .where( + and( + eq(crmQuotationTopicItems.topicId, topicId), + eq(crmQuotationTopicItems.organizationId, organizationId), + isNull(crmQuotationTopicItems.deletedAt) + ) + ); + + if (payload.items.length) { + await tx.insert(crmQuotationTopicItems).values( + payload.items.map((item, index) => ({ + id: crypto.randomUUID(), + organizationId, + topicId, + content: item.content.trim(), + sortOrder: item.sortOrder ?? index + })) + ); + } + + return updatedTopic; + }); +} + +export async function softDeleteQuotationTopic( + quotationId: string, + topicId: string, + organizationId: string +) { + await assertQuotationTopicBelongsToQuotation(quotationId, topicId, organizationId); + const now = new Date(); + + const [updated] = await db + .update(crmQuotationTopics) + .set({ deletedAt: now, updatedAt: now }) + .where(eq(crmQuotationTopics.id, topicId)) + .returning(); + + await db + .update(crmQuotationTopicItems) + .set({ deletedAt: now, updatedAt: now }) + .where(and(eq(crmQuotationTopicItems.topicId, topicId), eq(crmQuotationTopicItems.organizationId, organizationId))); + + return updated; +} + +export async function listQuotationFollowups(id: string, organizationId: string) { + await assertQuotationBelongsToOrganization(id, organizationId); + const rows = await db + .select() + .from(crmQuotationFollowups) + .where( + and( + eq(crmQuotationFollowups.quotationId, id), + eq(crmQuotationFollowups.organizationId, organizationId), + isNull(crmQuotationFollowups.deletedAt) + ) + ) + .orderBy(desc(crmQuotationFollowups.followupDate), desc(crmQuotationFollowups.createdAt)); + + return rows.map(mapQuotationFollowupRecord); +} + +export async function createQuotationFollowup( + quotationId: string, + organizationId: string, + userId: string, + payload: QuotationFollowupMutationPayload +) { + await validateQuotationFollowupPayload(quotationId, organizationId, payload); + + const [created] = await db + .insert(crmQuotationFollowups) + .values({ + id: crypto.randomUUID(), + organizationId, + quotationId, + followupDate: new Date(payload.followupDate), + followupType: payload.followupType, + contactId: payload.contactId ?? null, + outcome: payload.outcome?.trim() || null, + notes: payload.notes?.trim() || null, + nextFollowupDate: payload.nextFollowupDate ? new Date(payload.nextFollowupDate) : null, + nextAction: payload.nextAction?.trim() || null, + createdBy: userId, + updatedBy: userId + }) + .returning(); + + return created; +} + +export async function updateQuotationFollowup( + quotationId: string, + followupId: string, + organizationId: string, + userId: string, + payload: QuotationFollowupMutationPayload +) { + await assertQuotationFollowupBelongsToQuotation(quotationId, followupId, organizationId); + await validateQuotationFollowupPayload(quotationId, organizationId, payload); + + const [updated] = await db + .update(crmQuotationFollowups) + .set({ + followupDate: new Date(payload.followupDate), + followupType: payload.followupType, + contactId: payload.contactId ?? null, + outcome: payload.outcome?.trim() || null, + notes: payload.notes?.trim() || null, + nextFollowupDate: payload.nextFollowupDate ? new Date(payload.nextFollowupDate) : null, + nextAction: payload.nextAction?.trim() || null, + updatedAt: new Date(), + updatedBy: userId + }) + .where(eq(crmQuotationFollowups.id, followupId)) + .returning(); + + return updated; +} + +export async function softDeleteQuotationFollowup( + quotationId: string, + followupId: string, + organizationId: string, + userId: string +) { + await assertQuotationFollowupBelongsToQuotation(quotationId, followupId, organizationId); + + const [updated] = await db + .update(crmQuotationFollowups) + .set({ deletedAt: new Date(), updatedAt: new Date(), updatedBy: userId }) + .where(eq(crmQuotationFollowups.id, followupId)) + .returning(); + + return updated; +} + +export async function listQuotationAttachments(id: string, organizationId: string) { + await assertQuotationBelongsToOrganization(id, organizationId); + const rows = await db + .select() + .from(crmQuotationAttachments) + .where( + and( + eq(crmQuotationAttachments.quotationId, id), + eq(crmQuotationAttachments.organizationId, organizationId), + isNull(crmQuotationAttachments.deletedAt) + ) + ) + .orderBy(desc(crmQuotationAttachments.uploadedAt)); + + return rows.map(mapQuotationAttachmentRecord); +} + +export async function createQuotationAttachment( + quotationId: string, + organizationId: string, + userId: string, + payload: QuotationAttachmentMutationPayload +) { + await assertQuotationBelongsToOrganization(quotationId, organizationId); + + const [created] = await db + .insert(crmQuotationAttachments) + .values({ + id: crypto.randomUUID(), + organizationId, + quotationId, + fileName: payload.fileName.trim(), + originalFileName: payload.originalFileName.trim(), + filePath: payload.filePath.trim(), + fileSize: payload.fileSize ?? null, + fileType: payload.fileType ?? null, + description: payload.description?.trim() || null, + uploadedBy: userId + }) + .returning(); + + return created; +} + +export async function updateQuotationAttachment( + quotationId: string, + attachmentId: string, + organizationId: string, + payload: QuotationAttachmentMutationPayload +) { + await assertQuotationAttachmentBelongsToQuotation(quotationId, attachmentId, organizationId); + + const [updated] = await db + .update(crmQuotationAttachments) + .set({ + fileName: payload.fileName.trim(), + originalFileName: payload.originalFileName.trim(), + filePath: payload.filePath.trim(), + fileSize: payload.fileSize ?? null, + fileType: payload.fileType ?? null, + description: payload.description?.trim() || null, + updatedAt: new Date() + }) + .where(eq(crmQuotationAttachments.id, attachmentId)) + .returning(); + + return updated; +} + +export async function softDeleteQuotationAttachment( + quotationId: string, + attachmentId: string, + organizationId: string +) { + await assertQuotationAttachmentBelongsToQuotation(quotationId, attachmentId, organizationId); + + const [updated] = await db + .update(crmQuotationAttachments) + .set({ deletedAt: new Date(), updatedAt: new Date() }) + .where(eq(crmQuotationAttachments.id, attachmentId)) + .returning(); + + return updated; +} + +export async function createQuotationRevision( + quotationId: string, + organizationId: string, + userId: string, + revisionRemark?: string +) { + const parent = await assertQuotationBelongsToOrganization(quotationId, organizationId); + const familyRootId = parent.parentQuotationId ?? parent.id; + const familyRows = await db + .select() + .from(crmQuotations) + .where( + and( + eq(crmQuotations.organizationId, organizationId), + isNull(crmQuotations.deletedAt), + or(eq(crmQuotations.id, familyRootId), eq(crmQuotations.parentQuotationId, familyRootId))! + ) + ) + .orderBy(desc(crmQuotations.revision)); + const nextRevision = (familyRows[0]?.revision ?? parent.revision) + 1; + const currentItems = await listQuotationItems(quotationId, organizationId); + const currentCustomers = await listQuotationCustomers(quotationId, organizationId); + const currentTopics = await listQuotationTopics(quotationId, organizationId); + const documentCode = await generateNextDocumentCode({ + organizationId, + documentType: 'quotation', + branchId: parent.branchId ?? '' + }); + + return db.transaction(async (tx) => { + const [created] = await tx + .insert(crmQuotations) + .values({ + id: crypto.randomUUID(), + organizationId, + branchId: parent.branchId, + code: documentCode.code, + enquiryId: parent.enquiryId, + customerId: parent.customerId, + contactId: parent.contactId, + quotationDate: new Date(), + validUntil: parent.validUntil, + quotationType: parent.quotationType, + projectName: parent.projectName, + projectLocation: parent.projectLocation, + attention: parent.attention, + reference: parent.reference, + notes: parent.notes, + status: parent.status, + revision: nextRevision, + parentQuotationId: familyRootId, + revisionRemark: revisionRemark?.trim() || null, + currency: parent.currency, + exchangeRate: parent.exchangeRate, + subtotal: parent.subtotal, + discount: parent.discount, + discountType: parent.discountType, + taxRate: parent.taxRate, + taxAmount: parent.taxAmount, + totalAmount: parent.totalAmount, + chancePercent: parent.chancePercent, + isHotProject: parent.isHotProject, + competitor: parent.competitor, + salesmanId: parent.salesmanId, + isSent: false, + sentVia: parent.sentVia, + isActive: parent.isActive, + createdBy: userId, + updatedBy: userId + }) + .returning(); + + if (currentItems.length) { + await tx.insert(crmQuotationItems).values( + currentItems.map((item) => ({ + id: crypto.randomUUID(), + organizationId, + quotationId: created.id, + itemNumber: item.itemNumber, + productType: item.productType, + description: item.description, + quantity: item.quantity, + unit: item.unit, + unitPrice: item.unitPrice, + discount: item.discount, + discountType: item.discountType, + taxRate: item.taxRate, + totalPrice: item.totalPrice, + notes: item.notes, + sortOrder: item.sortOrder + })) + ); + } + + if (currentCustomers.length) { + await tx.insert(crmQuotationCustomers).values( + currentCustomers.map((item) => ({ + id: crypto.randomUUID(), + organizationId, + quotationId: created.id, + customerId: item.customerId, + role: item.role, + isPrimary: item.isPrimary + })) + ); + } + + for (const topic of currentTopics) { + const [createdTopic] = await tx + .insert(crmQuotationTopics) + .values({ + id: crypto.randomUUID(), + organizationId, + quotationId: created.id, + topicType: topic.topicType, + title: topic.title, + sortOrder: topic.sortOrder + }) + .returning(); + + if (topic.items.length) { + await tx.insert(crmQuotationTopicItems).values( + topic.items.map((item) => ({ + id: crypto.randomUUID(), + organizationId, + topicId: createdTopic.id, + content: item.content, + sortOrder: item.sortOrder + })) + ); + } + } + + return created; + }); +} + +export async function listQuotationRevisions(id: string, organizationId: string) { + const quotation = await assertQuotationBelongsToOrganization(id, organizationId); + const familyRootId = quotation.parentQuotationId ?? quotation.id; + const rows = await db + .select() + .from(crmQuotations) + .where( + and( + eq(crmQuotations.organizationId, organizationId), + isNull(crmQuotations.deletedAt), + or(eq(crmQuotations.id, familyRootId), eq(crmQuotations.parentQuotationId, familyRootId))! + ) + ) + .orderBy(asc(crmQuotations.revision), asc(crmQuotations.createdAt)); + + return rows.map((row) => ({ + id: row.id, + code: row.code, + status: row.status, + quotationType: row.quotationType, + totalAmount: row.totalAmount, + updatedAt: row.updatedAt.toISOString() + })); +} + +export async function listEnquiryQuotationRelations( + enquiryId: string, + organizationId: string +): Promise { + const rows = await db + .select() + .from(crmQuotations) + .where( + and( + eq(crmQuotations.organizationId, organizationId), + eq(crmQuotations.enquiryId, enquiryId), + isNull(crmQuotations.deletedAt) + ) + ) + .orderBy(desc(crmQuotations.updatedAt)); + + return rows.map((row) => ({ + id: row.id, + code: row.code, + status: row.status, + quotationType: row.quotationType, + totalAmount: row.totalAmount, + updatedAt: row.updatedAt.toISOString() + })); +} + +export async function listCustomerQuotationRelations( + customerId: string, + organizationId: string +): Promise { + const rows = await db + .select() + .from(crmQuotations) + .where( + and( + eq(crmQuotations.organizationId, organizationId), + eq(crmQuotations.customerId, customerId), + isNull(crmQuotations.deletedAt) + ) + ) + .orderBy(desc(crmQuotations.updatedAt)); + + return rows.map((row) => ({ + id: row.id, + code: row.code, + status: row.status, + quotationType: row.quotationType, + totalAmount: row.totalAmount, + updatedAt: row.updatedAt.toISOString() + })); +} diff --git a/src/lib/auth/rbac.ts b/src/lib/auth/rbac.ts index e406f17..45d24f6 100644 --- a/src/lib/auth/rbac.ts +++ b/src/lib/auth/rbac.ts @@ -34,7 +34,17 @@ export const PERMISSIONS = { crmEnquiryFollowupRead: 'crm.enquiry.followup.read', crmEnquiryFollowupCreate: 'crm.enquiry.followup.create', crmEnquiryFollowupUpdate: 'crm.enquiry.followup.update', - crmEnquiryFollowupDelete: 'crm.enquiry.followup.delete' + crmEnquiryFollowupDelete: 'crm.enquiry.followup.delete', + crmQuotationRead: 'crm.quotation.read', + crmQuotationCreate: 'crm.quotation.create', + crmQuotationUpdate: 'crm.quotation.update', + crmQuotationDelete: 'crm.quotation.delete', + crmQuotationItemManage: 'crm.quotation.item.manage', + crmQuotationCustomerManage: 'crm.quotation.customer.manage', + crmQuotationTopicManage: 'crm.quotation.topic.manage', + crmQuotationFollowupManage: 'crm.quotation.followup.manage', + crmQuotationAttachmentManage: 'crm.quotation.attachment.manage', + crmQuotationRevisionCreate: 'crm.quotation.revision.create' } as const; export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS]; @@ -61,7 +71,17 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] { PERMISSIONS.crmEnquiryFollowupRead, PERMISSIONS.crmEnquiryFollowupCreate, PERMISSIONS.crmEnquiryFollowupUpdate, - PERMISSIONS.crmEnquiryFollowupDelete + PERMISSIONS.crmEnquiryFollowupDelete, + PERMISSIONS.crmQuotationRead, + PERMISSIONS.crmQuotationCreate, + PERMISSIONS.crmQuotationUpdate, + PERMISSIONS.crmQuotationDelete, + PERMISSIONS.crmQuotationItemManage, + PERMISSIONS.crmQuotationCustomerManage, + PERMISSIONS.crmQuotationTopicManage, + PERMISSIONS.crmQuotationFollowupManage, + PERMISSIONS.crmQuotationAttachmentManage, + PERMISSIONS.crmQuotationRevisionCreate ]; } @@ -70,7 +90,8 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] { PERMISSIONS.crmCustomerRead, PERMISSIONS.crmContactRead, PERMISSIONS.crmEnquiryRead, - PERMISSIONS.crmEnquiryFollowupRead + PERMISSIONS.crmEnquiryFollowupRead, + PERMISSIONS.crmQuotationRead ]; } diff --git a/src/lib/searchparams.ts b/src/lib/searchparams.ts index d5d711d..54b441e 100644 --- a/src/lib/searchparams.ts +++ b/src/lib/searchparams.ts @@ -14,6 +14,7 @@ export const searchParams = { customerType: parseAsString, customerStatus: parseAsString, customer: parseAsString, + enquiry: parseAsString, branch: parseAsString, productType: parseAsString, priority: parseAsString,
+ {customer?.name ?? 'Unknown customer'} + {contact ? ` - ${contact.name}` : ''} +