This commit is contained in:
phaichayon
2026-06-16 09:19:17 +07:00
parent b8f13e36b3
commit dff22d75b5
32 changed files with 5786 additions and 68 deletions

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

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

File diff suppressed because it is too large Load Diff

View File

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

View File

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

View File

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