task-e
This commit is contained in:
153
docs/implementation/task-e-quotation-production.md
Normal file
153
docs/implementation/task-e-quotation-production.md
Normal file
@@ -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`
|
||||||
117
docs/implementation/technical-debt.md
Normal file
117
docs/implementation/technical-debt.md
Normal file
@@ -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
|
||||||
138
drizzle/0004_worthless_ender_wiggin.sql
Normal file
138
drizzle/0004_worthless_ender_wiggin.sql
Normal file
@@ -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");
|
||||||
2133
drizzle/meta/0004_snapshot.json
Normal file
2133
drizzle/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,13 @@
|
|||||||
"when": 1781505431077,
|
"when": 1781505431077,
|
||||||
"tag": "0003_blushing_bruce_banner",
|
"tag": "0003_blushing_bruce_banner",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781511534267,
|
||||||
|
"tag": "0004_worthless_ender_wiggin",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
537
plans/task-e.md
Normal file
537
plans/task-e.md
Normal file
@@ -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 เต็ม
|
||||||
204
src/app/api/crm/quotations/[id]/attachments/route.ts
Normal file
204
src/app/api/crm/quotations/[id]/attachments/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
204
src/app/api/crm/quotations/[id]/customers/route.ts
Normal file
204
src/app/api/crm/quotations/[id]/customers/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
215
src/app/api/crm/quotations/[id]/followups/route.ts
Normal file
215
src/app/api/crm/quotations/[id]/followups/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
109
src/app/api/crm/quotations/[id]/items/[itemId]/route.ts
Normal file
109
src/app/api/crm/quotations/[id]/items/[itemId]/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
89
src/app/api/crm/quotations/[id]/items/route.ts
Normal file
89
src/app/api/crm/quotations/[id]/items/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
94
src/app/api/crm/quotations/[id]/revisions/route.ts
Normal file
94
src/app/api/crm/quotations/[id]/revisions/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
128
src/app/api/crm/quotations/[id]/route.ts
Normal file
128
src/app/api/crm/quotations/[id]/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
202
src/app/api/crm/quotations/[id]/topics/route.ts
Normal file
202
src/app/api/crm/quotations/[id]/topics/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
104
src/app/api/crm/quotations/route.ts
Normal file
104
src/app/api/crm/quotations/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { CustomerDetail } from '@/features/crm/customers/components/customer-det
|
|||||||
import { customerByIdOptions, customerContactsOptions } from '@/features/crm/customers/api/queries';
|
import { customerByIdOptions, customerContactsOptions } from '@/features/crm/customers/api/queries';
|
||||||
import { getCustomerReferenceData } from '@/features/crm/customers/server/service';
|
import { getCustomerReferenceData } from '@/features/crm/customers/server/service';
|
||||||
import { listCustomerEnquiryRelations } from '@/features/crm/enquiries/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 { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
import { getQueryClient } from '@/lib/query-client';
|
import { getQueryClient } from '@/lib/query-client';
|
||||||
|
|
||||||
@@ -55,6 +56,10 @@ export default async function CustomerDetailRoute({ params }: PageProps) {
|
|||||||
canRead && session?.user?.activeOrganizationId
|
canRead && session?.user?.activeOrganizationId
|
||||||
? await listCustomerEnquiryRelations(id, session.user.activeOrganizationId)
|
? await listCustomerEnquiryRelations(id, session.user.activeOrganizationId)
|
||||||
: [];
|
: [];
|
||||||
|
const relatedQuotations =
|
||||||
|
canRead && session?.user?.activeOrganizationId
|
||||||
|
? await listCustomerQuotationRelations(id, session.user.activeOrganizationId)
|
||||||
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer
|
<PageContainer
|
||||||
@@ -79,6 +84,7 @@ export default async function CustomerDetailRoute({ params }: PageProps) {
|
|||||||
delete: canContactDelete
|
delete: canContactDelete
|
||||||
}}
|
}}
|
||||||
relatedEnquiries={relatedEnquiries}
|
relatedEnquiries={relatedEnquiries}
|
||||||
|
relatedQuotations={relatedQuotations}
|
||||||
/>
|
/>
|
||||||
</HydrationBoundary>
|
</HydrationBoundary>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import PageContainer from '@/components/layout/page-container';
|
|||||||
import { enquiryByIdOptions, enquiryFollowupsOptions } from '@/features/crm/enquiries/api/queries';
|
import { enquiryByIdOptions, enquiryFollowupsOptions } from '@/features/crm/enquiries/api/queries';
|
||||||
import { EnquiryDetail } from '@/features/crm/enquiries/components/enquiry-detail';
|
import { EnquiryDetail } from '@/features/crm/enquiries/components/enquiry-detail';
|
||||||
import { getEnquiryReferenceData } from '@/features/crm/enquiries/server/service';
|
import { getEnquiryReferenceData } from '@/features/crm/enquiries/server/service';
|
||||||
|
import { listEnquiryQuotationRelations } from '@/features/crm/quotations/server/service';
|
||||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
import { getQueryClient } from '@/lib/query-client';
|
import { getQueryClient } from '@/lib/query-client';
|
||||||
|
|
||||||
@@ -50,6 +51,10 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
|
|||||||
canRead && session?.user?.activeOrganizationId
|
canRead && session?.user?.activeOrganizationId
|
||||||
? await getEnquiryReferenceData(session.user.activeOrganizationId)
|
? await getEnquiryReferenceData(session.user.activeOrganizationId)
|
||||||
: null;
|
: null;
|
||||||
|
const relatedQuotations =
|
||||||
|
canRead && session?.user?.activeOrganizationId
|
||||||
|
? await listEnquiryQuotationRelations(id, session.user.activeOrganizationId)
|
||||||
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer
|
<PageContainer
|
||||||
@@ -67,6 +72,7 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
|
|||||||
<EnquiryDetail
|
<EnquiryDetail
|
||||||
enquiryId={id}
|
enquiryId={id}
|
||||||
referenceData={referenceData}
|
referenceData={referenceData}
|
||||||
|
relatedQuotations={relatedQuotations}
|
||||||
canUpdate={canUpdate}
|
canUpdate={canUpdate}
|
||||||
canManageFollowups={{
|
canManageFollowups={{
|
||||||
create: canFollowupCreate,
|
create: canFollowupCreate,
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
|
import { auth } from '@/auth';
|
||||||
|
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
||||||
import PageContainer from '@/components/layout/page-container';
|
import PageContainer from '@/components/layout/page-container';
|
||||||
import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder';
|
import {
|
||||||
|
quotationAttachmentsOptions,
|
||||||
|
quotationByIdOptions,
|
||||||
|
quotationCustomersOptions,
|
||||||
|
quotationFollowupsOptions,
|
||||||
|
quotationItemsOptions,
|
||||||
|
quotationRevisionsOptions,
|
||||||
|
quotationTopicsOptions
|
||||||
|
} from '@/features/crm/quotations/api/queries';
|
||||||
|
import { QuotationDetail } from '@/features/crm/quotations/components/quotation-detail';
|
||||||
|
import { getQuotationReferenceData } from '@/features/crm/quotations/server/service';
|
||||||
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
|
import { getQueryClient } from '@/lib/query-client';
|
||||||
|
|
||||||
type PageProps = {
|
type PageProps = {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
@@ -7,24 +21,90 @@ type PageProps = {
|
|||||||
|
|
||||||
export default async function QuotationDetailRoute({ params }: PageProps) {
|
export default async function QuotationDetailRoute({ params }: PageProps) {
|
||||||
const { id } = await params;
|
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 (
|
return (
|
||||||
<PageContainer
|
<PageContainer
|
||||||
pageTitle='Quotation Detail'
|
pageTitle='Quotation Detail'
|
||||||
pageDescription={`Production detail route placeholder for quotation ${id}.`}
|
pageDescription='Commercial detail, child records, revisions, and audit activity.'
|
||||||
|
access={canRead}
|
||||||
|
accessFallback={
|
||||||
|
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
|
||||||
|
You do not have access to this quotation.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<CrmProductionPlaceholder
|
{referenceData ? (
|
||||||
title='Quotation detail pending'
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
summary='This production detail route is isolated from the legacy quotation preview and approval mock flow.'
|
<QuotationDetail
|
||||||
foundationItems={[
|
quotationId={id}
|
||||||
'Document sequence helper',
|
referenceData={referenceData}
|
||||||
'Branch scope validation',
|
canUpdate={canUpdate}
|
||||||
'Master option lookups',
|
canManageItems={canManageItems}
|
||||||
'Audit trail foundation'
|
canManageCustomers={canManageCustomers}
|
||||||
]}
|
canManageTopics={canManageTopics}
|
||||||
nextStep='Implement production quotation detail with real approval, preview, and related-entity data.'
|
canManageFollowups={canManageFollowups}
|
||||||
demoHref={`/dashboard/crm-demo/quotations/${id}`}
|
canManageAttachments={canManageAttachments}
|
||||||
/>
|
canCreateRevision={canCreateRevision}
|
||||||
|
/>
|
||||||
|
</HydrationBoundary>
|
||||||
|
) : null}
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,68 @@
|
|||||||
|
import { auth } from '@/auth';
|
||||||
import PageContainer from '@/components/layout/page-container';
|
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 = {
|
export const metadata = {
|
||||||
title: 'Dashboard: CRM Quotations'
|
title: 'Dashboard: CRM Quotations'
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function QuotationsRoute() {
|
type PageProps = {
|
||||||
|
searchParams: Promise<SearchParams>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<PageContainer
|
<PageContainer
|
||||||
pageTitle='Quotations'
|
pageTitle='Quotations'
|
||||||
pageDescription='Production quotation flows will be built on foundation services, not on the legacy demo layer.'
|
pageDescription='Production quotation register with revisions, related parties, and server-calculated items.'
|
||||||
|
access={canRead}
|
||||||
|
accessFallback={
|
||||||
|
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
|
||||||
|
You do not have access to CRM quotations.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
pageHeaderAction={
|
||||||
|
canCreate && referenceData ? <QuotationFormSheetTrigger referenceData={referenceData} /> : null
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<CrmProductionPlaceholder
|
{referenceData ? (
|
||||||
title='Quotation module pending'
|
<QuotationListing referenceData={referenceData} canUpdate={canUpdate} canDelete={canDelete} />
|
||||||
summary='The mock quotation list, kanban, and detail experiences now live only under crm-demo.'
|
) : null}
|
||||||
foundationItems={[
|
|
||||||
'Quotation status master options',
|
|
||||||
'Server-side document sequence generation',
|
|
||||||
'Organization and branch context',
|
|
||||||
'Audit log helper for future status changes'
|
|
||||||
]}
|
|
||||||
nextStep='Implement quotation persistence and workflow after enquiry production APIs are ready.'
|
|
||||||
demoHref='/dashboard/crm-demo/quotations'
|
|
||||||
/>
|
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,49 +35,110 @@ import { NavGroup } from "@/types";
|
|||||||
*/
|
*/
|
||||||
export const navGroups: NavGroup[] = [
|
export const navGroups: NavGroup[] = [
|
||||||
{
|
{
|
||||||
label: "Overview",
|
label: 'Overview',
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: "Dashboard",
|
title: 'Dashboard',
|
||||||
url: "/dashboard",
|
url: '/dashboard',
|
||||||
icon: "dashboard",
|
icon: 'dashboard',
|
||||||
isActive: false,
|
isActive: false,
|
||||||
shortcut: ["d", "d"],
|
shortcut: ['d', 'd'],
|
||||||
items: [],
|
items: []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Workspaces",
|
title: 'Workspaces',
|
||||||
url: "/dashboard/workspaces",
|
url: '/dashboard/workspaces',
|
||||||
icon: "workspace",
|
icon: 'workspace',
|
||||||
isActive: false,
|
isActive: false,
|
||||||
items: [],
|
items: [],
|
||||||
access: { systemRole: "super_admin" },
|
access: { systemRole: 'super_admin' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Teams",
|
title: 'Teams',
|
||||||
url: "/dashboard/workspaces/team",
|
url: '/dashboard/workspaces/team',
|
||||||
icon: "teams",
|
icon: 'teams',
|
||||||
isActive: false,
|
isActive: false,
|
||||||
items: [],
|
items: [],
|
||||||
access: { requireOrg: true, role: "admin" },
|
access: { requireOrg: true, role: 'admin' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Users",
|
title: 'Users',
|
||||||
url: "/dashboard/users",
|
url: '/dashboard/users',
|
||||||
icon: "teams",
|
icon: 'teams',
|
||||||
shortcut: ["u", "u"],
|
shortcut: ['u', 'u'],
|
||||||
isActive: false,
|
isActive: false,
|
||||||
items: [],
|
items: [],
|
||||||
access: { requireOrg: true, permission: "users:manage" },
|
access: { requireOrg: true, permission: 'users:manage' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Kanban",
|
title: 'Kanban',
|
||||||
url: "/dashboard/kanban",
|
url: '/dashboard/kanban',
|
||||||
icon: "kanban",
|
icon: 'kanban',
|
||||||
shortcut: ["k", "k"],
|
shortcut: ['k', 'k'],
|
||||||
isActive: false,
|
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' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
146
src/db/schema.ts
146
src/db/schema.ts
@@ -246,3 +246,149 @@ export const crmEnquiryFollowups = pgTable('crm_enquiry_followups', {
|
|||||||
createdBy: text('created_by').notNull(),
|
createdBy: text('created_by').notNull(),
|
||||||
updatedBy: text('updated_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 })
|
||||||
|
});
|
||||||
|
|||||||
@@ -121,6 +121,11 @@ const FOUNDATION_OPTIONS = {
|
|||||||
{ code: 'cancelled', label: 'Cancelled', value: 'cancelled', sortOrder: 8 },
|
{ code: 'cancelled', label: 'Cancelled', value: 'cancelled', sortOrder: 8 },
|
||||||
{ code: 'revised', label: 'Revised', value: 'revised', sortOrder: 9 }
|
{ 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: [
|
crm_product_type: [
|
||||||
{ code: 'crane', label: 'Crane', value: 'crane', sortOrder: 1 },
|
{ code: 'crane', label: 'Crane', value: 'crane', sortOrder: 1 },
|
||||||
{ code: 'dockdoor', label: 'Dock Door', value: 'dockdoor', sortOrder: 2 },
|
{ 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: 'USD', label: 'USD', value: 'USD', sortOrder: 2 },
|
||||||
{ code: 'EUR', label: 'EUR', value: 'EUR', sortOrder: 3 }
|
{ 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: [
|
crm_payment_term: [
|
||||||
{ code: 'cash', label: 'Cash', value: 'cash', sortOrder: 1 },
|
{ code: 'cash', label: 'Cash', value: 'cash', sortOrder: 1 },
|
||||||
{ code: 'credit_30', label: 'Credit 30', value: 'credit_30', sortOrder: 2 },
|
{ code: 'credit_30', label: 'Credit 30', value: 'credit_30', sortOrder: 2 },
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { CustomerFormSheet } from './customer-form-sheet';
|
|||||||
import { CustomerContactsTab } from './customer-contacts-tab';
|
import { CustomerContactsTab } from './customer-contacts-tab';
|
||||||
import { CustomerStatusBadge } from './customer-status-badge';
|
import { CustomerStatusBadge } from './customer-status-badge';
|
||||||
import type { CustomerRelatedEnquiryItem } from '../api/types';
|
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 }) {
|
function FieldItem({ label, value }: { label: string; value: string | null | undefined }) {
|
||||||
return (
|
return (
|
||||||
@@ -30,7 +31,8 @@ export function CustomerDetail({
|
|||||||
referenceData,
|
referenceData,
|
||||||
canUpdate,
|
canUpdate,
|
||||||
canManageContacts,
|
canManageContacts,
|
||||||
relatedEnquiries
|
relatedEnquiries,
|
||||||
|
relatedQuotations
|
||||||
}: {
|
}: {
|
||||||
customerId: string;
|
customerId: string;
|
||||||
referenceData: CustomerReferenceData;
|
referenceData: CustomerReferenceData;
|
||||||
@@ -41,6 +43,7 @@ export function CustomerDetail({
|
|||||||
delete: boolean;
|
delete: boolean;
|
||||||
};
|
};
|
||||||
relatedEnquiries: CustomerRelatedEnquiryItem[];
|
relatedEnquiries: CustomerRelatedEnquiryItem[];
|
||||||
|
relatedQuotations: QuotationRelationItem[];
|
||||||
}) {
|
}) {
|
||||||
const { data } = useSuspenseQuery(customerByIdOptions(customerId));
|
const { data } = useSuspenseQuery(customerByIdOptions(customerId));
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
@@ -215,14 +218,13 @@ export function CustomerDetail({
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Related Documents</CardTitle>
|
<CardTitle>Related Documents</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Production enquiry links are now visible here, while quotation and approval
|
Production enquiry and quotation links for this customer.
|
||||||
modules stay deferred.
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{!relatedEnquiries.length ? (
|
{!relatedEnquiries.length && !relatedQuotations.length ? (
|
||||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
||||||
No enquiries have been linked to this customer yet.
|
No enquiries or quotations have been linked to this customer yet.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className='space-y-3'>
|
<div className='space-y-3'>
|
||||||
@@ -243,6 +245,23 @@ export function CustomerDetail({
|
|||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
{relatedQuotations.map((quotation) => (
|
||||||
|
<Link
|
||||||
|
key={quotation.id}
|
||||||
|
href={`/dashboard/crm/quotations/${quotation.id}`}
|
||||||
|
className='block rounded-lg border p-4 transition-colors hover:bg-muted/40'
|
||||||
|
>
|
||||||
|
<div className='flex items-center justify-between gap-3'>
|
||||||
|
<div className='space-y-1'>
|
||||||
|
<div className='font-medium'>{quotation.code}</div>
|
||||||
|
<div className='text-muted-foreground text-xs'>
|
||||||
|
{quotation.totalAmount.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Icons.arrowRight className='text-muted-foreground h-4 w-4' />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Separator } from '@/components/ui/separator';
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { enquiryByIdOptions } from '../api/queries';
|
import { enquiryByIdOptions } from '../api/queries';
|
||||||
import type { EnquiryReferenceData } from '../api/types';
|
import type { EnquiryReferenceData } from '../api/types';
|
||||||
|
import type { QuotationRelationItem } from '@/features/crm/quotations/api/types';
|
||||||
import { EnquiryFormSheet } from './enquiry-form-sheet';
|
import { EnquiryFormSheet } from './enquiry-form-sheet';
|
||||||
import { EnquiryFollowupsTab } from './enquiry-followups-tab';
|
import { EnquiryFollowupsTab } from './enquiry-followups-tab';
|
||||||
import { EnquiryStatusBadge } from './enquiry-status-badge';
|
import { EnquiryStatusBadge } from './enquiry-status-badge';
|
||||||
@@ -27,11 +28,13 @@ function FieldItem({ label, value }: { label: string; value: string | null | und
|
|||||||
export function EnquiryDetail({
|
export function EnquiryDetail({
|
||||||
enquiryId,
|
enquiryId,
|
||||||
referenceData,
|
referenceData,
|
||||||
|
relatedQuotations,
|
||||||
canUpdate,
|
canUpdate,
|
||||||
canManageFollowups
|
canManageFollowups
|
||||||
}: {
|
}: {
|
||||||
enquiryId: string;
|
enquiryId: string;
|
||||||
referenceData: EnquiryReferenceData;
|
referenceData: EnquiryReferenceData;
|
||||||
|
relatedQuotations: QuotationRelationItem[];
|
||||||
canUpdate: boolean;
|
canUpdate: boolean;
|
||||||
canManageFollowups: {
|
canManageFollowups: {
|
||||||
create: boolean;
|
create: boolean;
|
||||||
@@ -245,14 +248,35 @@ export function EnquiryDetail({
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Related Quotations</CardTitle>
|
<CardTitle>Related Quotations</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Task D ends at enquiry readiness, so quotation production stays
|
Production quotations already linked to this enquiry.
|
||||||
intentionally deferred.
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
{!relatedQuotations.length ? (
|
||||||
Quotation linkage will land here in Task E.
|
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
||||||
</div>
|
No quotations have been linked to this enquiry yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className='space-y-3'>
|
||||||
|
{relatedQuotations.map((quotation) => (
|
||||||
|
<Link
|
||||||
|
key={quotation.id}
|
||||||
|
href={`/dashboard/crm/quotations/${quotation.id}`}
|
||||||
|
className='block rounded-lg border p-4 transition-colors hover:bg-muted/40'
|
||||||
|
>
|
||||||
|
<div className='flex items-center justify-between gap-3'>
|
||||||
|
<div className='space-y-1'>
|
||||||
|
<div className='font-medium'>{quotation.code}</div>
|
||||||
|
<div className='text-muted-foreground text-sm'>
|
||||||
|
{quotation.totalAmount.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Icons.arrowRight className='text-muted-foreground h-4 w-4' />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -178,13 +178,17 @@ async function assertMasterOptionValue(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function assertCustomerBelongsToOrganization(id: string, organizationId: string) {
|
export async function assertCustomerBelongsToOrganization(id: string, organizationId: string) {
|
||||||
const customer = await db.query.crmCustomers.findFirst({
|
const [customer] = await db
|
||||||
where: and(
|
.select()
|
||||||
eq(crmCustomers.id, id),
|
.from(crmCustomers)
|
||||||
eq(crmCustomers.organizationId, organizationId),
|
.where(
|
||||||
isNull(crmCustomers.deletedAt)
|
and(
|
||||||
|
eq(crmCustomers.id, id),
|
||||||
|
eq(crmCustomers.organizationId, organizationId),
|
||||||
|
isNull(crmCustomers.deletedAt)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
});
|
.limit(1);
|
||||||
|
|
||||||
if (!customer) {
|
if (!customer) {
|
||||||
throw new AuthError('Customer not found', 404);
|
throw new AuthError('Customer not found', 404);
|
||||||
@@ -198,14 +202,18 @@ export async function assertContactBelongsToOrganization(
|
|||||||
organizationId: string,
|
organizationId: string,
|
||||||
customerId?: string | null
|
customerId?: string | null
|
||||||
) {
|
) {
|
||||||
const contact = await db.query.crmCustomerContacts.findFirst({
|
const [contact] = await db
|
||||||
where: and(
|
.select()
|
||||||
eq(crmCustomerContacts.id, contactId),
|
.from(crmCustomerContacts)
|
||||||
eq(crmCustomerContacts.organizationId, organizationId),
|
.where(
|
||||||
isNull(crmCustomerContacts.deletedAt),
|
and(
|
||||||
...(customerId ? [eq(crmCustomerContacts.customerId, customerId)] : [])
|
eq(crmCustomerContacts.id, contactId),
|
||||||
|
eq(crmCustomerContacts.organizationId, organizationId),
|
||||||
|
isNull(crmCustomerContacts.deletedAt),
|
||||||
|
...(customerId ? [eq(crmCustomerContacts.customerId, customerId)] : [])
|
||||||
|
)
|
||||||
)
|
)
|
||||||
});
|
.limit(1);
|
||||||
|
|
||||||
if (!contact) {
|
if (!contact) {
|
||||||
throw new AuthError('Contact not found', 404);
|
throw new AuthError('Contact not found', 404);
|
||||||
@@ -215,13 +223,17 @@ export async function assertContactBelongsToOrganization(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function assertEnquiryBelongsToOrganization(id: string, organizationId: string) {
|
async function assertEnquiryBelongsToOrganization(id: string, organizationId: string) {
|
||||||
const enquiry = await db.query.crmEnquiries.findFirst({
|
const [enquiry] = await db
|
||||||
where: and(
|
.select()
|
||||||
eq(crmEnquiries.id, id),
|
.from(crmEnquiries)
|
||||||
eq(crmEnquiries.organizationId, organizationId),
|
.where(
|
||||||
isNull(crmEnquiries.deletedAt)
|
and(
|
||||||
|
eq(crmEnquiries.id, id),
|
||||||
|
eq(crmEnquiries.organizationId, organizationId),
|
||||||
|
isNull(crmEnquiries.deletedAt)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
});
|
.limit(1);
|
||||||
|
|
||||||
if (!enquiry) {
|
if (!enquiry) {
|
||||||
throw new AuthError('Enquiry not found', 404);
|
throw new AuthError('Enquiry not found', 404);
|
||||||
@@ -235,14 +247,18 @@ async function assertFollowupBelongsToEnquiry(
|
|||||||
enquiryId: string,
|
enquiryId: string,
|
||||||
organizationId: string
|
organizationId: string
|
||||||
) {
|
) {
|
||||||
const followup = await db.query.crmEnquiryFollowups.findFirst({
|
const [followup] = await db
|
||||||
where: and(
|
.select()
|
||||||
eq(crmEnquiryFollowups.id, followupId),
|
.from(crmEnquiryFollowups)
|
||||||
eq(crmEnquiryFollowups.enquiryId, enquiryId),
|
.where(
|
||||||
eq(crmEnquiryFollowups.organizationId, organizationId),
|
and(
|
||||||
isNull(crmEnquiryFollowups.deletedAt)
|
eq(crmEnquiryFollowups.id, followupId),
|
||||||
|
eq(crmEnquiryFollowups.enquiryId, enquiryId),
|
||||||
|
eq(crmEnquiryFollowups.organizationId, organizationId),
|
||||||
|
isNull(crmEnquiryFollowups.deletedAt)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
});
|
.limit(1);
|
||||||
|
|
||||||
if (!followup) {
|
if (!followup) {
|
||||||
throw new AuthError('Follow-up not found', 404);
|
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.priority, { organizationId }),
|
||||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.leadChannel, { organizationId }),
|
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.leadChannel, { organizationId }),
|
||||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.followupType, { organizationId }),
|
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.followupType, { organizationId }),
|
||||||
db.query.crmCustomers.findMany({
|
db
|
||||||
where: and(eq(crmCustomers.organizationId, organizationId), isNull(crmCustomers.deletedAt)),
|
.select()
|
||||||
orderBy: [asc(crmCustomers.name)]
|
.from(crmCustomers)
|
||||||
}),
|
.where(and(eq(crmCustomers.organizationId, organizationId), isNull(crmCustomers.deletedAt)))
|
||||||
db.query.crmCustomerContacts.findMany({
|
.orderBy(asc(crmCustomers.name)),
|
||||||
where: and(
|
db
|
||||||
eq(crmCustomerContacts.organizationId, organizationId),
|
.select()
|
||||||
isNull(crmCustomerContacts.deletedAt)
|
.from(crmCustomerContacts)
|
||||||
),
|
.where(
|
||||||
orderBy: [desc(crmCustomerContacts.isPrimary), asc(crmCustomerContacts.name)]
|
and(
|
||||||
})
|
eq(crmCustomerContacts.organizationId, organizationId),
|
||||||
|
isNull(crmCustomerContacts.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(desc(crmCustomerContacts.isPrimary), asc(crmCustomerContacts.name))
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -407,20 +427,26 @@ export async function listEnquiries(
|
|||||||
|
|
||||||
const [customers, contacts, followupCounts] = await Promise.all([
|
const [customers, contacts, followupCounts] = await Promise.all([
|
||||||
customerIds.length
|
customerIds.length
|
||||||
? db.query.crmCustomers.findMany({
|
? db
|
||||||
where: and(
|
.select()
|
||||||
eq(crmCustomers.organizationId, organizationId),
|
.from(crmCustomers)
|
||||||
inArray(crmCustomers.id, customerIds)
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmCustomers.organizationId, organizationId),
|
||||||
|
inArray(crmCustomers.id, customerIds)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
})
|
|
||||||
: [],
|
: [],
|
||||||
contactIds.length
|
contactIds.length
|
||||||
? db.query.crmCustomerContacts.findMany({
|
? db
|
||||||
where: and(
|
.select()
|
||||||
eq(crmCustomerContacts.organizationId, organizationId),
|
.from(crmCustomerContacts)
|
||||||
inArray(crmCustomerContacts.id, contactIds)
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmCustomerContacts.organizationId, organizationId),
|
||||||
|
inArray(crmCustomerContacts.id, contactIds)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
})
|
|
||||||
: [],
|
: [],
|
||||||
enquiryIds.length
|
enquiryIds.length
|
||||||
? db
|
? db
|
||||||
@@ -618,14 +644,17 @@ export async function softDeleteEnquiry(id: string, organizationId: string, user
|
|||||||
|
|
||||||
export async function listEnquiryFollowups(id: string, organizationId: string) {
|
export async function listEnquiryFollowups(id: string, organizationId: string) {
|
||||||
await assertEnquiryBelongsToOrganization(id, organizationId);
|
await assertEnquiryBelongsToOrganization(id, organizationId);
|
||||||
const rows = await db.query.crmEnquiryFollowups.findMany({
|
const rows = await db
|
||||||
where: and(
|
.select()
|
||||||
eq(crmEnquiryFollowups.enquiryId, id),
|
.from(crmEnquiryFollowups)
|
||||||
eq(crmEnquiryFollowups.organizationId, organizationId),
|
.where(
|
||||||
isNull(crmEnquiryFollowups.deletedAt)
|
and(
|
||||||
),
|
eq(crmEnquiryFollowups.enquiryId, id),
|
||||||
orderBy: [desc(crmEnquiryFollowups.followupDate)]
|
eq(crmEnquiryFollowups.organizationId, organizationId),
|
||||||
});
|
isNull(crmEnquiryFollowups.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(desc(crmEnquiryFollowups.followupDate));
|
||||||
|
|
||||||
return rows.map(mapFollowupRecord);
|
return rows.map(mapFollowupRecord);
|
||||||
}
|
}
|
||||||
@@ -713,15 +742,18 @@ export async function listCustomerEnquiryRelations(
|
|||||||
customerId: string,
|
customerId: string,
|
||||||
organizationId: string
|
organizationId: string
|
||||||
): Promise<EnquiryCustomerRelationItem[]> {
|
): Promise<EnquiryCustomerRelationItem[]> {
|
||||||
const rows = await db.query.crmEnquiries.findMany({
|
const rows = await db
|
||||||
where: and(
|
.select()
|
||||||
eq(crmEnquiries.customerId, customerId),
|
.from(crmEnquiries)
|
||||||
eq(crmEnquiries.organizationId, organizationId),
|
.where(
|
||||||
isNull(crmEnquiries.deletedAt)
|
and(
|
||||||
),
|
eq(crmEnquiries.customerId, customerId),
|
||||||
orderBy: [desc(crmEnquiries.updatedAt)],
|
eq(crmEnquiries.organizationId, organizationId),
|
||||||
limit: 10
|
isNull(crmEnquiries.deletedAt)
|
||||||
});
|
)
|
||||||
|
)
|
||||||
|
.orderBy(desc(crmEnquiries.updatedAt))
|
||||||
|
.limit(10);
|
||||||
|
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
|
|||||||
258
src/features/crm/quotations/api/mutations.ts
Normal file
258
src/features/crm/quotations/api/mutations.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
72
src/features/crm/quotations/api/queries.ts
Normal file
72
src/features/crm/quotations/api/queries.ts
Normal file
@@ -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)
|
||||||
|
});
|
||||||
214
src/features/crm/quotations/api/service.ts
Normal file
214
src/features/crm/quotations/api/service.ts
Normal file
@@ -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<QuotationListResponse> {
|
||||||
|
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<QuotationListResponse>(`/crm/quotations${query ? `?${query}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getQuotationById(id: string): Promise<QuotationDetailResponse> {
|
||||||
|
return apiClient<QuotationDetailResponse>(`/crm/quotations/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createQuotation(data: QuotationMutationPayload) {
|
||||||
|
return apiClient<MutationSuccessResponse>('/crm/quotations', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateQuotation(id: string, data: QuotationMutationPayload) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`/crm/quotations/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteQuotation(id: string) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`/crm/quotations/${id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getQuotationItems(id: string): Promise<QuotationItemsResponse> {
|
||||||
|
return apiClient<QuotationItemsResponse>(`/crm/quotations/${id}/items`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createQuotationItem(id: string, data: QuotationItemMutationPayload) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`/crm/quotations/${id}/items`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateQuotationItem(
|
||||||
|
quotationId: string,
|
||||||
|
itemId: string,
|
||||||
|
data: QuotationItemMutationPayload
|
||||||
|
) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`/crm/quotations/${quotationId}/items/${itemId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteQuotationItem(quotationId: string, itemId: string) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`/crm/quotations/${quotationId}/items/${itemId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getQuotationCustomers(id: string): Promise<QuotationCustomersResponse> {
|
||||||
|
return apiClient<QuotationCustomersResponse>(`/crm/quotations/${id}/customers`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createQuotationCustomer(id: string, data: QuotationCustomerMutationPayload) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`/crm/quotations/${id}/customers`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateQuotationCustomer(
|
||||||
|
quotationId: string,
|
||||||
|
relationId: string,
|
||||||
|
data: QuotationCustomerMutationPayload
|
||||||
|
) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`/crm/quotations/${quotationId}/customers`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ id: relationId, ...data })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteQuotationCustomer(quotationId: string, relationId: string) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`/crm/quotations/${quotationId}/customers`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
body: JSON.stringify({ id: relationId })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getQuotationTopics(id: string): Promise<QuotationTopicsResponse> {
|
||||||
|
return apiClient<QuotationTopicsResponse>(`/crm/quotations/${id}/topics`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createQuotationTopic(id: string, data: QuotationTopicMutationPayload) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`/crm/quotations/${id}/topics`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateQuotationTopic(id: string, data: QuotationTopicMutationPayload) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`/crm/quotations/${id}/topics`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteQuotationTopic(quotationId: string, topicId: string) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`/crm/quotations/${quotationId}/topics`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
body: JSON.stringify({ id: topicId })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getQuotationFollowups(id: string): Promise<QuotationFollowupsResponse> {
|
||||||
|
return apiClient<QuotationFollowupsResponse>(`/crm/quotations/${id}/followups`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createQuotationFollowup(id: string, data: QuotationFollowupMutationPayload) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`/crm/quotations/${id}/followups`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateQuotationFollowup(
|
||||||
|
quotationId: string,
|
||||||
|
data: QuotationFollowupMutationPayload
|
||||||
|
) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`/crm/quotations/${quotationId}/followups`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteQuotationFollowup(quotationId: string, followupId: string) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`/crm/quotations/${quotationId}/followups`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
body: JSON.stringify({ id: followupId })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getQuotationAttachments(id: string): Promise<QuotationAttachmentsResponse> {
|
||||||
|
return apiClient<QuotationAttachmentsResponse>(`/crm/quotations/${id}/attachments`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createQuotationAttachment(
|
||||||
|
id: string,
|
||||||
|
data: QuotationAttachmentMutationPayload
|
||||||
|
) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`/crm/quotations/${id}/attachments`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateQuotationAttachment(
|
||||||
|
quotationId: string,
|
||||||
|
data: QuotationAttachmentMutationPayload
|
||||||
|
) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`/crm/quotations/${quotationId}/attachments`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteQuotationAttachment(quotationId: string, attachmentId: string) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`/crm/quotations/${quotationId}/attachments`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
body: JSON.stringify({ id: attachmentId })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getQuotationRevisions(id: string): Promise<QuotationRevisionsResponse> {
|
||||||
|
return apiClient<QuotationRevisionsResponse>(`/crm/quotations/${id}/revisions`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createQuotationRevision(id: string, revisionRemark?: string) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`/crm/quotations/${id}/revisions`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ revisionRemark })
|
||||||
|
});
|
||||||
|
}
|
||||||
392
src/features/crm/quotations/api/types.ts
Normal file
392
src/features/crm/quotations/api/types.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<AlertModal
|
||||||
|
isOpen={deleteOpen}
|
||||||
|
onClose={() => setDeleteOpen(false)}
|
||||||
|
onConfirm={() => deleteMutation.mutate(data.id)}
|
||||||
|
loading={deleteMutation.isPending}
|
||||||
|
/>
|
||||||
|
<QuotationFormSheet
|
||||||
|
quotation={data}
|
||||||
|
open={editOpen}
|
||||||
|
onOpenChange={setEditOpen}
|
||||||
|
referenceData={referenceData}
|
||||||
|
/>
|
||||||
|
<DropdownMenu modal={false}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant='ghost' className='h-8 w-8 p-0'>
|
||||||
|
<span className='sr-only'>Open actions</span>
|
||||||
|
<Icons.ellipsis className='h-4 w-4' />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align='end'>
|
||||||
|
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem onClick={() => router.push(`/dashboard/crm/quotations/${data.id}`)}>
|
||||||
|
<Icons.arrowRight className='mr-2 h-4 w-4' /> View
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setEditOpen(true)} disabled={!canUpdate}>
|
||||||
|
<Icons.edit className='mr-2 h-4 w-4' /> Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setDeleteOpen(true)} disabled={!canDelete}>
|
||||||
|
<Icons.trash className='mr-2 h-4 w-4' /> Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
167
src/features/crm/quotations/components/quotation-columns.tsx
Normal file
167
src/features/crm/quotations/components/quotation-columns.tsx
Normal file
@@ -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<QuotationListItem>[] {
|
||||||
|
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<QuotationListItem, unknown> }) => (
|
||||||
|
<DataTableColumnHeader column={column} title='Quotation' />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className='flex flex-col'>
|
||||||
|
<Link href={`/dashboard/crm/quotations/${row.original.id}`} className='font-medium hover:underline'>
|
||||||
|
{row.original.code}
|
||||||
|
</Link>
|
||||||
|
<span className='text-muted-foreground text-xs'>
|
||||||
|
{row.original.customerName}
|
||||||
|
{row.original.projectName ? ` - ${row.original.projectName}` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
label: 'Quotation',
|
||||||
|
placeholder: 'Search quotations...',
|
||||||
|
variant: 'text' as const,
|
||||||
|
icon: Icons.search
|
||||||
|
},
|
||||||
|
enableColumnFilter: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
accessorKey: 'status',
|
||||||
|
header: ({ column }: { column: Column<QuotationListItem, unknown> }) => (
|
||||||
|
<DataTableColumnHeader column={column} title='Status' />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const status = statusMap.get(row.original.status);
|
||||||
|
return <QuotationStatusBadge code={status?.code} label={status?.label ?? 'Unknown'} />;
|
||||||
|
},
|
||||||
|
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<QuotationListItem, unknown> }) => (
|
||||||
|
<DataTableColumnHeader column={column} title='Type' />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge variant='outline'>
|
||||||
|
{typeMap.get(row.original.quotationType)?.label ?? 'Unknown'}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
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<QuotationListItem, unknown> }) => (
|
||||||
|
<DataTableColumnHeader column={column} title='Branch' />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span>{row.original.branchId ? (branchMap.get(row.original.branchId)?.name ?? 'Unknown') : 'Unassigned'}</span>
|
||||||
|
),
|
||||||
|
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<QuotationListItem, unknown> }) => (
|
||||||
|
<DataTableColumnHeader column={column} title='Customer' />
|
||||||
|
),
|
||||||
|
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<QuotationListItem, unknown> }) => (
|
||||||
|
<DataTableColumnHeader column={column} title='Enquiry' />
|
||||||
|
),
|
||||||
|
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<QuotationListItem, unknown> }) => (
|
||||||
|
<DataTableColumnHeader column={column} title='Hot' />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (row.original.isHotProject ? <Badge>Hot</Badge> : <span>-</span>),
|
||||||
|
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<QuotationListItem, unknown> }) => (
|
||||||
|
<DataTableColumnHeader column={column} title='Total' />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => row.original.totalAmount.toLocaleString()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<QuotationCellAction
|
||||||
|
data={row.original}
|
||||||
|
referenceData={referenceData}
|
||||||
|
canUpdate={canUpdate}
|
||||||
|
canDelete={canDelete}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
1202
src/features/crm/quotations/components/quotation-detail.tsx
Normal file
1202
src/features/crm/quotations/components/quotation-detail.tsx
Normal file
File diff suppressed because it is too large
Load Diff
477
src/features/crm/quotations/components/quotation-form-sheet.tsx
Normal file
477
src/features/crm/quotations/components/quotation-form-sheet.tsx
Normal file
@@ -0,0 +1,477 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Icons } from '@/components/icons';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetFooter,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle
|
||||||
|
} from '@/components/ui/sheet';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { createQuotationMutation, updateQuotationMutation } from '../api/mutations';
|
||||||
|
import type { QuotationMutationPayload, QuotationRecord, QuotationReferenceData } from '../api/types';
|
||||||
|
|
||||||
|
type FormState = {
|
||||||
|
enquiryId: string;
|
||||||
|
customerId: string;
|
||||||
|
contactId: string;
|
||||||
|
quotationDate: string;
|
||||||
|
validUntil: string;
|
||||||
|
quotationType: string;
|
||||||
|
projectName: string;
|
||||||
|
projectLocation: string;
|
||||||
|
attention: string;
|
||||||
|
branchId: string;
|
||||||
|
currency: string;
|
||||||
|
exchangeRate: string;
|
||||||
|
status: string;
|
||||||
|
chancePercent: string;
|
||||||
|
competitor: string;
|
||||||
|
reference: string;
|
||||||
|
notes: string;
|
||||||
|
salesmanId: string;
|
||||||
|
discount: string;
|
||||||
|
discountType: string;
|
||||||
|
taxRate: string;
|
||||||
|
revisionRemark: string;
|
||||||
|
isHotProject: boolean;
|
||||||
|
isActive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toFormState(
|
||||||
|
quotation: QuotationRecord | undefined,
|
||||||
|
referenceData: QuotationReferenceData
|
||||||
|
): FormState {
|
||||||
|
return {
|
||||||
|
enquiryId: quotation?.enquiryId ?? '',
|
||||||
|
customerId: quotation?.customerId ?? referenceData.customers[0]?.id ?? '',
|
||||||
|
contactId: quotation?.contactId ?? '',
|
||||||
|
quotationDate:
|
||||||
|
quotation?.quotationDate?.slice(0, 10) ?? new Date().toISOString().slice(0, 10),
|
||||||
|
validUntil: quotation?.validUntil?.slice(0, 10) ?? '',
|
||||||
|
quotationType: quotation?.quotationType ?? referenceData.quotationTypes[0]?.id ?? '',
|
||||||
|
projectName: quotation?.projectName ?? '',
|
||||||
|
projectLocation: quotation?.projectLocation ?? '',
|
||||||
|
attention: quotation?.attention ?? '',
|
||||||
|
branchId: quotation?.branchId ?? '',
|
||||||
|
currency: quotation?.currency ?? referenceData.currencies[0]?.id ?? '',
|
||||||
|
exchangeRate: String(quotation?.exchangeRate ?? 1),
|
||||||
|
status: quotation?.status ?? referenceData.statuses[0]?.id ?? '',
|
||||||
|
chancePercent:
|
||||||
|
quotation?.chancePercent === null || quotation?.chancePercent === undefined
|
||||||
|
? ''
|
||||||
|
: String(quotation.chancePercent),
|
||||||
|
competitor: quotation?.competitor ?? '',
|
||||||
|
reference: quotation?.reference ?? '',
|
||||||
|
notes: quotation?.notes ?? '',
|
||||||
|
salesmanId: quotation?.salesmanId ?? '',
|
||||||
|
discount: String(quotation?.discount ?? 0),
|
||||||
|
discountType: quotation?.discountType ?? '',
|
||||||
|
taxRate: String(quotation?.taxRate ?? 0),
|
||||||
|
revisionRemark: quotation?.revisionRemark ?? '',
|
||||||
|
isHotProject: quotation?.isHotProject ?? false,
|
||||||
|
isActive: quotation?.isActive ?? true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
className
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<div className='mb-2 text-sm font-medium'>{label}</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuotationFormSheet({
|
||||||
|
quotation,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
referenceData
|
||||||
|
}: {
|
||||||
|
quotation?: QuotationRecord;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
referenceData: QuotationReferenceData;
|
||||||
|
}) {
|
||||||
|
const isEdit = !!quotation;
|
||||||
|
const defaultState = useMemo(() => toFormState(quotation, referenceData), [quotation, referenceData]);
|
||||||
|
const [state, setState] = useState<FormState>(defaultState);
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
...createQuotationMutation,
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Quotation created successfully');
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
onError: (error) =>
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Failed to create quotation')
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
...updateQuotationMutation,
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Quotation updated successfully');
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
onError: (error) =>
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Failed to update quotation')
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setState(defaultState);
|
||||||
|
}
|
||||||
|
}, [defaultState, open]);
|
||||||
|
|
||||||
|
const contacts = useMemo(
|
||||||
|
() => referenceData.contacts.filter((item) => item.customerId === state.customerId),
|
||||||
|
[referenceData.contacts, state.customerId]
|
||||||
|
);
|
||||||
|
|
||||||
|
function setField<K extends keyof FormState>(key: K, value: FormState[K]) {
|
||||||
|
setState((current) => ({ ...current, [key]: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEnquiryChange(value: string) {
|
||||||
|
const enquiry = referenceData.enquiries.find((item) => item.id === value);
|
||||||
|
setState((current) => ({
|
||||||
|
...current,
|
||||||
|
enquiryId: value === '__none__' ? '' : value,
|
||||||
|
customerId: enquiry?.customerId ?? current.customerId,
|
||||||
|
contactId: enquiry?.contactId ?? '',
|
||||||
|
projectName: enquiry?.projectName ?? current.projectName,
|
||||||
|
projectLocation: enquiry?.projectLocation ?? current.projectLocation,
|
||||||
|
branchId: enquiry?.branchId ?? current.branchId
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const payload: QuotationMutationPayload = {
|
||||||
|
enquiryId: state.enquiryId || null,
|
||||||
|
customerId: state.customerId,
|
||||||
|
contactId: state.contactId || null,
|
||||||
|
quotationDate: state.quotationDate,
|
||||||
|
validUntil: state.validUntil || null,
|
||||||
|
quotationType: state.quotationType,
|
||||||
|
projectName: state.projectName,
|
||||||
|
projectLocation: state.projectLocation,
|
||||||
|
attention: state.attention,
|
||||||
|
branchId: state.branchId || null,
|
||||||
|
currency: state.currency,
|
||||||
|
exchangeRate: state.exchangeRate ? Number(state.exchangeRate) : 1,
|
||||||
|
status: state.status,
|
||||||
|
chancePercent: state.chancePercent ? Number(state.chancePercent) : null,
|
||||||
|
isHotProject: state.isHotProject,
|
||||||
|
competitor: state.competitor,
|
||||||
|
reference: state.reference,
|
||||||
|
notes: state.notes,
|
||||||
|
salesmanId: state.salesmanId || null,
|
||||||
|
discount: state.discount ? Number(state.discount) : 0,
|
||||||
|
discountType: state.discountType || null,
|
||||||
|
taxRate: state.taxRate ? Number(state.taxRate) : 0,
|
||||||
|
revisionRemark: state.revisionRemark || null,
|
||||||
|
isActive: state.isActive
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEdit && quotation) {
|
||||||
|
await updateMutation.mutateAsync({ id: quotation.id, values: payload });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await createMutation.mutateAsync(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent className='flex flex-col sm:max-w-5xl'>
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>{isEdit ? 'Edit Quotation' : 'New Quotation'}</SheetTitle>
|
||||||
|
<SheetDescription>
|
||||||
|
Capture quotation header data, customer linkage, and commercial context.
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<form id='quotation-form-sheet' onSubmit={onSubmit} className='flex-1 overflow-auto'>
|
||||||
|
<div className='grid gap-4 py-4 md:grid-cols-2'>
|
||||||
|
<Field label='Enquiry'>
|
||||||
|
<Select value={state.enquiryId || '__none__'} onValueChange={handleEnquiryChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder='Select enquiry' />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value='__none__'>No linked enquiry</SelectItem>
|
||||||
|
{referenceData.enquiries.map((item) => (
|
||||||
|
<SelectItem key={item.id} value={item.id}>
|
||||||
|
{item.code} - {item.title}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label='Customer'>
|
||||||
|
<Select
|
||||||
|
value={state.customerId}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setField('customerId', value);
|
||||||
|
setField('contactId', '');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder='Select customer' />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{referenceData.customers.map((item) => (
|
||||||
|
<SelectItem key={item.id} value={item.id}>
|
||||||
|
{item.name} ({item.code})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label='Contact'>
|
||||||
|
<Select
|
||||||
|
value={state.contactId || '__none__'}
|
||||||
|
onValueChange={(value) => setField('contactId', value === '__none__' ? '' : value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder='Select contact' />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value='__none__'>No contact</SelectItem>
|
||||||
|
{contacts.map((item) => (
|
||||||
|
<SelectItem key={item.id} value={item.id}>
|
||||||
|
{item.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label='Quotation Type'>
|
||||||
|
<Select value={state.quotationType} onValueChange={(value) => setField('quotationType', value)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder='Select type' />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{referenceData.quotationTypes.map((item) => (
|
||||||
|
<SelectItem key={item.id} value={item.id}>
|
||||||
|
{item.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label='Quotation Date'>
|
||||||
|
<Input type='date' value={state.quotationDate} onChange={(e) => setField('quotationDate', e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label='Valid Until'>
|
||||||
|
<Input type='date' value={state.validUntil} onChange={(e) => setField('validUntil', e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label='Branch'>
|
||||||
|
<Select value={state.branchId || '__none__'} onValueChange={(value) => setField('branchId', value === '__none__' ? '' : value)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder='Select branch' />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value='__none__'>Unassigned</SelectItem>
|
||||||
|
{referenceData.branches.map((item) => (
|
||||||
|
<SelectItem key={item.id} value={item.id}>
|
||||||
|
{item.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label='Status'>
|
||||||
|
<Select value={state.status} onValueChange={(value) => setField('status', value)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder='Select status' />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{referenceData.statuses.map((item) => (
|
||||||
|
<SelectItem key={item.id} value={item.id}>
|
||||||
|
{item.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label='Currency'>
|
||||||
|
<Select value={state.currency} onValueChange={(value) => setField('currency', value)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder='Select currency' />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{referenceData.currencies.map((item) => (
|
||||||
|
<SelectItem key={item.id} value={item.id}>
|
||||||
|
{item.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label='Exchange Rate'>
|
||||||
|
<Input value={state.exchangeRate} onChange={(e) => setField('exchangeRate', e.target.value)} type='number' step='0.0001' />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label='Project Name'>
|
||||||
|
<Input value={state.projectName} onChange={(e) => setField('projectName', e.target.value)} placeholder='Project name' />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label='Project Location'>
|
||||||
|
<Input value={state.projectLocation} onChange={(e) => setField('projectLocation', e.target.value)} placeholder='Project location' />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label='Attention'>
|
||||||
|
<Input value={state.attention} onChange={(e) => setField('attention', e.target.value)} placeholder='Attention line' />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label='Reference'>
|
||||||
|
<Input value={state.reference} onChange={(e) => setField('reference', e.target.value)} placeholder='Reference note' />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label='Salesman'>
|
||||||
|
<Select value={state.salesmanId || '__none__'} onValueChange={(value) => setField('salesmanId', value === '__none__' ? '' : value)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder='Select salesman' />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value='__none__'>Unassigned</SelectItem>
|
||||||
|
{referenceData.salesmen.map((item) => (
|
||||||
|
<SelectItem key={item.id} value={item.id}>
|
||||||
|
{item.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label='Chance %'>
|
||||||
|
<Input value={state.chancePercent} onChange={(e) => setField('chancePercent', e.target.value)} type='number' min='0' max='100' />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label='Discount'>
|
||||||
|
<Input value={state.discount} onChange={(e) => setField('discount', e.target.value)} type='number' step='0.01' />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label='Discount Type'>
|
||||||
|
<Select value={state.discountType || '__none__'} onValueChange={(value) => setField('discountType', value === '__none__' ? '' : value)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder='Select discount type' />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value='__none__'>None</SelectItem>
|
||||||
|
{referenceData.discountTypes.map((item) => (
|
||||||
|
<SelectItem key={item.id} value={item.id}>
|
||||||
|
{item.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label='Tax Rate %'>
|
||||||
|
<Input value={state.taxRate} onChange={(e) => setField('taxRate', e.target.value)} type='number' step='0.01' />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label='Competitor'>
|
||||||
|
<Input value={state.competitor} onChange={(e) => setField('competitor', e.target.value)} placeholder='Competitor' />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className='md:col-span-2'>
|
||||||
|
<Field label='Notes'>
|
||||||
|
<Textarea value={state.notes} onChange={(e) => setField('notes', e.target.value)} placeholder='Internal notes' />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='md:col-span-2'>
|
||||||
|
<Field label='Revision Remark'>
|
||||||
|
<Textarea value={state.revisionRemark} onChange={(e) => setField('revisionRemark', e.target.value)} placeholder='Revision note or approval context' />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex items-center justify-between rounded-lg border px-4 py-3'>
|
||||||
|
<div>
|
||||||
|
<div className='font-medium'>Hot Project</div>
|
||||||
|
<div className='text-muted-foreground text-sm'>Highlight urgent or strategic deals.</div>
|
||||||
|
</div>
|
||||||
|
<Switch checked={state.isHotProject} onCheckedChange={(checked) => setField('isHotProject', checked)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex items-center justify-between rounded-lg border px-4 py-3'>
|
||||||
|
<div>
|
||||||
|
<div className='font-medium'>Active Record</div>
|
||||||
|
<div className='text-muted-foreground text-sm'>Inactive quotations stay as history only.</div>
|
||||||
|
</div>
|
||||||
|
<Switch checked={state.isActive} onCheckedChange={(checked) => setField('isActive', checked)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<SheetFooter>
|
||||||
|
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type='submit' form='quotation-form-sheet' isLoading={isPending}>
|
||||||
|
<Icons.check className='mr-2 h-4 w-4' />
|
||||||
|
{isEdit ? 'Update Quotation' : 'Create Quotation'}
|
||||||
|
</Button>
|
||||||
|
</SheetFooter>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuotationFormSheetTrigger({
|
||||||
|
referenceData
|
||||||
|
}: {
|
||||||
|
referenceData: QuotationReferenceData;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button onClick={() => setOpen(true)}>
|
||||||
|
<Icons.add className='mr-2 h-4 w-4' /> Add Quotation
|
||||||
|
</Button>
|
||||||
|
<QuotationFormSheet open={open} onOpenChange={setOpen} referenceData={referenceData} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
src/features/crm/quotations/components/quotation-listing.tsx
Normal file
48
src/features/crm/quotations/components/quotation-listing.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
||||||
|
import { getQueryClient } from '@/lib/query-client';
|
||||||
|
import { searchParamsCache } from '@/lib/searchparams';
|
||||||
|
import type { QuotationReferenceData } from '../api/types';
|
||||||
|
import { quotationsQueryOptions } from '../api/queries';
|
||||||
|
import { QuotationsTable } from './quotations-table';
|
||||||
|
|
||||||
|
export default function QuotationListing({
|
||||||
|
referenceData,
|
||||||
|
canUpdate,
|
||||||
|
canDelete
|
||||||
|
}: {
|
||||||
|
referenceData: QuotationReferenceData;
|
||||||
|
canUpdate: boolean;
|
||||||
|
canDelete: boolean;
|
||||||
|
}) {
|
||||||
|
const page = searchParamsCache.get('page');
|
||||||
|
const limit = searchParamsCache.get('perPage');
|
||||||
|
const search = searchParamsCache.get('name');
|
||||||
|
const status = searchParamsCache.get('status');
|
||||||
|
const quotationType = searchParamsCache.get('quotationType');
|
||||||
|
const branch = searchParamsCache.get('branch');
|
||||||
|
const customer = searchParamsCache.get('customer');
|
||||||
|
const enquiry = searchParamsCache.get('enquiry');
|
||||||
|
const hot = searchParamsCache.get('hot');
|
||||||
|
const sort = searchParamsCache.get('sort');
|
||||||
|
const filters = {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
...(search && { search }),
|
||||||
|
...(status && { status }),
|
||||||
|
...(quotationType && { quotationType }),
|
||||||
|
...(branch && { branch }),
|
||||||
|
...(customer && { customer }),
|
||||||
|
...(enquiry && { enquiry }),
|
||||||
|
...(hot && { hot }),
|
||||||
|
...(sort && { sort })
|
||||||
|
};
|
||||||
|
const queryClient = getQueryClient();
|
||||||
|
|
||||||
|
void queryClient.prefetchQuery(quotationsQueryOptions(filters));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
|
<QuotationsTable referenceData={referenceData} canUpdate={canUpdate} canDelete={canDelete} />
|
||||||
|
</HydrationBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Icons } from '@/components/icons';
|
||||||
|
|
||||||
|
export function QuotationStatusBadge({ code, label }: { code?: string | null; label: string }) {
|
||||||
|
const normalized = code?.toLowerCase() ?? '';
|
||||||
|
const variant =
|
||||||
|
normalized === 'approved' || normalized === 'accepted' || normalized === 'won'
|
||||||
|
? 'default'
|
||||||
|
: normalized === 'draft' || normalized === 'new'
|
||||||
|
? 'secondary'
|
||||||
|
: normalized === 'rejected' || normalized === 'lost'
|
||||||
|
? 'destructive'
|
||||||
|
: 'outline';
|
||||||
|
const Icon =
|
||||||
|
normalized === 'approved' || normalized === 'accepted' || normalized === 'won'
|
||||||
|
? Icons.circleCheck
|
||||||
|
: normalized === 'rejected' || normalized === 'lost'
|
||||||
|
? Icons.warning
|
||||||
|
: Icons.circle;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge variant={variant} className='capitalize'>
|
||||||
|
<Icon className='h-3 w-3' />
|
||||||
|
{label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
src/features/crm/quotations/components/quotations-table.tsx
Normal file
72
src/features/crm/quotations/components/quotations-table.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs';
|
||||||
|
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||||
|
import { DataTable } from '@/components/ui/table/data-table';
|
||||||
|
import { DataTableToolbar } from '@/components/ui/table/data-table-toolbar';
|
||||||
|
import { useDataTable } from '@/hooks/use-data-table';
|
||||||
|
import { getSortingStateParser } from '@/lib/parsers';
|
||||||
|
import { quotationsQueryOptions } from '../api/queries';
|
||||||
|
import type { QuotationReferenceData } from '../api/types';
|
||||||
|
import { getQuotationColumns } from './quotation-columns';
|
||||||
|
|
||||||
|
export function QuotationsTable({
|
||||||
|
referenceData,
|
||||||
|
canUpdate,
|
||||||
|
canDelete
|
||||||
|
}: {
|
||||||
|
referenceData: QuotationReferenceData;
|
||||||
|
canUpdate: boolean;
|
||||||
|
canDelete: boolean;
|
||||||
|
}) {
|
||||||
|
const columns = useMemo(
|
||||||
|
() => getQuotationColumns({ referenceData, canUpdate, canDelete }),
|
||||||
|
[referenceData, canUpdate, canDelete]
|
||||||
|
);
|
||||||
|
const columnIds = columns.map((column) => column.id).filter(Boolean) as string[];
|
||||||
|
const [params] = useQueryStates({
|
||||||
|
page: parseAsInteger.withDefault(1),
|
||||||
|
perPage: parseAsInteger.withDefault(10),
|
||||||
|
name: parseAsString,
|
||||||
|
status: parseAsString,
|
||||||
|
quotationType: parseAsString,
|
||||||
|
branch: parseAsString,
|
||||||
|
customer: parseAsString,
|
||||||
|
enquiry: parseAsString,
|
||||||
|
hot: parseAsString,
|
||||||
|
sort: getSortingStateParser(columnIds).withDefault([])
|
||||||
|
});
|
||||||
|
|
||||||
|
const filters = {
|
||||||
|
page: params.page,
|
||||||
|
limit: params.perPage,
|
||||||
|
...(params.name && { search: params.name }),
|
||||||
|
...(params.status && { status: params.status }),
|
||||||
|
...(params.quotationType && { quotationType: params.quotationType }),
|
||||||
|
...(params.branch && { branch: params.branch }),
|
||||||
|
...(params.customer && { customer: params.customer }),
|
||||||
|
...(params.enquiry && { enquiry: params.enquiry }),
|
||||||
|
...(params.hot && { hot: params.hot }),
|
||||||
|
...(params.sort.length > 0 && { sort: JSON.stringify(params.sort) })
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data } = useSuspenseQuery(quotationsQueryOptions(filters));
|
||||||
|
const pageCount = Math.ceil(data.totalItems / params.perPage);
|
||||||
|
const { table } = useDataTable({
|
||||||
|
data: data.items,
|
||||||
|
columns,
|
||||||
|
pageCount,
|
||||||
|
shallow: true,
|
||||||
|
debounceMs: 500,
|
||||||
|
initialState: {
|
||||||
|
columnPinning: { right: ['actions'] }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTable table={table}>
|
||||||
|
<DataTableToolbar table={table} />
|
||||||
|
</DataTable>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
src/features/crm/quotations/schemas/quotation.schema.ts
Normal file
103
src/features/crm/quotations/schemas/quotation.schema.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import * as z from 'zod';
|
||||||
|
|
||||||
|
export const quotationSchema = z.object({
|
||||||
|
enquiryId: z.string().optional().nullable(),
|
||||||
|
customerId: z.string().min(1, 'Please select a customer'),
|
||||||
|
contactId: z.string().optional().nullable(),
|
||||||
|
quotationDate: z.string().min(1, 'Quotation date is required'),
|
||||||
|
validUntil: z.string().optional().nullable(),
|
||||||
|
quotationType: z.string().min(1, 'Please select a quotation type'),
|
||||||
|
projectName: z.string().optional(),
|
||||||
|
projectLocation: z.string().optional(),
|
||||||
|
attention: z.string().optional(),
|
||||||
|
branchId: z.string().optional().nullable(),
|
||||||
|
currency: z.string().min(1, 'Please select a currency'),
|
||||||
|
exchangeRate: z.number().nullable().optional(),
|
||||||
|
status: z.string().min(1, 'Please select a status'),
|
||||||
|
chancePercent: z
|
||||||
|
.number()
|
||||||
|
.nullable()
|
||||||
|
.optional()
|
||||||
|
.refine(
|
||||||
|
(value) => value === null || value === undefined || (value >= 0 && value <= 100),
|
||||||
|
'Chance percent must be between 0 and 100'
|
||||||
|
),
|
||||||
|
isHotProject: z.boolean().default(false),
|
||||||
|
competitor: z.string().optional(),
|
||||||
|
reference: z.string().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
salesmanId: z.string().optional().nullable(),
|
||||||
|
discount: z.number().nullable().optional(),
|
||||||
|
discountType: z.string().optional().nullable(),
|
||||||
|
taxRate: z.number().nullable().optional(),
|
||||||
|
isActive: z.boolean().default(true),
|
||||||
|
sentVia: z.string().optional().nullable(),
|
||||||
|
revisionRemark: z.string().optional().nullable()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const quotationItemSchema = z.object({
|
||||||
|
productType: z.string().min(1, 'Please select a product type'),
|
||||||
|
description: z.string().min(2, 'Description must be at least 2 characters'),
|
||||||
|
quantity: z.number().positive('Quantity must be greater than 0'),
|
||||||
|
unit: z.string().optional(),
|
||||||
|
unitPrice: z.number().min(0, 'Unit price must be 0 or greater'),
|
||||||
|
discount: z.number().min(0).nullable().optional(),
|
||||||
|
discountType: z.string().nullable().optional(),
|
||||||
|
taxRate: z.number().min(0).nullable().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
sortOrder: z.number().nullable().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const quotationCustomerSchema = z.object({
|
||||||
|
customerId: z.string().min(1, 'Please select a customer'),
|
||||||
|
role: z.string().min(1, 'Please select a role'),
|
||||||
|
isPrimary: z.boolean().default(false)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const quotationTopicSchema = z.object({
|
||||||
|
id: z.string().optional(),
|
||||||
|
topicType: z.string().min(1, 'Please select a topic type'),
|
||||||
|
title: z.string().min(2, 'Title must be at least 2 characters'),
|
||||||
|
sortOrder: z.number().nullable().optional(),
|
||||||
|
items: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string().optional(),
|
||||||
|
content: z.string().min(1, 'Topic item content is required'),
|
||||||
|
sortOrder: z.number().nullable().optional()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.min(1, 'At least one topic item is required')
|
||||||
|
});
|
||||||
|
|
||||||
|
export const quotationFollowupSchema = z.object({
|
||||||
|
id: z.string().optional(),
|
||||||
|
followupDate: z.string().min(1, 'Follow-up date is required'),
|
||||||
|
followupType: z.string().min(1, 'Please select a follow-up type'),
|
||||||
|
contactId: z.string().optional().nullable(),
|
||||||
|
outcome: z.string().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
nextFollowupDate: z.string().optional().nullable(),
|
||||||
|
nextAction: z.string().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const quotationAttachmentSchema = z.object({
|
||||||
|
id: z.string().optional(),
|
||||||
|
fileName: z.string().min(1, 'Stored file name is required'),
|
||||||
|
originalFileName: z.string().min(1, 'Original file name is required'),
|
||||||
|
filePath: z.string().min(1, 'File path is required'),
|
||||||
|
fileSize: z.number().nullable().optional(),
|
||||||
|
fileType: z.string().nullable().optional(),
|
||||||
|
description: z.string().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const quotationRevisionSchema = z.object({
|
||||||
|
revisionRemark: z.string().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type QuotationFormValues = z.input<typeof quotationSchema>;
|
||||||
|
export type QuotationItemFormValues = z.input<typeof quotationItemSchema>;
|
||||||
|
export type QuotationCustomerFormValues = z.input<typeof quotationCustomerSchema>;
|
||||||
|
export type QuotationTopicFormValues = z.input<typeof quotationTopicSchema>;
|
||||||
|
export type QuotationFollowupFormValues = z.input<typeof quotationFollowupSchema>;
|
||||||
|
export type QuotationAttachmentFormValues = z.input<typeof quotationAttachmentSchema>;
|
||||||
1850
src/features/crm/quotations/server/service.ts
Normal file
1850
src/features/crm/quotations/server/service.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -34,7 +34,17 @@ export const PERMISSIONS = {
|
|||||||
crmEnquiryFollowupRead: 'crm.enquiry.followup.read',
|
crmEnquiryFollowupRead: 'crm.enquiry.followup.read',
|
||||||
crmEnquiryFollowupCreate: 'crm.enquiry.followup.create',
|
crmEnquiryFollowupCreate: 'crm.enquiry.followup.create',
|
||||||
crmEnquiryFollowupUpdate: 'crm.enquiry.followup.update',
|
crmEnquiryFollowupUpdate: 'crm.enquiry.followup.update',
|
||||||
crmEnquiryFollowupDelete: 'crm.enquiry.followup.delete'
|
crmEnquiryFollowupDelete: 'crm.enquiry.followup.delete',
|
||||||
|
crmQuotationRead: 'crm.quotation.read',
|
||||||
|
crmQuotationCreate: 'crm.quotation.create',
|
||||||
|
crmQuotationUpdate: 'crm.quotation.update',
|
||||||
|
crmQuotationDelete: 'crm.quotation.delete',
|
||||||
|
crmQuotationItemManage: 'crm.quotation.item.manage',
|
||||||
|
crmQuotationCustomerManage: 'crm.quotation.customer.manage',
|
||||||
|
crmQuotationTopicManage: 'crm.quotation.topic.manage',
|
||||||
|
crmQuotationFollowupManage: 'crm.quotation.followup.manage',
|
||||||
|
crmQuotationAttachmentManage: 'crm.quotation.attachment.manage',
|
||||||
|
crmQuotationRevisionCreate: 'crm.quotation.revision.create'
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
|
export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
|
||||||
@@ -61,7 +71,17 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] {
|
|||||||
PERMISSIONS.crmEnquiryFollowupRead,
|
PERMISSIONS.crmEnquiryFollowupRead,
|
||||||
PERMISSIONS.crmEnquiryFollowupCreate,
|
PERMISSIONS.crmEnquiryFollowupCreate,
|
||||||
PERMISSIONS.crmEnquiryFollowupUpdate,
|
PERMISSIONS.crmEnquiryFollowupUpdate,
|
||||||
PERMISSIONS.crmEnquiryFollowupDelete
|
PERMISSIONS.crmEnquiryFollowupDelete,
|
||||||
|
PERMISSIONS.crmQuotationRead,
|
||||||
|
PERMISSIONS.crmQuotationCreate,
|
||||||
|
PERMISSIONS.crmQuotationUpdate,
|
||||||
|
PERMISSIONS.crmQuotationDelete,
|
||||||
|
PERMISSIONS.crmQuotationItemManage,
|
||||||
|
PERMISSIONS.crmQuotationCustomerManage,
|
||||||
|
PERMISSIONS.crmQuotationTopicManage,
|
||||||
|
PERMISSIONS.crmQuotationFollowupManage,
|
||||||
|
PERMISSIONS.crmQuotationAttachmentManage,
|
||||||
|
PERMISSIONS.crmQuotationRevisionCreate
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +90,8 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] {
|
|||||||
PERMISSIONS.crmCustomerRead,
|
PERMISSIONS.crmCustomerRead,
|
||||||
PERMISSIONS.crmContactRead,
|
PERMISSIONS.crmContactRead,
|
||||||
PERMISSIONS.crmEnquiryRead,
|
PERMISSIONS.crmEnquiryRead,
|
||||||
PERMISSIONS.crmEnquiryFollowupRead
|
PERMISSIONS.crmEnquiryFollowupRead,
|
||||||
|
PERMISSIONS.crmQuotationRead
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const searchParams = {
|
|||||||
customerType: parseAsString,
|
customerType: parseAsString,
|
||||||
customerStatus: parseAsString,
|
customerStatus: parseAsString,
|
||||||
customer: parseAsString,
|
customer: parseAsString,
|
||||||
|
enquiry: parseAsString,
|
||||||
branch: parseAsString,
|
branch: parseAsString,
|
||||||
productType: parseAsString,
|
productType: parseAsString,
|
||||||
priority: parseAsString,
|
priority: parseAsString,
|
||||||
|
|||||||
Reference in New Issue
Block a user