This commit is contained in:
phaichayon
2026-06-15 16:34:41 +07:00
parent 1d2406483e
commit eae0dee1f2
40 changed files with 9982 additions and 132 deletions

View 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`

View 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

View 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");

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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 เต็ม

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View File

@@ -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}

View File

@@ -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,

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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' }
}
]
}
]
}
];

View File

@@ -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 })
});

View File

@@ -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 },

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,

View 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 });
}
});

View 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)
});

View 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 })
});
}

View 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;
}

View File

@@ -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>
</>
);
}

View 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}
/>
)
}
];
}

File diff suppressed because it is too large Load Diff

View 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} />
</>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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>;

File diff suppressed because it is too large Load Diff

View File

@@ -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
];
}

View File

@@ -14,6 +14,7 @@ export const searchParams = {
customerType: parseAsString,
customerStatus: parseAsString,
customer: parseAsString,
enquiry: parseAsString,
branch: parseAsString,
productType: parseAsString,
priority: parseAsString,