task-f
This commit is contained in:
45
docs/implementation/task-f-approval-production.md
Normal file
45
docs/implementation/task-f-approval-production.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Task F - Approval Production
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Task F delivered the first production-ready approval workflow on top of the CRM foundation, starting with quotation approval and keeping the service layer generic enough for future enquiry, PR, PO, and document flows.
|
||||||
|
|
||||||
|
## Delivered Scope
|
||||||
|
|
||||||
|
- Added approval persistence tables in [src/db/schema.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/db/schema.ts).
|
||||||
|
- Seeded the `quotation_standard_approval` workflow with sequential `sales_manager -> department_manager -> top_manager` steps in [src/db/seeds/foundation.seed.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/db/seeds/foundation.seed.ts).
|
||||||
|
- Added generic approval feature APIs in:
|
||||||
|
- [src/features/foundation/approval/types.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/approval/types.ts)
|
||||||
|
- [src/features/foundation/approval/service.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/approval/service.ts)
|
||||||
|
- [src/features/foundation/approval/queries.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/approval/queries.ts)
|
||||||
|
- [src/features/foundation/approval/mutations.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/approval/mutations.ts)
|
||||||
|
- [src/features/foundation/approval/server/service.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/approval/server/service.ts)
|
||||||
|
- Added production routes:
|
||||||
|
- [src/app/api/crm/approvals/route.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/approvals/route.ts)
|
||||||
|
- [src/app/api/crm/approvals/[id]/route.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/approvals/[id]/route.ts)
|
||||||
|
- [src/app/api/crm/approvals/[id]/approve/route.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/approvals/[id]/approve/route.ts)
|
||||||
|
- [src/app/api/crm/approvals/[id]/reject/route.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/approvals/[id]/reject/route.ts)
|
||||||
|
- [src/app/api/crm/approvals/[id]/return/route.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/approvals/[id]/return/route.ts)
|
||||||
|
- [src/app/api/crm/quotations/[id]/submit-approval/route.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/quotations/[id]/submit-approval/route.ts)
|
||||||
|
- Added production approvals UI:
|
||||||
|
- list page [src/app/dashboard/crm/approvals/page.tsx](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/dashboard/crm/approvals/page.tsx)
|
||||||
|
- detail page [src/app/dashboard/crm/approvals/[id]/page.tsx](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/dashboard/crm/approvals/[id]/page.tsx)
|
||||||
|
- reusable approval panels in [src/features/foundation/approval/components/](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/approval/components)
|
||||||
|
- Replaced the quotation approval placeholder with a real approval tab in [src/features/crm/quotations/components/quotation-detail.tsx](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/components/quotation-detail.tsx) and [src/features/crm/quotations/components/quotation-approval-tab.tsx](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/quotations/components/quotation-approval-tab.tsx).
|
||||||
|
- Added approval permissions and business roles in [src/lib/auth/rbac.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/lib/auth/rbac.ts).
|
||||||
|
- Updated side navigation and search-param plumbing in:
|
||||||
|
- [src/config/nav-config.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/config/nav-config.ts)
|
||||||
|
- [src/lib/searchparams.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/lib/searchparams.ts)
|
||||||
|
|
||||||
|
## Behavioral Notes
|
||||||
|
|
||||||
|
- Quotation submission validates draft or revised status, active customer linkage, and at least one quotation item before opening an approval request.
|
||||||
|
- Final approval promotes quotation status to `approved`.
|
||||||
|
- Reject moves quotation status to `rejected`.
|
||||||
|
- Return or cancel sends quotation back to `draft`.
|
||||||
|
- Approval actions are audited under `crm_approval_request` and `crm_approval_action`.
|
||||||
|
|
||||||
|
## Follow-up
|
||||||
|
|
||||||
|
- Run schema generation and migration creation after reviewing the new tables.
|
||||||
|
- Seed or sync membership permission arrays if non-admin approvers need the new approval permissions immediately in existing environments.
|
||||||
57
drizzle/0005_numerous_blob.sql
Normal file
57
drizzle/0005_numerous_blob.sql
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
CREATE TABLE "crm_approval_actions" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"organization_id" text NOT NULL,
|
||||||
|
"approval_request_id" text NOT NULL,
|
||||||
|
"step_number" integer NOT NULL,
|
||||||
|
"action" text NOT NULL,
|
||||||
|
"remark" text,
|
||||||
|
"acted_by" text NOT NULL,
|
||||||
|
"acted_at" timestamp with time zone DEFAULT now() 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_approval_requests" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"organization_id" text NOT NULL,
|
||||||
|
"workflow_id" text NOT NULL,
|
||||||
|
"entity_type" text NOT NULL,
|
||||||
|
"entity_id" text NOT NULL,
|
||||||
|
"current_step" integer DEFAULT 1 NOT NULL,
|
||||||
|
"status" text NOT NULL,
|
||||||
|
"requested_by" text NOT NULL,
|
||||||
|
"requested_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"completed_at" timestamp with time zone,
|
||||||
|
"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_approval_steps" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"organization_id" text NOT NULL,
|
||||||
|
"workflow_id" text NOT NULL,
|
||||||
|
"step_number" integer NOT NULL,
|
||||||
|
"role_code" text NOT NULL,
|
||||||
|
"role_name" text NOT NULL,
|
||||||
|
"is_required" 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
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "crm_approval_workflows" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"organization_id" text NOT NULL,
|
||||||
|
"code" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"entity_type" text NOT NULL,
|
||||||
|
"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
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "crm_approval_steps_workflow_step_idx" ON "crm_approval_steps" USING btree ("workflow_id","step_number");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "crm_approval_workflows_org_code_idx" ON "crm_approval_workflows" USING btree ("organization_id","code");
|
||||||
2500
drizzle/meta/0005_snapshot.json
Normal file
2500
drizzle/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,13 @@
|
|||||||
"when": 1781511534267,
|
"when": 1781511534267,
|
||||||
"tag": "0004_worthless_ender_wiggin",
|
"tag": "0004_worthless_ender_wiggin",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 5,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781575976324,
|
||||||
|
"tag": "0005_numerous_blob",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
551
plans/task-f.md
Normal file
551
plans/task-f.md
Normal file
@@ -0,0 +1,551 @@
|
|||||||
|
# Task F: Approval Production Module
|
||||||
|
|
||||||
|
## ALLA OS CRM vNext
|
||||||
|
|
||||||
|
คุณคือ Senior Full-stack Engineer
|
||||||
|
|
||||||
|
ให้ implement Approval Production Module โดยต่อยอดจาก:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Task A
|
||||||
|
Task B
|
||||||
|
Task B.1
|
||||||
|
Task C
|
||||||
|
Task D
|
||||||
|
Task E
|
||||||
|
Task E.1
|
||||||
|
```
|
||||||
|
|
||||||
|
Approval Module ต้องเป็น Generic Foundation ที่สามารถใช้กับ:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Quotation
|
||||||
|
Enquiry (future)
|
||||||
|
Purchase Request (future)
|
||||||
|
Purchase Order (future)
|
||||||
|
Document Approval (future)
|
||||||
|
```
|
||||||
|
|
||||||
|
ห้ามผูกกับ Quotation เพียงอย่างเดียว
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# ต้องอ่านก่อน
|
||||||
|
|
||||||
|
```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
|
||||||
|
|
||||||
|
docs/adr/0007-quotation-revision-strategy.md
|
||||||
|
docs/adr/0008-attachment-storage-strategy.md
|
||||||
|
|
||||||
|
Layout.md
|
||||||
|
AGENTS.md
|
||||||
|
.agents/skills/kiranism-shadcn-dashboard
|
||||||
|
|
||||||
|
src/features/foundation/**
|
||||||
|
src/features/crm/customers/**
|
||||||
|
src/features/crm/enquiries/**
|
||||||
|
src/features/crm/quotations/**
|
||||||
|
src/db/schema.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Goal
|
||||||
|
|
||||||
|
สร้าง Approval Engine ที่ reusable ได้
|
||||||
|
|
||||||
|
รองรับ:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Submit For Approval
|
||||||
|
Approve
|
||||||
|
Reject
|
||||||
|
Request Change
|
||||||
|
Approval Timeline
|
||||||
|
Approval History
|
||||||
|
Current Approval Status
|
||||||
|
```
|
||||||
|
|
||||||
|
เริ่มใช้กับ Quotation ก่อน
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Approval Workflow Version 1
|
||||||
|
|
||||||
|
รองรับ sequential approval
|
||||||
|
|
||||||
|
ตัวอย่าง:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Step 1
|
||||||
|
Sales Manager
|
||||||
|
|
||||||
|
Step 2
|
||||||
|
Department Manager
|
||||||
|
|
||||||
|
Step 3
|
||||||
|
Top Manager
|
||||||
|
```
|
||||||
|
|
||||||
|
Approval ต้องเดินทีละ step
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope F1: Approval Schema
|
||||||
|
|
||||||
|
เพิ่ม tables
|
||||||
|
|
||||||
|
```txt
|
||||||
|
crm_approval_workflows
|
||||||
|
crm_approval_steps
|
||||||
|
crm_approval_requests
|
||||||
|
crm_approval_actions
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## crm_approval_workflows
|
||||||
|
|
||||||
|
```txt
|
||||||
|
id
|
||||||
|
organizationId
|
||||||
|
|
||||||
|
code
|
||||||
|
name
|
||||||
|
entityType
|
||||||
|
|
||||||
|
isActive
|
||||||
|
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
deletedAt
|
||||||
|
```
|
||||||
|
|
||||||
|
entityType ตัวอย่าง
|
||||||
|
|
||||||
|
```txt
|
||||||
|
quotation
|
||||||
|
enquiry
|
||||||
|
purchase_request
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## crm_approval_steps
|
||||||
|
|
||||||
|
```txt
|
||||||
|
id
|
||||||
|
organizationId
|
||||||
|
|
||||||
|
workflowId
|
||||||
|
|
||||||
|
stepNumber
|
||||||
|
|
||||||
|
roleCode
|
||||||
|
roleName
|
||||||
|
|
||||||
|
isRequired
|
||||||
|
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
deletedAt
|
||||||
|
```
|
||||||
|
|
||||||
|
ตัวอย่าง
|
||||||
|
|
||||||
|
```txt
|
||||||
|
sales_manager
|
||||||
|
department_manager
|
||||||
|
top_manager
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## crm_approval_requests
|
||||||
|
|
||||||
|
```txt
|
||||||
|
id
|
||||||
|
organizationId
|
||||||
|
|
||||||
|
workflowId
|
||||||
|
|
||||||
|
entityType
|
||||||
|
entityId
|
||||||
|
|
||||||
|
currentStep
|
||||||
|
|
||||||
|
status
|
||||||
|
|
||||||
|
requestedBy
|
||||||
|
requestedAt
|
||||||
|
|
||||||
|
completedAt
|
||||||
|
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
deletedAt
|
||||||
|
```
|
||||||
|
|
||||||
|
status
|
||||||
|
|
||||||
|
```txt
|
||||||
|
pending
|
||||||
|
approved
|
||||||
|
rejected
|
||||||
|
returned
|
||||||
|
cancelled
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## crm_approval_actions
|
||||||
|
|
||||||
|
```txt
|
||||||
|
id
|
||||||
|
organizationId
|
||||||
|
|
||||||
|
approvalRequestId
|
||||||
|
|
||||||
|
stepNumber
|
||||||
|
|
||||||
|
action
|
||||||
|
|
||||||
|
remark
|
||||||
|
|
||||||
|
actedBy
|
||||||
|
actedAt
|
||||||
|
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
deletedAt
|
||||||
|
```
|
||||||
|
|
||||||
|
action
|
||||||
|
|
||||||
|
```txt
|
||||||
|
approve
|
||||||
|
reject
|
||||||
|
return
|
||||||
|
submit
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope F2: Approval Service
|
||||||
|
|
||||||
|
สร้าง:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
src/features/foundation/approval/**
|
||||||
|
```
|
||||||
|
|
||||||
|
ประกอบด้วย
|
||||||
|
|
||||||
|
```txt
|
||||||
|
types.ts
|
||||||
|
service.ts
|
||||||
|
queries.ts
|
||||||
|
mutations.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Functions
|
||||||
|
|
||||||
|
```ts
|
||||||
|
submitForApproval()
|
||||||
|
approveApproval()
|
||||||
|
rejectApproval()
|
||||||
|
returnApproval()
|
||||||
|
cancelApproval()
|
||||||
|
|
||||||
|
getApprovalRequest()
|
||||||
|
getApprovalTimeline()
|
||||||
|
getCurrentApprovalStep()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope F3: Quotation Integration
|
||||||
|
|
||||||
|
เชื่อม Approval เข้ากับ Quotation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Submit For Approval
|
||||||
|
|
||||||
|
จาก:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
draft
|
||||||
|
revised
|
||||||
|
```
|
||||||
|
|
||||||
|
ไป:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
pending_approval
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules
|
||||||
|
|
||||||
|
```txt
|
||||||
|
ต้องมี item อย่างน้อย 1 รายการ
|
||||||
|
ต้องมี customer
|
||||||
|
ต้องไม่ deleted
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Approval Success
|
||||||
|
|
||||||
|
เมื่อทุก step approve
|
||||||
|
|
||||||
|
เปลี่ยน quotation status
|
||||||
|
|
||||||
|
```txt
|
||||||
|
approved
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reject
|
||||||
|
|
||||||
|
เปลี่ยน quotation status
|
||||||
|
|
||||||
|
```txt
|
||||||
|
rejected
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Return
|
||||||
|
|
||||||
|
เปลี่ยน quotation status
|
||||||
|
|
||||||
|
```txt
|
||||||
|
draft
|
||||||
|
```
|
||||||
|
|
||||||
|
และเก็บ remark
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope F4: API Routes
|
||||||
|
|
||||||
|
สร้าง:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
src/app/api/crm/approvals/route.ts
|
||||||
|
src/app/api/crm/approvals/[id]/route.ts
|
||||||
|
|
||||||
|
src/app/api/crm/approvals/[id]/approve/route.ts
|
||||||
|
src/app/api/crm/approvals/[id]/reject/route.ts
|
||||||
|
src/app/api/crm/approvals/[id]/return/route.ts
|
||||||
|
|
||||||
|
src/app/api/crm/quotations/[id]/submit-approval/route.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope F5: Permission Model
|
||||||
|
|
||||||
|
เพิ่ม permissions
|
||||||
|
|
||||||
|
```txt
|
||||||
|
crm.approval.read
|
||||||
|
crm.approval.submit
|
||||||
|
|
||||||
|
crm.approval.approve
|
||||||
|
crm.approval.reject
|
||||||
|
crm.approval.return
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope F6: Approval UI
|
||||||
|
|
||||||
|
แทน placeholder route
|
||||||
|
|
||||||
|
```txt
|
||||||
|
/ dashboard / crm / approvals
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Approval List
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Pending
|
||||||
|
Approved
|
||||||
|
Rejected
|
||||||
|
Returned
|
||||||
|
```
|
||||||
|
|
||||||
|
ใช้
|
||||||
|
|
||||||
|
```txt
|
||||||
|
PageContainer
|
||||||
|
DataTable
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Approval Detail
|
||||||
|
|
||||||
|
แสดง
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Request Information
|
||||||
|
Workflow
|
||||||
|
Current Step
|
||||||
|
Timeline
|
||||||
|
Actions
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope F7: Quotation Detail Integration
|
||||||
|
|
||||||
|
ใน
|
||||||
|
|
||||||
|
```txt
|
||||||
|
/ dashboard / crm / quotations / [id]
|
||||||
|
```
|
||||||
|
|
||||||
|
เพิ่ม
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Approval Tab
|
||||||
|
```
|
||||||
|
|
||||||
|
แสดง
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Current Status
|
||||||
|
Current Step
|
||||||
|
Timeline
|
||||||
|
```
|
||||||
|
|
||||||
|
Actions
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Submit For Approval
|
||||||
|
Approve
|
||||||
|
Reject
|
||||||
|
Return
|
||||||
|
```
|
||||||
|
|
||||||
|
ตาม permission
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope F8: Audit Integration
|
||||||
|
|
||||||
|
ทุก action ต้อง audit
|
||||||
|
|
||||||
|
entityType
|
||||||
|
|
||||||
|
```txt
|
||||||
|
crm_approval_request
|
||||||
|
crm_approval_action
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Scope F9: Seed Data
|
||||||
|
|
||||||
|
เพิ่มใน
|
||||||
|
|
||||||
|
```txt
|
||||||
|
foundation.seed.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Workflow เริ่มต้น
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Quotation Standard Approval
|
||||||
|
```
|
||||||
|
|
||||||
|
Steps
|
||||||
|
|
||||||
|
```txt
|
||||||
|
1 sales_manager
|
||||||
|
2 department_manager
|
||||||
|
3 top_manager
|
||||||
|
```
|
||||||
|
|
||||||
|
Seed แบบ idempotent
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# ห้ามทำ
|
||||||
|
|
||||||
|
```txt
|
||||||
|
Email Notification
|
||||||
|
Line Notification
|
||||||
|
Mobile Push
|
||||||
|
PDF Generation
|
||||||
|
Report
|
||||||
|
Dashboard KPI
|
||||||
|
Parallel Approval
|
||||||
|
Conditional Approval
|
||||||
|
Escalation
|
||||||
|
Delegation
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Output
|
||||||
|
|
||||||
|
สรุป:
|
||||||
|
|
||||||
|
1. Files Added
|
||||||
|
2. Files Modified
|
||||||
|
3. Schema Added
|
||||||
|
4. Approval APIs Added
|
||||||
|
5. Approval UI Added
|
||||||
|
6. Quotation Integration
|
||||||
|
7. Permission Added
|
||||||
|
8. Audit Integration
|
||||||
|
9. Seed Added
|
||||||
|
10. Remaining Risks
|
||||||
|
11. Task G Readiness
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Definition of Done
|
||||||
|
|
||||||
|
Task F ผ่านเมื่อ:
|
||||||
|
|
||||||
|
✔ Approval workflow schema พร้อม
|
||||||
|
|
||||||
|
✔ Approval request พร้อม
|
||||||
|
|
||||||
|
✔ Approval actions พร้อม
|
||||||
|
|
||||||
|
✔ Approval timeline พร้อม
|
||||||
|
|
||||||
|
✔ Submit For Approval ใช้งานได้
|
||||||
|
|
||||||
|
✔ Approve ใช้งานได้
|
||||||
|
|
||||||
|
✔ Reject ใช้งานได้
|
||||||
|
|
||||||
|
✔ Return ใช้งานได้
|
||||||
|
|
||||||
|
✔ Quotation เชื่อม approval แล้ว
|
||||||
|
|
||||||
|
✔ Audit ครบทุก approval action
|
||||||
|
|
||||||
|
✔ Approval UI ใช้งานได้
|
||||||
|
|
||||||
|
✔ ไม่ทำ notification
|
||||||
|
|
||||||
|
✔ ไม่ทำ PDF
|
||||||
|
|
||||||
|
✔ ไม่ทำ report
|
||||||
|
|
||||||
|
✔ พร้อมเริ่ม Task G
|
||||||
43
src/app/api/crm/approvals/[id]/approve/route.ts
Normal file
43
src/app/api/crm/approvals/[id]/approve/route.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { approveApproval } from '@/features/foundation/approval/server/service';
|
||||||
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionSchema = z.object({
|
||||||
|
remark: z.string().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, { params }: Params) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const { organization, session } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmApprovalApprove
|
||||||
|
});
|
||||||
|
const payload = actionSchema.parse(await request.json());
|
||||||
|
await approveApproval(id, organization.id, session.user.id, payload.remark);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Approval completed for current step'
|
||||||
|
});
|
||||||
|
} 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 approve request' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/app/api/crm/approvals/[id]/reject/route.ts
Normal file
43
src/app/api/crm/approvals/[id]/reject/route.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { rejectApproval } from '@/features/foundation/approval/server/service';
|
||||||
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionSchema = z.object({
|
||||||
|
remark: z.string().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, { params }: Params) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const { organization, session } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmApprovalReject
|
||||||
|
});
|
||||||
|
const payload = actionSchema.parse(await request.json());
|
||||||
|
await rejectApproval(id, organization.id, session.user.id, payload.remark);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Approval request rejected'
|
||||||
|
});
|
||||||
|
} 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 reject approval request' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/app/api/crm/approvals/[id]/return/route.ts
Normal file
43
src/app/api/crm/approvals/[id]/return/route.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { returnApproval } from '@/features/foundation/approval/server/service';
|
||||||
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionSchema = z.object({
|
||||||
|
remark: z.string().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, { params }: Params) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const { organization, session } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmApprovalReturn
|
||||||
|
});
|
||||||
|
const payload = actionSchema.parse(await request.json());
|
||||||
|
await returnApproval(id, organization.id, session.user.id, payload.remark);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Approval request returned for change'
|
||||||
|
});
|
||||||
|
} 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 return approval request' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/app/api/crm/approvals/[id]/route.ts
Normal file
56
src/app/api/crm/approvals/[id]/route.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cancelApproval, getApprovalRequest } from '@/features/foundation/approval/server/service';
|
||||||
|
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.crmApprovalRead
|
||||||
|
});
|
||||||
|
const approval = await getApprovalRequest(id, organization.id);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
message: 'Approval detail loaded successfully',
|
||||||
|
approval
|
||||||
|
});
|
||||||
|
} 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 approval detail' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(_request: NextRequest, { params }: Params) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const { organization, session } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmApprovalSubmit
|
||||||
|
});
|
||||||
|
await cancelApproval(id, organization.id, session.user.id);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Approval request cancelled 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 cancel approval request' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
88
src/app/api/crm/approvals/route.ts
Normal file
88
src/app/api/crm/approvals/route.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { listApprovalRequests, submitForApproval } from '@/features/foundation/approval/server/service';
|
||||||
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
|
const submitApprovalSchema = z.object({
|
||||||
|
workflowCode: z.string().optional(),
|
||||||
|
entityType: z.string().min(1, 'Entity type is required'),
|
||||||
|
entityId: z.string().min(1, 'Entity id is required'),
|
||||||
|
remark: z.string().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { organization } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmApprovalRead
|
||||||
|
});
|
||||||
|
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 entityType = searchParams.get('entityType') ?? undefined;
|
||||||
|
const entityId = searchParams.get('entityId') ?? undefined;
|
||||||
|
const sort = searchParams.get('sort') ?? undefined;
|
||||||
|
const result = await listApprovalRequests(organization.id, {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
search,
|
||||||
|
status,
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
sort
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
message: 'Approvals loaded successfully',
|
||||||
|
totalItems: result.totalItems,
|
||||||
|
offset: (page - 1) * limit,
|
||||||
|
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 approvals' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { organization, session } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmApprovalSubmit
|
||||||
|
});
|
||||||
|
const payload = submitApprovalSchema.parse(await request.json());
|
||||||
|
const created = await submitForApproval(organization.id, session.user.id, payload);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
message: 'Approval request submitted successfully',
|
||||||
|
approvalRequest: 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 submit approval request' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/app/api/crm/quotations/[id]/submit-approval/route.ts
Normal file
48
src/app/api/crm/quotations/[id]/submit-approval/route.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { submitForApproval } from '@/features/foundation/approval/server/service';
|
||||||
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitSchema = z.object({
|
||||||
|
remark: z.string().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest, { params }: Params) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const { organization, session } = await requireOrganizationAccess({
|
||||||
|
permission: PERMISSIONS.crmApprovalSubmit
|
||||||
|
});
|
||||||
|
const payload = submitSchema.parse(await request.json());
|
||||||
|
await submitForApproval(organization.id, session.user.id, {
|
||||||
|
workflowCode: 'quotation_standard_approval',
|
||||||
|
entityType: 'quotation',
|
||||||
|
entityId: id,
|
||||||
|
remark: payload.remark
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Quotation submitted for approval 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 submit quotation for approval' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/app/dashboard/crm/approvals/[id]/page.tsx
Normal file
76
src/app/dashboard/crm/approvals/[id]/page.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { auth } from '@/auth';
|
||||||
|
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
||||||
|
import PageContainer from '@/components/layout/page-container';
|
||||||
|
import { ApprovalDetail } from '@/features/foundation/approval/components/approval-detail';
|
||||||
|
import { approvalByIdOptions } from '@/features/foundation/approval/queries';
|
||||||
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
|
import { getQueryClient } from '@/lib/query-client';
|
||||||
|
|
||||||
|
type PageProps = {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function ApprovalDetailRoute({ 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.crmApprovalRead)));
|
||||||
|
const canApprove =
|
||||||
|
session?.user?.systemRole === 'super_admin' ||
|
||||||
|
(!!session?.user?.activeOrganizationId &&
|
||||||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
|
session.user.activePermissions.includes(PERMISSIONS.crmApprovalApprove)));
|
||||||
|
const canReject =
|
||||||
|
session?.user?.systemRole === 'super_admin' ||
|
||||||
|
(!!session?.user?.activeOrganizationId &&
|
||||||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
|
session.user.activePermissions.includes(PERMISSIONS.crmApprovalReject)));
|
||||||
|
const canReturn =
|
||||||
|
session?.user?.systemRole === 'super_admin' ||
|
||||||
|
(!!session?.user?.activeOrganizationId &&
|
||||||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
|
session.user.activePermissions.includes(PERMISSIONS.crmApprovalReturn)));
|
||||||
|
const canCancel =
|
||||||
|
session?.user?.systemRole === 'super_admin' ||
|
||||||
|
(!!session?.user?.activeOrganizationId &&
|
||||||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
|
session.user.activePermissions.includes(PERMISSIONS.crmApprovalSubmit)));
|
||||||
|
const isOrgAdmin =
|
||||||
|
session?.user?.systemRole === 'super_admin' || session?.user?.activeMembershipRole === 'admin';
|
||||||
|
const queryClient = getQueryClient();
|
||||||
|
|
||||||
|
if (canRead) {
|
||||||
|
void queryClient.prefetchQuery(approvalByIdOptions(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer
|
||||||
|
pageTitle='Approval Detail'
|
||||||
|
pageDescription='Workflow steps, current approver, and timeline for a production approval request.'
|
||||||
|
access={canRead}
|
||||||
|
accessFallback={
|
||||||
|
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
|
||||||
|
You do not have access to this approval request.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{canRead && session?.user ? (
|
||||||
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
|
<ApprovalDetail
|
||||||
|
approvalId={id}
|
||||||
|
canApprove={canApprove}
|
||||||
|
canReject={canReject}
|
||||||
|
canReturn={canReturn}
|
||||||
|
canCancel={canCancel}
|
||||||
|
activeBusinessRole={session.user.activeBusinessRole}
|
||||||
|
isOrgAdmin={isOrgAdmin}
|
||||||
|
currentUserId={session.user.id}
|
||||||
|
/>
|
||||||
|
</HydrationBoundary>
|
||||||
|
) : null}
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,28 +1,41 @@
|
|||||||
|
import { auth } from '@/auth';
|
||||||
import PageContainer from '@/components/layout/page-container';
|
import PageContainer from '@/components/layout/page-container';
|
||||||
import { CrmProductionPlaceholder } from '@/features/foundation/components/crm-production-placeholder';
|
import ApprovalListing from '@/features/foundation/approval/components/approval-listing';
|
||||||
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
|
import { searchParamsCache } from '@/lib/searchparams';
|
||||||
|
import type { SearchParams } from 'nuqs/server';
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Dashboard: CRM Approvals'
|
title: 'Dashboard: CRM Approvals'
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ApprovalsRoute() {
|
type PageProps = {
|
||||||
|
searchParams: Promise<SearchParams>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function ApprovalsRoute(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.crmApprovalRead)));
|
||||||
|
|
||||||
|
searchParamsCache.parse(searchParams);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer
|
<PageContainer
|
||||||
pageTitle='Approvals'
|
pageTitle='Approvals'
|
||||||
pageDescription='Production approval workflow has not started yet. This route no longer imports demo approval state.'
|
pageDescription='Production approval queue for quotations, with real workflow routing and action history.'
|
||||||
|
access={canRead}
|
||||||
|
accessFallback={
|
||||||
|
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
|
||||||
|
You do not have access to CRM approvals.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<CrmProductionPlaceholder
|
{canRead ? <ApprovalListing /> : null}
|
||||||
title='Approval workflow pending'
|
|
||||||
summary='The old pending approvals mock list now lives only under crm-demo.'
|
|
||||||
foundationItems={[
|
|
||||||
'Permission checks',
|
|
||||||
'Business-role-aware access helpers',
|
|
||||||
'Branch validation',
|
|
||||||
'Audit log helper'
|
|
||||||
]}
|
|
||||||
nextStep='Implement production approval workflow only after quotation persistence and routing are in place.'
|
|
||||||
demoHref='/dashboard/crm-demo/approvals'
|
|
||||||
/>
|
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from '@/features/crm/quotations/api/queries';
|
} from '@/features/crm/quotations/api/queries';
|
||||||
import { QuotationDetail } from '@/features/crm/quotations/components/quotation-detail';
|
import { QuotationDetail } from '@/features/crm/quotations/components/quotation-detail';
|
||||||
import { getQuotationReferenceData } from '@/features/crm/quotations/server/service';
|
import { getQuotationReferenceData } from '@/features/crm/quotations/server/service';
|
||||||
|
import { approvalsQueryOptions } from '@/features/foundation/approval/queries';
|
||||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
import { getQueryClient } from '@/lib/query-client';
|
import { getQueryClient } from '@/lib/query-client';
|
||||||
|
|
||||||
@@ -62,6 +63,28 @@ export default async function QuotationDetailRoute({ params }: PageProps) {
|
|||||||
(!!session?.user?.activeOrganizationId &&
|
(!!session?.user?.activeOrganizationId &&
|
||||||
(session.user.activeMembershipRole === 'admin' ||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
session.user.activePermissions.includes(PERMISSIONS.crmQuotationRevisionCreate)));
|
session.user.activePermissions.includes(PERMISSIONS.crmQuotationRevisionCreate)));
|
||||||
|
const canSubmitApproval =
|
||||||
|
session?.user?.systemRole === 'super_admin' ||
|
||||||
|
(!!session?.user?.activeOrganizationId &&
|
||||||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
|
session.user.activePermissions.includes(PERMISSIONS.crmApprovalSubmit)));
|
||||||
|
const canApproveApproval =
|
||||||
|
session?.user?.systemRole === 'super_admin' ||
|
||||||
|
(!!session?.user?.activeOrganizationId &&
|
||||||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
|
session.user.activePermissions.includes(PERMISSIONS.crmApprovalApprove)));
|
||||||
|
const canRejectApproval =
|
||||||
|
session?.user?.systemRole === 'super_admin' ||
|
||||||
|
(!!session?.user?.activeOrganizationId &&
|
||||||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
|
session.user.activePermissions.includes(PERMISSIONS.crmApprovalReject)));
|
||||||
|
const canReturnApproval =
|
||||||
|
session?.user?.systemRole === 'super_admin' ||
|
||||||
|
(!!session?.user?.activeOrganizationId &&
|
||||||
|
(session.user.activeMembershipRole === 'admin' ||
|
||||||
|
session.user.activePermissions.includes(PERMISSIONS.crmApprovalReturn)));
|
||||||
|
const isOrgAdmin =
|
||||||
|
session?.user?.systemRole === 'super_admin' || session?.user?.activeMembershipRole === 'admin';
|
||||||
const queryClient = getQueryClient();
|
const queryClient = getQueryClient();
|
||||||
|
|
||||||
if (canRead) {
|
if (canRead) {
|
||||||
@@ -72,6 +95,13 @@ export default async function QuotationDetailRoute({ params }: PageProps) {
|
|||||||
void queryClient.prefetchQuery(quotationFollowupsOptions(id));
|
void queryClient.prefetchQuery(quotationFollowupsOptions(id));
|
||||||
void queryClient.prefetchQuery(quotationAttachmentsOptions(id));
|
void queryClient.prefetchQuery(quotationAttachmentsOptions(id));
|
||||||
void queryClient.prefetchQuery(quotationRevisionsOptions(id));
|
void queryClient.prefetchQuery(quotationRevisionsOptions(id));
|
||||||
|
void queryClient.prefetchQuery(
|
||||||
|
approvalsQueryOptions({
|
||||||
|
entityType: 'quotation',
|
||||||
|
entityId: id,
|
||||||
|
limit: 20
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const referenceData =
|
const referenceData =
|
||||||
@@ -102,6 +132,13 @@ export default async function QuotationDetailRoute({ params }: PageProps) {
|
|||||||
canManageFollowups={canManageFollowups}
|
canManageFollowups={canManageFollowups}
|
||||||
canManageAttachments={canManageAttachments}
|
canManageAttachments={canManageAttachments}
|
||||||
canCreateRevision={canCreateRevision}
|
canCreateRevision={canCreateRevision}
|
||||||
|
canSubmitApproval={canSubmitApproval}
|
||||||
|
canApproveApproval={canApproveApproval}
|
||||||
|
canRejectApproval={canRejectApproval}
|
||||||
|
canReturnApproval={canReturnApproval}
|
||||||
|
activeBusinessRole={session?.user?.activeBusinessRole ?? null}
|
||||||
|
isOrgAdmin={isOrgAdmin}
|
||||||
|
currentUserId={session?.user?.id ?? ''}
|
||||||
/>
|
/>
|
||||||
</HydrationBoundary>
|
</HydrationBoundary>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export const navGroups: NavGroup[] = [
|
|||||||
icon: 'checks',
|
icon: 'checks',
|
||||||
isActive: false,
|
isActive: false,
|
||||||
items: [],
|
items: [],
|
||||||
access: { requireOrg: true, role: 'admin' }
|
access: { requireOrg: true, permission: 'crm.approval.read' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'CRM Settings',
|
title: 'CRM Settings',
|
||||||
|
|||||||
@@ -392,3 +392,76 @@ export const crmQuotationAttachments = pgTable('crm_quotation_attachments', {
|
|||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
deletedAt: timestamp('deleted_at', { withTimezone: true })
|
deletedAt: timestamp('deleted_at', { withTimezone: true })
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const crmApprovalWorkflows = pgTable(
|
||||||
|
'crm_approval_workflows',
|
||||||
|
{
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
organizationId: text('organization_id').notNull(),
|
||||||
|
code: text('code').notNull(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
entityType: text('entity_type').notNull(),
|
||||||
|
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 })
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
organizationCodeIdx: uniqueIndex('crm_approval_workflows_org_code_idx').on(
|
||||||
|
table.organizationId,
|
||||||
|
table.code
|
||||||
|
)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export const crmApprovalSteps = pgTable(
|
||||||
|
'crm_approval_steps',
|
||||||
|
{
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
organizationId: text('organization_id').notNull(),
|
||||||
|
workflowId: text('workflow_id').notNull(),
|
||||||
|
stepNumber: integer('step_number').notNull(),
|
||||||
|
roleCode: text('role_code').notNull(),
|
||||||
|
roleName: text('role_name').notNull(),
|
||||||
|
isRequired: boolean('is_required').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 })
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
workflowStepIdx: uniqueIndex('crm_approval_steps_workflow_step_idx').on(
|
||||||
|
table.workflowId,
|
||||||
|
table.stepNumber
|
||||||
|
)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export const crmApprovalRequests = pgTable('crm_approval_requests', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
organizationId: text('organization_id').notNull(),
|
||||||
|
workflowId: text('workflow_id').notNull(),
|
||||||
|
entityType: text('entity_type').notNull(),
|
||||||
|
entityId: text('entity_id').notNull(),
|
||||||
|
currentStep: integer('current_step').default(1).notNull(),
|
||||||
|
status: text('status').notNull(),
|
||||||
|
requestedBy: text('requested_by').notNull(),
|
||||||
|
requestedAt: timestamp('requested_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
deletedAt: timestamp('deleted_at', { withTimezone: true })
|
||||||
|
});
|
||||||
|
|
||||||
|
export const crmApprovalActions = pgTable('crm_approval_actions', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
organizationId: text('organization_id').notNull(),
|
||||||
|
approvalRequestId: text('approval_request_id').notNull(),
|
||||||
|
stepNumber: integer('step_number').notNull(),
|
||||||
|
action: text('action').notNull(),
|
||||||
|
remark: text('remark'),
|
||||||
|
actedBy: text('acted_by').notNull(),
|
||||||
|
actedAt: timestamp('acted_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
deletedAt: timestamp('deleted_at', { withTimezone: true })
|
||||||
|
});
|
||||||
|
|||||||
@@ -200,6 +200,19 @@ const DOCUMENT_SEQUENCES = [
|
|||||||
{ documentType: 'approval', prefix: 'APV' }
|
{ documentType: 'approval', prefix: 'APV' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const APPROVAL_WORKFLOWS = [
|
||||||
|
{
|
||||||
|
code: 'quotation_standard_approval',
|
||||||
|
name: 'Quotation Standard Approval',
|
||||||
|
entityType: 'quotation',
|
||||||
|
steps: [
|
||||||
|
{ stepNumber: 1, roleCode: 'sales_manager', roleName: 'Sales Manager' },
|
||||||
|
{ stepNumber: 2, roleCode: 'department_manager', roleName: 'Department Manager' },
|
||||||
|
{ stepNumber: 3, roleCode: 'top_manager', roleName: 'Top Manager' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
async function upsertMasterOptionsForOrganization(
|
async function upsertMasterOptionsForOrganization(
|
||||||
sql: SqlClient,
|
sql: SqlClient,
|
||||||
organizationId: string
|
organizationId: string
|
||||||
@@ -306,6 +319,69 @@ async function upsertDocumentSequencesForOrganization(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function upsertApprovalWorkflowsForOrganization(sql: SqlClient, organizationId: string) {
|
||||||
|
for (const workflow of APPROVAL_WORKFLOWS) {
|
||||||
|
const [workflowRow] = await sql`
|
||||||
|
insert into crm_approval_workflows (
|
||||||
|
id,
|
||||||
|
organization_id,
|
||||||
|
code,
|
||||||
|
name,
|
||||||
|
entity_type,
|
||||||
|
is_active,
|
||||||
|
deleted_at
|
||||||
|
) values (
|
||||||
|
${crypto.randomUUID()},
|
||||||
|
${organizationId},
|
||||||
|
${workflow.code},
|
||||||
|
${workflow.name},
|
||||||
|
${workflow.entityType},
|
||||||
|
${true},
|
||||||
|
${null}
|
||||||
|
)
|
||||||
|
on conflict (organization_id, code) do update
|
||||||
|
set
|
||||||
|
name = excluded.name,
|
||||||
|
entity_type = excluded.entity_type,
|
||||||
|
is_active = excluded.is_active,
|
||||||
|
deleted_at = excluded.deleted_at,
|
||||||
|
updated_at = now()
|
||||||
|
returning id
|
||||||
|
`;
|
||||||
|
|
||||||
|
for (const step of workflow.steps) {
|
||||||
|
await sql`
|
||||||
|
insert into crm_approval_steps (
|
||||||
|
id,
|
||||||
|
organization_id,
|
||||||
|
workflow_id,
|
||||||
|
step_number,
|
||||||
|
role_code,
|
||||||
|
role_name,
|
||||||
|
is_required,
|
||||||
|
deleted_at
|
||||||
|
) values (
|
||||||
|
${crypto.randomUUID()},
|
||||||
|
${organizationId},
|
||||||
|
${workflowRow.id},
|
||||||
|
${step.stepNumber},
|
||||||
|
${step.roleCode},
|
||||||
|
${step.roleName},
|
||||||
|
${true},
|
||||||
|
${null}
|
||||||
|
)
|
||||||
|
on conflict (workflow_id, step_number) do update
|
||||||
|
set
|
||||||
|
role_code = excluded.role_code,
|
||||||
|
role_name = excluded.role_name,
|
||||||
|
is_required = excluded.is_required,
|
||||||
|
deleted_at = excluded.deleted_at,
|
||||||
|
updated_at = now()
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
loadLocalEnv();
|
loadLocalEnv();
|
||||||
|
|
||||||
@@ -328,6 +404,7 @@ async function main() {
|
|||||||
for (const organization of organizations) {
|
for (const organization of organizations) {
|
||||||
const branchRows = await upsertMasterOptionsForOrganization(sql, organization.id);
|
const branchRows = await upsertMasterOptionsForOrganization(sql, organization.id);
|
||||||
await upsertDocumentSequencesForOrganization(sql, organization.id, branchRows);
|
await upsertDocumentSequencesForOrganization(sql, organization.id, branchRows);
|
||||||
|
await upsertApprovalWorkflowsForOrganization(sql, organization.id);
|
||||||
console.log(`[seed-foundation] Seeded foundation data for ${organization.name}`);
|
console.log(`[seed-foundation] Seeded foundation data for ${organization.name}`);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useMutation, useQuery, useSuspenseQuery } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Icons } from '@/components/icons';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import {
|
||||||
|
approvalByIdOptions,
|
||||||
|
approvalsQueryOptions
|
||||||
|
} from '@/features/foundation/approval/queries';
|
||||||
|
import { submitQuotationApprovalMutation } from '@/features/foundation/approval/mutations';
|
||||||
|
import { ApprovalRequestPanel } from '@/features/foundation/approval/components/approval-request-panel';
|
||||||
|
import { ApprovalStatusBadge } from '@/features/foundation/approval/components/approval-status-badge';
|
||||||
|
|
||||||
|
export function QuotationApprovalTab({
|
||||||
|
quotationId,
|
||||||
|
quotationStatusCode,
|
||||||
|
canSubmitApproval,
|
||||||
|
canApproveApproval,
|
||||||
|
canRejectApproval,
|
||||||
|
canReturnApproval,
|
||||||
|
activeBusinessRole,
|
||||||
|
isOrgAdmin,
|
||||||
|
currentUserId
|
||||||
|
}: {
|
||||||
|
quotationId: string;
|
||||||
|
quotationStatusCode: string | null;
|
||||||
|
canSubmitApproval: boolean;
|
||||||
|
canApproveApproval: boolean;
|
||||||
|
canRejectApproval: boolean;
|
||||||
|
canReturnApproval: boolean;
|
||||||
|
activeBusinessRole: string | null;
|
||||||
|
isOrgAdmin: boolean;
|
||||||
|
currentUserId: string;
|
||||||
|
}) {
|
||||||
|
const [remark, setRemark] = useState('');
|
||||||
|
const { data: requestsData } = useSuspenseQuery(
|
||||||
|
approvalsQueryOptions({
|
||||||
|
entityType: 'quotation',
|
||||||
|
entityId: quotationId,
|
||||||
|
limit: 20
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const latestRequest = requestsData.items[0] ?? null;
|
||||||
|
const { data: approvalDetailData } = useQuery({
|
||||||
|
...approvalByIdOptions(latestRequest?.id ?? ''),
|
||||||
|
enabled: !!latestRequest
|
||||||
|
});
|
||||||
|
|
||||||
|
const submitApproval = useMutation({
|
||||||
|
...submitQuotationApprovalMutation,
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Quotation submitted for approval');
|
||||||
|
setRemark('');
|
||||||
|
},
|
||||||
|
onError: (error) =>
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Unable to submit quotation')
|
||||||
|
});
|
||||||
|
const canSubmitNow = canSubmitApproval && ['draft', 'revised'].includes(quotationStatusCode ?? '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='space-y-6'>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Current Status</CardTitle>
|
||||||
|
<CardDescription>Submission readiness and latest approval request state.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='space-y-4'>
|
||||||
|
<div className='flex flex-wrap items-center gap-3'>
|
||||||
|
<ApprovalStatusBadge status={latestRequest?.status ?? quotationStatusCode ?? 'draft'} />
|
||||||
|
<span className='text-muted-foreground text-sm'>
|
||||||
|
{latestRequest
|
||||||
|
? `Latest request: ${latestRequest.workflowName}`
|
||||||
|
: 'No approval request has been submitted yet.'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{canSubmitNow ? (
|
||||||
|
<div className='space-y-3 rounded-lg border p-4'>
|
||||||
|
<div className='text-sm font-medium'>Submit quotation into approval flow</div>
|
||||||
|
<Textarea
|
||||||
|
value={remark}
|
||||||
|
onChange={(event) => setRemark(event.target.value)}
|
||||||
|
placeholder='Optional remark for approvers'
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
isLoading={submitApproval.isPending}
|
||||||
|
onClick={() => submitApproval.mutate({ id: quotationId, remark })}
|
||||||
|
>
|
||||||
|
<Icons.send className='mr-2 h-4 w-4' />
|
||||||
|
Submit for Approval
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{approvalDetailData?.approval ? (
|
||||||
|
<ApprovalRequestPanel
|
||||||
|
approval={approvalDetailData.approval}
|
||||||
|
canApprove={canApproveApproval}
|
||||||
|
canReject={canRejectApproval}
|
||||||
|
canReturn={canReturnApproval}
|
||||||
|
canCancel={canSubmitApproval}
|
||||||
|
activeBusinessRole={activeBusinessRole}
|
||||||
|
isOrgAdmin={isOrgAdmin}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Approval Timeline</CardTitle>
|
||||||
|
<CardDescription>Submission and approver history will appear here.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
||||||
|
No approval activity recorded yet.
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -40,7 +40,6 @@ import {
|
|||||||
deleteQuotationFollowupMutation,
|
deleteQuotationFollowupMutation,
|
||||||
deleteQuotationItemMutation,
|
deleteQuotationItemMutation,
|
||||||
deleteQuotationTopicMutation,
|
deleteQuotationTopicMutation,
|
||||||
updateQuotationMutation,
|
|
||||||
updateQuotationAttachmentMutation,
|
updateQuotationAttachmentMutation,
|
||||||
updateQuotationCustomerMutation,
|
updateQuotationCustomerMutation,
|
||||||
updateQuotationFollowupMutation,
|
updateQuotationFollowupMutation,
|
||||||
@@ -69,7 +68,9 @@ import type {
|
|||||||
QuotationTopicMutationPayload,
|
QuotationTopicMutationPayload,
|
||||||
QuotationTopicRecord
|
QuotationTopicRecord
|
||||||
} from '../api/types';
|
} from '../api/types';
|
||||||
|
import { submitQuotationApprovalMutation } from '@/features/foundation/approval/mutations';
|
||||||
import { QuotationFormSheet } from './quotation-form-sheet';
|
import { QuotationFormSheet } from './quotation-form-sheet';
|
||||||
|
import { QuotationApprovalTab } from './quotation-approval-tab';
|
||||||
import { QuotationStatusBadge } from './quotation-status-badge';
|
import { QuotationStatusBadge } from './quotation-status-badge';
|
||||||
|
|
||||||
function FieldItem({ label, value }: { label: string; value: string | null | undefined }) {
|
function FieldItem({ label, value }: { label: string; value: string | null | undefined }) {
|
||||||
@@ -473,7 +474,14 @@ export function QuotationDetail({
|
|||||||
canManageTopics,
|
canManageTopics,
|
||||||
canManageFollowups,
|
canManageFollowups,
|
||||||
canManageAttachments,
|
canManageAttachments,
|
||||||
canCreateRevision
|
canCreateRevision,
|
||||||
|
canSubmitApproval,
|
||||||
|
canApproveApproval,
|
||||||
|
canRejectApproval,
|
||||||
|
canReturnApproval,
|
||||||
|
activeBusinessRole,
|
||||||
|
isOrgAdmin,
|
||||||
|
currentUserId
|
||||||
}: {
|
}: {
|
||||||
quotationId: string;
|
quotationId: string;
|
||||||
referenceData: QuotationReferenceData;
|
referenceData: QuotationReferenceData;
|
||||||
@@ -484,6 +492,13 @@ export function QuotationDetail({
|
|||||||
canManageFollowups: boolean;
|
canManageFollowups: boolean;
|
||||||
canManageAttachments: boolean;
|
canManageAttachments: boolean;
|
||||||
canCreateRevision: boolean;
|
canCreateRevision: boolean;
|
||||||
|
canSubmitApproval: boolean;
|
||||||
|
canApproveApproval: boolean;
|
||||||
|
canRejectApproval: boolean;
|
||||||
|
canReturnApproval: boolean;
|
||||||
|
activeBusinessRole: string | null;
|
||||||
|
isOrgAdmin: boolean;
|
||||||
|
currentUserId: string;
|
||||||
}) {
|
}) {
|
||||||
const { data } = useSuspenseQuery(quotationByIdOptions(quotationId));
|
const { data } = useSuspenseQuery(quotationByIdOptions(quotationId));
|
||||||
const { data: itemsData } = useSuspenseQuery(quotationItemsOptions(quotationId));
|
const { data: itemsData } = useSuspenseQuery(quotationItemsOptions(quotationId));
|
||||||
@@ -611,7 +626,7 @@ export function QuotationDetail({
|
|||||||
onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to create revision')
|
onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to create revision')
|
||||||
});
|
});
|
||||||
const submitForApproval = useMutation({
|
const submitForApproval = useMutation({
|
||||||
...updateQuotationMutation,
|
...submitQuotationApprovalMutation,
|
||||||
onSuccess: () => toast.success('Quotation moved to pending approval'),
|
onSuccess: () => toast.success('Quotation moved to pending approval'),
|
||||||
onError: (error) =>
|
onError: (error) =>
|
||||||
toast.error(error instanceof Error ? error.message : 'Failed to submit for approval')
|
toast.error(error instanceof Error ? error.message : 'Failed to submit for approval')
|
||||||
@@ -655,7 +670,8 @@ export function QuotationDetail({
|
|||||||
const canReviseByStatus = ['accepted', 'rejected', 'sent', 'approved'].includes(
|
const canReviseByStatus = ['accepted', 'rejected', 'sent', 'approved'].includes(
|
||||||
status?.code ?? ''
|
status?.code ?? ''
|
||||||
);
|
);
|
||||||
const canSubmitApproval = ['draft', 'revised'].includes(status?.code ?? '');
|
const canSubmitCurrentQuotation =
|
||||||
|
canSubmitApproval && ['draft', 'revised'].includes(status?.code ?? '');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='space-y-6'>
|
<div className='space-y-6'>
|
||||||
@@ -814,44 +830,11 @@ export function QuotationDetail({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex flex-wrap gap-2'>
|
<div className='flex flex-wrap gap-2'>
|
||||||
{canUpdate && canSubmitApproval ? (
|
{canSubmitCurrentQuotation ? (
|
||||||
<Button
|
<Button
|
||||||
variant='outline'
|
variant='outline'
|
||||||
isLoading={submitForApproval.isPending}
|
isLoading={submitForApproval.isPending}
|
||||||
onClick={() =>
|
onClick={() => submitForApproval.mutate({ id: quotationId })}
|
||||||
submitForApproval.mutate({
|
|
||||||
id: quotationId,
|
|
||||||
values: {
|
|
||||||
enquiryId: quotation.enquiryId,
|
|
||||||
customerId: quotation.customerId,
|
|
||||||
contactId: quotation.contactId,
|
|
||||||
quotationDate: quotation.quotationDate,
|
|
||||||
validUntil: quotation.validUntil,
|
|
||||||
quotationType: quotation.quotationType,
|
|
||||||
projectName: quotation.projectName ?? '',
|
|
||||||
projectLocation: quotation.projectLocation ?? '',
|
|
||||||
attention: quotation.attention ?? '',
|
|
||||||
branchId: quotation.branchId,
|
|
||||||
currency: quotation.currency,
|
|
||||||
exchangeRate: quotation.exchangeRate,
|
|
||||||
status:
|
|
||||||
referenceData.statuses.find((item) => item.code === 'pending_approval')?.id ??
|
|
||||||
quotation.status,
|
|
||||||
chancePercent: quotation.chancePercent,
|
|
||||||
isHotProject: quotation.isHotProject,
|
|
||||||
competitor: quotation.competitor ?? '',
|
|
||||||
reference: quotation.reference ?? '',
|
|
||||||
notes: quotation.notes ?? '',
|
|
||||||
salesmanId: quotation.salesmanId,
|
|
||||||
discount: quotation.discount,
|
|
||||||
discountType: quotation.discountType,
|
|
||||||
taxRate: quotation.taxRate,
|
|
||||||
sentVia: quotation.sentVia,
|
|
||||||
revisionRemark: quotation.revisionRemark,
|
|
||||||
isActive: quotation.isActive
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Icons.send className='mr-2 h-4 w-4' /> Submit for approval
|
<Icons.send className='mr-2 h-4 w-4' /> Submit for approval
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1191,15 +1174,17 @@ export function QuotationDetail({
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value='approval'>
|
<TabsContent value='approval'>
|
||||||
<Card>
|
<QuotationApprovalTab
|
||||||
<CardHeader>
|
quotationId={quotationId}
|
||||||
<CardTitle>Approval Placeholder</CardTitle>
|
quotationStatusCode={status?.code ?? null}
|
||||||
<CardDescription>Approval workflow is deferred beyond Task E.</CardDescription>
|
canSubmitApproval={canSubmitApproval}
|
||||||
</CardHeader>
|
canApproveApproval={canApproveApproval}
|
||||||
<CardContent>
|
canRejectApproval={canRejectApproval}
|
||||||
<EmptyState message='Approval state and approver routing will land in a later task.' />
|
canReturnApproval={canReturnApproval}
|
||||||
</CardContent>
|
activeBusinessRole={activeBusinessRole}
|
||||||
</Card>
|
isOrgAdmin={isOrgAdmin}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value='preview'>
|
<TabsContent value='preview'>
|
||||||
|
|||||||
103
src/features/foundation/approval/components/approval-columns.tsx
Normal file
103
src/features/foundation/approval/components/approval-columns.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import type { Column, ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { DataTableColumnHeader } from '@/components/ui/table/data-table-column-header';
|
||||||
|
import { Icons } from '@/components/icons';
|
||||||
|
import type { ApprovalListItem } from '../types';
|
||||||
|
import { ApprovalStatusBadge } from './approval-status-badge';
|
||||||
|
|
||||||
|
export function getApprovalColumns(): ColumnDef<ApprovalListItem>[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'entityCode',
|
||||||
|
accessorFn: (row) => row.entityCode ?? row.entityId,
|
||||||
|
header: ({ column }: { column: Column<ApprovalListItem, unknown> }) => (
|
||||||
|
<DataTableColumnHeader column={column} title='Request' />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className='flex flex-col'>
|
||||||
|
<Link href={`/dashboard/crm/approvals/${row.original.id}`} className='font-medium hover:underline'>
|
||||||
|
{row.original.entityCode ?? row.original.entityId}
|
||||||
|
</Link>
|
||||||
|
<span className='text-muted-foreground text-xs'>
|
||||||
|
{row.original.workflowName}
|
||||||
|
{row.original.entityTitle ? ` - ${row.original.entityTitle}` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
meta: {
|
||||||
|
label: 'Request',
|
||||||
|
placeholder: 'Search approvals...',
|
||||||
|
variant: 'text' as const,
|
||||||
|
icon: Icons.search
|
||||||
|
},
|
||||||
|
enableColumnFilter: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
accessorKey: 'status',
|
||||||
|
header: ({ column }: { column: Column<ApprovalListItem, unknown> }) => (
|
||||||
|
<DataTableColumnHeader column={column} title='Status' />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => <ApprovalStatusBadge status={row.original.status} />,
|
||||||
|
meta: {
|
||||||
|
label: 'Status',
|
||||||
|
variant: 'multiSelect' as const,
|
||||||
|
options: [
|
||||||
|
{ value: 'pending', label: 'Pending' },
|
||||||
|
{ value: 'approved', label: 'Approved' },
|
||||||
|
{ value: 'rejected', label: 'Rejected' },
|
||||||
|
{ value: 'returned', label: 'Returned' },
|
||||||
|
{ value: 'cancelled', label: 'Cancelled' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
enableColumnFilter: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'entityType',
|
||||||
|
accessorKey: 'entityType',
|
||||||
|
header: ({ column }: { column: Column<ApprovalListItem, unknown> }) => (
|
||||||
|
<DataTableColumnHeader column={column} title='Entity' />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => row.original.entityType.replaceAll('_', ' '),
|
||||||
|
meta: {
|
||||||
|
label: 'Entity',
|
||||||
|
variant: 'multiSelect' as const,
|
||||||
|
options: [{ value: 'quotation', label: 'Quotation' }]
|
||||||
|
},
|
||||||
|
enableColumnFilter: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'currentStep',
|
||||||
|
accessorKey: 'currentStep',
|
||||||
|
header: ({ column }: { column: Column<ApprovalListItem, unknown> }) => (
|
||||||
|
<DataTableColumnHeader column={column} title='Current Step' />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className='flex flex-col'>
|
||||||
|
<span>Step {row.original.currentStep}</span>
|
||||||
|
<span className='text-muted-foreground text-xs'>
|
||||||
|
{row.original.currentStepRoleName ?? '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'requestedBy',
|
||||||
|
accessorFn: (row) => row.requestedByName ?? row.requestedBy,
|
||||||
|
header: ({ column }: { column: Column<ApprovalListItem, unknown> }) => (
|
||||||
|
<DataTableColumnHeader column={column} title='Requested By' />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => row.original.requestedByName ?? row.original.requestedBy
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'requestedAt',
|
||||||
|
accessorKey: 'requestedAt',
|
||||||
|
header: ({ column }: { column: Column<ApprovalListItem, unknown> }) => (
|
||||||
|
<DataTableColumnHeader column={column} title='Requested At' />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => new Date(row.original.requestedAt).toLocaleString()
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||||
|
import { approvalByIdOptions } from '../queries';
|
||||||
|
import { ApprovalRequestPanel } from './approval-request-panel';
|
||||||
|
|
||||||
|
export function ApprovalDetail({
|
||||||
|
approvalId,
|
||||||
|
canApprove,
|
||||||
|
canReject,
|
||||||
|
canReturn,
|
||||||
|
canCancel,
|
||||||
|
activeBusinessRole,
|
||||||
|
isOrgAdmin,
|
||||||
|
currentUserId
|
||||||
|
}: {
|
||||||
|
approvalId: string;
|
||||||
|
canApprove: boolean;
|
||||||
|
canReject: boolean;
|
||||||
|
canReturn: boolean;
|
||||||
|
canCancel: boolean;
|
||||||
|
activeBusinessRole: string | null;
|
||||||
|
isOrgAdmin: boolean;
|
||||||
|
currentUserId: string;
|
||||||
|
}) {
|
||||||
|
const { data } = useSuspenseQuery(approvalByIdOptions(approvalId));
|
||||||
|
const entityHref =
|
||||||
|
data.approval.request.entityType === 'quotation'
|
||||||
|
? `/dashboard/crm/quotations/${data.approval.request.entityId}`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ApprovalRequestPanel
|
||||||
|
approval={data.approval}
|
||||||
|
canApprove={canApprove}
|
||||||
|
canReject={canReject}
|
||||||
|
canReturn={canReturn}
|
||||||
|
canCancel={canCancel}
|
||||||
|
activeBusinessRole={activeBusinessRole}
|
||||||
|
isOrgAdmin={isOrgAdmin}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
entityHref={entityHref}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
||||||
|
import { getQueryClient } from '@/lib/query-client';
|
||||||
|
import { searchParamsCache } from '@/lib/searchparams';
|
||||||
|
import { approvalsQueryOptions } from '../queries';
|
||||||
|
import { ApprovalsTable } from './approvals-table';
|
||||||
|
|
||||||
|
export default function ApprovalListing() {
|
||||||
|
const page = searchParamsCache.get('page');
|
||||||
|
const limit = searchParamsCache.get('perPage');
|
||||||
|
const search = searchParamsCache.get('name');
|
||||||
|
const status = searchParamsCache.get('status');
|
||||||
|
const entityType = searchParamsCache.get('entityType');
|
||||||
|
const sort = searchParamsCache.get('sort');
|
||||||
|
const filters = {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
...(search && { search }),
|
||||||
|
...(status && { status }),
|
||||||
|
...(entityType && { entityType }),
|
||||||
|
...(sort && { sort })
|
||||||
|
};
|
||||||
|
const queryClient = getQueryClient();
|
||||||
|
|
||||||
|
void queryClient.prefetchQuery(approvalsQueryOptions(filters));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
|
<ApprovalsTable />
|
||||||
|
</HydrationBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Icons } from '@/components/icons';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import {
|
||||||
|
approveApprovalMutation,
|
||||||
|
cancelApprovalMutation,
|
||||||
|
rejectApprovalMutation,
|
||||||
|
returnApprovalMutation
|
||||||
|
} from '../mutations';
|
||||||
|
import type { ApprovalDetailRecord } from '../types';
|
||||||
|
import { ApprovalStatusBadge } from './approval-status-badge';
|
||||||
|
|
||||||
|
function FieldItem({ label, value }: { label: string; value: string | null | undefined }) {
|
||||||
|
return (
|
||||||
|
<div className='space-y-1'>
|
||||||
|
<div className='text-muted-foreground text-xs'>{label}</div>
|
||||||
|
<div className='text-sm'>{value || '-'}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ApprovalRequestPanel({
|
||||||
|
approval,
|
||||||
|
canApprove,
|
||||||
|
canReject,
|
||||||
|
canReturn,
|
||||||
|
canCancel,
|
||||||
|
activeBusinessRole,
|
||||||
|
isOrgAdmin,
|
||||||
|
currentUserId,
|
||||||
|
entityHref
|
||||||
|
}: {
|
||||||
|
approval: ApprovalDetailRecord;
|
||||||
|
canApprove: boolean;
|
||||||
|
canReject: boolean;
|
||||||
|
canReturn: boolean;
|
||||||
|
canCancel: boolean;
|
||||||
|
activeBusinessRole: string | null;
|
||||||
|
isOrgAdmin: boolean;
|
||||||
|
currentUserId: string;
|
||||||
|
entityHref?: string;
|
||||||
|
}) {
|
||||||
|
const [remark, setRemark] = useState('');
|
||||||
|
const isPending = approval.request.status === 'pending';
|
||||||
|
const canHandleCurrentStep =
|
||||||
|
!!approval.currentStep &&
|
||||||
|
(isOrgAdmin || activeBusinessRole === approval.currentStep.roleCode);
|
||||||
|
const canCancelCurrentRequest =
|
||||||
|
canCancel && isPending && (isOrgAdmin || approval.request.requestedBy === currentUserId);
|
||||||
|
|
||||||
|
const approveAction = useMutation({
|
||||||
|
...approveApprovalMutation,
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Approval step completed');
|
||||||
|
setRemark('');
|
||||||
|
},
|
||||||
|
onError: (error) =>
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Unable to approve request')
|
||||||
|
});
|
||||||
|
const rejectAction = useMutation({
|
||||||
|
...rejectApprovalMutation,
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Approval request rejected');
|
||||||
|
setRemark('');
|
||||||
|
},
|
||||||
|
onError: (error) =>
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Unable to reject request')
|
||||||
|
});
|
||||||
|
const returnAction = useMutation({
|
||||||
|
...returnApprovalMutation,
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Approval request returned to draft');
|
||||||
|
setRemark('');
|
||||||
|
},
|
||||||
|
onError: (error) =>
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Unable to return request')
|
||||||
|
});
|
||||||
|
const cancelAction = useMutation({
|
||||||
|
...cancelApprovalMutation,
|
||||||
|
onSuccess: () => toast.success('Approval request cancelled'),
|
||||||
|
onError: (error) =>
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Unable to cancel request')
|
||||||
|
});
|
||||||
|
const isActing =
|
||||||
|
approveAction.isPending ||
|
||||||
|
rejectAction.isPending ||
|
||||||
|
returnAction.isPending ||
|
||||||
|
cancelAction.isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='space-y-6'>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Request Information</CardTitle>
|
||||||
|
<CardDescription>Current workflow state for this approval request.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
||||||
|
<FieldItem label='Workflow' value={approval.workflow.name} />
|
||||||
|
<div className='space-y-1'>
|
||||||
|
<div className='text-muted-foreground text-xs'>Status</div>
|
||||||
|
<ApprovalStatusBadge status={approval.request.status} />
|
||||||
|
</div>
|
||||||
|
<FieldItem label='Entity Type' value={approval.request.entityType.replaceAll('_', ' ')} />
|
||||||
|
<FieldItem label='Entity Code' value={approval.entityCode ?? approval.request.entityId} />
|
||||||
|
<FieldItem label='Entity Title' value={approval.entityTitle} />
|
||||||
|
<FieldItem
|
||||||
|
label='Requested At'
|
||||||
|
value={new Date(approval.request.requestedAt).toLocaleString()}
|
||||||
|
/>
|
||||||
|
<FieldItem label='Requested By' value={approval.request.requestedBy} />
|
||||||
|
<FieldItem
|
||||||
|
label='Current Step'
|
||||||
|
value={
|
||||||
|
approval.currentStep
|
||||||
|
? `Step ${approval.currentStep.stepNumber} - ${approval.currentStep.roleName}`
|
||||||
|
: approval.request.status === 'approved'
|
||||||
|
? 'Completed'
|
||||||
|
: '-'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{entityHref ? (
|
||||||
|
<div className='md:col-span-2 xl:col-span-4'>
|
||||||
|
<Button asChild variant='outline'>
|
||||||
|
<Link href={entityHref}>
|
||||||
|
<Icons.arrowRight className='mr-2 h-4 w-4' />
|
||||||
|
Open Related Document
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Workflow</CardTitle>
|
||||||
|
<CardDescription>Sequential approver chain configured for this document.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='space-y-3'>
|
||||||
|
{approval.steps.map((step) => {
|
||||||
|
const isCurrent = approval.currentStep?.id === step.id && isPending;
|
||||||
|
const isCompleted = approval.request.currentStep > step.stepNumber || approval.request.status === 'approved';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={step.id}
|
||||||
|
className='flex items-center justify-between rounded-lg border p-4'
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className='font-medium'>
|
||||||
|
Step {step.stepNumber} - {step.roleName}
|
||||||
|
</div>
|
||||||
|
<div className='text-muted-foreground text-sm'>{step.roleCode}</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant={isCurrent ? 'secondary' : isCompleted ? 'default' : 'outline'}>
|
||||||
|
{isCurrent ? 'Current' : isCompleted ? 'Completed' : 'Pending'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Actions</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Approvers can approve, reject, or return on their assigned step.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='space-y-4'>
|
||||||
|
<Textarea
|
||||||
|
value={remark}
|
||||||
|
onChange={(event) => setRemark(event.target.value)}
|
||||||
|
placeholder='Optional remark for approval history'
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
<div className='flex flex-wrap gap-2'>
|
||||||
|
{canApprove && canHandleCurrentStep && isPending ? (
|
||||||
|
<Button
|
||||||
|
isLoading={approveAction.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
approveAction.mutate({ id: approval.request.id, values: { remark } })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icons.circleCheck className='mr-2 h-4 w-4' />
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{canReject && canHandleCurrentStep && isPending ? (
|
||||||
|
<Button
|
||||||
|
variant='destructive'
|
||||||
|
isLoading={rejectAction.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
rejectAction.mutate({ id: approval.request.id, values: { remark } })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icons.warning className='mr-2 h-4 w-4' />
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{canReturn && canHandleCurrentStep && isPending ? (
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
isLoading={returnAction.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
returnAction.mutate({ id: approval.request.id, values: { remark } })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icons.chevronLeft className='mr-2 h-4 w-4' />
|
||||||
|
Return
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{canCancelCurrentRequest ? (
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
isLoading={cancelAction.isPending}
|
||||||
|
onClick={() => cancelAction.mutate(approval.request.id)}
|
||||||
|
>
|
||||||
|
<Icons.close className='mr-2 h-4 w-4' />
|
||||||
|
Cancel Request
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{!canHandleCurrentStep && isPending ? (
|
||||||
|
<div className='text-muted-foreground text-sm'>
|
||||||
|
Current step is assigned to{' '}
|
||||||
|
<span className='font-medium'>{approval.currentStep?.roleName ?? 'another role'}</span>.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{!isPending ? (
|
||||||
|
<div className='text-muted-foreground text-sm'>
|
||||||
|
This request is already {approval.request.status.replaceAll('_', ' ')}.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{isActing ? (
|
||||||
|
<div className='text-muted-foreground text-xs'>Saving approval action...</div>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Timeline</CardTitle>
|
||||||
|
<CardDescription>Full audit trail of request submission and approval actions.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='space-y-4'>
|
||||||
|
{!approval.timeline.length ? (
|
||||||
|
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
||||||
|
No approval actions recorded yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
approval.timeline.map((item, index) => (
|
||||||
|
<div key={item.id} className='space-y-4'>
|
||||||
|
<div className='flex items-start gap-3'>
|
||||||
|
<div className='bg-primary mt-2 h-2 w-2 rounded-full' />
|
||||||
|
<div className='space-y-1'>
|
||||||
|
<div className='flex flex-wrap items-center gap-2'>
|
||||||
|
<Badge variant='outline' className='capitalize'>
|
||||||
|
{item.action}
|
||||||
|
</Badge>
|
||||||
|
<span className='text-sm font-medium'>
|
||||||
|
{item.actorName ?? item.actedBy}
|
||||||
|
</span>
|
||||||
|
<span className='text-muted-foreground text-xs'>
|
||||||
|
Step {item.stepNumber}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className='text-muted-foreground text-xs'>
|
||||||
|
{new Date(item.actedAt).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
{item.remark ? <div className='text-sm'>{item.remark}</div> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{index < approval.timeline.length - 1 ? <Separator /> : null}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Icons } from '@/components/icons';
|
||||||
|
|
||||||
|
export function ApprovalStatusBadge({ status }: { status: string }) {
|
||||||
|
const normalized = status.toLowerCase();
|
||||||
|
const variant =
|
||||||
|
normalized === 'approved'
|
||||||
|
? 'default'
|
||||||
|
: normalized === 'pending'
|
||||||
|
? 'secondary'
|
||||||
|
: normalized === 'rejected'
|
||||||
|
? 'destructive'
|
||||||
|
: 'outline';
|
||||||
|
const Icon =
|
||||||
|
normalized === 'approved'
|
||||||
|
? Icons.circleCheck
|
||||||
|
: normalized === 'rejected'
|
||||||
|
? Icons.warning
|
||||||
|
: normalized === 'returned'
|
||||||
|
? Icons.chevronLeft
|
||||||
|
: Icons.clock;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge variant={variant} className='capitalize'>
|
||||||
|
<Icon className='h-3 w-3' />
|
||||||
|
{status.replaceAll('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
'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 { approvalsQueryOptions } from '../queries';
|
||||||
|
import { getApprovalColumns } from './approval-columns';
|
||||||
|
|
||||||
|
export function ApprovalsTable() {
|
||||||
|
const columns = useMemo(() => getApprovalColumns(), []);
|
||||||
|
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,
|
||||||
|
entityType: parseAsString,
|
||||||
|
sort: getSortingStateParser(columnIds).withDefault([])
|
||||||
|
});
|
||||||
|
|
||||||
|
const filters = {
|
||||||
|
page: params.page,
|
||||||
|
limit: params.perPage,
|
||||||
|
...(params.name && { search: params.name }),
|
||||||
|
...(params.status && { status: params.status }),
|
||||||
|
...(params.entityType && { entityType: params.entityType }),
|
||||||
|
...(params.sort.length > 0 && { sort: JSON.stringify(params.sort) })
|
||||||
|
};
|
||||||
|
const { data } = useSuspenseQuery(approvalsQueryOptions(filters));
|
||||||
|
const pageCount = Math.ceil(data.totalItems / params.perPage);
|
||||||
|
const { table } = useDataTable({
|
||||||
|
data: data.items,
|
||||||
|
columns,
|
||||||
|
pageCount,
|
||||||
|
shallow: true,
|
||||||
|
debounceMs: 500
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTable table={table}>
|
||||||
|
<DataTableToolbar table={table} />
|
||||||
|
</DataTable>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
src/features/foundation/approval/mutations.ts
Normal file
84
src/features/foundation/approval/mutations.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { mutationOptions } from '@tanstack/react-query';
|
||||||
|
import { getQueryClient } from '@/lib/query-client';
|
||||||
|
import {
|
||||||
|
approveApproval,
|
||||||
|
cancelApproval,
|
||||||
|
rejectApproval,
|
||||||
|
returnApproval,
|
||||||
|
submitApproval,
|
||||||
|
submitQuotationForApproval
|
||||||
|
} from './service';
|
||||||
|
import { approvalKeys } from './queries';
|
||||||
|
import { quotationKeys } from '@/features/crm/quotations/api/queries';
|
||||||
|
import type { ApprovalActionPayload, SubmitApprovalPayload } from './types';
|
||||||
|
|
||||||
|
function invalidateQuotationQueries(quotationId: string) {
|
||||||
|
const queryClient = getQueryClient();
|
||||||
|
queryClient.invalidateQueries({ queryKey: quotationKeys.all });
|
||||||
|
queryClient.invalidateQueries({ queryKey: quotationKeys.detail(quotationId) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateRelatedEntityQueries(approvalId: string) {
|
||||||
|
const queryClient = getQueryClient();
|
||||||
|
const cachedDetail = queryClient.getQueryData<{ approval: { request: { entityType: string; entityId: string } } }>(
|
||||||
|
approvalKeys.detail(approvalId)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cachedDetail?.approval.request.entityType === 'quotation') {
|
||||||
|
invalidateQuotationQueries(cachedDetail.approval.request.entityId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const submitApprovalMutation = mutationOptions({
|
||||||
|
mutationFn: (data: SubmitApprovalPayload) => submitApproval(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
getQueryClient().invalidateQueries({ queryKey: approvalKeys.all });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const submitQuotationApprovalMutation = mutationOptions({
|
||||||
|
mutationFn: ({ id, remark }: { id: string; remark?: string }) => submitQuotationForApproval(id, remark),
|
||||||
|
onSuccess: (_data, variables) => {
|
||||||
|
getQueryClient().invalidateQueries({ queryKey: approvalKeys.all });
|
||||||
|
invalidateQuotationQueries(variables.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const cancelApprovalMutation = mutationOptions({
|
||||||
|
mutationFn: (id: string) => cancelApproval(id),
|
||||||
|
onSuccess: (_data, id) => {
|
||||||
|
getQueryClient().invalidateQueries({ queryKey: approvalKeys.all });
|
||||||
|
getQueryClient().invalidateQueries({ queryKey: approvalKeys.detail(id) });
|
||||||
|
invalidateRelatedEntityQueries(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const approveApprovalMutation = mutationOptions({
|
||||||
|
mutationFn: ({ id, values }: { id: string; values: ApprovalActionPayload }) =>
|
||||||
|
approveApproval(id, values),
|
||||||
|
onSuccess: (_data, variables) => {
|
||||||
|
getQueryClient().invalidateQueries({ queryKey: approvalKeys.all });
|
||||||
|
getQueryClient().invalidateQueries({ queryKey: approvalKeys.detail(variables.id) });
|
||||||
|
invalidateRelatedEntityQueries(variables.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const rejectApprovalMutation = mutationOptions({
|
||||||
|
mutationFn: ({ id, values }: { id: string; values: ApprovalActionPayload }) =>
|
||||||
|
rejectApproval(id, values),
|
||||||
|
onSuccess: (_data, variables) => {
|
||||||
|
getQueryClient().invalidateQueries({ queryKey: approvalKeys.all });
|
||||||
|
getQueryClient().invalidateQueries({ queryKey: approvalKeys.detail(variables.id) });
|
||||||
|
invalidateRelatedEntityQueries(variables.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const returnApprovalMutation = mutationOptions({
|
||||||
|
mutationFn: ({ id, values }: { id: string; values: ApprovalActionPayload }) =>
|
||||||
|
returnApproval(id, values),
|
||||||
|
onSuccess: (_data, variables) => {
|
||||||
|
getQueryClient().invalidateQueries({ queryKey: approvalKeys.all });
|
||||||
|
getQueryClient().invalidateQueries({ queryKey: approvalKeys.detail(variables.id) });
|
||||||
|
invalidateRelatedEntityQueries(variables.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
21
src/features/foundation/approval/queries.ts
Normal file
21
src/features/foundation/approval/queries.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { queryOptions } from '@tanstack/react-query';
|
||||||
|
import { getApprovalById, getApprovals } from './service';
|
||||||
|
import type { ApprovalFilters } from './types';
|
||||||
|
|
||||||
|
export const approvalKeys = {
|
||||||
|
all: ['crm-approvals'] as const,
|
||||||
|
list: (filters: ApprovalFilters) => [...approvalKeys.all, 'list', filters] as const,
|
||||||
|
detail: (id: string) => [...approvalKeys.all, 'detail', id] as const
|
||||||
|
};
|
||||||
|
|
||||||
|
export const approvalsQueryOptions = (filters: ApprovalFilters) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: approvalKeys.list(filters),
|
||||||
|
queryFn: () => getApprovals(filters)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const approvalByIdOptions = (id: string) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: approvalKeys.detail(id),
|
||||||
|
queryFn: () => getApprovalById(id)
|
||||||
|
});
|
||||||
986
src/features/foundation/approval/server/service.ts
Normal file
986
src/features/foundation/approval/server/service.ts
Normal file
@@ -0,0 +1,986 @@
|
|||||||
|
import {
|
||||||
|
and,
|
||||||
|
asc,
|
||||||
|
count,
|
||||||
|
desc,
|
||||||
|
eq,
|
||||||
|
ilike,
|
||||||
|
inArray,
|
||||||
|
isNull,
|
||||||
|
or,
|
||||||
|
type SQL
|
||||||
|
} from 'drizzle-orm';
|
||||||
|
import {
|
||||||
|
crmApprovalActions,
|
||||||
|
crmApprovalRequests,
|
||||||
|
crmApprovalSteps,
|
||||||
|
crmApprovalWorkflows,
|
||||||
|
crmCustomers,
|
||||||
|
crmQuotationItems,
|
||||||
|
crmQuotations,
|
||||||
|
memberships,
|
||||||
|
users
|
||||||
|
} from '@/db/schema';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { AuthError } from '@/lib/auth/session';
|
||||||
|
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||||
|
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||||
|
import type {
|
||||||
|
ApprovalActionRecord,
|
||||||
|
ApprovalDetailRecord,
|
||||||
|
ApprovalFilters,
|
||||||
|
ApprovalListItem,
|
||||||
|
ApprovalRequestRecord,
|
||||||
|
ApprovalStepRecord,
|
||||||
|
ApprovalTimelineItem,
|
||||||
|
ApprovalWorkflowRecord,
|
||||||
|
SubmitApprovalPayload
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
const APPROVAL_REQUEST_STATUSES = {
|
||||||
|
pending: 'pending',
|
||||||
|
approved: 'approved',
|
||||||
|
rejected: 'rejected',
|
||||||
|
returned: 'returned',
|
||||||
|
cancelled: 'cancelled'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const APPROVAL_ACTIONS = {
|
||||||
|
submit: 'submit',
|
||||||
|
approve: 'approve',
|
||||||
|
reject: 'reject',
|
||||||
|
return: 'return',
|
||||||
|
cancel: 'cancel'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const QUOTATION_STATUS_CATEGORY = 'crm_quotation_status';
|
||||||
|
|
||||||
|
function mapWorkflowRecord(row: typeof crmApprovalWorkflows.$inferSelect): ApprovalWorkflowRecord {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
organizationId: row.organizationId,
|
||||||
|
code: row.code,
|
||||||
|
name: row.name,
|
||||||
|
entityType: row.entityType,
|
||||||
|
isActive: row.isActive,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
|
deletedAt: row.deletedAt?.toISOString() ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapStepRecord(row: typeof crmApprovalSteps.$inferSelect): ApprovalStepRecord {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
organizationId: row.organizationId,
|
||||||
|
workflowId: row.workflowId,
|
||||||
|
stepNumber: row.stepNumber,
|
||||||
|
roleCode: row.roleCode,
|
||||||
|
roleName: row.roleName,
|
||||||
|
isRequired: row.isRequired,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
|
deletedAt: row.deletedAt?.toISOString() ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapRequestRecord(row: typeof crmApprovalRequests.$inferSelect): ApprovalRequestRecord {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
organizationId: row.organizationId,
|
||||||
|
workflowId: row.workflowId,
|
||||||
|
entityType: row.entityType,
|
||||||
|
entityId: row.entityId,
|
||||||
|
currentStep: row.currentStep,
|
||||||
|
status: row.status,
|
||||||
|
requestedBy: row.requestedBy,
|
||||||
|
requestedAt: row.requestedAt.toISOString(),
|
||||||
|
completedAt: row.completedAt?.toISOString() ?? null,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
|
deletedAt: row.deletedAt?.toISOString() ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapActionRecord(row: typeof crmApprovalActions.$inferSelect): ApprovalActionRecord {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
organizationId: row.organizationId,
|
||||||
|
approvalRequestId: row.approvalRequestId,
|
||||||
|
stepNumber: row.stepNumber,
|
||||||
|
action: row.action,
|
||||||
|
remark: row.remark,
|
||||||
|
actedBy: row.actedBy,
|
||||||
|
actedAt: row.actedAt.toISOString(),
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
|
deletedAt: row.deletedAt?.toISOString() ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSort(sort?: string) {
|
||||||
|
if (!sort) {
|
||||||
|
return desc(crmApprovalRequests.updatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(sort) as Array<{ id: string; desc?: boolean }>;
|
||||||
|
const [rule] = parsed;
|
||||||
|
|
||||||
|
if (!rule) {
|
||||||
|
return desc(crmApprovalRequests.updatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortMap = {
|
||||||
|
status: crmApprovalRequests.status,
|
||||||
|
currentStep: crmApprovalRequests.currentStep,
|
||||||
|
requestedAt: crmApprovalRequests.requestedAt,
|
||||||
|
updatedAt: crmApprovalRequests.updatedAt
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const column = sortMap[rule.id as keyof typeof sortMap];
|
||||||
|
if (!column) {
|
||||||
|
return desc(crmApprovalRequests.updatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rule.desc ? desc(column) : asc(column);
|
||||||
|
} catch {
|
||||||
|
return desc(crmApprovalRequests.updatedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveQuotationStatusIdByCode(organizationId: string, code: string) {
|
||||||
|
const options = await getActiveOptionsByCategory(QUOTATION_STATUS_CATEGORY, { organizationId });
|
||||||
|
return options.find((option) => option.code === code)?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveQuotationStatusCodeById(organizationId: string, id: string) {
|
||||||
|
const options = await getActiveOptionsByCategory(QUOTATION_STATUS_CATEGORY, { organizationId });
|
||||||
|
return options.find((option) => option.id === id)?.code ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertWorkflowByCode(
|
||||||
|
organizationId: string,
|
||||||
|
code: string,
|
||||||
|
entityType?: string
|
||||||
|
) {
|
||||||
|
const [workflow] = await db
|
||||||
|
.select()
|
||||||
|
.from(crmApprovalWorkflows)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmApprovalWorkflows.organizationId, organizationId),
|
||||||
|
eq(crmApprovalWorkflows.code, code),
|
||||||
|
eq(crmApprovalWorkflows.isActive, true),
|
||||||
|
isNull(crmApprovalWorkflows.deletedAt),
|
||||||
|
...(entityType ? [eq(crmApprovalWorkflows.entityType, entityType)] : [])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!workflow) {
|
||||||
|
throw new AuthError('Approval workflow not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return workflow;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertApprovalRequest(id: string, organizationId: string) {
|
||||||
|
const [request] = await db
|
||||||
|
.select()
|
||||||
|
.from(crmApprovalRequests)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmApprovalRequests.id, id),
|
||||||
|
eq(crmApprovalRequests.organizationId, organizationId),
|
||||||
|
isNull(crmApprovalRequests.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!request) {
|
||||||
|
throw new AuthError('Approval request not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listWorkflowSteps(
|
||||||
|
workflowId: string,
|
||||||
|
organizationId: string
|
||||||
|
): Promise<ApprovalStepRecord[]> {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(crmApprovalSteps)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmApprovalSteps.workflowId, workflowId),
|
||||||
|
eq(crmApprovalSteps.organizationId, organizationId),
|
||||||
|
isNull(crmApprovalSteps.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(asc(crmApprovalSteps.stepNumber));
|
||||||
|
|
||||||
|
return rows.map(mapStepRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertActivePendingRequestForEntity(
|
||||||
|
organizationId: string,
|
||||||
|
entityType: string,
|
||||||
|
entityId: string
|
||||||
|
) {
|
||||||
|
const [request] = await db
|
||||||
|
.select()
|
||||||
|
.from(crmApprovalRequests)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmApprovalRequests.organizationId, organizationId),
|
||||||
|
eq(crmApprovalRequests.entityType, entityType),
|
||||||
|
eq(crmApprovalRequests.entityId, entityId),
|
||||||
|
eq(crmApprovalRequests.status, APPROVAL_REQUEST_STATUSES.pending),
|
||||||
|
isNull(crmApprovalRequests.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return request ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertQuotationForApprovalReadiness(entityId: string, organizationId: string) {
|
||||||
|
const [quotation] = await db
|
||||||
|
.select()
|
||||||
|
.from(crmQuotations)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmQuotations.id, entityId),
|
||||||
|
eq(crmQuotations.organizationId, organizationId),
|
||||||
|
isNull(crmQuotations.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!quotation) {
|
||||||
|
throw new AuthError('Quotation not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusCode = await resolveQuotationStatusCodeById(organizationId, quotation.status);
|
||||||
|
if (!statusCode || !['draft', 'revised'].includes(statusCode)) {
|
||||||
|
throw new AuthError('Only draft or revised quotations can be submitted for approval', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [itemCount] = await db
|
||||||
|
.select({ value: count() })
|
||||||
|
.from(crmQuotationItems)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmQuotationItems.organizationId, organizationId),
|
||||||
|
eq(crmQuotationItems.quotationId, entityId),
|
||||||
|
isNull(crmQuotationItems.deletedAt)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!itemCount?.value) {
|
||||||
|
throw new AuthError('Quotation must contain at least one item before approval', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [customer] = await db
|
||||||
|
.select()
|
||||||
|
.from(crmCustomers)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmCustomers.id, quotation.customerId),
|
||||||
|
eq(crmCustomers.organizationId, organizationId),
|
||||||
|
isNull(crmCustomers.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!customer) {
|
||||||
|
throw new AuthError('Quotation must be linked to an active customer', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return quotation;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncQuotationStatusFromApproval(
|
||||||
|
quotationId: string,
|
||||||
|
organizationId: string,
|
||||||
|
userId: string,
|
||||||
|
nextStatusCode: string
|
||||||
|
) {
|
||||||
|
const statusId = await resolveQuotationStatusIdByCode(organizationId, nextStatusCode);
|
||||||
|
|
||||||
|
if (!statusId) {
|
||||||
|
throw new AuthError(`Quotation status ${nextStatusCode} is not configured`, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(crmQuotations)
|
||||||
|
.set({
|
||||||
|
status: statusId,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
updatedBy: userId
|
||||||
|
})
|
||||||
|
.where(eq(crmQuotations.id, quotationId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncEntityStatusFromApproval(
|
||||||
|
organizationId: string,
|
||||||
|
entityType: string,
|
||||||
|
entityId: string,
|
||||||
|
userId: string,
|
||||||
|
approvalStatus: string
|
||||||
|
) {
|
||||||
|
if (entityType !== 'quotation') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (approvalStatus === APPROVAL_REQUEST_STATUSES.pending) {
|
||||||
|
await syncQuotationStatusFromApproval(entityId, organizationId, userId, 'pending_approval');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (approvalStatus === APPROVAL_REQUEST_STATUSES.approved) {
|
||||||
|
await syncQuotationStatusFromApproval(entityId, organizationId, userId, 'approved');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (approvalStatus === APPROVAL_REQUEST_STATUSES.rejected) {
|
||||||
|
await syncQuotationStatusFromApproval(entityId, organizationId, userId, 'rejected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (approvalStatus === APPROVAL_REQUEST_STATUSES.returned) {
|
||||||
|
await syncQuotationStatusFromApproval(entityId, organizationId, userId, 'draft');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertActorCanHandleStep(
|
||||||
|
organizationId: string,
|
||||||
|
userId: string,
|
||||||
|
roleCode: string
|
||||||
|
) {
|
||||||
|
const [userRow] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
|
||||||
|
|
||||||
|
if (!userRow) {
|
||||||
|
throw new AuthError('User not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userRow.systemRole === 'super_admin') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [membership] = await db
|
||||||
|
.select()
|
||||||
|
.from(memberships)
|
||||||
|
.where(and(eq(memberships.organizationId, organizationId), eq(memberships.userId, userId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!membership) {
|
||||||
|
throw new AuthError('Organization membership required', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (membership.role === 'admin' || membership.businessRole === roleCode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new AuthError('You are not allowed to act on this approval step', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveEntitySummaries(
|
||||||
|
organizationId: string,
|
||||||
|
items: Array<{ entityType: string; entityId: string }>
|
||||||
|
) {
|
||||||
|
const quotationIds = items
|
||||||
|
.filter((item) => item.entityType === 'quotation')
|
||||||
|
.map((item) => item.entityId);
|
||||||
|
const quotations = quotationIds.length
|
||||||
|
? await db
|
||||||
|
.select({
|
||||||
|
id: crmQuotations.id,
|
||||||
|
code: crmQuotations.code,
|
||||||
|
projectName: crmQuotations.projectName
|
||||||
|
})
|
||||||
|
.from(crmQuotations)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmQuotations.organizationId, organizationId),
|
||||||
|
inArray(crmQuotations.id, quotationIds),
|
||||||
|
isNull(crmQuotations.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return new Map(
|
||||||
|
quotations.map((quotation) => [
|
||||||
|
`quotation:${quotation.id}`,
|
||||||
|
{
|
||||||
|
entityCode: quotation.code,
|
||||||
|
entityTitle: quotation.projectName ?? quotation.code
|
||||||
|
}
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildApprovalFilters(organizationId: string, filters: ApprovalFilters): SQL[] {
|
||||||
|
return [
|
||||||
|
eq(crmApprovalRequests.organizationId, organizationId),
|
||||||
|
isNull(crmApprovalRequests.deletedAt),
|
||||||
|
...(filters.status ? [eq(crmApprovalRequests.status, filters.status)] : []),
|
||||||
|
...(filters.entityType ? [eq(crmApprovalRequests.entityType, filters.entityType)] : []),
|
||||||
|
...(filters.entityId ? [eq(crmApprovalRequests.entityId, filters.entityId)] : []),
|
||||||
|
...(filters.search
|
||||||
|
? [
|
||||||
|
or(
|
||||||
|
ilike(crmApprovalRequests.entityId, `%${filters.search}%`),
|
||||||
|
ilike(crmApprovalRequests.status, `%${filters.search}%`)
|
||||||
|
)!
|
||||||
|
]
|
||||||
|
: [])
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listApprovalRequests(
|
||||||
|
organizationId: string,
|
||||||
|
filters: ApprovalFilters
|
||||||
|
): Promise<{ items: ApprovalListItem[]; totalItems: number }> {
|
||||||
|
const page = filters.page ?? 1;
|
||||||
|
const limit = filters.limit ?? 10;
|
||||||
|
const whereFilters = buildApprovalFilters(organizationId, filters);
|
||||||
|
const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters);
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const [totalResult] = await db.select({ value: count() }).from(crmApprovalRequests).where(where);
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(crmApprovalRequests)
|
||||||
|
.where(where)
|
||||||
|
.orderBy(parseSort(filters.sort))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
const workflowIds = [...new Set(rows.map((row) => row.workflowId))];
|
||||||
|
const requesterIds = [...new Set(rows.map((row) => row.requestedBy))];
|
||||||
|
const [workflows, requesters, entitySummaries] = await Promise.all([
|
||||||
|
workflowIds.length
|
||||||
|
? db
|
||||||
|
.select()
|
||||||
|
.from(crmApprovalWorkflows)
|
||||||
|
.where(inArray(crmApprovalWorkflows.id, workflowIds))
|
||||||
|
: [],
|
||||||
|
requesterIds.length ? db.select({ id: users.id, name: users.name }).from(users).where(inArray(users.id, requesterIds)) : [],
|
||||||
|
resolveEntitySummaries(
|
||||||
|
organizationId,
|
||||||
|
rows.map((row) => ({ entityType: row.entityType, entityId: row.entityId }))
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
const workflowMap = new Map(workflows.map((row) => [row.id, row]));
|
||||||
|
const requesterMap = new Map(requesters.map((row) => [row.id, row.name]));
|
||||||
|
const stepMap = new Map<string, ApprovalStepRecord>();
|
||||||
|
|
||||||
|
if (workflowIds.length) {
|
||||||
|
const steps = await db
|
||||||
|
.select()
|
||||||
|
.from(crmApprovalSteps)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmApprovalSteps.organizationId, organizationId),
|
||||||
|
inArray(crmApprovalSteps.workflowId, workflowIds),
|
||||||
|
isNull(crmApprovalSteps.deletedAt)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const step of steps) {
|
||||||
|
stepMap.set(`${step.workflowId}:${step.stepNumber}`, mapStepRecord(step));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: rows.map((row) => {
|
||||||
|
const workflow = workflowMap.get(row.workflowId);
|
||||||
|
const currentStep = stepMap.get(`${row.workflowId}:${row.currentStep}`) ?? null;
|
||||||
|
const entitySummary = entitySummaries.get(`${row.entityType}:${row.entityId}`) ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...mapRequestRecord(row),
|
||||||
|
workflowCode: workflow?.code ?? '',
|
||||||
|
workflowName: workflow?.name ?? '',
|
||||||
|
currentStepRoleCode: currentStep?.roleCode ?? null,
|
||||||
|
currentStepRoleName: currentStep?.roleName ?? null,
|
||||||
|
requestedByName: requesterMap.get(row.requestedBy) ?? null,
|
||||||
|
entityCode: entitySummary?.entityCode ?? null,
|
||||||
|
entityTitle: entitySummary?.entityTitle ?? null
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
totalItems: totalResult?.value ?? 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getApprovalTimeline(
|
||||||
|
approvalRequestId: string,
|
||||||
|
organizationId: string
|
||||||
|
): Promise<ApprovalTimelineItem[]> {
|
||||||
|
await assertApprovalRequest(approvalRequestId, organizationId);
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(crmApprovalActions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmApprovalActions.approvalRequestId, approvalRequestId),
|
||||||
|
eq(crmApprovalActions.organizationId, organizationId),
|
||||||
|
isNull(crmApprovalActions.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(asc(crmApprovalActions.actedAt), asc(crmApprovalActions.createdAt));
|
||||||
|
const actorIds = [...new Set(rows.map((row) => row.actedBy))];
|
||||||
|
const actors = actorIds.length
|
||||||
|
? await db.select({ id: users.id, name: users.name }).from(users).where(inArray(users.id, actorIds))
|
||||||
|
: [];
|
||||||
|
const actorMap = new Map(actors.map((row) => [row.id, row.name]));
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
...mapActionRecord(row),
|
||||||
|
actorName: actorMap.get(row.actedBy) ?? null
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCurrentApprovalStep(
|
||||||
|
approvalRequestId: string,
|
||||||
|
organizationId: string
|
||||||
|
): Promise<ApprovalStepRecord | null> {
|
||||||
|
const request = await assertApprovalRequest(approvalRequestId, organizationId);
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(crmApprovalSteps)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(crmApprovalSteps.workflowId, request.workflowId),
|
||||||
|
eq(crmApprovalSteps.organizationId, organizationId),
|
||||||
|
eq(crmApprovalSteps.stepNumber, request.currentStep),
|
||||||
|
isNull(crmApprovalSteps.deletedAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return row ? mapStepRecord(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getApprovalRequest(
|
||||||
|
id: string,
|
||||||
|
organizationId: string
|
||||||
|
): Promise<ApprovalDetailRecord> {
|
||||||
|
const requestRow = await assertApprovalRequest(id, organizationId);
|
||||||
|
const [workflowRow] = await db
|
||||||
|
.select()
|
||||||
|
.from(crmApprovalWorkflows)
|
||||||
|
.where(eq(crmApprovalWorkflows.id, requestRow.workflowId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!workflowRow) {
|
||||||
|
throw new AuthError('Approval workflow not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [steps, timeline, entitySummaries] = await Promise.all([
|
||||||
|
listWorkflowSteps(requestRow.workflowId, organizationId),
|
||||||
|
getApprovalTimeline(id, organizationId),
|
||||||
|
resolveEntitySummaries(organizationId, [
|
||||||
|
{ entityType: requestRow.entityType, entityId: requestRow.entityId }
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
const currentStep = steps.find((step) => step.stepNumber === requestRow.currentStep) ?? null;
|
||||||
|
const entitySummary =
|
||||||
|
entitySummaries.get(`${requestRow.entityType}:${requestRow.entityId}`) ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
request: mapRequestRecord(requestRow),
|
||||||
|
workflow: mapWorkflowRecord(workflowRow),
|
||||||
|
steps,
|
||||||
|
currentStep,
|
||||||
|
timeline,
|
||||||
|
entityCode: entitySummary?.entityCode ?? null,
|
||||||
|
entityTitle: entitySummary?.entityTitle ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitForApproval(
|
||||||
|
organizationId: string,
|
||||||
|
userId: string,
|
||||||
|
payload: SubmitApprovalPayload
|
||||||
|
) {
|
||||||
|
const workflow = await assertWorkflowByCode(
|
||||||
|
organizationId,
|
||||||
|
payload.workflowCode ?? `${payload.entityType}_standard_approval`,
|
||||||
|
payload.entityType
|
||||||
|
);
|
||||||
|
const steps = await listWorkflowSteps(workflow.id, organizationId);
|
||||||
|
|
||||||
|
if (!steps.length) {
|
||||||
|
throw new AuthError('Approval workflow has no steps', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingRequest = await assertActivePendingRequestForEntity(
|
||||||
|
organizationId,
|
||||||
|
payload.entityType,
|
||||||
|
payload.entityId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingRequest) {
|
||||||
|
throw new AuthError('This document already has a pending approval request', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.entityType === 'quotation') {
|
||||||
|
await assertQuotationForApprovalReadiness(payload.entityId, organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [createdRequest] = await db
|
||||||
|
.insert(crmApprovalRequests)
|
||||||
|
.values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
organizationId,
|
||||||
|
workflowId: workflow.id,
|
||||||
|
entityType: payload.entityType,
|
||||||
|
entityId: payload.entityId,
|
||||||
|
currentStep: steps[0].stepNumber,
|
||||||
|
status: APPROVAL_REQUEST_STATUSES.pending,
|
||||||
|
requestedBy: userId
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const [createdAction] = await db
|
||||||
|
.insert(crmApprovalActions)
|
||||||
|
.values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
organizationId,
|
||||||
|
approvalRequestId: createdRequest.id,
|
||||||
|
stepNumber: 0,
|
||||||
|
action: APPROVAL_ACTIONS.submit,
|
||||||
|
remark: payload.remark?.trim() || null,
|
||||||
|
actedBy: userId
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await syncEntityStatusFromApproval(
|
||||||
|
organizationId,
|
||||||
|
payload.entityType,
|
||||||
|
payload.entityId,
|
||||||
|
userId,
|
||||||
|
APPROVAL_REQUEST_STATUSES.pending
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
auditAction({
|
||||||
|
organizationId,
|
||||||
|
userId,
|
||||||
|
entityType: 'crm_approval_request',
|
||||||
|
entityId: createdRequest.id,
|
||||||
|
action: APPROVAL_ACTIONS.submit,
|
||||||
|
afterData: createdRequest
|
||||||
|
}),
|
||||||
|
auditAction({
|
||||||
|
organizationId,
|
||||||
|
userId,
|
||||||
|
entityType: 'crm_approval_action',
|
||||||
|
entityId: createdAction.id,
|
||||||
|
action: APPROVAL_ACTIONS.submit,
|
||||||
|
afterData: createdAction
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
return createdRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function approveApproval(
|
||||||
|
approvalRequestId: string,
|
||||||
|
organizationId: string,
|
||||||
|
userId: string,
|
||||||
|
remark?: string
|
||||||
|
) {
|
||||||
|
const request = await assertApprovalRequest(approvalRequestId, organizationId);
|
||||||
|
|
||||||
|
if (request.status !== APPROVAL_REQUEST_STATUSES.pending) {
|
||||||
|
throw new AuthError('Only pending approvals can be approved', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const steps = await listWorkflowSteps(request.workflowId, organizationId);
|
||||||
|
const currentStep = steps.find((step) => step.stepNumber === request.currentStep);
|
||||||
|
|
||||||
|
if (!currentStep) {
|
||||||
|
throw new AuthError('Current approval step not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
await assertActorCanHandleStep(organizationId, userId, currentStep.roleCode);
|
||||||
|
const nextStep = steps.find((step) => step.stepNumber > currentStep.stepNumber) ?? null;
|
||||||
|
|
||||||
|
const [createdAction] = await db
|
||||||
|
.insert(crmApprovalActions)
|
||||||
|
.values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
organizationId,
|
||||||
|
approvalRequestId,
|
||||||
|
stepNumber: currentStep.stepNumber,
|
||||||
|
action: APPROVAL_ACTIONS.approve,
|
||||||
|
remark: remark?.trim() || null,
|
||||||
|
actedBy: userId
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const [updatedRequest] = await db
|
||||||
|
.update(crmApprovalRequests)
|
||||||
|
.set({
|
||||||
|
currentStep: nextStep?.stepNumber ?? currentStep.stepNumber,
|
||||||
|
status: nextStep ? APPROVAL_REQUEST_STATUSES.pending : APPROVAL_REQUEST_STATUSES.approved,
|
||||||
|
completedAt: nextStep ? null : new Date(),
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(eq(crmApprovalRequests.id, approvalRequestId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!nextStep) {
|
||||||
|
await syncEntityStatusFromApproval(
|
||||||
|
organizationId,
|
||||||
|
request.entityType,
|
||||||
|
request.entityId,
|
||||||
|
userId,
|
||||||
|
APPROVAL_REQUEST_STATUSES.approved
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
auditAction({
|
||||||
|
organizationId,
|
||||||
|
userId,
|
||||||
|
entityType: 'crm_approval_request',
|
||||||
|
entityId: approvalRequestId,
|
||||||
|
action: APPROVAL_ACTIONS.approve,
|
||||||
|
beforeData: request,
|
||||||
|
afterData: updatedRequest
|
||||||
|
}),
|
||||||
|
auditAction({
|
||||||
|
organizationId,
|
||||||
|
userId,
|
||||||
|
entityType: 'crm_approval_action',
|
||||||
|
entityId: createdAction.id,
|
||||||
|
action: APPROVAL_ACTIONS.approve,
|
||||||
|
afterData: createdAction
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
return updatedRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rejectApproval(
|
||||||
|
approvalRequestId: string,
|
||||||
|
organizationId: string,
|
||||||
|
userId: string,
|
||||||
|
remark?: string
|
||||||
|
) {
|
||||||
|
const request = await assertApprovalRequest(approvalRequestId, organizationId);
|
||||||
|
|
||||||
|
if (request.status !== APPROVAL_REQUEST_STATUSES.pending) {
|
||||||
|
throw new AuthError('Only pending approvals can be rejected', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentStep = await getCurrentApprovalStep(approvalRequestId, organizationId);
|
||||||
|
|
||||||
|
if (!currentStep) {
|
||||||
|
throw new AuthError('Current approval step not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
await assertActorCanHandleStep(organizationId, userId, currentStep.roleCode);
|
||||||
|
|
||||||
|
const [createdAction] = await db
|
||||||
|
.insert(crmApprovalActions)
|
||||||
|
.values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
organizationId,
|
||||||
|
approvalRequestId,
|
||||||
|
stepNumber: currentStep.stepNumber,
|
||||||
|
action: APPROVAL_ACTIONS.reject,
|
||||||
|
remark: remark?.trim() || null,
|
||||||
|
actedBy: userId
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const [updatedRequest] = await db
|
||||||
|
.update(crmApprovalRequests)
|
||||||
|
.set({
|
||||||
|
status: APPROVAL_REQUEST_STATUSES.rejected,
|
||||||
|
completedAt: new Date(),
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(eq(crmApprovalRequests.id, approvalRequestId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await syncEntityStatusFromApproval(
|
||||||
|
organizationId,
|
||||||
|
request.entityType,
|
||||||
|
request.entityId,
|
||||||
|
userId,
|
||||||
|
APPROVAL_REQUEST_STATUSES.rejected
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
auditAction({
|
||||||
|
organizationId,
|
||||||
|
userId,
|
||||||
|
entityType: 'crm_approval_request',
|
||||||
|
entityId: approvalRequestId,
|
||||||
|
action: APPROVAL_ACTIONS.reject,
|
||||||
|
beforeData: request,
|
||||||
|
afterData: updatedRequest
|
||||||
|
}),
|
||||||
|
auditAction({
|
||||||
|
organizationId,
|
||||||
|
userId,
|
||||||
|
entityType: 'crm_approval_action',
|
||||||
|
entityId: createdAction.id,
|
||||||
|
action: APPROVAL_ACTIONS.reject,
|
||||||
|
afterData: createdAction
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
return updatedRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function returnApproval(
|
||||||
|
approvalRequestId: string,
|
||||||
|
organizationId: string,
|
||||||
|
userId: string,
|
||||||
|
remark?: string
|
||||||
|
) {
|
||||||
|
const request = await assertApprovalRequest(approvalRequestId, organizationId);
|
||||||
|
|
||||||
|
if (request.status !== APPROVAL_REQUEST_STATUSES.pending) {
|
||||||
|
throw new AuthError('Only pending approvals can be returned', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentStep = await getCurrentApprovalStep(approvalRequestId, organizationId);
|
||||||
|
|
||||||
|
if (!currentStep) {
|
||||||
|
throw new AuthError('Current approval step not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
await assertActorCanHandleStep(organizationId, userId, currentStep.roleCode);
|
||||||
|
|
||||||
|
const [createdAction] = await db
|
||||||
|
.insert(crmApprovalActions)
|
||||||
|
.values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
organizationId,
|
||||||
|
approvalRequestId,
|
||||||
|
stepNumber: currentStep.stepNumber,
|
||||||
|
action: APPROVAL_ACTIONS.return,
|
||||||
|
remark: remark?.trim() || null,
|
||||||
|
actedBy: userId
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const [updatedRequest] = await db
|
||||||
|
.update(crmApprovalRequests)
|
||||||
|
.set({
|
||||||
|
status: APPROVAL_REQUEST_STATUSES.returned,
|
||||||
|
completedAt: new Date(),
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(eq(crmApprovalRequests.id, approvalRequestId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await syncEntityStatusFromApproval(
|
||||||
|
organizationId,
|
||||||
|
request.entityType,
|
||||||
|
request.entityId,
|
||||||
|
userId,
|
||||||
|
APPROVAL_REQUEST_STATUSES.returned
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
auditAction({
|
||||||
|
organizationId,
|
||||||
|
userId,
|
||||||
|
entityType: 'crm_approval_request',
|
||||||
|
entityId: approvalRequestId,
|
||||||
|
action: APPROVAL_ACTIONS.return,
|
||||||
|
beforeData: request,
|
||||||
|
afterData: updatedRequest
|
||||||
|
}),
|
||||||
|
auditAction({
|
||||||
|
organizationId,
|
||||||
|
userId,
|
||||||
|
entityType: 'crm_approval_action',
|
||||||
|
entityId: createdAction.id,
|
||||||
|
action: APPROVAL_ACTIONS.return,
|
||||||
|
afterData: createdAction
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
return updatedRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelApproval(
|
||||||
|
approvalRequestId: string,
|
||||||
|
organizationId: string,
|
||||||
|
userId: string
|
||||||
|
) {
|
||||||
|
const request = await assertApprovalRequest(approvalRequestId, organizationId);
|
||||||
|
|
||||||
|
if (request.status !== APPROVAL_REQUEST_STATUSES.pending) {
|
||||||
|
throw new AuthError('Only pending approvals can be cancelled', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.requestedBy !== userId) {
|
||||||
|
await assertActorCanHandleStep(organizationId, userId, 'sales_manager');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [createdAction] = await db
|
||||||
|
.insert(crmApprovalActions)
|
||||||
|
.values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
organizationId,
|
||||||
|
approvalRequestId,
|
||||||
|
stepNumber: request.currentStep,
|
||||||
|
action: APPROVAL_ACTIONS.cancel,
|
||||||
|
remark: null,
|
||||||
|
actedBy: userId
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const [updatedRequest] = await db
|
||||||
|
.update(crmApprovalRequests)
|
||||||
|
.set({
|
||||||
|
status: APPROVAL_REQUEST_STATUSES.cancelled,
|
||||||
|
completedAt: new Date(),
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(eq(crmApprovalRequests.id, approvalRequestId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await syncEntityStatusFromApproval(
|
||||||
|
organizationId,
|
||||||
|
request.entityType,
|
||||||
|
request.entityId,
|
||||||
|
userId,
|
||||||
|
APPROVAL_REQUEST_STATUSES.returned
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
auditAction({
|
||||||
|
organizationId,
|
||||||
|
userId,
|
||||||
|
entityType: 'crm_approval_request',
|
||||||
|
entityId: approvalRequestId,
|
||||||
|
action: APPROVAL_ACTIONS.cancel,
|
||||||
|
beforeData: request,
|
||||||
|
afterData: updatedRequest
|
||||||
|
}),
|
||||||
|
auditAction({
|
||||||
|
organizationId,
|
||||||
|
userId,
|
||||||
|
entityType: 'crm_approval_action',
|
||||||
|
entityId: createdAction.id,
|
||||||
|
action: APPROVAL_ACTIONS.cancel,
|
||||||
|
afterData: createdAction
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
return updatedRequest;
|
||||||
|
}
|
||||||
69
src/features/foundation/approval/service.ts
Normal file
69
src/features/foundation/approval/service.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import type {
|
||||||
|
ApprovalActionPayload,
|
||||||
|
ApprovalDetailResponse,
|
||||||
|
ApprovalFilters,
|
||||||
|
ApprovalListResponse,
|
||||||
|
MutationSuccessResponse,
|
||||||
|
SubmitApprovalPayload
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export async function getApprovals(filters: ApprovalFilters): Promise<ApprovalListResponse> {
|
||||||
|
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.entityType) searchParams.set('entityType', filters.entityType);
|
||||||
|
if (filters.entityId) searchParams.set('entityId', filters.entityId);
|
||||||
|
if (filters.sort) searchParams.set('sort', filters.sort);
|
||||||
|
|
||||||
|
const query = searchParams.toString();
|
||||||
|
return apiClient<ApprovalListResponse>(`/crm/approvals${query ? `?${query}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getApprovalById(id: string): Promise<ApprovalDetailResponse> {
|
||||||
|
return apiClient<ApprovalDetailResponse>(`/crm/approvals/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitApproval(data: SubmitApprovalPayload) {
|
||||||
|
return apiClient<MutationSuccessResponse>('/crm/approvals', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelApproval(id: string) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`/crm/approvals/${id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function approveApproval(id: string, data: ApprovalActionPayload) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`/crm/approvals/${id}/approve`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rejectApproval(id: string, data: ApprovalActionPayload) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`/crm/approvals/${id}/reject`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function returnApproval(id: string, data: ApprovalActionPayload) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`/crm/approvals/${id}/return`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitQuotationForApproval(id: string, remark?: string) {
|
||||||
|
return apiClient<MutationSuccessResponse>(`/crm/quotations/${id}/submit-approval`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ remark })
|
||||||
|
});
|
||||||
|
}
|
||||||
121
src/features/foundation/approval/types.ts
Normal file
121
src/features/foundation/approval/types.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
export interface ApprovalWorkflowRecord {
|
||||||
|
id: string;
|
||||||
|
organizationId: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
entityType: string;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApprovalStepRecord {
|
||||||
|
id: string;
|
||||||
|
organizationId: string;
|
||||||
|
workflowId: string;
|
||||||
|
stepNumber: number;
|
||||||
|
roleCode: string;
|
||||||
|
roleName: string;
|
||||||
|
isRequired: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApprovalRequestRecord {
|
||||||
|
id: string;
|
||||||
|
organizationId: string;
|
||||||
|
workflowId: string;
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
currentStep: number;
|
||||||
|
status: string;
|
||||||
|
requestedBy: string;
|
||||||
|
requestedAt: string;
|
||||||
|
completedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApprovalActionRecord {
|
||||||
|
id: string;
|
||||||
|
organizationId: string;
|
||||||
|
approvalRequestId: string;
|
||||||
|
stepNumber: number;
|
||||||
|
action: string;
|
||||||
|
remark: string | null;
|
||||||
|
actedBy: string;
|
||||||
|
actedAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApprovalListItem extends ApprovalRequestRecord {
|
||||||
|
workflowCode: string;
|
||||||
|
workflowName: string;
|
||||||
|
currentStepRoleCode: string | null;
|
||||||
|
currentStepRoleName: string | null;
|
||||||
|
requestedByName: string | null;
|
||||||
|
entityCode: string | null;
|
||||||
|
entityTitle: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApprovalTimelineItem extends ApprovalActionRecord {
|
||||||
|
actorName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApprovalDetailRecord {
|
||||||
|
request: ApprovalRequestRecord;
|
||||||
|
workflow: ApprovalWorkflowRecord;
|
||||||
|
steps: ApprovalStepRecord[];
|
||||||
|
currentStep: ApprovalStepRecord | null;
|
||||||
|
timeline: ApprovalTimelineItem[];
|
||||||
|
entityCode: string | null;
|
||||||
|
entityTitle: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApprovalFilters {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
search?: string;
|
||||||
|
status?: string;
|
||||||
|
entityType?: string;
|
||||||
|
entityId?: string;
|
||||||
|
sort?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubmitApprovalPayload {
|
||||||
|
workflowCode?: string;
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
remark?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApprovalActionPayload {
|
||||||
|
remark?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApprovalListResponse {
|
||||||
|
success: boolean;
|
||||||
|
time: string;
|
||||||
|
message: string;
|
||||||
|
totalItems: number;
|
||||||
|
offset: number;
|
||||||
|
limit: number;
|
||||||
|
items: ApprovalListItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApprovalDetailResponse {
|
||||||
|
success: boolean;
|
||||||
|
time: string;
|
||||||
|
message: string;
|
||||||
|
approval: ApprovalDetailRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MutationSuccessResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
@@ -6,7 +6,10 @@ export const BUSINESS_ROLES = [
|
|||||||
'infrastructure',
|
'infrastructure',
|
||||||
'application',
|
'application',
|
||||||
'auditor',
|
'auditor',
|
||||||
'viewer'
|
'viewer',
|
||||||
|
'sales_manager',
|
||||||
|
'department_manager',
|
||||||
|
'top_manager'
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type SystemRole = (typeof SYSTEM_ROLES)[number];
|
export type SystemRole = (typeof SYSTEM_ROLES)[number];
|
||||||
@@ -44,7 +47,12 @@ export const PERMISSIONS = {
|
|||||||
crmQuotationTopicManage: 'crm.quotation.topic.manage',
|
crmQuotationTopicManage: 'crm.quotation.topic.manage',
|
||||||
crmQuotationFollowupManage: 'crm.quotation.followup.manage',
|
crmQuotationFollowupManage: 'crm.quotation.followup.manage',
|
||||||
crmQuotationAttachmentManage: 'crm.quotation.attachment.manage',
|
crmQuotationAttachmentManage: 'crm.quotation.attachment.manage',
|
||||||
crmQuotationRevisionCreate: 'crm.quotation.revision.create'
|
crmQuotationRevisionCreate: 'crm.quotation.revision.create',
|
||||||
|
crmApprovalRead: 'crm.approval.read',
|
||||||
|
crmApprovalSubmit: 'crm.approval.submit',
|
||||||
|
crmApprovalApprove: 'crm.approval.approve',
|
||||||
|
crmApprovalReject: 'crm.approval.reject',
|
||||||
|
crmApprovalReturn: 'crm.approval.return'
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
|
export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
|
||||||
@@ -81,7 +89,12 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] {
|
|||||||
PERMISSIONS.crmQuotationTopicManage,
|
PERMISSIONS.crmQuotationTopicManage,
|
||||||
PERMISSIONS.crmQuotationFollowupManage,
|
PERMISSIONS.crmQuotationFollowupManage,
|
||||||
PERMISSIONS.crmQuotationAttachmentManage,
|
PERMISSIONS.crmQuotationAttachmentManage,
|
||||||
PERMISSIONS.crmQuotationRevisionCreate
|
PERMISSIONS.crmQuotationRevisionCreate,
|
||||||
|
PERMISSIONS.crmApprovalRead,
|
||||||
|
PERMISSIONS.crmApprovalSubmit,
|
||||||
|
PERMISSIONS.crmApprovalApprove,
|
||||||
|
PERMISSIONS.crmApprovalReject,
|
||||||
|
PERMISSIONS.crmApprovalReturn
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +104,8 @@ function getMembershipBasePermissions(role: MembershipRole): Permission[] {
|
|||||||
PERMISSIONS.crmContactRead,
|
PERMISSIONS.crmContactRead,
|
||||||
PERMISSIONS.crmEnquiryRead,
|
PERMISSIONS.crmEnquiryRead,
|
||||||
PERMISSIONS.crmEnquiryFollowupRead,
|
PERMISSIONS.crmEnquiryFollowupRead,
|
||||||
PERMISSIONS.crmQuotationRead
|
PERMISSIONS.crmQuotationRead,
|
||||||
|
PERMISSIONS.crmApprovalRead
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +122,9 @@ export function getBusinessRolePermissions(role: BusinessRole): Permission[] {
|
|||||||
case 'auditor':
|
case 'auditor':
|
||||||
return [PERMISSIONS.reportRead];
|
return [PERMISSIONS.reportRead];
|
||||||
case 'viewer':
|
case 'viewer':
|
||||||
|
case 'sales_manager':
|
||||||
|
case 'department_manager':
|
||||||
|
case 'top_manager':
|
||||||
default:
|
default:
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export const searchParams = {
|
|||||||
hot: parseAsString,
|
hot: parseAsString,
|
||||||
view: parseAsString,
|
view: parseAsString,
|
||||||
status: parseAsString,
|
status: parseAsString,
|
||||||
|
entityType: parseAsString,
|
||||||
assetType: parseAsString,
|
assetType: parseAsString,
|
||||||
role: parseAsString,
|
role: parseAsString,
|
||||||
sort: parseAsString
|
sort: parseAsString
|
||||||
|
|||||||
Reference in New Issue
Block a user