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} + /> + + + + + + + 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, 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, taxRate: e.target.value }))} type='number' placeholder='Tax rate %' /> +
+