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,
|
||||
"tag": "0003_blushing_bruce_banner",
|
||||
"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 { getCustomerReferenceData } from '@/features/crm/customers/server/service';
|
||||
import { listCustomerEnquiryRelations } from '@/features/crm/enquiries/server/service';
|
||||
import { listCustomerQuotationRelations } from '@/features/crm/quotations/server/service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { getQueryClient } from '@/lib/query-client';
|
||||
|
||||
@@ -55,6 +56,10 @@ export default async function CustomerDetailRoute({ params }: PageProps) {
|
||||
canRead && session?.user?.activeOrganizationId
|
||||
? await listCustomerEnquiryRelations(id, session.user.activeOrganizationId)
|
||||
: [];
|
||||
const relatedQuotations =
|
||||
canRead && session?.user?.activeOrganizationId
|
||||
? await listCustomerQuotationRelations(id, session.user.activeOrganizationId)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
@@ -79,6 +84,7 @@ export default async function CustomerDetailRoute({ params }: PageProps) {
|
||||
delete: canContactDelete
|
||||
}}
|
||||
relatedEnquiries={relatedEnquiries}
|
||||
relatedQuotations={relatedQuotations}
|
||||
/>
|
||||
</HydrationBoundary>
|
||||
) : null}
|
||||
|
||||
@@ -4,6 +4,7 @@ import PageContainer from '@/components/layout/page-container';
|
||||
import { enquiryByIdOptions, enquiryFollowupsOptions } from '@/features/crm/enquiries/api/queries';
|
||||
import { EnquiryDetail } from '@/features/crm/enquiries/components/enquiry-detail';
|
||||
import { getEnquiryReferenceData } from '@/features/crm/enquiries/server/service';
|
||||
import { listEnquiryQuotationRelations } from '@/features/crm/quotations/server/service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { getQueryClient } from '@/lib/query-client';
|
||||
|
||||
@@ -50,6 +51,10 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
|
||||
canRead && session?.user?.activeOrganizationId
|
||||
? await getEnquiryReferenceData(session.user.activeOrganizationId)
|
||||
: null;
|
||||
const relatedQuotations =
|
||||
canRead && session?.user?.activeOrganizationId
|
||||
? await listEnquiryQuotationRelations(id, session.user.activeOrganizationId)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
@@ -67,6 +72,7 @@ export default async function EnquiryDetailRoute({ params }: PageProps) {
|
||||
<EnquiryDetail
|
||||
enquiryId={id}
|
||||
referenceData={referenceData}
|
||||
relatedQuotations={relatedQuotations}
|
||||
canUpdate={canUpdate}
|
||||
canManageFollowups={{
|
||||
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 { 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 = {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -7,24 +21,90 @@ type PageProps = {
|
||||
|
||||
export default async function QuotationDetailRoute({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
const session = await auth();
|
||||
const canRead =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationRead)));
|
||||
const canUpdate =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationUpdate)));
|
||||
const canManageItems =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationItemManage)));
|
||||
const canManageCustomers =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationCustomerManage)));
|
||||
const canManageTopics =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationTopicManage)));
|
||||
const canManageFollowups =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationFollowupManage)));
|
||||
const canManageAttachments =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationAttachmentManage)));
|
||||
const canCreateRevision =
|
||||
session?.user?.systemRole === 'super_admin' ||
|
||||
(!!session?.user?.activeOrganizationId &&
|
||||
(session.user.activeMembershipRole === 'admin' ||
|
||||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationRevisionCreate)));
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
if (canRead) {
|
||||
void queryClient.prefetchQuery(quotationByIdOptions(id));
|
||||
void queryClient.prefetchQuery(quotationItemsOptions(id));
|
||||
void queryClient.prefetchQuery(quotationCustomersOptions(id));
|
||||
void queryClient.prefetchQuery(quotationTopicsOptions(id));
|
||||
void queryClient.prefetchQuery(quotationFollowupsOptions(id));
|
||||
void queryClient.prefetchQuery(quotationAttachmentsOptions(id));
|
||||
void queryClient.prefetchQuery(quotationRevisionsOptions(id));
|
||||
}
|
||||
|
||||
const referenceData =
|
||||
canRead && session?.user?.activeOrganizationId
|
||||
? await getQuotationReferenceData(session.user.activeOrganizationId)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
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
|
||||
title='Quotation detail pending'
|
||||
summary='This production detail route is isolated from the legacy quotation preview and approval mock flow.'
|
||||
foundationItems={[
|
||||
'Document sequence helper',
|
||||
'Branch scope validation',
|
||||
'Master option lookups',
|
||||
'Audit trail foundation'
|
||||
]}
|
||||
nextStep='Implement production quotation detail with real approval, preview, and related-entity data.'
|
||||
demoHref={`/dashboard/crm-demo/quotations/${id}`}
|
||||
/>
|
||||
{referenceData ? (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<QuotationDetail
|
||||
quotationId={id}
|
||||
referenceData={referenceData}
|
||||
canUpdate={canUpdate}
|
||||
canManageItems={canManageItems}
|
||||
canManageCustomers={canManageCustomers}
|
||||
canManageTopics={canManageTopics}
|
||||
canManageFollowups={canManageFollowups}
|
||||
canManageAttachments={canManageAttachments}
|
||||
canCreateRevision={canCreateRevision}
|
||||
/>
|
||||
</HydrationBoundary>
|
||||
) : null}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,28 +1,68 @@
|
||||
import { auth } from '@/auth';
|
||||
import PageContainer from '@/components/layout/page-container';
|
||||
import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder';
|
||||
import QuotationListing from '@/features/crm/quotations/components/quotation-listing';
|
||||
import { QuotationFormSheetTrigger } from '@/features/crm/quotations/components/quotation-form-sheet';
|
||||
import { getQuotationReferenceData } from '@/features/crm/quotations/server/service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { searchParamsCache } from '@/lib/searchparams';
|
||||
import type { SearchParams } from 'nuqs/server';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Dashboard: CRM Quotations'
|
||||
};
|
||||
|
||||
export default function QuotationsRoute() {
|
||||
type PageProps = {
|
||||
searchParams: Promise<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 (
|
||||
<PageContainer
|
||||
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
|
||||
title='Quotation module pending'
|
||||
summary='The mock quotation list, kanban, and detail experiences now live only under crm-demo.'
|
||||
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'
|
||||
/>
|
||||
{referenceData ? (
|
||||
<QuotationListing referenceData={referenceData} canUpdate={canUpdate} canDelete={canDelete} />
|
||||
) : null}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,49 +35,110 @@ import { NavGroup } from "@/types";
|
||||
*/
|
||||
export const navGroups: NavGroup[] = [
|
||||
{
|
||||
label: "Overview",
|
||||
label: 'Overview',
|
||||
items: [
|
||||
{
|
||||
title: "Dashboard",
|
||||
url: "/dashboard",
|
||||
icon: "dashboard",
|
||||
title: 'Dashboard',
|
||||
url: '/dashboard',
|
||||
icon: 'dashboard',
|
||||
isActive: false,
|
||||
shortcut: ["d", "d"],
|
||||
items: [],
|
||||
shortcut: ['d', 'd'],
|
||||
items: []
|
||||
},
|
||||
{
|
||||
title: "Workspaces",
|
||||
url: "/dashboard/workspaces",
|
||||
icon: "workspace",
|
||||
title: 'Workspaces',
|
||||
url: '/dashboard/workspaces',
|
||||
icon: 'workspace',
|
||||
isActive: false,
|
||||
items: [],
|
||||
access: { systemRole: "super_admin" },
|
||||
access: { systemRole: 'super_admin' }
|
||||
},
|
||||
{
|
||||
title: "Teams",
|
||||
url: "/dashboard/workspaces/team",
|
||||
icon: "teams",
|
||||
title: 'Teams',
|
||||
url: '/dashboard/workspaces/team',
|
||||
icon: 'teams',
|
||||
isActive: false,
|
||||
items: [],
|
||||
access: { requireOrg: true, role: "admin" },
|
||||
access: { requireOrg: true, role: 'admin' }
|
||||
},
|
||||
{
|
||||
title: "Users",
|
||||
url: "/dashboard/users",
|
||||
icon: "teams",
|
||||
shortcut: ["u", "u"],
|
||||
title: 'Users',
|
||||
url: '/dashboard/users',
|
||||
icon: 'teams',
|
||||
shortcut: ['u', 'u'],
|
||||
isActive: false,
|
||||
items: [],
|
||||
access: { requireOrg: true, permission: "users:manage" },
|
||||
access: { requireOrg: true, permission: 'users:manage' }
|
||||
},
|
||||
{
|
||||
title: "Kanban",
|
||||
url: "/dashboard/kanban",
|
||||
icon: "kanban",
|
||||
shortcut: ["k", "k"],
|
||||
title: 'Kanban',
|
||||
url: '/dashboard/kanban',
|
||||
icon: 'kanban',
|
||||
shortcut: ['k', 'k'],
|
||||
isActive: false,
|
||||
items: [],
|
||||
},
|
||||
],
|
||||
items: []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'CRM',
|
||||
items: [
|
||||
{
|
||||
title: 'Customers',
|
||||
url: '/dashboard/crm/customers',
|
||||
icon: 'teams',
|
||||
isActive: false,
|
||||
items: [],
|
||||
access: { requireOrg: true, permission: 'crm.customer.read' }
|
||||
},
|
||||
{
|
||||
title: 'Enquiries',
|
||||
url: '/dashboard/crm/enquiries',
|
||||
icon: 'forms',
|
||||
isActive: false,
|
||||
items: [],
|
||||
access: { requireOrg: true, permission: 'crm.enquiry.read' }
|
||||
},
|
||||
{
|
||||
title: 'Quotations',
|
||||
url: '/dashboard/crm/quotations',
|
||||
icon: 'post',
|
||||
isActive: false,
|
||||
items: [],
|
||||
access: { requireOrg: true, role: 'admin' }
|
||||
},
|
||||
{
|
||||
title: 'Approvals',
|
||||
url: '/dashboard/crm/approvals',
|
||||
icon: 'checks',
|
||||
isActive: false,
|
||||
items: [],
|
||||
access: { requireOrg: true, role: 'admin' }
|
||||
},
|
||||
{
|
||||
title: 'CRM Settings',
|
||||
url: '/dashboard/crm/settings/master-options',
|
||||
icon: 'settings',
|
||||
isActive: false,
|
||||
access: { requireOrg: true, permission: 'organization:manage' },
|
||||
items: [
|
||||
{
|
||||
title: 'Master Options',
|
||||
url: '/dashboard/crm/settings/master-options',
|
||||
access: { requireOrg: true, permission: 'organization:manage' }
|
||||
},
|
||||
{
|
||||
title: 'Document Sequences',
|
||||
url: '/dashboard/crm/settings/document-sequences',
|
||||
access: { requireOrg: true, permission: 'organization:manage' }
|
||||
},
|
||||
{
|
||||
title: 'Templates',
|
||||
url: '/dashboard/crm/settings/templates',
|
||||
access: { requireOrg: true, permission: 'organization:manage' }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
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(),
|
||||
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: 'revised', label: 'Revised', value: 'revised', sortOrder: 9 }
|
||||
],
|
||||
crm_quotation_type: [
|
||||
{ code: 'standard', label: 'Standard', value: 'standard', sortOrder: 1 },
|
||||
{ code: 'budgetary', label: 'Budgetary', value: 'budgetary', sortOrder: 2 },
|
||||
{ code: 'service', label: 'Service', value: 'service', sortOrder: 3 }
|
||||
],
|
||||
crm_product_type: [
|
||||
{ code: 'crane', label: 'Crane', value: 'crane', sortOrder: 1 },
|
||||
{ code: 'dockdoor', label: 'Dock Door', value: 'dockdoor', sortOrder: 2 },
|
||||
@@ -131,6 +136,15 @@ const FOUNDATION_OPTIONS = {
|
||||
{ code: 'USD', label: 'USD', value: 'USD', sortOrder: 2 },
|
||||
{ code: 'EUR', label: 'EUR', value: 'EUR', sortOrder: 3 }
|
||||
],
|
||||
crm_discount_type: [
|
||||
{ code: 'amount', label: 'Amount', value: 'amount', sortOrder: 1 },
|
||||
{ code: 'percent', label: 'Percent', value: 'percent', sortOrder: 2 }
|
||||
],
|
||||
crm_topic_type: [
|
||||
{ code: 'scope', label: 'Scope', value: 'scope', sortOrder: 1 },
|
||||
{ code: 'exclusion', label: 'Exclusion', value: 'exclusion', sortOrder: 2 },
|
||||
{ code: 'payment', label: 'Payment', value: 'payment', sortOrder: 3 }
|
||||
],
|
||||
crm_payment_term: [
|
||||
{ code: 'cash', label: 'Cash', value: 'cash', sortOrder: 1 },
|
||||
{ code: 'credit_30', label: 'Credit 30', value: 'credit_30', sortOrder: 2 },
|
||||
|
||||
@@ -15,6 +15,7 @@ import { CustomerFormSheet } from './customer-form-sheet';
|
||||
import { CustomerContactsTab } from './customer-contacts-tab';
|
||||
import { CustomerStatusBadge } from './customer-status-badge';
|
||||
import type { CustomerRelatedEnquiryItem } from '../api/types';
|
||||
import type { QuotationRelationItem } from '@/features/crm/quotations/api/types';
|
||||
|
||||
function FieldItem({ label, value }: { label: string; value: string | null | undefined }) {
|
||||
return (
|
||||
@@ -30,7 +31,8 @@ export function CustomerDetail({
|
||||
referenceData,
|
||||
canUpdate,
|
||||
canManageContacts,
|
||||
relatedEnquiries
|
||||
relatedEnquiries,
|
||||
relatedQuotations
|
||||
}: {
|
||||
customerId: string;
|
||||
referenceData: CustomerReferenceData;
|
||||
@@ -41,6 +43,7 @@ export function CustomerDetail({
|
||||
delete: boolean;
|
||||
};
|
||||
relatedEnquiries: CustomerRelatedEnquiryItem[];
|
||||
relatedQuotations: QuotationRelationItem[];
|
||||
}) {
|
||||
const { data } = useSuspenseQuery(customerByIdOptions(customerId));
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
@@ -215,14 +218,13 @@ export function CustomerDetail({
|
||||
<CardHeader>
|
||||
<CardTitle>Related Documents</CardTitle>
|
||||
<CardDescription>
|
||||
Production enquiry links are now visible here, while quotation and approval
|
||||
modules stay deferred.
|
||||
Production enquiry and quotation links for this customer.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!relatedEnquiries.length ? (
|
||||
{!relatedEnquiries.length && !relatedQuotations.length ? (
|
||||
<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 className='space-y-3'>
|
||||
@@ -243,6 +245,23 @@ export function CustomerDetail({
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Separator } from '@/components/ui/separator';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { enquiryByIdOptions } from '../api/queries';
|
||||
import type { EnquiryReferenceData } from '../api/types';
|
||||
import type { QuotationRelationItem } from '@/features/crm/quotations/api/types';
|
||||
import { EnquiryFormSheet } from './enquiry-form-sheet';
|
||||
import { EnquiryFollowupsTab } from './enquiry-followups-tab';
|
||||
import { EnquiryStatusBadge } from './enquiry-status-badge';
|
||||
@@ -27,11 +28,13 @@ function FieldItem({ label, value }: { label: string; value: string | null | und
|
||||
export function EnquiryDetail({
|
||||
enquiryId,
|
||||
referenceData,
|
||||
relatedQuotations,
|
||||
canUpdate,
|
||||
canManageFollowups
|
||||
}: {
|
||||
enquiryId: string;
|
||||
referenceData: EnquiryReferenceData;
|
||||
relatedQuotations: QuotationRelationItem[];
|
||||
canUpdate: boolean;
|
||||
canManageFollowups: {
|
||||
create: boolean;
|
||||
@@ -245,14 +248,35 @@ export function EnquiryDetail({
|
||||
<CardHeader>
|
||||
<CardTitle>Related Quotations</CardTitle>
|
||||
<CardDescription>
|
||||
Task D ends at enquiry readiness, so quotation production stays
|
||||
intentionally deferred.
|
||||
Production quotations already linked to this enquiry.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
||||
Quotation linkage will land here in Task E.
|
||||
</div>
|
||||
{!relatedQuotations.length ? (
|
||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
||||
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>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
@@ -178,13 +178,17 @@ async function assertMasterOptionValue(
|
||||
}
|
||||
|
||||
export async function assertCustomerBelongsToOrganization(id: string, organizationId: string) {
|
||||
const customer = await db.query.crmCustomers.findFirst({
|
||||
where: and(
|
||||
eq(crmCustomers.id, id),
|
||||
eq(crmCustomers.organizationId, organizationId),
|
||||
isNull(crmCustomers.deletedAt)
|
||||
const [customer] = await db
|
||||
.select()
|
||||
.from(crmCustomers)
|
||||
.where(
|
||||
and(
|
||||
eq(crmCustomers.id, id),
|
||||
eq(crmCustomers.organizationId, organizationId),
|
||||
isNull(crmCustomers.deletedAt)
|
||||
)
|
||||
)
|
||||
});
|
||||
.limit(1);
|
||||
|
||||
if (!customer) {
|
||||
throw new AuthError('Customer not found', 404);
|
||||
@@ -198,14 +202,18 @@ export async function assertContactBelongsToOrganization(
|
||||
organizationId: string,
|
||||
customerId?: string | null
|
||||
) {
|
||||
const contact = await db.query.crmCustomerContacts.findFirst({
|
||||
where: and(
|
||||
eq(crmCustomerContacts.id, contactId),
|
||||
eq(crmCustomerContacts.organizationId, organizationId),
|
||||
isNull(crmCustomerContacts.deletedAt),
|
||||
...(customerId ? [eq(crmCustomerContacts.customerId, customerId)] : [])
|
||||
const [contact] = await db
|
||||
.select()
|
||||
.from(crmCustomerContacts)
|
||||
.where(
|
||||
and(
|
||||
eq(crmCustomerContacts.id, contactId),
|
||||
eq(crmCustomerContacts.organizationId, organizationId),
|
||||
isNull(crmCustomerContacts.deletedAt),
|
||||
...(customerId ? [eq(crmCustomerContacts.customerId, customerId)] : [])
|
||||
)
|
||||
)
|
||||
});
|
||||
.limit(1);
|
||||
|
||||
if (!contact) {
|
||||
throw new AuthError('Contact not found', 404);
|
||||
@@ -215,13 +223,17 @@ export async function assertContactBelongsToOrganization(
|
||||
}
|
||||
|
||||
async function assertEnquiryBelongsToOrganization(id: string, organizationId: string) {
|
||||
const enquiry = await db.query.crmEnquiries.findFirst({
|
||||
where: and(
|
||||
eq(crmEnquiries.id, id),
|
||||
eq(crmEnquiries.organizationId, organizationId),
|
||||
isNull(crmEnquiries.deletedAt)
|
||||
const [enquiry] = await db
|
||||
.select()
|
||||
.from(crmEnquiries)
|
||||
.where(
|
||||
and(
|
||||
eq(crmEnquiries.id, id),
|
||||
eq(crmEnquiries.organizationId, organizationId),
|
||||
isNull(crmEnquiries.deletedAt)
|
||||
)
|
||||
)
|
||||
});
|
||||
.limit(1);
|
||||
|
||||
if (!enquiry) {
|
||||
throw new AuthError('Enquiry not found', 404);
|
||||
@@ -235,14 +247,18 @@ async function assertFollowupBelongsToEnquiry(
|
||||
enquiryId: string,
|
||||
organizationId: string
|
||||
) {
|
||||
const followup = await db.query.crmEnquiryFollowups.findFirst({
|
||||
where: and(
|
||||
eq(crmEnquiryFollowups.id, followupId),
|
||||
eq(crmEnquiryFollowups.enquiryId, enquiryId),
|
||||
eq(crmEnquiryFollowups.organizationId, organizationId),
|
||||
isNull(crmEnquiryFollowups.deletedAt)
|
||||
const [followup] = await db
|
||||
.select()
|
||||
.from(crmEnquiryFollowups)
|
||||
.where(
|
||||
and(
|
||||
eq(crmEnquiryFollowups.id, followupId),
|
||||
eq(crmEnquiryFollowups.enquiryId, enquiryId),
|
||||
eq(crmEnquiryFollowups.organizationId, organizationId),
|
||||
isNull(crmEnquiryFollowups.deletedAt)
|
||||
)
|
||||
)
|
||||
});
|
||||
.limit(1);
|
||||
|
||||
if (!followup) {
|
||||
throw new AuthError('Follow-up not found', 404);
|
||||
@@ -345,17 +361,21 @@ export async function getEnquiryReferenceData(
|
||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.priority, { organizationId }),
|
||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.leadChannel, { organizationId }),
|
||||
getActiveOptionsByCategory(ENQUIRY_OPTION_CATEGORIES.followupType, { organizationId }),
|
||||
db.query.crmCustomers.findMany({
|
||||
where: and(eq(crmCustomers.organizationId, organizationId), isNull(crmCustomers.deletedAt)),
|
||||
orderBy: [asc(crmCustomers.name)]
|
||||
}),
|
||||
db.query.crmCustomerContacts.findMany({
|
||||
where: and(
|
||||
eq(crmCustomerContacts.organizationId, organizationId),
|
||||
isNull(crmCustomerContacts.deletedAt)
|
||||
),
|
||||
orderBy: [desc(crmCustomerContacts.isPrimary), asc(crmCustomerContacts.name)]
|
||||
})
|
||||
db
|
||||
.select()
|
||||
.from(crmCustomers)
|
||||
.where(and(eq(crmCustomers.organizationId, organizationId), isNull(crmCustomers.deletedAt)))
|
||||
.orderBy(asc(crmCustomers.name)),
|
||||
db
|
||||
.select()
|
||||
.from(crmCustomerContacts)
|
||||
.where(
|
||||
and(
|
||||
eq(crmCustomerContacts.organizationId, organizationId),
|
||||
isNull(crmCustomerContacts.deletedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(crmCustomerContacts.isPrimary), asc(crmCustomerContacts.name))
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -407,20 +427,26 @@ export async function listEnquiries(
|
||||
|
||||
const [customers, contacts, followupCounts] = await Promise.all([
|
||||
customerIds.length
|
||||
? db.query.crmCustomers.findMany({
|
||||
where: and(
|
||||
eq(crmCustomers.organizationId, organizationId),
|
||||
inArray(crmCustomers.id, customerIds)
|
||||
? db
|
||||
.select()
|
||||
.from(crmCustomers)
|
||||
.where(
|
||||
and(
|
||||
eq(crmCustomers.organizationId, organizationId),
|
||||
inArray(crmCustomers.id, customerIds)
|
||||
)
|
||||
)
|
||||
})
|
||||
: [],
|
||||
contactIds.length
|
||||
? db.query.crmCustomerContacts.findMany({
|
||||
where: and(
|
||||
eq(crmCustomerContacts.organizationId, organizationId),
|
||||
inArray(crmCustomerContacts.id, contactIds)
|
||||
? db
|
||||
.select()
|
||||
.from(crmCustomerContacts)
|
||||
.where(
|
||||
and(
|
||||
eq(crmCustomerContacts.organizationId, organizationId),
|
||||
inArray(crmCustomerContacts.id, contactIds)
|
||||
)
|
||||
)
|
||||
})
|
||||
: [],
|
||||
enquiryIds.length
|
||||
? db
|
||||
@@ -618,14 +644,17 @@ export async function softDeleteEnquiry(id: string, organizationId: string, user
|
||||
|
||||
export async function listEnquiryFollowups(id: string, organizationId: string) {
|
||||
await assertEnquiryBelongsToOrganization(id, organizationId);
|
||||
const rows = await db.query.crmEnquiryFollowups.findMany({
|
||||
where: and(
|
||||
eq(crmEnquiryFollowups.enquiryId, id),
|
||||
eq(crmEnquiryFollowups.organizationId, organizationId),
|
||||
isNull(crmEnquiryFollowups.deletedAt)
|
||||
),
|
||||
orderBy: [desc(crmEnquiryFollowups.followupDate)]
|
||||
});
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(crmEnquiryFollowups)
|
||||
.where(
|
||||
and(
|
||||
eq(crmEnquiryFollowups.enquiryId, id),
|
||||
eq(crmEnquiryFollowups.organizationId, organizationId),
|
||||
isNull(crmEnquiryFollowups.deletedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(crmEnquiryFollowups.followupDate));
|
||||
|
||||
return rows.map(mapFollowupRecord);
|
||||
}
|
||||
@@ -713,15 +742,18 @@ export async function listCustomerEnquiryRelations(
|
||||
customerId: string,
|
||||
organizationId: string
|
||||
): Promise<EnquiryCustomerRelationItem[]> {
|
||||
const rows = await db.query.crmEnquiries.findMany({
|
||||
where: and(
|
||||
eq(crmEnquiries.customerId, customerId),
|
||||
eq(crmEnquiries.organizationId, organizationId),
|
||||
isNull(crmEnquiries.deletedAt)
|
||||
),
|
||||
orderBy: [desc(crmEnquiries.updatedAt)],
|
||||
limit: 10
|
||||
});
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(crmEnquiries)
|
||||
.where(
|
||||
and(
|
||||
eq(crmEnquiries.customerId, customerId),
|
||||
eq(crmEnquiries.organizationId, organizationId),
|
||||
isNull(crmEnquiries.deletedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(crmEnquiries.updatedAt))
|
||||
.limit(10);
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
|
||||
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',
|
||||
crmEnquiryFollowupCreate: 'crm.enquiry.followup.create',
|
||||
crmEnquiryFollowupUpdate: 'crm.enquiry.followup.update',
|
||||
crmEnquiryFollowupDelete: 'crm.enquiry.followup.delete'
|
||||
crmEnquiryFollowupDelete: 'crm.enquiry.followup.delete',
|
||||
crmQuotationRead: 'crm.quotation.read',
|
||||
crmQuotationCreate: 'crm.quotation.create',
|
||||
crmQuotationUpdate: 'crm.quotation.update',
|
||||
crmQuotationDelete: 'crm.quotation.delete',
|
||||
crmQuotationItemManage: 'crm.quotation.item.manage',
|
||||
crmQuotationCustomerManage: 'crm.quotation.customer.manage',
|
||||
crmQuotationTopicManage: 'crm.quotation.topic.manage',
|
||||
crmQuotationFollowupManage: 'crm.quotation.followup.manage',
|
||||
crmQuotationAttachmentManage: 'crm.quotation.attachment.manage',
|
||||
crmQuotationRevisionCreate: 'crm.quotation.revision.create'
|
||||
} as const;
|
||||
|
||||
export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
|
||||
@@ -61,7 +71,17 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] {
|
||||
PERMISSIONS.crmEnquiryFollowupRead,
|
||||
PERMISSIONS.crmEnquiryFollowupCreate,
|
||||
PERMISSIONS.crmEnquiryFollowupUpdate,
|
||||
PERMISSIONS.crmEnquiryFollowupDelete
|
||||
PERMISSIONS.crmEnquiryFollowupDelete,
|
||||
PERMISSIONS.crmQuotationRead,
|
||||
PERMISSIONS.crmQuotationCreate,
|
||||
PERMISSIONS.crmQuotationUpdate,
|
||||
PERMISSIONS.crmQuotationDelete,
|
||||
PERMISSIONS.crmQuotationItemManage,
|
||||
PERMISSIONS.crmQuotationCustomerManage,
|
||||
PERMISSIONS.crmQuotationTopicManage,
|
||||
PERMISSIONS.crmQuotationFollowupManage,
|
||||
PERMISSIONS.crmQuotationAttachmentManage,
|
||||
PERMISSIONS.crmQuotationRevisionCreate
|
||||
];
|
||||
}
|
||||
|
||||
@@ -70,7 +90,8 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] {
|
||||
PERMISSIONS.crmCustomerRead,
|
||||
PERMISSIONS.crmContactRead,
|
||||
PERMISSIONS.crmEnquiryRead,
|
||||
PERMISSIONS.crmEnquiryFollowupRead
|
||||
PERMISSIONS.crmEnquiryFollowupRead,
|
||||
PERMISSIONS.crmQuotationRead
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ export const searchParams = {
|
||||
customerType: parseAsString,
|
||||
customerStatus: parseAsString,
|
||||
customer: parseAsString,
|
||||
enquiry: parseAsString,
|
||||
branch: parseAsString,
|
||||
productType: parseAsString,
|
||||
priority: parseAsString,
|
||||
|
||||
Reference in New Issue
Block a user