This commit is contained in:
phaichayon
2026-06-26 12:04:17 +07:00
parent 052677fa2c
commit 2ad57f0ad9
26 changed files with 2653 additions and 250 deletions

View File

@@ -0,0 +1,112 @@
# Task F.3: Approval Workflow Builder Foundation
## Summary
This task upgrades the existing approval workflow settings surface into a safer workflow builder foundation. Admins can now manage workflow metadata and steps through dedicated APIs and a richer settings UI without editing seed data directly.
## Delivered
- Extended approval workflow schema in [src/db/schema.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/db/schema.ts):
- workflow: `description`, `isSystem`, `createdBy`, `updatedBy`
- step: `approvalMode`
- Added migration [drizzle/0003_workflow_builder_foundation.sql](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/drizzle/0003_workflow_builder_foundation.sql)
- Added builder validation schemas:
- [src/features/crm/approval/schemas/approval-workflow.schema.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/approval/schemas/approval-workflow.schema.ts)
- [src/features/crm/approval/schemas/approval-step.schema.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/approval/schemas/approval-step.schema.ts)
- Added builder server rules in [src/features/crm/approval/server/workflow-builder.service.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/crm/approval/server/workflow-builder.service.ts)
- Replaced the settings APIs with builder-aware behavior under:
- [src/app/api/crm/settings/approval-workflows/route.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/settings/approval-workflows/route.ts)
- [src/app/api/crm/settings/approval-workflows/[id]/route.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/settings/approval-workflows/[id]/route.ts)
- [src/app/api/crm/settings/approval-workflows/[id]/clone/route.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/settings/approval-workflows/[id]/clone/route.ts)
- [src/app/api/crm/settings/approval-workflows/[id]/activate/route.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/settings/approval-workflows/[id]/activate/route.ts)
- [src/app/api/crm/settings/approval-workflows/[id]/deactivate/route.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/settings/approval-workflows/[id]/deactivate/route.ts)
- [src/app/api/crm/settings/approval-workflows/[id]/steps/route.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/settings/approval-workflows/[id]/steps/route.ts)
- [src/app/api/crm/settings/approval-workflows/[id]/steps/[stepId]/route.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/settings/approval-workflows/[id]/steps/[stepId]/route.ts)
- [src/app/api/crm/settings/approval-workflows/[id]/steps/reorder/route.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/app/api/crm/settings/approval-workflows/[id]/steps/reorder/route.ts)
- Rebuilt workflow builder client contracts and UI 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/components/approval-workflow-settings.tsx](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/features/foundation/approval/components/approval-workflow-settings.tsx)
## Workflow Safety Strategy
F.3 uses the recommended lock strategy from the task:
- if a workflow has ever been used by any approval request, the workflow becomes locked for metadata edits
- locked workflows also block step create, update, delete, and reorder
- admins must clone a used workflow before changing its behavior
This prevents historical approval requests from silently changing meaning after configuration edits.
## Clone and Activation Rules
- clone copies workflow metadata and all active steps
- cloned workflows start inactive
- deactivation is blocked when:
- active approval requests still use the workflow
- active approval matrices still point to the workflow
- deletion is blocked when:
- any approval request has ever used the workflow
- active approval matrices still point to the workflow
## Step Builder Rules
- steps are managed individually instead of replacing the whole step list
- create supports inserting at a target `stepNumber`
- delete reorders remaining steps to continuous numbering
- reorder uses explicit `orderedStepIds`
- active workflows must retain at least one required step
## Approval Mode Support
Stored step modes:
- `sequential`
- `any_one`
- `all_required`
Current runtime still executes sequentially only. The builder UI marks non-sequential modes as stored for future use instead of pretending they already affect runtime behavior.
## Permissions Added
Extended in [src/lib/auth/rbac.ts](/c:/Users/mtpphtaps/Documents/gitea/alla-allaos-fullstack/src/lib/auth/rbac.ts):
- `crm.approval.workflow.clone`
- `crm.approval.workflow.activate`
- `crm.approval.workflow.deactivate`
- `crm.approval.workflow.step.manage`
Existing workflow read/create/update/delete permissions remain in use.
## Audit Actions
Builder routes now emit:
- `create_approval_workflow`
- `update_approval_workflow`
- `delete_approval_workflow`
- `clone_approval_workflow`
- `activate_approval_workflow`
- `deactivate_approval_workflow`
- `create_approval_step`
- `update_approval_step`
- `delete_approval_step`
- `reorder_approval_steps`
## Matrix Integration Hook
Workflow list API now accepts optional filters:
- `entityType`
- `activeOnly`
This keeps Approval Matrix integration future-ready without requiring F.3 to build the matrix UI itself.
## Known Limitations
- approval runtime still executes sequentially even if a step stores `any_one` or `all_required`
- no drag-and-drop builder is included; reorder uses simple up/down actions
- no workflow version table exists yet; lock-and-clone is the current compatibility strategy
- matrix admin UI is still outside this task scope

View File

@@ -0,0 +1,14 @@
ALTER TABLE "crm_approval_workflows"
ADD COLUMN "description" text;
--> statement-breakpoint
ALTER TABLE "crm_approval_workflows"
ADD COLUMN "is_system" boolean DEFAULT false NOT NULL;
--> statement-breakpoint
ALTER TABLE "crm_approval_workflows"
ADD COLUMN "created_by" text NOT NULL DEFAULT '';
--> statement-breakpoint
ALTER TABLE "crm_approval_workflows"
ADD COLUMN "updated_by" text NOT NULL DEFAULT '';
--> statement-breakpoint
ALTER TABLE "crm_approval_steps"
ADD COLUMN "approval_mode" text DEFAULT 'sequential' NOT NULL;

View File

@@ -22,6 +22,13 @@
"when": 1782604800000,
"tag": "0002_calm_approval_matrix",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1782691200000,
"tag": "0003_workflow_builder_foundation",
"breakpoints": true
}
]
}

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

504
plans/task-f.3.md Normal file
View File

@@ -0,0 +1,504 @@
# Task F.3: Approval Workflow Builder Foundation
## Status
Planned
## Objective
Build the Approval Workflow Builder foundation so admins can manage approval workflows and workflow steps without changing code or seed data.
This task extends the F.2 Approval Matrix Foundation by making workflow definitions editable and maintainable through service/API/UI foundations.
The goal is to support:
* workflow list
* workflow create/edit
* workflow clone
* workflow activate/deactivate
* step add/update/delete
* step reorder
* matrix-ready workflow selection
* safe compatibility with existing approval requests
This task does not implement Notification, Reminder, Escalation, or full parallel approval behavior yet.
---
## Mandatory Review
Review before implementation:
* `AGENTS.md`
* `docs/standards/project-foundations.md`
* `docs/standards/task-catalog.md`
* `docs/implementation/task-f2-approval-matrix-foundation.md`
* existing Approval implementation
* existing Approval Matrix implementation
* `src/db/schema.ts`
* `src/features/crm/approval/**`
* `src/features/foundation/approval/**`
* `src/features/foundation/audit-log/**`
* `src/lib/auth/rbac.ts`
* `docs/security/crm-authorization-boundaries.md`
---
## Current Foundation
Already available:
```txt
crm_approval_workflows
crm_approval_steps
crm_approval_requests
crm_approval_actions
crm_approval_matrices
Approval matrix resolver
Quotation submit approval flow
Default quotation matrix
Approval audit foundation
```
Current limitation:
```txt
Approval workflows and steps are mostly seed/config driven.
```
This task makes them manageable through Builder APIs and UI foundation.
---
## Business Requirement
Admin must be able to configure approval workflows such as:
```txt
Quotation Standard Approval
1. Sales Manager
2. Department Manager
3. CEO
Quotation Crane Executive Approval
1. Sales Manager
2. Product Head
3. Finance
4. CEO
```
Approval Matrix from F.2 can then point to any active workflow.
---
## Scope F.3.1 Workflow Builder Domain Rules
Workflow has:
```txt
code
name
entityType
description
isActive
steps
```
Step has:
```txt
stepNumber
roleCode
roleName
approvalMode
isRequired
```
Recommended `approvalMode` values:
```txt
sequential
any_one
all_required
```
For F.3 behavior:
* `sequential` remains the only fully executed runtime mode if current approval engine supports only sequential
* `any_one` and `all_required` can be stored for future use if schema supports it
* if runtime does not support non-sequential modes, UI/API must clearly mark them as future/disabled
---
## Scope F.3.2 Schema Review / Extension
Review existing tables:
```txt
crm_approval_workflows
crm_approval_steps
```
Add missing fields only if needed:
Recommended workflow fields:
```txt
description text null
is_system boolean default false
created_by text
updated_by text
```
Recommended step fields:
```txt
approval_mode text default 'sequential'
approver_type text default 'role'
approver_ref text null
sort_order integer
```
Definitions:
```txt
approver_type = role | user | permission | manager
approver_ref = role code, user id, permission code, or manager rule key
```
If current phase should stay simple, use only:
```txt
roleCode
roleName
stepNumber
isRequired
```
and defer advanced approver resolution to later tasks.
---
## Scope F.3.3 Workflow CRUD API
Create or extend settings APIs:
```txt
GET /api/crm/settings/approval-workflows
POST /api/crm/settings/approval-workflows
GET /api/crm/settings/approval-workflows/[id]
PATCH /api/crm/settings/approval-workflows/[id]
DELETE /api/crm/settings/approval-workflows/[id]
POST /api/crm/settings/approval-workflows/[id]/clone
POST /api/crm/settings/approval-workflows/[id]/activate
POST /api/crm/settings/approval-workflows/[id]/deactivate
```
Rules:
* delete should be soft delete
* do not allow hard delete
* do not allow deleting workflow used by an active approval request
* deactivation allowed only if not used by active matrix or active approval request unless forced by admin policy
* clone creates a new workflow and copies steps
* code must be unique within organization
---
## Scope F.3.4 Step Management API
Create:
```txt
GET /api/crm/settings/approval-workflows/[id]/steps
POST /api/crm/settings/approval-workflows/[id]/steps
PATCH /api/crm/settings/approval-workflows/[id]/steps/[stepId]
DELETE /api/crm/settings/approval-workflows/[id]/steps/[stepId]
POST /api/crm/settings/approval-workflows/[id]/steps/reorder
```
Rules:
* step numbers must be continuous: 1, 2, 3...
* deleting a step reorders remaining steps
* reorder must be transactional
* at least one required step must remain for active workflow
* cannot modify steps of workflow with active approval requests unless versioning is implemented
* F.3 may choose strict lock behavior:
* if workflow is already used by any approval request, require clone before edit
Recommended:
```txt
Existing used workflow = locked
Edit by clone only
```
This prevents historical approval requests from changing meaning retroactively.
---
## Scope F.3.5 Workflow Version Safety
Implement one of these strategies:
### Option A: Lock Used Workflows
If workflow has ever been used in approval request:
```txt
workflow cannot be edited
steps cannot be edited
admin must clone workflow
```
This is recommended for F.3.
### Option B: Versioned Workflows
Create workflow versions.
This is more powerful but larger scope.
Recommendation:
```txt
Use Option A in F.3.
Defer versioned workflows to future task if needed.
```
---
## Scope F.3.6 Workflow Builder UI
Create settings page:
```txt
/dashboard/crm/settings/approval-workflows
```
Required UI:
* workflow list
* create workflow
* edit workflow metadata
* clone workflow
* activate/deactivate workflow
* view steps
* add step
* edit step
* delete step
* reorder steps
* usage warning if workflow is locked
Use existing UI conventions:
```txt
shadcn/ui
card layout
dialog/sheet forms
table
bento/card section where useful
design tokens only
```
No drag-and-drop is required in F.3. Simple up/down reorder is enough.
---
## Scope F.3.7 Matrix Integration UI Hook
Approval Matrix settings must be able to select an active workflow.
If F.2 has no matrix UI yet, expose workflow list API so future matrix UI can consume it.
Workflow list API should support:
```txt
entityType filter
active only filter
```
---
## Scope F.3.8 Validation Rules
Create schemas:
```txt
src/features/crm/approval/schemas/approval-workflow.schema.ts
src/features/crm/approval/schemas/approval-step.schema.ts
```
Workflow validation:
* code required
* code normalized lowercase / snake_case
* name required
* entityType required
* code unique per organization
* cannot update code if workflow is used
Step validation:
* roleCode required
* roleName required
* stepNumber positive integer
* approvalMode required
* isRequired boolean
* cannot create duplicate stepNumber
* cannot leave active workflow with no required step
---
## Scope F.3.9 Audit Logging
Audit actions:
```txt
create_approval_workflow
update_approval_workflow
delete_approval_workflow
clone_approval_workflow
activate_approval_workflow
deactivate_approval_workflow
create_approval_step
update_approval_step
delete_approval_step
reorder_approval_steps
```
Audit must capture:
```txt
workflowId
stepId
before
after
actor
timestamp
```
---
## Scope F.3.10 Permissions
Add or verify permissions:
```txt
crm.approval.workflow.read
crm.approval.workflow.create
crm.approval.workflow.update
crm.approval.workflow.delete
crm.approval.workflow.clone
crm.approval.workflow.activate
crm.approval.workflow.deactivate
crm.approval.workflow.step.manage
```
Rules:
* Admin/settings-only feature
* route handlers must not check role strings directly
* use existing CRM authorization foundation
* do not expose builder to normal sales users
---
## Scope F.3.11 Documentation
Create:
```txt
docs/implementation/task-f3-approval-workflow-builder-foundation.md
```
Document:
```txt
workflow schema
step schema
lock strategy
clone behavior
API routes
permission map
known limitations
```
---
## Explicit Non-Scope
Do not implement:
* Notification Center
* Email sending
* LINE sending
* approval reminder
* approval escalation
* delegation
* vacation substitute approval
* manager hierarchy approver lookup
* full workflow versioning
* drag-and-drop builder
* advanced parallel approval runtime
* approval dashboard
These belong to later F tasks.
---
## Verification
Run:
```txt
npm exec tsc --noEmit
npm run build
```
Manual verification:
```txt
Create workflow
Edit unused workflow
Add steps
Edit steps
Delete step and verify reorder
Reorder steps
Clone workflow
Deactivate workflow
Activate workflow
Cannot edit used workflow if lock strategy is applied
Cannot delete workflow used by active approval request
Approval Matrix can resolve to newly created active workflow
Submit quotation approval still works
Approve/reject existing flow still works
Audit logs are created
```
---
## Definition of Done
Task is complete when:
* Approval Workflow Builder APIs exist
* Approval Workflow settings UI exists
* Admin can create/clone/activate/deactivate workflow
* Admin can add/edit/delete/reorder steps
* Used workflow edit safety is enforced
* Approval Matrix can select active workflows
* Existing quotation approval submit/approve/reject flow remains operational
* Audit logs capture workflow and step maintenance
Result:
```txt
Approval Workflow Builder Foundation = Established
Approval Matrix Configuration = Maintainable
Ready for F.4 Notification Framework
```

View File

@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server';
import { auditAction } from '@/features/foundation/audit-log/service';
import {
activateApprovalWorkflow,
getApprovalWorkflow
} from '@/features/crm/approval/server/workflow-builder.service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
export async function POST(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmApprovalWorkflowActivate
});
const before = await getApprovalWorkflow(id, organization.id);
const updated = await activateApprovalWorkflow(id, organization.id, session.user.id);
await auditAction({
organizationId: organization.id,
userId: session.user.id,
entityType: 'crm_approval_workflow',
entityId: id,
action: 'activate_approval_workflow',
beforeData: before,
afterData: updated
});
return NextResponse.json({
success: true,
message: 'Approval workflow activated 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 activate approval workflow' }, { status: 500 });
}
}

View File

@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server';
import { auditAction } from '@/features/foundation/audit-log/service';
import { cloneApprovalWorkflow, getApprovalWorkflow } from '@/features/crm/approval/server/workflow-builder.service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
export async function POST(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmApprovalWorkflowClone
});
const before = await getApprovalWorkflow(id, organization.id);
const cloned = await cloneApprovalWorkflow(id, organization.id, session.user.id);
await auditAction({
organizationId: organization.id,
userId: session.user.id,
entityType: 'crm_approval_workflow',
entityId: cloned.id,
action: 'clone_approval_workflow',
beforeData: before,
afterData: cloned
});
return NextResponse.json({
success: true,
message: 'Approval workflow cloned 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 clone approval workflow' }, { status: 500 });
}
}

View File

@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server';
import { auditAction } from '@/features/foundation/audit-log/service';
import {
deactivateApprovalWorkflow,
getApprovalWorkflow
} from '@/features/crm/approval/server/workflow-builder.service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
export async function POST(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmApprovalWorkflowDeactivate
});
const before = await getApprovalWorkflow(id, organization.id);
const updated = await deactivateApprovalWorkflow(id, organization.id, session.user.id);
await auditAction({
organizationId: organization.id,
userId: session.user.id,
entityType: 'crm_approval_workflow',
entityId: id,
action: 'deactivate_approval_workflow',
beforeData: before,
afterData: updated
});
return NextResponse.json({
success: true,
message: 'Approval workflow deactivated 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 deactivate approval workflow' }, { status: 500 });
}
}

View File

@@ -1,11 +1,12 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service';
import { auditAction } from '@/features/foundation/audit-log/service';
import { updateApprovalWorkflowSchema } from '@/features/crm/approval/schemas/approval-workflow.schema';
import {
getApprovalWorkflow,
softDeleteApprovalWorkflow,
updateApprovalWorkflow
} from '@/features/foundation/approval/server/service';
} from '@/features/crm/approval/server/workflow-builder.service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
@@ -13,13 +14,6 @@ type Params = {
params: Promise<{ id: string }>;
};
const workflowSchema = z.object({
code: z.string().min(1).optional(),
name: z.string().min(1).optional(),
entityType: z.string().min(1).optional(),
isActive: z.boolean().optional()
});
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
@@ -41,7 +35,7 @@ export async function GET(_request: NextRequest, { params }: Params) {
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to load approval workflow' }, { status: 500 });
return NextResponse.json({ message: 'Unable load approval workflow' }, { status: 500 });
}
}
@@ -51,15 +45,16 @@ export async function PATCH(request: NextRequest, { params }: Params) {
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmApprovalWorkflowUpdate
});
const payload = workflowSchema.parse(await request.json());
const payload = updateApprovalWorkflowSchema.parse(await request.json());
const before = await getApprovalWorkflow(id, organization.id);
const updated = await updateApprovalWorkflow(id, organization.id, payload);
const updated = await updateApprovalWorkflow(id, organization.id, session.user.id, payload);
await auditUpdate({
await auditAction({
organizationId: organization.id,
userId: session.user.id,
entityType: 'crm_approval_workflow',
entityId: id,
action: 'update_approval_workflow',
beforeData: before,
afterData: updated
});
@@ -81,7 +76,7 @@ export async function PATCH(request: NextRequest, { params }: Params) {
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to update approval workflow' }, { status: 500 });
return NextResponse.json({ message: 'Unable update approval workflow' }, { status: 500 });
}
}
@@ -91,13 +86,14 @@ export async function DELETE(_request: NextRequest, { params }: Params) {
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmApprovalWorkflowDelete
});
const result = await softDeleteApprovalWorkflow(id, organization.id);
const result = await softDeleteApprovalWorkflow(id, organization.id, session.user.id);
await auditDelete({
await auditAction({
organizationId: organization.id,
userId: session.user.id,
entityType: 'crm_approval_workflow',
entityId: id,
action: 'delete_approval_workflow',
beforeData: result.before,
afterData: result.after
});
@@ -113,6 +109,6 @@ export async function DELETE(_request: NextRequest, { params }: Params) {
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to delete approval workflow' }, { status: 500 });
return NextResponse.json({ message: 'Unable delete approval workflow' }, { status: 500 });
}
}

View File

@@ -0,0 +1,90 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditAction } from '@/features/foundation/audit-log/service';
import { updateApprovalStepSchema } from '@/features/crm/approval/schemas/approval-step.schema';
import {
deleteApprovalWorkflowStep,
getApprovalWorkflow,
updateApprovalWorkflowStep
} from '@/features/crm/approval/server/workflow-builder.service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string; stepId: string }>;
};
export async function PATCH(request: NextRequest, { params }: Params) {
try {
const { id, stepId } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmApprovalWorkflowStepManage
});
const payload = updateApprovalStepSchema.parse(await request.json());
const before = await getApprovalWorkflow(id, organization.id);
const updated = await updateApprovalWorkflowStep(stepId, organization.id, payload);
await auditAction({
organizationId: organization.id,
userId: session.user.id,
entityType: 'crm_approval_step',
entityId: stepId,
action: 'update_approval_step',
beforeData: before.steps,
afterData: updated
});
return NextResponse.json({
success: true,
message: 'Approval workflow step updated 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 update approval workflow step' }, { status: 500 });
}
}
export async function DELETE(_request: NextRequest, { params }: Params) {
try {
const { id, stepId } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmApprovalWorkflowStepManage
});
const before = await getApprovalWorkflow(id, organization.id);
const result = await deleteApprovalWorkflowStep(stepId, organization.id);
await auditAction({
organizationId: organization.id,
userId: session.user.id,
entityType: 'crm_approval_step',
entityId: stepId,
action: 'delete_approval_step',
beforeData: before.steps,
afterData: result.steps
});
return NextResponse.json({
success: true,
message: 'Approval workflow step deleted successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable delete approval workflow step' }, { status: 500 });
}
}

View File

@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditAction } from '@/features/foundation/audit-log/service';
import { reorderApprovalStepsSchema } from '@/features/crm/approval/schemas/approval-step.schema';
import {
getApprovalWorkflow,
reorderApprovalWorkflowSteps
} from '@/features/crm/approval/server/workflow-builder.service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmApprovalWorkflowStepManage
});
const payload = reorderApprovalStepsSchema.parse(await request.json());
const before = await getApprovalWorkflow(id, organization.id);
const steps = await reorderApprovalWorkflowSteps(id, organization.id, payload);
await auditAction({
organizationId: organization.id,
userId: session.user.id,
entityType: 'crm_approval_step',
entityId: id,
action: 'reorder_approval_steps',
beforeData: before.steps,
afterData: steps
});
return NextResponse.json({
success: true,
message: 'Approval workflow steps reordered 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 reorder approval workflow steps' }, { status: 500 });
}
}

View File

@@ -1,50 +1,67 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditUpdate } from '@/features/foundation/audit-log/service';
import { auditAction } from '@/features/foundation/audit-log/service';
import { createApprovalStepSchema } from '@/features/crm/approval/schemas/approval-step.schema';
import {
createApprovalWorkflowStep,
getApprovalWorkflow,
replaceApprovalWorkflowSteps
} from '@/features/foundation/approval/server/service';
import { BUSINESS_ROLES, PERMISSIONS } from '@/lib/auth/rbac';
listApprovalWorkflowSteps
} from '@/features/crm/approval/server/workflow-builder.service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
type Params = {
params: Promise<{ id: string }>;
};
const stepSchema = z.object({
stepNumber: z.number().int().min(1),
roleCode: z.enum(BUSINESS_ROLES),
roleName: z.string().min(1),
isRequired: z.boolean().optional()
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization } = await requireOrganizationAccess({
permission: PERMISSIONS.crmApprovalWorkflowRead
});
const items = await listApprovalWorkflowSteps(id, organization.id);
const payloadSchema = z.object({
steps: z.array(stepSchema)
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Approval workflow steps loaded successfully',
items
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable load approval workflow steps' }, { status: 500 });
}
}
export async function PUT(request: NextRequest, { params }: Params) {
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmApprovalWorkflowUpdate
permission: PERMISSIONS.crmApprovalWorkflowStepManage
});
const payload = payloadSchema.parse(await request.json());
const payload = createApprovalStepSchema.parse(await request.json());
const before = await getApprovalWorkflow(id, organization.id);
const steps = await replaceApprovalWorkflowSteps(id, organization.id, payload.steps);
const steps = await createApprovalWorkflowStep(id, organization.id, payload);
await auditUpdate({
await auditAction({
organizationId: organization.id,
userId: session.user.id,
entityType: 'crm_approval_step',
entityId: id,
action: 'create_approval_step',
beforeData: before.steps,
afterData: steps
});
return NextResponse.json({
success: true,
message: 'Approval workflow steps updated successfully'
message: 'Approval workflow step created successfully'
});
} catch (error) {
if (error instanceof AuthError) {
@@ -59,9 +76,6 @@ export async function PUT(request: NextRequest, { params }: Params) {
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json(
{ message: 'Unable to update approval workflow steps' },
{ status: 500 }
);
return NextResponse.json({ message: 'Unable create approval workflow step' }, { status: 500 });
}
}

View File

@@ -1,26 +1,24 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { auditCreate } from '@/features/foundation/audit-log/service';
import { auditAction } from '@/features/foundation/audit-log/service';
import { createApprovalWorkflowSchema, approvalWorkflowFilterSchema } from '@/features/crm/approval/schemas/approval-workflow.schema';
import {
createApprovalWorkflow,
listApprovalWorkflows
} from '@/features/foundation/approval/server/service';
import { BUSINESS_ROLES, PERMISSIONS } from '@/lib/auth/rbac';
} from '@/features/crm/approval/server/workflow-builder.service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
const workflowSchema = z.object({
code: z.string().min(1),
name: z.string().min(1),
entityType: z.string().min(1),
isActive: z.boolean().optional()
});
export async function GET(_request: NextRequest) {
export async function GET(request: NextRequest) {
try {
const { organization } = await requireOrganizationAccess({
permission: PERMISSIONS.crmApprovalWorkflowRead
});
const items = await listApprovalWorkflows(organization.id);
const filters = approvalWorkflowFilterSchema.parse({
entityType: request.nextUrl.searchParams.get('entityType') ?? undefined,
activeOnly: request.nextUrl.searchParams.get('activeOnly') ?? undefined
});
const items = await listApprovalWorkflows(organization.id, filters);
return NextResponse.json({
success: true,
@@ -32,10 +30,16 @@ export async function GET(_request: NextRequest) {
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 filters' },
{ status: 400 }
);
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to load approval workflows' }, { status: 500 });
return NextResponse.json({ message: 'Unable load approval workflows' }, { status: 500 });
}
}
@@ -44,21 +48,21 @@ export async function POST(request: NextRequest) {
const { organization, session } = await requireOrganizationAccess({
permission: PERMISSIONS.crmApprovalWorkflowCreate
});
const payload = workflowSchema.parse(await request.json());
const created = await createApprovalWorkflow(organization.id, payload);
const payload = createApprovalWorkflowSchema.parse(await request.json());
const created = await createApprovalWorkflow(organization.id, session.user.id, payload);
await auditCreate({
await auditAction({
organizationId: organization.id,
userId: session.user.id,
entityType: 'crm_approval_workflow',
entityId: created.id,
action: 'create_approval_workflow',
afterData: created
});
return NextResponse.json({
success: true,
message: 'Approval workflow created successfully',
allowedRoles: BUSINESS_ROLES
message: 'Approval workflow created successfully'
});
} catch (error) {
if (error instanceof AuthError) {
@@ -73,6 +77,6 @@ export async function POST(request: NextRequest) {
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to create approval workflow' }, { status: 500 });
return NextResponse.json({ message: 'Unable create approval workflow' }, { status: 500 });
}
}

View File

@@ -1,24 +1,33 @@
'use client';
"use client";
import { useRouter } from 'next/navigation';
import { useRouter } from "next/navigation";
import { Button } from '@/components/ui/button';
import { Button } from "@/components/ui/button";
export default function NotFound() {
const router = useRouter();
return (
<div className='absolute top-1/2 left-1/2 mb-16 -translate-x-1/2 -translate-y-1/2 items-center justify-center text-center'>
<span className='from-foreground bg-linear-to-b to-transparent bg-clip-text text-[10rem] leading-none font-extrabold text-transparent'>
<div className="absolute top-1/2 left-1/2 mb-16 -translate-x-1/2 -translate-y-1/2 items-center justify-center text-center">
<span className="from-foreground bg-linear-to-b to-transparent bg-clip-text text-[10rem] leading-none font-extrabold text-transparent">
404
</span>
<h2 className='font-heading my-2 text-2xl font-bold'>Something&apos;s missing</h2>
<p>Sorry, the page you are looking for doesn&apos;t exist or has been moved.</p>
<div className='mt-8 flex justify-center gap-2'>
<Button onClick={() => router.back()} variant='default' size='lg'>
<h2 className="font-heading my-2 text-2xl font-bold">
Something&apos;s missing
</h2>
<p>
Sorry, the page you are looking for doesn&apos;t exist or has been
moved.
</p>
<div className="mt-8 flex justify-center gap-2">
<Button onClick={() => router.back()} variant="default" size="lg">
Go back
</Button>
<Button onClick={() => router.push('/dashboard')} variant='ghost' size='lg'>
<Button
onClick={() => router.push("/dashboard/crm")}
variant="ghost"
size="lg"
>
Back to Home
</Button>
</div>

View File

@@ -645,7 +645,11 @@ export const crmApprovalWorkflows = pgTable(
code: text('code').notNull(),
name: text('name').notNull(),
entityType: text('entity_type').notNull(),
description: text('description'),
isSystem: boolean('is_system').default(false).notNull(),
isActive: boolean('is_active').default(true).notNull(),
createdBy: text('created_by').notNull(),
updatedBy: text('updated_by').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
deletedAt: timestamp('deleted_at', { withTimezone: true })
@@ -667,6 +671,7 @@ export const crmApprovalSteps = pgTable(
stepNumber: integer('step_number').notNull(),
roleCode: text('role_code').notNull(),
roleName: text('role_name').notNull(),
approvalMode: text('approval_mode').default('sequential').notNull(),
isRequired: boolean('is_required').default(true).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),

View File

@@ -658,10 +658,27 @@ const APPROVAL_WORKFLOWS = [
code: 'quotation_standard_approval',
name: 'Quotation Standard Approval',
entityType: 'quotation',
description: 'Default sequential quotation approval workflow',
isSystem: true,
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' }
{
stepNumber: 1,
roleCode: 'sales_manager',
roleName: 'Sales Manager',
approvalMode: 'sequential'
},
{
stepNumber: 2,
roleCode: 'department_manager',
roleName: 'Department Manager',
approvalMode: 'sequential'
},
{
stepNumber: 3,
roleCode: 'top_manager',
roleName: 'Top Manager',
approvalMode: 'sequential'
}
]
}
];

View File

@@ -0,0 +1,29 @@
import { z } from 'zod';
import { BUSINESS_ROLES } from '@/lib/auth/rbac';
export const APPROVAL_MODES = ['sequential', 'any_one', 'all_required'] as const;
export const approvalStepSchema = z.object({
stepNumber: z.number().int().min(1),
roleCode: z.enum(BUSINESS_ROLES),
roleName: z.string().trim().min(1),
approvalMode: z.enum(APPROVAL_MODES).default('sequential'),
isRequired: z.boolean().default(true)
});
export const createApprovalStepSchema = approvalStepSchema;
export const updateApprovalStepSchema = z.object({
roleCode: z.enum(BUSINESS_ROLES).optional(),
roleName: z.string().trim().min(1).optional(),
approvalMode: z.enum(APPROVAL_MODES).optional(),
isRequired: z.boolean().optional()
});
export const reorderApprovalStepsSchema = z.object({
orderedStepIds: z.array(z.string().min(1)).min(1)
});
export type CreateApprovalStepInput = z.infer<typeof createApprovalStepSchema>;
export type UpdateApprovalStepInput = z.infer<typeof updateApprovalStepSchema>;
export type ReorderApprovalStepsInput = z.infer<typeof reorderApprovalStepsSchema>;

View File

@@ -0,0 +1,58 @@
import { z } from 'zod';
function normalizeWorkflowCode(value: string) {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
}
const nullableTrimmedString = z
.string()
.trim()
.optional()
.nullable()
.transform((value) => {
if (!value) {
return null;
}
return value;
});
export const approvalWorkflowFilterSchema = z.object({
entityType: z.string().trim().optional(),
activeOnly: z
.union([z.literal('true'), z.literal('false')])
.optional()
.transform((value) => value === 'true')
});
export const createApprovalWorkflowSchema = z.object({
code: z
.string()
.min(1)
.transform((value) => normalizeWorkflowCode(value))
.refine((value) => value.length > 0, {
message: 'code is required'
}),
name: z.string().trim().min(1),
entityType: z.string().trim().min(1),
description: nullableTrimmedString,
isActive: z.boolean().optional()
});
export const updateApprovalWorkflowSchema = z.object({
code: z
.string()
.min(1)
.optional()
.transform((value) => (value ? normalizeWorkflowCode(value) : value)),
name: z.string().trim().min(1).optional(),
entityType: z.string().trim().min(1).optional(),
description: nullableTrimmedString.optional()
});
export type ApprovalWorkflowFiltersInput = z.infer<typeof approvalWorkflowFilterSchema>;
export type CreateApprovalWorkflowInput = z.infer<typeof createApprovalWorkflowSchema>;
export type UpdateApprovalWorkflowInput = z.infer<typeof updateApprovalWorkflowSchema>;

View File

@@ -0,0 +1,767 @@
import { and, asc, count, desc, eq, inArray, isNull, sql } from 'drizzle-orm';
import {
crmApprovalMatrices,
crmApprovalRequests,
crmApprovalSteps,
crmApprovalWorkflows
} from '@/db/schema';
import { db } from '@/lib/db';
import { AuthError } from '@/lib/auth/session';
import type {
CreateApprovalStepInput,
ReorderApprovalStepsInput,
UpdateApprovalStepInput
} from '../schemas/approval-step.schema';
import type {
ApprovalWorkflowFiltersInput,
CreateApprovalWorkflowInput,
UpdateApprovalWorkflowInput
} from '../schemas/approval-workflow.schema';
export interface ApprovalWorkflowUsage {
totalRequestCount: number;
activeRequestCount: number;
activeMatrixCount: number;
isLocked: boolean;
lockedReason: string | null;
canEdit: boolean;
canDelete: boolean;
canDeactivate: boolean;
}
export interface ApprovalWorkflowRecord {
id: string;
organizationId: string;
code: string;
name: string;
entityType: string;
description: string | null;
isSystem: boolean;
isActive: boolean;
createdBy: string;
updatedBy: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
export interface ApprovalStepRecord {
id: string;
organizationId: string;
workflowId: string;
stepNumber: number;
roleCode: string;
roleName: string;
approvalMode: 'sequential' | 'any_one' | 'all_required';
isRequired: boolean;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
export interface ApprovalWorkflowListItem extends ApprovalWorkflowRecord {
stepCount: number;
usage: ApprovalWorkflowUsage;
}
export interface ApprovalWorkflowDetail extends ApprovalWorkflowRecord {
steps: ApprovalStepRecord[];
usage: ApprovalWorkflowUsage;
}
export interface ApprovalWorkflowListFilters {
entityType?: string;
activeOnly?: boolean;
}
function mapWorkflowRecord(row: typeof crmApprovalWorkflows.$inferSelect): ApprovalWorkflowRecord {
return {
id: row.id,
organizationId: row.organizationId,
code: row.code,
name: row.name,
entityType: row.entityType,
description: row.description ?? null,
isSystem: row.isSystem,
isActive: row.isActive,
createdBy: row.createdBy,
updatedBy: row.updatedBy,
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,
approvalMode: row.approvalMode as ApprovalStepRecord['approvalMode'],
isRequired: row.isRequired,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
deletedAt: row.deletedAt?.toISOString() ?? null
};
}
async function assertWorkflow(id: string, organizationId: string) {
const [workflow] = await db
.select()
.from(crmApprovalWorkflows)
.where(
and(
eq(crmApprovalWorkflows.id, id),
eq(crmApprovalWorkflows.organizationId, organizationId),
isNull(crmApprovalWorkflows.deletedAt)
)
)
.limit(1);
if (!workflow) {
throw new AuthError('Approval workflow not found', 404);
}
return workflow;
}
async function assertStep(stepId: string, organizationId: string) {
const [step] = await db
.select()
.from(crmApprovalSteps)
.where(
and(
eq(crmApprovalSteps.id, stepId),
eq(crmApprovalSteps.organizationId, organizationId),
isNull(crmApprovalSteps.deletedAt)
)
)
.limit(1);
if (!step) {
throw new AuthError('Approval step not found', 404);
}
return step;
}
async function listWorkflowStepsInternal(workflowId: string, organizationId: string) {
return db
.select()
.from(crmApprovalSteps)
.where(
and(
eq(crmApprovalSteps.workflowId, workflowId),
eq(crmApprovalSteps.organizationId, organizationId),
isNull(crmApprovalSteps.deletedAt)
)
)
.orderBy(asc(crmApprovalSteps.stepNumber), asc(crmApprovalSteps.createdAt));
}
async function ensureWorkflowCodeUnique(
organizationId: string,
code: string,
excludedWorkflowId?: string
) {
const [existing] = await db
.select({ id: crmApprovalWorkflows.id })
.from(crmApprovalWorkflows)
.where(
and(
eq(crmApprovalWorkflows.organizationId, organizationId),
eq(crmApprovalWorkflows.code, code),
isNull(crmApprovalWorkflows.deletedAt),
...(excludedWorkflowId ? [sql`${crmApprovalWorkflows.id} <> ${excludedWorkflowId}`] : [])
)
)
.limit(1);
if (existing) {
throw new AuthError('Approval workflow code already exists', 409);
}
}
async function getWorkflowUsageMap(workflowIds: string[], organizationId: string) {
if (workflowIds.length === 0) {
return new Map<string, ApprovalWorkflowUsage>();
}
const [allRequests, activeRequests, activeMatrices] = await Promise.all([
db
.select({
workflowId: crmApprovalRequests.workflowId,
count: count()
})
.from(crmApprovalRequests)
.where(
and(
eq(crmApprovalRequests.organizationId, organizationId),
inArray(crmApprovalRequests.workflowId, workflowIds),
isNull(crmApprovalRequests.deletedAt)
)
)
.groupBy(crmApprovalRequests.workflowId),
db
.select({
workflowId: crmApprovalRequests.workflowId,
count: count()
})
.from(crmApprovalRequests)
.where(
and(
eq(crmApprovalRequests.organizationId, organizationId),
inArray(crmApprovalRequests.workflowId, workflowIds),
eq(crmApprovalRequests.status, 'pending'),
isNull(crmApprovalRequests.deletedAt)
)
)
.groupBy(crmApprovalRequests.workflowId),
db
.select({
workflowId: crmApprovalMatrices.workflowId,
count: count()
})
.from(crmApprovalMatrices)
.where(
and(
eq(crmApprovalMatrices.organizationId, organizationId),
inArray(crmApprovalMatrices.workflowId, workflowIds),
eq(crmApprovalMatrices.isActive, true),
isNull(crmApprovalMatrices.deletedAt)
)
)
.groupBy(crmApprovalMatrices.workflowId)
]);
const requestMap = new Map(allRequests.map((row) => [row.workflowId, Number(row.count)]));
const activeRequestMap = new Map(activeRequests.map((row) => [row.workflowId, Number(row.count)]));
const activeMatrixMap = new Map(activeMatrices.map((row) => [row.workflowId, Number(row.count)]));
return new Map(
workflowIds.map((workflowId) => {
const totalRequestCount = requestMap.get(workflowId) ?? 0;
const activeRequestCount = activeRequestMap.get(workflowId) ?? 0;
const activeMatrixCount = activeMatrixMap.get(workflowId) ?? 0;
const isLocked = totalRequestCount > 0;
const lockedReason = isLocked
? 'This workflow has already been used by approval requests. Clone it before editing.'
: null;
return [
workflowId,
{
totalRequestCount,
activeRequestCount,
activeMatrixCount,
isLocked,
lockedReason,
canEdit: !isLocked,
canDelete: totalRequestCount === 0 && activeMatrixCount === 0,
canDeactivate: activeRequestCount === 0 && activeMatrixCount === 0
} satisfies ApprovalWorkflowUsage
];
})
);
}
async function assertWorkflowEditable(workflowId: string, organizationId: string) {
const usage = (await getWorkflowUsageMap([workflowId], organizationId)).get(workflowId);
if (usage?.isLocked) {
throw new AuthError(usage.lockedReason ?? 'Workflow is locked', 400);
}
return usage;
}
async function assertWorkflowCanDeactivate(workflowId: string, organizationId: string) {
const usage = (await getWorkflowUsageMap([workflowId], organizationId)).get(workflowId);
if (!usage) {
throw new AuthError('Approval workflow not found', 404);
}
if (!usage.canDeactivate) {
throw new AuthError(
'Workflow cannot be deactivated while active matrices or pending approval requests still reference it',
400
);
}
return usage;
}
async function assertWorkflowCanDelete(workflowId: string, organizationId: string) {
const usage = (await getWorkflowUsageMap([workflowId], organizationId)).get(workflowId);
if (!usage) {
throw new AuthError('Approval workflow not found', 404);
}
if (!usage.canDelete) {
throw new AuthError(
'Workflow cannot be deleted because it is used by approval requests or active approval matrices',
400
);
}
return usage;
}
async function assertWorkflowHasRequiredSteps(workflowId: string, organizationId: string) {
const steps = await listWorkflowStepsInternal(workflowId, organizationId);
if (steps.length === 0) {
throw new AuthError('Active workflow must have at least one step', 400);
}
if (!steps.some((step) => step.isRequired)) {
throw new AuthError('Active workflow must have at least one required step', 400);
}
}
async function nextCloneCode(organizationId: string, baseCode: string) {
const normalizedBase = `${baseCode}_copy`;
const existingCodes = await db
.select({ code: crmApprovalWorkflows.code })
.from(crmApprovalWorkflows)
.where(
and(
eq(crmApprovalWorkflows.organizationId, organizationId),
isNull(crmApprovalWorkflows.deletedAt)
)
);
const existing = new Set(existingCodes.map((row) => row.code));
if (!existing.has(normalizedBase)) {
return normalizedBase;
}
let index = 2;
while (existing.has(`${normalizedBase}_${index}`)) {
index += 1;
}
return `${normalizedBase}_${index}`;
}
export async function listApprovalWorkflows(
organizationId: string,
filters: ApprovalWorkflowListFilters = {}
): Promise<ApprovalWorkflowListItem[]> {
const rows = await db
.select()
.from(crmApprovalWorkflows)
.where(
and(
eq(crmApprovalWorkflows.organizationId, organizationId),
isNull(crmApprovalWorkflows.deletedAt),
...(filters.entityType ? [eq(crmApprovalWorkflows.entityType, filters.entityType)] : []),
...(filters.activeOnly ? [eq(crmApprovalWorkflows.isActive, true)] : [])
)
)
.orderBy(
asc(crmApprovalWorkflows.entityType),
desc(crmApprovalWorkflows.isActive),
asc(crmApprovalWorkflows.name)
);
const workflowIds = rows.map((row) => row.id);
const [steps, usageMap] = await Promise.all([
workflowIds.length
? db
.select({
workflowId: crmApprovalSteps.workflowId
})
.from(crmApprovalSteps)
.where(
and(
eq(crmApprovalSteps.organizationId, organizationId),
inArray(crmApprovalSteps.workflowId, workflowIds),
isNull(crmApprovalSteps.deletedAt)
)
)
: Promise.resolve([]),
getWorkflowUsageMap(workflowIds, organizationId)
]);
const stepCountMap = new Map<string, number>();
for (const step of steps) {
stepCountMap.set(step.workflowId, (stepCountMap.get(step.workflowId) ?? 0) + 1);
}
return rows.map((row) => ({
...mapWorkflowRecord(row),
stepCount: stepCountMap.get(row.id) ?? 0,
usage: usageMap.get(row.id) ?? {
totalRequestCount: 0,
activeRequestCount: 0,
activeMatrixCount: 0,
isLocked: false,
lockedReason: null,
canEdit: true,
canDelete: true,
canDeactivate: true
}
}));
}
export async function getApprovalWorkflow(
id: string,
organizationId: string
): Promise<ApprovalWorkflowDetail> {
const workflow = await assertWorkflow(id, organizationId);
const [steps, usageMap] = await Promise.all([
listWorkflowStepsInternal(id, organizationId),
getWorkflowUsageMap([id], organizationId)
]);
return {
...mapWorkflowRecord(workflow),
steps: steps.map(mapStepRecord),
usage: usageMap.get(id) ?? {
totalRequestCount: 0,
activeRequestCount: 0,
activeMatrixCount: 0,
isLocked: false,
lockedReason: null,
canEdit: true,
canDelete: true,
canDeactivate: true
}
};
}
export async function createApprovalWorkflow(
organizationId: string,
userId: string,
payload: CreateApprovalWorkflowInput
): Promise<ApprovalWorkflowRecord> {
await ensureWorkflowCodeUnique(organizationId, payload.code);
const [created] = await db
.insert(crmApprovalWorkflows)
.values({
id: crypto.randomUUID(),
organizationId,
code: payload.code,
name: payload.name.trim(),
entityType: payload.entityType.trim(),
description: payload.description ?? null,
isSystem: false,
isActive: payload.isActive ?? true,
createdBy: userId,
updatedBy: userId
})
.returning();
return mapWorkflowRecord(created);
}
export async function updateApprovalWorkflow(
id: string,
organizationId: string,
userId: string,
payload: UpdateApprovalWorkflowInput
): Promise<ApprovalWorkflowRecord> {
await assertWorkflowEditable(id, organizationId);
const current = await assertWorkflow(id, organizationId);
const nextCode = payload.code ?? current.code;
await ensureWorkflowCodeUnique(organizationId, nextCode, id);
const [updated] = await db
.update(crmApprovalWorkflows)
.set({
code: nextCode,
name: payload.name?.trim() ?? current.name,
entityType: payload.entityType?.trim() ?? current.entityType,
description: payload.description !== undefined ? payload.description : current.description,
updatedAt: new Date(),
updatedBy: userId
})
.where(eq(crmApprovalWorkflows.id, id))
.returning();
return mapWorkflowRecord(updated);
}
export async function cloneApprovalWorkflow(
id: string,
organizationId: string,
userId: string
): Promise<ApprovalWorkflowDetail> {
const current = await assertWorkflow(id, organizationId);
const currentSteps = await listWorkflowStepsInternal(id, organizationId);
const cloneCode = await nextCloneCode(organizationId, current.code);
const clonedWorkflow = await db.transaction(async (tx) => {
const [workflow] = await tx
.insert(crmApprovalWorkflows)
.values({
id: crypto.randomUUID(),
organizationId,
code: cloneCode,
name: `${current.name} Copy`,
entityType: current.entityType,
description: current.description,
isSystem: false,
isActive: false,
createdBy: userId,
updatedBy: userId
})
.returning();
if (currentSteps.length > 0) {
await tx.insert(crmApprovalSteps).values(
currentSteps.map((step) => ({
id: crypto.randomUUID(),
organizationId,
workflowId: workflow.id,
stepNumber: step.stepNumber,
roleCode: step.roleCode,
roleName: step.roleName,
approvalMode: step.approvalMode,
isRequired: step.isRequired
}))
);
}
return workflow;
});
return getApprovalWorkflow(clonedWorkflow.id, organizationId);
}
export async function activateApprovalWorkflow(
id: string,
organizationId: string,
userId: string
): Promise<ApprovalWorkflowRecord> {
await assertWorkflowHasRequiredSteps(id, organizationId);
const [updated] = await db
.update(crmApprovalWorkflows)
.set({
isActive: true,
updatedAt: new Date(),
updatedBy: userId
})
.where(eq(crmApprovalWorkflows.id, id))
.returning();
return mapWorkflowRecord(updated);
}
export async function deactivateApprovalWorkflow(
id: string,
organizationId: string,
userId: string
): Promise<ApprovalWorkflowRecord> {
await assertWorkflowCanDeactivate(id, organizationId);
const [updated] = await db
.update(crmApprovalWorkflows)
.set({
isActive: false,
updatedAt: new Date(),
updatedBy: userId
})
.where(eq(crmApprovalWorkflows.id, id))
.returning();
return mapWorkflowRecord(updated);
}
export async function softDeleteApprovalWorkflow(
id: string,
organizationId: string,
userId: string
): Promise<{ before: ApprovalWorkflowDetail; after: ApprovalWorkflowRecord }> {
const before = await getApprovalWorkflow(id, organizationId);
await assertWorkflowCanDelete(id, organizationId);
const [updated] = await db
.update(crmApprovalWorkflows)
.set({
deletedAt: new Date(),
updatedAt: new Date(),
updatedBy: userId
})
.where(eq(crmApprovalWorkflows.id, id))
.returning();
return {
before,
after: mapWorkflowRecord(updated)
};
}
export async function listApprovalWorkflowSteps(
workflowId: string,
organizationId: string
): Promise<ApprovalStepRecord[]> {
await assertWorkflow(workflowId, organizationId);
const steps = await listWorkflowStepsInternal(workflowId, organizationId);
return steps.map(mapStepRecord);
}
export async function createApprovalWorkflowStep(
workflowId: string,
organizationId: string,
payload: CreateApprovalStepInput
): Promise<ApprovalStepRecord[]> {
await assertWorkflowEditable(workflowId, organizationId);
const workflow = await assertWorkflow(workflowId, organizationId);
await db.transaction(async (tx) => {
const existingSteps = await tx
.select()
.from(crmApprovalSteps)
.where(
and(
eq(crmApprovalSteps.workflowId, workflowId),
eq(crmApprovalSteps.organizationId, organizationId),
isNull(crmApprovalSteps.deletedAt)
)
)
.orderBy(asc(crmApprovalSteps.stepNumber));
const maxStepNumber = existingSteps.length + 1;
if (payload.stepNumber > maxStepNumber) {
throw new AuthError(`stepNumber must be between 1 and ${maxStepNumber}`, 400);
}
await Promise.all(
existingSteps
.filter((step) => step.stepNumber >= payload.stepNumber)
.sort((left, right) => right.stepNumber - left.stepNumber)
.map((step) =>
tx
.update(crmApprovalSteps)
.set({
stepNumber: step.stepNumber + 1,
updatedAt: new Date()
})
.where(eq(crmApprovalSteps.id, step.id))
)
);
await tx.insert(crmApprovalSteps).values({
id: crypto.randomUUID(),
organizationId,
workflowId,
stepNumber: payload.stepNumber,
roleCode: payload.roleCode,
roleName: payload.roleName.trim(),
approvalMode: payload.approvalMode,
isRequired: payload.isRequired
});
});
if (workflow.isActive) {
await assertWorkflowHasRequiredSteps(workflowId, organizationId);
}
return listApprovalWorkflowSteps(workflowId, organizationId);
}
export async function updateApprovalWorkflowStep(
stepId: string,
organizationId: string,
payload: UpdateApprovalStepInput
): Promise<ApprovalStepRecord> {
const current = await assertStep(stepId, organizationId);
await assertWorkflowEditable(current.workflowId, organizationId);
const [updated] = await db
.update(crmApprovalSteps)
.set({
roleCode: payload.roleCode ?? current.roleCode,
roleName: payload.roleName?.trim() ?? current.roleName,
approvalMode: payload.approvalMode ?? current.approvalMode,
isRequired: payload.isRequired ?? current.isRequired,
updatedAt: new Date()
})
.where(eq(crmApprovalSteps.id, stepId))
.returning();
const workflow = await assertWorkflow(current.workflowId, organizationId);
if (workflow.isActive) {
await assertWorkflowHasRequiredSteps(current.workflowId, organizationId);
}
return mapStepRecord(updated);
}
export async function deleteApprovalWorkflowStep(
stepId: string,
organizationId: string
): Promise<{ before: ApprovalStepRecord; after: ApprovalStepRecord; steps: ApprovalStepRecord[] }> {
const current = await assertStep(stepId, organizationId);
await assertWorkflowEditable(current.workflowId, organizationId);
const workflow = await assertWorkflow(current.workflowId, organizationId);
const [updated] = await db
.update(crmApprovalSteps)
.set({
deletedAt: new Date(),
updatedAt: new Date()
})
.where(eq(crmApprovalSteps.id, stepId))
.returning();
const remainingSteps = await listWorkflowStepsInternal(current.workflowId, organizationId);
await Promise.all(
remainingSteps.map((step, index) =>
db
.update(crmApprovalSteps)
.set({
stepNumber: index + 1,
updatedAt: new Date()
})
.where(eq(crmApprovalSteps.id, step.id))
)
);
if (workflow.isActive) {
await assertWorkflowHasRequiredSteps(current.workflowId, organizationId);
}
return {
before: mapStepRecord(current),
after: mapStepRecord(updated),
steps: await listApprovalWorkflowSteps(current.workflowId, organizationId)
};
}
export async function reorderApprovalWorkflowSteps(
workflowId: string,
organizationId: string,
payload: ReorderApprovalStepsInput
): Promise<ApprovalStepRecord[]> {
await assertWorkflowEditable(workflowId, organizationId);
const existingSteps = await listWorkflowStepsInternal(workflowId, organizationId);
if (existingSteps.length !== payload.orderedStepIds.length) {
throw new AuthError('orderedStepIds must include every active step exactly once', 400);
}
const existingIds = new Set(existingSteps.map((step) => step.id));
for (const stepId of payload.orderedStepIds) {
if (!existingIds.has(stepId)) {
throw new AuthError('orderedStepIds contains an unknown step', 400);
}
}
await db.transaction(async (tx) => {
await Promise.all(
payload.orderedStepIds.map((stepId, index) =>
tx
.update(crmApprovalSteps)
.set({
stepNumber: index + 1,
updatedAt: new Date()
})
.where(eq(crmApprovalSteps.id, stepId))
)
);
});
const workflow = await assertWorkflow(workflowId, organizationId);
if (workflow.isActive) {
await assertWorkflowHasRequiredSteps(workflowId, organizationId);
}
return listApprovalWorkflowSteps(workflowId, organizationId);
}

View File

@@ -3,6 +3,7 @@
import * as React from 'react';
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
import { toast } from 'sonner';
import { BUSINESS_ROLES } from '@/lib/auth/rbac';
import { Icons } from '@/components/icons';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -16,54 +17,75 @@ import {
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import {
activateApprovalWorkflowMutation,
cloneApprovalWorkflowMutation,
createApprovalWorkflowMutation,
createApprovalWorkflowStepMutation,
deactivateApprovalWorkflowMutation,
deleteApprovalWorkflowMutation,
replaceApprovalWorkflowStepsMutation,
updateApprovalWorkflowMutation
deleteApprovalWorkflowStepMutation,
reorderApprovalWorkflowStepsMutation,
updateApprovalWorkflowMutation,
updateApprovalWorkflowStepMutation
} from '../mutations';
import {
approvalWorkflowByIdOptions,
approvalWorkflowsQueryOptions
} from '../queries';
import { approvalWorkflowByIdOptions, approvalWorkflowsQueryOptions } from '../queries';
import type {
ApprovalStepMutationPayload,
ApprovalStepRecord,
ApprovalStepUpdatePayload,
ApprovalWorkflowDetail,
ApprovalWorkflowMutationPayload
} from '../types';
type WorkflowState = {
const APPROVAL_MODES = [
{ value: 'sequential', label: 'Sequential', description: 'Runs step-by-step in current runtime.' },
{ value: 'any_one', label: 'Any One', description: 'Stored for future runtime support.' },
{ value: 'all_required', label: 'All Required', description: 'Stored for future runtime support.' }
] as const;
type WorkflowFormState = {
code: string;
name: string;
entityType: string;
description: string;
isActive: boolean;
};
type StepState = {
type StepFormState = {
stepNumber: string;
roleCode: string;
roleName: string;
approvalMode: ApprovalStepMutationPayload['approvalMode'];
isRequired: boolean;
};
function toWorkflowState(workflow?: ApprovalWorkflowDetail): WorkflowState {
function toWorkflowState(workflow?: ApprovalWorkflowDetail): WorkflowFormState {
return {
code: workflow?.code ?? '',
name: workflow?.name ?? '',
entityType: workflow?.entityType ?? 'quotation',
description: workflow?.description ?? '',
isActive: workflow?.isActive ?? true
};
}
function toStepState(workflow?: ApprovalWorkflowDetail): StepState[] {
return (
workflow?.steps.map((step) => ({
stepNumber: String(step.stepNumber),
roleCode: step.roleCode,
roleName: step.roleName,
isRequired: step.isRequired
})) ?? [{ stepNumber: '1', roleCode: 'sales_manager', roleName: 'Sales Manager', isRequired: true }]
);
function toStepState(step?: ApprovalStepRecord, stepNumber = 1): StepFormState {
return {
stepNumber: String(step?.stepNumber ?? stepNumber),
roleCode: step?.roleCode ?? 'sales_manager',
roleName: step?.roleName ?? 'Sales Manager',
approvalMode: step?.approvalMode ?? 'sequential',
isRequired: step?.isRequired ?? true
};
}
function friendlyRoleName(roleCode: string) {
return roleCode
.split('_')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
function WorkflowDialog({
@@ -75,7 +97,7 @@ function WorkflowDialog({
onOpenChange: (open: boolean) => void;
workflow?: ApprovalWorkflowDetail;
}) {
const [state, setState] = React.useState<WorkflowState>(toWorkflowState(workflow));
const [state, setState] = React.useState<WorkflowFormState>(toWorkflowState(workflow));
const createMutation = useMutation({
...createApprovalWorkflowMutation,
onSuccess: () => {
@@ -99,12 +121,16 @@ function WorkflowDialog({
}
}, [open, workflow]);
const isPending = createMutation.isPending || updateMutation.isPending;
async function onSubmit(event: React.FormEvent) {
event.preventDefault();
const payload: ApprovalWorkflowMutationPayload = {
code: state.code,
name: state.name,
entityType: state.entityType,
description: state.description.trim() || null,
isActive: state.isActive
};
@@ -116,26 +142,73 @@ function WorkflowDialog({
await createMutation.mutateAsync(payload);
}
const isPending = createMutation.isPending || updateMutation.isPending;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogContent className='sm:max-w-2xl'>
<DialogHeader>
<DialogTitle>{workflow ? 'Edit Workflow' : 'Create Workflow'}</DialogTitle>
<DialogDescription>Sequential workflow only. No parallel or conditional logic is introduced here.</DialogDescription>
<DialogDescription>
Configure workflow metadata. Runtime still executes steps sequentially for now.
</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className='grid gap-4'>
<Input placeholder='Workflow code' value={state.code} onChange={(e) => setState((current) => ({ ...current, code: e.target.value }))} />
<Input placeholder='Workflow name' value={state.name} onChange={(e) => setState((current) => ({ ...current, name: e.target.value }))} />
<Input placeholder='Entity type' value={state.entityType} onChange={(e) => setState((current) => ({ ...current, entityType: e.target.value }))} />
<form onSubmit={onSubmit} className='space-y-4'>
<div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-2'>
<div className='text-sm font-medium'>Code</div>
<Input
placeholder='quotation_standard_approval'
value={state.code}
onChange={(event) => setState((current) => ({ ...current, code: event.target.value }))}
/>
</div>
<div className='space-y-2'>
<div className='text-sm font-medium'>Entity Type</div>
<Input
placeholder='quotation'
value={state.entityType}
onChange={(event) =>
setState((current) => ({ ...current, entityType: event.target.value }))
}
/>
</div>
</div>
<div className='space-y-2'>
<div className='text-sm font-medium'>Name</div>
<Input
placeholder='Quotation Standard Approval'
value={state.name}
onChange={(event) => setState((current) => ({ ...current, name: event.target.value }))}
/>
</div>
<div className='space-y-2'>
<div className='text-sm font-medium'>Description</div>
<Textarea
placeholder='Describe when this workflow should be used'
value={state.description}
onChange={(event) =>
setState((current) => ({ ...current, description: event.target.value }))
}
/>
</div>
{!workflow ? (
<div className='flex items-center justify-between rounded-lg border px-4 py-3'>
<div>
<div className='font-medium'>Active</div>
<div className='text-muted-foreground text-sm'>Inactive workflows stay available in audit history only.</div>
<div className='font-medium'>Active on creation</div>
<div className='text-muted-foreground text-sm'>
Keep this off if you want to finish step configuration first.
</div>
<Switch checked={state.isActive} onCheckedChange={(checked) => setState((current) => ({ ...current, isActive: checked }))} />
</div>
<Switch
checked={state.isActive}
onCheckedChange={(checked) => setState((current) => ({ ...current, isActive: checked }))}
/>
</div>
) : null}
<DialogFooter>
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
Cancel
@@ -150,20 +223,30 @@ function WorkflowDialog({
);
}
function StepsDialog({
workflow,
function StepDialog({
open,
onOpenChange
onOpenChange,
workflow,
step
}: {
workflow: ApprovalWorkflowDetail;
open: boolean;
onOpenChange: (open: boolean) => void;
workflow: ApprovalWorkflowDetail;
step?: ApprovalStepRecord;
}) {
const [steps, setSteps] = React.useState<StepState[]>(toStepState(workflow));
const replaceMutation = useMutation({
...replaceApprovalWorkflowStepsMutation,
const [state, setState] = React.useState<StepFormState>(toStepState(step, workflow.steps.length + 1));
const createMutation = useMutation({
...createApprovalWorkflowStepMutation,
onSuccess: () => {
toast.success('Workflow steps updated');
toast.success('Step created');
onOpenChange(false);
},
onError: (error) => toast.error(error instanceof Error ? error.message : 'Create failed')
});
const updateMutation = useMutation({
...updateApprovalWorkflowStepMutation,
onSuccess: () => {
toast.success('Step updated');
onOpenChange(false);
},
onError: (error) => toast.error(error instanceof Error ? error.message : 'Update failed')
@@ -171,73 +254,142 @@ function StepsDialog({
React.useEffect(() => {
if (open) {
setSteps(toStepState(workflow));
setState(toStepState(step, workflow.steps.length + 1));
}
}, [open, workflow]);
}, [open, step, workflow.steps.length]);
function updateStep(index: number, field: keyof StepState, value: string | boolean) {
setSteps((current) =>
current.map((step, stepIndex) =>
stepIndex === index ? { ...step, [field]: value } : step
)
);
}
const isPending = createMutation.isPending || updateMutation.isPending;
async function onSubmit(event: React.FormEvent) {
event.preventDefault();
const payload: ApprovalStepMutationPayload[] = steps.map((step, index) => ({
stepNumber: Number(step.stepNumber || index + 1),
roleCode: step.roleCode,
roleName: step.roleName,
isRequired: step.isRequired
}));
await replaceMutation.mutateAsync({ workflowId: workflow.id, steps: payload });
if (step) {
const payload: ApprovalStepUpdatePayload = {
roleCode: state.roleCode,
roleName: state.roleName,
approvalMode: state.approvalMode,
isRequired: state.isRequired
};
await updateMutation.mutateAsync({
workflowId: workflow.id,
stepId: step.id,
values: payload
});
return;
}
const payload: ApprovalStepMutationPayload = {
stepNumber: Number(state.stepNumber),
roleCode: state.roleCode,
roleName: state.roleName,
approvalMode: state.approvalMode,
isRequired: state.isRequired
};
await createMutation.mutateAsync({
workflowId: workflow.id,
values: payload
});
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-4xl'>
<DialogContent className='sm:max-w-2xl'>
<DialogHeader>
<DialogTitle>Manage Steps</DialogTitle>
<DialogDescription>Each step resolves to a CRM business role and runs strictly in order.</DialogDescription>
<DialogTitle>{step ? 'Edit Step' : 'Add Step'}</DialogTitle>
<DialogDescription>
Non-sequential approval modes are stored now for future runtime support.
</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className='space-y-4'>
{steps.map((step, index) => (
<div key={`${index}-${step.roleCode}`} className='grid gap-3 rounded-lg border p-4 md:grid-cols-4'>
<Input placeholder='Step no.' value={step.stepNumber} onChange={(e) => updateStep(index, 'stepNumber', e.target.value)} />
<Input placeholder='Role code' value={step.roleCode} onChange={(e) => updateStep(index, 'roleCode', e.target.value)} />
<Input placeholder='Role name' value={step.roleName} onChange={(e) => updateStep(index, 'roleName', e.target.value)} />
<div className='flex items-center justify-between rounded-lg border px-3 py-2'>
<span className='text-sm font-medium'>Required</span>
<Switch checked={step.isRequired} onCheckedChange={(checked) => updateStep(index, 'isRequired', checked)} />
</div>
</div>
))}
<Button
type='button'
variant='outline'
onClick={() =>
setSteps((current) => [
...current,
{
stepNumber: String(current.length + 1),
roleCode: '',
roleName: '',
isRequired: true
{!step ? (
<div className='space-y-2'>
<div className='text-sm font-medium'>Step Number</div>
<Input
type='number'
min={1}
value={state.stepNumber}
onChange={(event) =>
setState((current) => ({ ...current, stepNumber: event.target.value }))
}
])
/>
</div>
) : null}
<div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-2'>
<div className='text-sm font-medium'>Role Code</div>
<select
className='border-input bg-background flex h-10 w-full rounded-md border px-3 py-2 text-sm'
value={state.roleCode}
onChange={(event) =>
setState((current) => ({
...current,
roleCode: event.target.value,
roleName: friendlyRoleName(event.target.value)
}))
}
>
<Icons.add className='mr-2 h-4 w-4' />
Add Step
</Button>
{BUSINESS_ROLES.map((role) => (
<option key={role} value={role}>
{role}
</option>
))}
</select>
</div>
<div className='space-y-2'>
<div className='text-sm font-medium'>Role Name</div>
<Input
value={state.roleName}
onChange={(event) =>
setState((current) => ({ ...current, roleName: event.target.value }))
}
/>
</div>
</div>
<div className='space-y-2'>
<div className='text-sm font-medium'>Approval Mode</div>
<select
className='border-input bg-background flex h-10 w-full rounded-md border px-3 py-2 text-sm'
value={state.approvalMode}
onChange={(event) =>
setState((current) => ({
...current,
approvalMode: event.target.value as StepFormState['approvalMode']
}))
}
>
{APPROVAL_MODES.map((mode) => (
<option key={mode.value} value={mode.value}>
{mode.label}
</option>
))}
</select>
<div className='text-muted-foreground text-xs'>
{APPROVAL_MODES.find((mode) => mode.value === state.approvalMode)?.description}
</div>
</div>
<div className='flex items-center justify-between rounded-lg border px-4 py-3'>
<div>
<div className='font-medium'>Required Step</div>
<div className='text-muted-foreground text-sm'>
Active workflows must keep at least one required step.
</div>
</div>
<Switch
checked={state.isRequired}
onCheckedChange={(checked) => setState((current) => ({ ...current, isRequired: checked }))}
/>
</div>
<DialogFooter>
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type='submit' isLoading={replaceMutation.isPending}>
Save Steps
<Button type='submit' isLoading={isPending}>
{step ? 'Save Step' : 'Add Step'}
</Button>
</DialogFooter>
</form>
@@ -246,47 +398,241 @@ function StepsDialog({
);
}
function WorkflowDetailActions({ workflowId }: { workflowId: string }) {
function WorkflowCard({ workflowId }: { workflowId: string }) {
const { data } = useSuspenseQuery(approvalWorkflowByIdOptions(workflowId));
const workflow = data.workflow;
const [workflowDialogOpen, setWorkflowDialogOpen] = React.useState(false);
const [stepsDialogOpen, setStepsDialogOpen] = React.useState(false);
const [createStepOpen, setCreateStepOpen] = React.useState(false);
const [editingStep, setEditingStep] = React.useState<ApprovalStepRecord | null>(null);
const deleteMutation = useMutation({
...deleteApprovalWorkflowMutation,
onSuccess: () => toast.success('Workflow deleted'),
onError: (error) => toast.error(error instanceof Error ? error.message : 'Delete failed')
});
const cloneMutation = useMutation({
...cloneApprovalWorkflowMutation,
onSuccess: () => toast.success('Workflow cloned'),
onError: (error) => toast.error(error instanceof Error ? error.message : 'Clone failed')
});
const activateMutation = useMutation({
...activateApprovalWorkflowMutation,
onSuccess: () => toast.success('Workflow activated'),
onError: (error) => toast.error(error instanceof Error ? error.message : 'Activate failed')
});
const deactivateMutation = useMutation({
...deactivateApprovalWorkflowMutation,
onSuccess: () => toast.success('Workflow deactivated'),
onError: (error) => toast.error(error instanceof Error ? error.message : 'Deactivate failed')
});
const deleteStepMutation = useMutation({
...deleteApprovalWorkflowStepMutation,
onSuccess: () => toast.success('Step deleted'),
onError: (error) => toast.error(error instanceof Error ? error.message : 'Delete step failed')
});
const reorderMutation = useMutation({
...reorderApprovalWorkflowStepsMutation,
onSuccess: () => toast.success('Step order updated'),
onError: (error) => toast.error(error instanceof Error ? error.message : 'Reorder failed')
});
async function moveStep(stepId: string, direction: 'up' | 'down') {
const index = workflow.steps.findIndex((step) => step.id === stepId);
if (index < 0) return;
const nextIndex = direction === 'up' ? index - 1 : index + 1;
if (nextIndex < 0 || nextIndex >= workflow.steps.length) return;
const reordered = [...workflow.steps];
const [item] = reordered.splice(index, 1);
reordered.splice(nextIndex, 0, item);
await reorderMutation.mutateAsync({
workflowId: workflow.id,
values: {
orderedStepIds: reordered.map((step) => step.id)
}
});
}
return (
<>
<div className='flex gap-2'>
<Button variant='outline' onClick={() => setWorkflowDialogOpen(true)}>
<div className='rounded-xl border p-5'>
<div className='flex flex-wrap items-start justify-between gap-4'>
<div className='space-y-2'>
<div className='text-xl font-semibold'>{workflow.name}</div>
<div className='text-muted-foreground text-sm'>
{workflow.code} | {workflow.entityType}
</div>
{workflow.description ? (
<div className='text-muted-foreground text-sm'>{workflow.description}</div>
) : null}
</div>
<div className='flex flex-wrap gap-2'>
<Badge variant={workflow.isActive ? 'secondary' : 'outline'}>
{workflow.isActive ? 'Active' : 'Inactive'}
</Badge>
<Badge variant='outline'>{workflow.steps.length} steps</Badge>
{workflow.isSystem ? <Badge variant='outline'>System</Badge> : null}
{workflow.usage.isLocked ? <Badge variant='destructive'>Locked</Badge> : null}
</div>
</div>
<div className='mt-4 grid gap-3 md:grid-cols-3 xl:grid-cols-6'>
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>Total Requests</div>
<div className='text-lg font-semibold'>{workflow.usage.totalRequestCount}</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>Pending Requests</div>
<div className='text-lg font-semibold'>{workflow.usage.activeRequestCount}</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-muted-foreground text-xs'>Active Matrices</div>
<div className='text-lg font-semibold'>{workflow.usage.activeMatrixCount}</div>
</div>
<div className='rounded-lg border p-3 md:col-span-3 xl:col-span-3'>
<div className='text-muted-foreground text-xs'>Safety</div>
<div className='mt-1 text-sm'>
{workflow.usage.lockedReason ?? 'This workflow is editable.'}
</div>
</div>
</div>
<div className='mt-4 flex flex-wrap gap-2'>
<Button
variant='outline'
onClick={() => setWorkflowDialogOpen(true)}
disabled={!workflow.usage.canEdit}
>
Edit Workflow
</Button>
<Button variant='outline' onClick={() => setStepsDialogOpen(true)}>
Manage Steps
<Button variant='outline' onClick={() => cloneMutation.mutate(workflow.id)} isLoading={cloneMutation.isPending}>
Clone
</Button>
<Button variant='outline' onClick={() => deleteMutation.mutate(workflow.id)} isLoading={deleteMutation.isPending}>
{workflow.isActive ? (
<Button
variant='outline'
onClick={() => deactivateMutation.mutate(workflow.id)}
isLoading={deactivateMutation.isPending}
disabled={!workflow.usage.canDeactivate}
>
Deactivate
</Button>
) : (
<Button
variant='outline'
onClick={() => activateMutation.mutate(workflow.id)}
isLoading={activateMutation.isPending}
>
Activate
</Button>
)}
<Button
variant='outline'
onClick={() => deleteMutation.mutate(workflow.id)}
isLoading={deleteMutation.isPending}
disabled={!workflow.usage.canDelete}
>
Delete
</Button>
<Button
onClick={() => setCreateStepOpen(true)}
disabled={!workflow.usage.canEdit}
>
<Icons.add className='mr-2 h-4 w-4' />
Add Step
</Button>
</div>
<div className='mt-3 grid gap-3 md:grid-cols-2 xl:grid-cols-4'>
{workflow.steps.map((step) => (
<div className='mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-4'>
{workflow.steps.map((step, index) => (
<div key={step.id} className='rounded-lg border p-4'>
<div className='flex items-start justify-between gap-2'>
<div>
<div className='font-medium'>Step {step.stepNumber}</div>
<div className='text-muted-foreground mt-1 text-sm'>{step.roleName}</div>
<div className='mt-2 flex gap-2'>
<Badge variant='outline'>{step.roleCode}</Badge>
</div>
<Badge variant={step.isRequired ? 'secondary' : 'outline'}>
{step.isRequired ? 'Required' : 'Optional'}
</Badge>
</div>
<div className='mt-3 flex flex-wrap gap-2'>
<Badge variant='outline'>{step.roleCode}</Badge>
<Badge variant='outline'>{step.approvalMode}</Badge>
</div>
{step.approvalMode !== 'sequential' ? (
<div className='text-muted-foreground mt-3 text-xs'>
Stored for future runtime support. Current engine still runs sequentially.
</div>
) : null}
<div className='mt-4 flex flex-wrap gap-2'>
<Button
size='sm'
variant='outline'
onClick={() => moveStep(step.id, 'up')}
disabled={!workflow.usage.canEdit || index === 0 || reorderMutation.isPending}
>
Up
</Button>
<Button
size='sm'
variant='outline'
onClick={() => moveStep(step.id, 'down')}
disabled={
!workflow.usage.canEdit ||
index === workflow.steps.length - 1 ||
reorderMutation.isPending
}
>
Down
</Button>
<Button
size='sm'
variant='outline'
onClick={() => setEditingStep(step)}
disabled={!workflow.usage.canEdit}
>
Edit
</Button>
<Button
size='sm'
variant='outline'
onClick={() =>
deleteStepMutation.mutate({
workflowId: workflow.id,
stepId: step.id
})
}
isLoading={deleteStepMutation.isPending}
disabled={!workflow.usage.canEdit}
>
Delete
</Button>
</div>
</div>
))}
</div>
<WorkflowDialog open={workflowDialogOpen} onOpenChange={setWorkflowDialogOpen} workflow={workflow} />
<StepsDialog open={stepsDialogOpen} onOpenChange={setStepsDialogOpen} workflow={workflow} />
</>
<WorkflowDialog
open={workflowDialogOpen}
onOpenChange={setWorkflowDialogOpen}
workflow={workflow}
/>
<StepDialog open={createStepOpen} onOpenChange={setCreateStepOpen} workflow={workflow} />
<StepDialog
open={editingStep !== null}
onOpenChange={(open) => {
if (!open) {
setEditingStep(null);
}
}}
workflow={workflow}
step={editingStep ?? undefined}
/>
</div>
);
}
@@ -298,8 +644,10 @@ export function ApprovalWorkflowSettings() {
<div className='space-y-6'>
<div className='flex items-center justify-between gap-4 rounded-lg border p-4'>
<div>
<div className='font-medium'>Approval Workflow Admin</div>
<div className='text-muted-foreground text-sm'>Configure sequential CRM approval workflows and business-role steps.</div>
<div className='font-medium'>Approval Workflow Builder</div>
<div className='text-muted-foreground text-sm'>
Configure approval workflows, clone locked flows, and manage ordered steps without touching seed code.
</div>
</div>
<Button onClick={() => setCreateOpen(true)}>
<Icons.add className='mr-2 h-4 w-4' />
@@ -309,30 +657,23 @@ export function ApprovalWorkflowSettings() {
{data.items.length === 0 ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
No approval workflows configured.
No approval workflows configured yet.
</div>
) : (
data.items.map((workflow) => (
<div key={workflow.id} className='rounded-xl border p-5'>
<div className='flex flex-wrap items-start justify-between gap-4'>
<div>
<div className='text-xl font-semibold'>{workflow.name}</div>
<div className='text-muted-foreground text-sm'>
{workflow.code} {workflow.entityType}
<div className='space-y-4'>
{data.items.map((workflow) => (
<React.Suspense
key={workflow.id}
fallback={
<div className='text-muted-foreground rounded-lg border p-5 text-sm'>
Loading workflow detail...
</div>
</div>
<div className='flex gap-2'>
<Badge variant={workflow.isActive ? 'secondary' : 'outline'}>
{workflow.isActive ? 'Active' : 'Inactive'}
</Badge>
<Badge variant='outline'>{workflow.stepCount} steps</Badge>
</div>
</div>
<React.Suspense fallback={<div className='text-muted-foreground mt-4 text-sm'>Loading workflow detail...</div>}>
<WorkflowDetailActions workflowId={workflow.id} />
}
>
<WorkflowCard workflowId={workflow.id} />
</React.Suspense>
))}
</div>
))
)}
<WorkflowDialog open={createOpen} onOpenChange={setCreateOpen} />

View File

@@ -1,23 +1,32 @@
import { mutationOptions } from '@tanstack/react-query';
import { quotationDocumentKeys } from '@/features/crm/quotations/document/queries';
import { quotationKeys } from '@/features/crm/quotations/api/queries';
import { getQueryClient } from '@/lib/query-client';
import {
activateApprovalWorkflow,
approveApproval,
cancelApproval,
cloneApprovalWorkflow,
createApprovalWorkflow,
createApprovalWorkflowStep,
deactivateApprovalWorkflow,
deleteApprovalWorkflow,
deleteApprovalWorkflowStep,
rejectApproval,
replaceApprovalWorkflowSteps,
reorderApprovalWorkflowSteps,
returnApproval,
submitApproval,
submitQuotationForApproval,
updateApprovalWorkflow
updateApprovalWorkflow,
updateApprovalWorkflowStep
} from './service';
import { approvalKeys, approvalWorkflowKeys } from './queries';
import { quotationKeys } from '@/features/crm/quotations/api/queries';
import type {
ApprovalActionPayload,
ApprovalStepMutationPayload,
ApprovalStepReorderPayload,
ApprovalStepUpdatePayload,
ApprovalWorkflowFilters,
ApprovalWorkflowMutationPayload,
SubmitApprovalPayload
} from './types';
@@ -30,12 +39,20 @@ async function invalidateApprovalDetail(id: string) {
await getQueryClient().invalidateQueries({ queryKey: approvalKeys.detail(id) });
}
async function invalidateApprovalWorkflows() {
await getQueryClient().invalidateQueries({ queryKey: approvalWorkflowKeys.lists() });
async function invalidateApprovalWorkflowLists(filters?: ApprovalWorkflowFilters) {
const queryClient = getQueryClient();
await Promise.all([
queryClient.invalidateQueries({ queryKey: approvalWorkflowKeys.lists() }),
...(filters ? [queryClient.invalidateQueries({ queryKey: approvalWorkflowKeys.list(filters) })] : [])
]);
}
async function invalidateApprovalWorkflowDetail(id: string) {
await getQueryClient().invalidateQueries({ queryKey: approvalWorkflowKeys.detail(id) });
const queryClient = getQueryClient();
await Promise.all([
queryClient.invalidateQueries({ queryKey: approvalWorkflowKeys.detail(id) }),
queryClient.invalidateQueries({ queryKey: approvalWorkflowKeys.steps(id) })
]);
}
async function invalidateQuotationQueries(quotationId: string) {
@@ -60,7 +77,7 @@ async function invalidateRelatedEntityQueries(approvalId: string) {
}
export const submitApprovalMutation = mutationOptions({
mutationFn: (data: SubmitApprovalPayload) => submitApproval(data),
mutationFn: (values: SubmitApprovalPayload) => submitApproval(values),
onSettled: async (_data, error) => {
if (!error) {
await invalidateApprovalLists();
@@ -68,7 +85,7 @@ export const submitApprovalMutation = mutationOptions({
}
});
export const submitQuotationApprovalMutation = mutationOptions({
export const submitQuotationForApprovalMutation = mutationOptions({
mutationFn: ({ id, remark }: { id: string; remark?: string }) => submitQuotationForApproval(id, remark),
onSettled: async (_data, error, variables) => {
if (!error) {
@@ -77,22 +94,19 @@ export const submitQuotationApprovalMutation = mutationOptions({
}
});
export const submitQuotationApprovalMutation = submitQuotationForApprovalMutation;
export const cancelApprovalMutation = mutationOptions({
mutationFn: (id: string) => cancelApproval(id),
onSettled: async (_data, error, id) => {
if (!error) {
await Promise.all([
invalidateApprovalLists(),
invalidateApprovalDetail(id),
invalidateRelatedEntityQueries(id)
]);
await Promise.all([invalidateApprovalLists(), invalidateApprovalDetail(id), invalidateRelatedEntityQueries(id)]);
}
}
});
export const approveApprovalMutation = mutationOptions({
mutationFn: ({ id, values }: { id: string; values: ApprovalActionPayload }) =>
approveApproval(id, values),
mutationFn: ({ id, values }: { id: string; values: ApprovalActionPayload }) => approveApproval(id, values),
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([
@@ -105,8 +119,7 @@ export const approveApprovalMutation = mutationOptions({
});
export const rejectApprovalMutation = mutationOptions({
mutationFn: ({ id, values }: { id: string; values: ApprovalActionPayload }) =>
rejectApproval(id, values),
mutationFn: ({ id, values }: { id: string; values: ApprovalActionPayload }) => rejectApproval(id, values),
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([
@@ -119,8 +132,7 @@ export const rejectApprovalMutation = mutationOptions({
});
export const returnApprovalMutation = mutationOptions({
mutationFn: ({ id, values }: { id: string; values: ApprovalActionPayload }) =>
returnApproval(id, values),
mutationFn: ({ id, values }: { id: string; values: ApprovalActionPayload }) => returnApproval(id, values),
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([
@@ -136,7 +148,7 @@ export const createApprovalWorkflowMutation = mutationOptions({
mutationFn: (data: ApprovalWorkflowMutationPayload) => createApprovalWorkflow(data),
onSettled: async (_data, error) => {
if (!error) {
await invalidateApprovalWorkflows();
await invalidateApprovalWorkflowLists();
}
}
});
@@ -152,7 +164,7 @@ export const updateApprovalWorkflowMutation = mutationOptions({
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([
invalidateApprovalWorkflows(),
invalidateApprovalWorkflowLists(),
invalidateApprovalWorkflowDetail(variables.id)
]);
}
@@ -164,24 +176,103 @@ export const deleteApprovalWorkflowMutation = mutationOptions({
onSettled: async (_data, error, id) => {
if (!error) {
const queryClient = getQueryClient();
await invalidateApprovalWorkflows();
await invalidateApprovalWorkflowLists();
queryClient.removeQueries({ queryKey: approvalWorkflowKeys.detail(id) });
queryClient.removeQueries({ queryKey: approvalWorkflowKeys.steps(id) });
}
}
});
export const replaceApprovalWorkflowStepsMutation = mutationOptions({
export const cloneApprovalWorkflowMutation = mutationOptions({
mutationFn: (id: string) => cloneApprovalWorkflow(id),
onSettled: async (_data, error) => {
if (!error) {
await invalidateApprovalWorkflowLists();
}
}
});
export const activateApprovalWorkflowMutation = mutationOptions({
mutationFn: (id: string) => activateApprovalWorkflow(id),
onSettled: async (_data, error, id) => {
if (!error) {
await Promise.all([invalidateApprovalWorkflowLists(), invalidateApprovalWorkflowDetail(id)]);
}
}
});
export const deactivateApprovalWorkflowMutation = mutationOptions({
mutationFn: (id: string) => deactivateApprovalWorkflow(id),
onSettled: async (_data, error, id) => {
if (!error) {
await Promise.all([invalidateApprovalWorkflowLists(), invalidateApprovalWorkflowDetail(id)]);
}
}
});
export const createApprovalWorkflowStepMutation = mutationOptions({
mutationFn: ({
workflowId,
steps
values
}: {
workflowId: string;
steps: ApprovalStepMutationPayload[];
}) => replaceApprovalWorkflowSteps(workflowId, steps),
values: ApprovalStepMutationPayload;
}) => createApprovalWorkflowStep(workflowId, values),
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([
invalidateApprovalWorkflows(),
invalidateApprovalWorkflowLists(),
invalidateApprovalWorkflowDetail(variables.workflowId)
]);
}
}
});
export const updateApprovalWorkflowStepMutation = mutationOptions({
mutationFn: ({
workflowId,
stepId,
values
}: {
workflowId: string;
stepId: string;
values: ApprovalStepUpdatePayload;
}) => updateApprovalWorkflowStep(workflowId, stepId, values),
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([
invalidateApprovalWorkflowLists(),
invalidateApprovalWorkflowDetail(variables.workflowId)
]);
}
}
});
export const deleteApprovalWorkflowStepMutation = mutationOptions({
mutationFn: ({ workflowId, stepId }: { workflowId: string; stepId: string }) =>
deleteApprovalWorkflowStep(workflowId, stepId),
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([
invalidateApprovalWorkflowLists(),
invalidateApprovalWorkflowDetail(variables.workflowId)
]);
}
}
});
export const reorderApprovalWorkflowStepsMutation = mutationOptions({
mutationFn: ({
workflowId,
values
}: {
workflowId: string;
values: ApprovalStepReorderPayload;
}) => reorderApprovalWorkflowSteps(workflowId, values),
onSettled: async (_data, error, variables) => {
if (!error) {
await Promise.all([
invalidateApprovalWorkflowLists(),
invalidateApprovalWorkflowDetail(variables.workflowId)
]);
}

View File

@@ -2,10 +2,11 @@ import { queryOptions } from '@tanstack/react-query';
import {
getApprovalById,
getApprovalWorkflowById,
getApprovalWorkflowSteps,
getApprovalWorkflows,
getApprovals
} from './service';
import type { ApprovalFilters } from './types';
import type { ApprovalFilters, ApprovalWorkflowFilters } from './types';
export const approvalKeys = {
all: ['crm-approvals'] as const,
@@ -18,9 +19,10 @@ export const approvalKeys = {
export const approvalWorkflowKeys = {
all: ['crm-approval-workflows'] as const,
lists: () => [...approvalWorkflowKeys.all, 'list'] as const,
list: () => [...approvalWorkflowKeys.lists(), 'all'] as const,
list: (filters: ApprovalWorkflowFilters = {}) => [...approvalWorkflowKeys.lists(), filters] as const,
details: () => [...approvalWorkflowKeys.all, 'detail'] as const,
detail: (id: string) => [...approvalWorkflowKeys.details(), id] as const
detail: (id: string) => [...approvalWorkflowKeys.details(), id] as const,
steps: (workflowId: string) => [...approvalWorkflowKeys.detail(workflowId), 'steps'] as const
};
export const approvalsQueryOptions = (filters: ApprovalFilters) =>
@@ -35,10 +37,10 @@ export const approvalByIdOptions = (id: string) =>
queryFn: () => getApprovalById(id)
});
export const approvalWorkflowsQueryOptions = () =>
export const approvalWorkflowsQueryOptions = (filters: ApprovalWorkflowFilters = {}) =>
queryOptions({
queryKey: approvalWorkflowKeys.list(),
queryFn: () => getApprovalWorkflows()
queryKey: approvalWorkflowKeys.list(filters),
queryFn: () => getApprovalWorkflows(filters)
});
export const approvalWorkflowByIdOptions = (id: string) =>
@@ -46,3 +48,9 @@ export const approvalWorkflowByIdOptions = (id: string) =>
queryKey: approvalWorkflowKeys.detail(id),
queryFn: () => getApprovalWorkflowById(id)
});
export const approvalWorkflowStepsOptions = (workflowId: string) =>
queryOptions({
queryKey: approvalWorkflowKeys.steps(workflowId),
queryFn: () => getApprovalWorkflowSteps(workflowId)
});

View File

@@ -63,7 +63,11 @@ function mapWorkflowRecord(row: typeof crmApprovalWorkflows.$inferSelect): Appro
code: row.code,
name: row.name,
entityType: row.entityType,
description: row.description ?? null,
isSystem: row.isSystem,
isActive: row.isActive,
createdBy: row.createdBy,
updatedBy: row.updatedBy,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
deletedAt: row.deletedAt?.toISOString() ?? null
@@ -78,6 +82,7 @@ function mapStepRecord(row: typeof crmApprovalSteps.$inferSelect): ApprovalStepR
stepNumber: row.stepNumber,
roleCode: row.roleCode,
roleName: row.roleName,
approvalMode: row.approvalMode as ApprovalStepRecord['approvalMode'],
isRequired: row.isRequired,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
@@ -285,7 +290,17 @@ export async function listApprovalWorkflows(
return workflows.map((workflow) => ({
...mapWorkflowRecord(workflow),
stepCount: steps.filter((step) => step.workflowId === workflow.id).length
stepCount: steps.filter((step) => step.workflowId === workflow.id).length,
usage: {
totalRequestCount: 0,
activeRequestCount: 0,
activeMatrixCount: 0,
isLocked: false,
lockedReason: null,
canEdit: true,
canDelete: true,
canDeactivate: true
}
}));
}
@@ -298,7 +313,17 @@ export async function getApprovalWorkflow(
return {
...mapWorkflowRecord(workflow),
steps
steps,
usage: {
totalRequestCount: 0,
activeRequestCount: 0,
activeMatrixCount: 0,
isLocked: false,
lockedReason: null,
canEdit: true,
canDelete: true,
canDeactivate: true
}
};
}
@@ -314,7 +339,11 @@ export async function createApprovalWorkflow(
code: payload.code.trim(),
name: payload.name.trim(),
entityType: payload.entityType.trim(),
isActive: payload.isActive ?? true
description: payload.description ?? null,
isSystem: false,
isActive: payload.isActive ?? true,
createdBy: organizationId,
updatedBy: organizationId
})
.returning();
@@ -333,8 +362,10 @@ export async function updateApprovalWorkflow(
code: payload.code?.trim() ?? current.code,
name: payload.name?.trim() ?? current.name,
entityType: payload.entityType?.trim() ?? current.entityType,
description: payload.description !== undefined ? payload.description ?? null : current.description,
isActive: payload.isActive ?? current.isActive,
updatedAt: new Date()
updatedAt: new Date(),
updatedBy: organizationId
})
.where(eq(crmApprovalWorkflows.id, id))
.returning();
@@ -374,6 +405,7 @@ export async function createApprovalStep(
stepNumber: payload.stepNumber,
roleCode: payload.roleCode.trim(),
roleName: payload.roleName.trim(),
approvalMode: payload.approvalMode ?? 'sequential',
isRequired: payload.isRequired ?? true
})
.returning();
@@ -393,6 +425,7 @@ export async function updateApprovalStep(
stepNumber: payload.stepNumber ?? current.stepNumber,
roleCode: payload.roleCode?.trim() ?? current.roleCode,
roleName: payload.roleName?.trim() ?? current.roleName,
approvalMode: payload.approvalMode ?? current.approvalMode,
isRequired: payload.isRequired ?? current.isRequired,
updatedAt: new Date()
})
@@ -451,6 +484,7 @@ export async function replaceApprovalWorkflowSteps(
stepNumber: step.stepNumber ?? index + 1,
roleCode: step.roleCode.trim(),
roleName: step.roleName.trim(),
approvalMode: step.approvalMode ?? 'sequential',
isRequired: step.isRequired ?? true
}))
)

View File

@@ -5,9 +5,13 @@ import type {
ApprovalFilters,
ApprovalListResponse,
ApprovalStepMutationPayload,
ApprovalStepReorderPayload,
ApprovalStepUpdatePayload,
ApprovalWorkflowDetailResponse,
ApprovalWorkflowFilters,
ApprovalWorkflowListResponse,
ApprovalWorkflowMutationPayload,
ApprovalWorkflowStepListResponse,
MutationSuccessResponse,
SubmitApprovalPayload
} from './types';
@@ -74,11 +78,18 @@ export async function submitQuotationForApproval(id: string, remark?: string) {
const SETTINGS_BASE = '/crm/settings/approval-workflows';
export async function getApprovalWorkflows() {
return apiClient<ApprovalWorkflowListResponse>(SETTINGS_BASE);
export async function getApprovalWorkflows(
filters: ApprovalWorkflowFilters = {}
): Promise<ApprovalWorkflowListResponse> {
const searchParams = new URLSearchParams();
if (filters.entityType) searchParams.set('entityType', filters.entityType);
if (filters.activeOnly) searchParams.set('activeOnly', 'true');
const query = searchParams.toString();
return apiClient<ApprovalWorkflowListResponse>(`${SETTINGS_BASE}${query ? `?${query}` : ''}`);
}
export async function getApprovalWorkflowById(id: string) {
export async function getApprovalWorkflowById(id: string): Promise<ApprovalWorkflowDetailResponse> {
return apiClient<ApprovalWorkflowDetailResponse>(`${SETTINGS_BASE}/${id}`);
}
@@ -105,12 +116,61 @@ export async function deleteApprovalWorkflow(id: string) {
});
}
export async function replaceApprovalWorkflowSteps(
workflowId: string,
data: ApprovalStepMutationPayload[]
) {
return apiClient<MutationSuccessResponse>(`${SETTINGS_BASE}/${workflowId}/steps`, {
method: 'PUT',
body: JSON.stringify({ steps: data })
export async function cloneApprovalWorkflow(id: string) {
return apiClient<MutationSuccessResponse>(`${SETTINGS_BASE}/${id}/clone`, {
method: 'POST'
});
}
export async function activateApprovalWorkflow(id: string) {
return apiClient<MutationSuccessResponse>(`${SETTINGS_BASE}/${id}/activate`, {
method: 'POST'
});
}
export async function deactivateApprovalWorkflow(id: string) {
return apiClient<MutationSuccessResponse>(`${SETTINGS_BASE}/${id}/deactivate`, {
method: 'POST'
});
}
export async function getApprovalWorkflowSteps(workflowId: string) {
return apiClient<ApprovalWorkflowStepListResponse>(`${SETTINGS_BASE}/${workflowId}/steps`);
}
export async function createApprovalWorkflowStep(
workflowId: string,
data: ApprovalStepMutationPayload
) {
return apiClient<MutationSuccessResponse>(`${SETTINGS_BASE}/${workflowId}/steps`, {
method: 'POST',
body: JSON.stringify(data)
});
}
export async function updateApprovalWorkflowStep(
workflowId: string,
stepId: string,
data: ApprovalStepUpdatePayload
) {
return apiClient<MutationSuccessResponse>(`${SETTINGS_BASE}/${workflowId}/steps/${stepId}`, {
method: 'PATCH',
body: JSON.stringify(data)
});
}
export async function deleteApprovalWorkflowStep(workflowId: string, stepId: string) {
return apiClient<MutationSuccessResponse>(`${SETTINGS_BASE}/${workflowId}/steps/${stepId}`, {
method: 'DELETE'
});
}
export async function reorderApprovalWorkflowSteps(
workflowId: string,
data: ApprovalStepReorderPayload
) {
return apiClient<MutationSuccessResponse>(`${SETTINGS_BASE}/${workflowId}/steps/reorder`, {
method: 'POST',
body: JSON.stringify(data)
});
}

View File

@@ -1,10 +1,25 @@
export interface ApprovalWorkflowUsage {
totalRequestCount: number;
activeRequestCount: number;
activeMatrixCount: number;
isLocked: boolean;
lockedReason: string | null;
canEdit: boolean;
canDelete: boolean;
canDeactivate: boolean;
}
export interface ApprovalWorkflowRecord {
id: string;
organizationId: string;
code: string;
name: string;
entityType: string;
description: string | null;
isSystem: boolean;
isActive: boolean;
createdBy: string;
updatedBy: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
@@ -17,6 +32,7 @@ export interface ApprovalStepRecord {
stepNumber: number;
roleCode: string;
roleName: string;
approvalMode: 'sequential' | 'any_one' | 'all_required';
isRequired: boolean;
createdAt: string;
updatedAt: string;
@@ -25,10 +41,12 @@ export interface ApprovalStepRecord {
export interface ApprovalWorkflowListItem extends ApprovalWorkflowRecord {
stepCount: number;
usage: ApprovalWorkflowUsage;
}
export interface ApprovalWorkflowDetail extends ApprovalWorkflowRecord {
steps: ApprovalStepRecord[];
usage: ApprovalWorkflowUsage;
}
export interface ApprovalRequestRecord {
@@ -95,6 +113,11 @@ export interface ApprovalFilters {
sort?: string;
}
export interface ApprovalWorkflowFilters {
entityType?: string;
activeOnly?: boolean;
}
export interface SubmitApprovalPayload {
workflowId?: string;
workflowCode?: string;
@@ -111,6 +134,7 @@ export interface ApprovalWorkflowMutationPayload {
code: string;
name: string;
entityType: string;
description?: string | null;
isActive?: boolean;
}
@@ -118,17 +142,27 @@ export interface ApprovalStepMutationPayload {
stepNumber: number;
roleCode: string;
roleName: string;
approvalMode: 'sequential' | 'any_one' | 'all_required';
isRequired?: boolean;
}
export interface ApprovalStepUpdatePayload {
roleCode?: string;
roleName?: string;
approvalMode?: 'sequential' | 'any_one' | 'all_required';
isRequired?: boolean;
}
export interface ApprovalStepReorderPayload {
orderedStepIds: string[];
}
export interface ApprovalListResponse {
success: boolean;
time: string;
message: string;
totalItems: number;
offset: number;
limit: number;
items: ApprovalListItem[];
totalItems: number;
}
export interface ApprovalDetailResponse {
@@ -152,6 +186,13 @@ export interface ApprovalWorkflowDetailResponse {
workflow: ApprovalWorkflowDetail;
}
export interface ApprovalWorkflowStepListResponse {
success: boolean;
time: string;
message: string;
items: ApprovalStepRecord[];
}
export interface MutationSuccessResponse {
success: boolean;
message: string;

View File

@@ -89,6 +89,10 @@ export const PERMISSIONS = {
crmApprovalWorkflowCreate: 'crm.approval.workflow.create',
crmApprovalWorkflowUpdate: 'crm.approval.workflow.update',
crmApprovalWorkflowDelete: 'crm.approval.workflow.delete',
crmApprovalWorkflowClone: 'crm.approval.workflow.clone',
crmApprovalWorkflowActivate: 'crm.approval.workflow.activate',
crmApprovalWorkflowDeactivate: 'crm.approval.workflow.deactivate',
crmApprovalWorkflowStepManage: 'crm.approval.workflow.step.manage',
crmDashboardRead: 'crm.dashboard.read',
crmDashboardExport: 'crm.dashboard.export',
crmReportRead: 'crm.report.read',
@@ -453,6 +457,10 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
PERMISSIONS.crmApprovalWorkflowCreate,
PERMISSIONS.crmApprovalWorkflowUpdate,
PERMISSIONS.crmApprovalWorkflowDelete,
PERMISSIONS.crmApprovalWorkflowClone,
PERMISSIONS.crmApprovalWorkflowActivate,
PERMISSIONS.crmApprovalWorkflowDeactivate,
PERMISSIONS.crmApprovalWorkflowStepManage,
PERMISSIONS.crmDocumentTemplateRead,
PERMISSIONS.crmDocumentTemplateCreate,
PERMISSIONS.crmDocumentTemplateUpdate,
@@ -613,6 +621,10 @@ export const CRM_PERMISSION_GROUPS: PermissionGroup[] = [
{ key: PERMISSIONS.crmApprovalWorkflowCreate, label: 'Create approval workflows' },
{ key: PERMISSIONS.crmApprovalWorkflowUpdate, label: 'Update approval workflows' },
{ key: PERMISSIONS.crmApprovalWorkflowDelete, label: 'Delete approval workflows' },
{ key: PERMISSIONS.crmApprovalWorkflowClone, label: 'Clone approval workflows' },
{ key: PERMISSIONS.crmApprovalWorkflowActivate, label: 'Activate approval workflows' },
{ key: PERMISSIONS.crmApprovalWorkflowDeactivate, label: 'Deactivate approval workflows' },
{ key: PERMISSIONS.crmApprovalWorkflowStepManage, label: 'Manage approval workflow steps' },
{ key: PERMISSIONS.crmDocumentTemplateRead, label: 'Read document templates' },
{ key: PERMISSIONS.crmDocumentTemplateCreate, label: 'Create document templates' },
{ key: PERMISSIONS.crmDocumentTemplateUpdate, label: 'Update document templates' },