task-f.3
This commit is contained in:
@@ -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
|
||||||
14
drizzle/0003_workflow_builder_foundation.sql
Normal file
14
drizzle/0003_workflow_builder_foundation.sql
Normal 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;
|
||||||
@@ -22,6 +22,13 @@
|
|||||||
"when": 1782604800000,
|
"when": 1782604800000,
|
||||||
"tag": "0002_calm_approval_matrix",
|
"tag": "0002_calm_approval_matrix",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1782691200000,
|
||||||
|
"tag": "0003_workflow_builder_foundation",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <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
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
504
plans/task-f.3.md
Normal file
504
plans/task-f.3.md
Normal 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
|
||||||
|
```
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { z } from 'zod';
|
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 {
|
import {
|
||||||
getApprovalWorkflow,
|
getApprovalWorkflow,
|
||||||
softDeleteApprovalWorkflow,
|
softDeleteApprovalWorkflow,
|
||||||
updateApprovalWorkflow
|
updateApprovalWorkflow
|
||||||
} from '@/features/foundation/approval/server/service';
|
} from '@/features/crm/approval/server/workflow-builder.service';
|
||||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
@@ -13,13 +14,6 @@ type Params = {
|
|||||||
params: Promise<{ id: string }>;
|
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) {
|
export async function GET(_request: NextRequest, { params }: Params) {
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
@@ -41,7 +35,7 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
|||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
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({
|
const { organization, session } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmApprovalWorkflowUpdate
|
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 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,
|
organizationId: organization.id,
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
entityType: 'crm_approval_workflow',
|
entityType: 'crm_approval_workflow',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
|
action: 'update_approval_workflow',
|
||||||
beforeData: before,
|
beforeData: before,
|
||||||
afterData: updated
|
afterData: updated
|
||||||
});
|
});
|
||||||
@@ -81,7 +76,7 @@ export async function PATCH(request: NextRequest, { params }: Params) {
|
|||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
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({
|
const { organization, session } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmApprovalWorkflowDelete
|
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,
|
organizationId: organization.id,
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
entityType: 'crm_approval_workflow',
|
entityType: 'crm_approval_workflow',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
|
action: 'delete_approval_workflow',
|
||||||
beforeData: result.before,
|
beforeData: result.before,
|
||||||
afterData: result.after
|
afterData: result.after
|
||||||
});
|
});
|
||||||
@@ -113,6 +109,6 @@ export async function DELETE(_request: NextRequest, { params }: Params) {
|
|||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,50 +1,67 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { z } from 'zod';
|
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 {
|
import {
|
||||||
|
createApprovalWorkflowStep,
|
||||||
getApprovalWorkflow,
|
getApprovalWorkflow,
|
||||||
replaceApprovalWorkflowSteps
|
listApprovalWorkflowSteps
|
||||||
} from '@/features/foundation/approval/server/service';
|
} from '@/features/crm/approval/server/workflow-builder.service';
|
||||||
import { BUSINESS_ROLES, PERMISSIONS } from '@/lib/auth/rbac';
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
type Params = {
|
type Params = {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const stepSchema = z.object({
|
export async function GET(_request: NextRequest, { params }: Params) {
|
||||||
stepNumber: z.number().int().min(1),
|
try {
|
||||||
roleCode: z.enum(BUSINESS_ROLES),
|
const { id } = await params;
|
||||||
roleName: z.string().min(1),
|
const { organization } = await requireOrganizationAccess({
|
||||||
isRequired: z.boolean().optional()
|
permission: PERMISSIONS.crmApprovalWorkflowRead
|
||||||
});
|
});
|
||||||
|
const items = await listApprovalWorkflowSteps(id, organization.id);
|
||||||
|
|
||||||
const payloadSchema = z.object({
|
return NextResponse.json({
|
||||||
steps: z.array(stepSchema)
|
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 {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { organization, session } = await requireOrganizationAccess({
|
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 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,
|
organizationId: organization.id,
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
entityType: 'crm_approval_step',
|
entityType: 'crm_approval_step',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
|
action: 'create_approval_step',
|
||||||
beforeData: before.steps,
|
beforeData: before.steps,
|
||||||
afterData: steps
|
afterData: steps
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Approval workflow steps updated successfully'
|
message: 'Approval workflow step created successfully'
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) {
|
if (error instanceof AuthError) {
|
||||||
@@ -59,9 +76,6 @@ export async function PUT(request: NextRequest, { params }: Params) {
|
|||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
return NextResponse.json({ message: error.message }, { status: 400 });
|
||||||
}
|
}
|
||||||
return NextResponse.json(
|
return NextResponse.json({ message: 'Unable create approval workflow step' }, { status: 500 });
|
||||||
{ message: 'Unable to update approval workflow steps' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,24 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { z } from 'zod';
|
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 {
|
import {
|
||||||
createApprovalWorkflow,
|
createApprovalWorkflow,
|
||||||
listApprovalWorkflows
|
listApprovalWorkflows
|
||||||
} from '@/features/foundation/approval/server/service';
|
} from '@/features/crm/approval/server/workflow-builder.service';
|
||||||
import { BUSINESS_ROLES, PERMISSIONS } from '@/lib/auth/rbac';
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
const workflowSchema = z.object({
|
export async function GET(request: NextRequest) {
|
||||||
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) {
|
|
||||||
try {
|
try {
|
||||||
const { organization } = await requireOrganizationAccess({
|
const { organization } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmApprovalWorkflowRead
|
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({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -32,10 +30,16 @@ export async function GET(_request: NextRequest) {
|
|||||||
if (error instanceof AuthError) {
|
if (error instanceof AuthError) {
|
||||||
return NextResponse.json({ message: error.message }, { status: error.status });
|
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) {
|
if (error instanceof Error) {
|
||||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
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({
|
const { organization, session } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmApprovalWorkflowCreate
|
permission: PERMISSIONS.crmApprovalWorkflowCreate
|
||||||
});
|
});
|
||||||
const payload = workflowSchema.parse(await request.json());
|
const payload = createApprovalWorkflowSchema.parse(await request.json());
|
||||||
const created = await createApprovalWorkflow(organization.id, payload);
|
const created = await createApprovalWorkflow(organization.id, session.user.id, payload);
|
||||||
|
|
||||||
await auditCreate({
|
await auditAction({
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
entityType: 'crm_approval_workflow',
|
entityType: 'crm_approval_workflow',
|
||||||
entityId: created.id,
|
entityId: created.id,
|
||||||
|
action: 'create_approval_workflow',
|
||||||
afterData: created
|
afterData: created
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Approval workflow created successfully',
|
message: 'Approval workflow created successfully'
|
||||||
allowedRoles: BUSINESS_ROLES
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) {
|
if (error instanceof AuthError) {
|
||||||
@@ -73,6 +77,6 @@ export async function POST(request: NextRequest) {
|
|||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
return NextResponse.json({ message: error.message }, { status: 400 });
|
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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
export default function NotFound() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
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'>
|
<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'>
|
<span className="from-foreground bg-linear-to-b to-transparent bg-clip-text text-[10rem] leading-none font-extrabold text-transparent">
|
||||||
404
|
404
|
||||||
</span>
|
</span>
|
||||||
<h2 className='font-heading my-2 text-2xl font-bold'>Something's missing</h2>
|
<h2 className="font-heading my-2 text-2xl font-bold">
|
||||||
<p>Sorry, the page you are looking for doesn't exist or has been moved.</p>
|
Something's missing
|
||||||
<div className='mt-8 flex justify-center gap-2'>
|
</h2>
|
||||||
<Button onClick={() => router.back()} variant='default' size='lg'>
|
<p>
|
||||||
|
Sorry, the page you are looking for doesn'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
|
Go back
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => router.push('/dashboard')} variant='ghost' size='lg'>
|
<Button
|
||||||
|
onClick={() => router.push("/dashboard/crm")}
|
||||||
|
variant="ghost"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
Back to Home
|
Back to Home
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -645,7 +645,11 @@ export const crmApprovalWorkflows = pgTable(
|
|||||||
code: text('code').notNull(),
|
code: text('code').notNull(),
|
||||||
name: text('name').notNull(),
|
name: text('name').notNull(),
|
||||||
entityType: text('entity_type').notNull(),
|
entityType: text('entity_type').notNull(),
|
||||||
|
description: text('description'),
|
||||||
|
isSystem: boolean('is_system').default(false).notNull(),
|
||||||
isActive: boolean('is_active').default(true).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(),
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
deletedAt: timestamp('deleted_at', { withTimezone: true })
|
deletedAt: timestamp('deleted_at', { withTimezone: true })
|
||||||
@@ -667,6 +671,7 @@ export const crmApprovalSteps = pgTable(
|
|||||||
stepNumber: integer('step_number').notNull(),
|
stepNumber: integer('step_number').notNull(),
|
||||||
roleCode: text('role_code').notNull(),
|
roleCode: text('role_code').notNull(),
|
||||||
roleName: text('role_name').notNull(),
|
roleName: text('role_name').notNull(),
|
||||||
|
approvalMode: text('approval_mode').default('sequential').notNull(),
|
||||||
isRequired: boolean('is_required').default(true).notNull(),
|
isRequired: boolean('is_required').default(true).notNull(),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
|||||||
@@ -658,10 +658,27 @@ const APPROVAL_WORKFLOWS = [
|
|||||||
code: 'quotation_standard_approval',
|
code: 'quotation_standard_approval',
|
||||||
name: 'Quotation Standard Approval',
|
name: 'Quotation Standard Approval',
|
||||||
entityType: 'quotation',
|
entityType: 'quotation',
|
||||||
|
description: 'Default sequential quotation approval workflow',
|
||||||
|
isSystem: true,
|
||||||
steps: [
|
steps: [
|
||||||
{ stepNumber: 1, roleCode: 'sales_manager', roleName: 'Sales Manager' },
|
{
|
||||||
{ stepNumber: 2, roleCode: 'department_manager', roleName: 'Department Manager' },
|
stepNumber: 1,
|
||||||
{ stepNumber: 3, roleCode: 'top_manager', roleName: 'Top Manager' }
|
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'
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
29
src/features/crm/approval/schemas/approval-step.schema.ts
Normal file
29
src/features/crm/approval/schemas/approval-step.schema.ts
Normal 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>;
|
||||||
@@ -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>;
|
||||||
767
src/features/crm/approval/server/workflow-builder.service.ts
Normal file
767
src/features/crm/approval/server/workflow-builder.service.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
|
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { BUSINESS_ROLES } from '@/lib/auth/rbac';
|
||||||
import { Icons } from '@/components/icons';
|
import { Icons } from '@/components/icons';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -16,54 +17,75 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import {
|
import {
|
||||||
|
activateApprovalWorkflowMutation,
|
||||||
|
cloneApprovalWorkflowMutation,
|
||||||
createApprovalWorkflowMutation,
|
createApprovalWorkflowMutation,
|
||||||
|
createApprovalWorkflowStepMutation,
|
||||||
|
deactivateApprovalWorkflowMutation,
|
||||||
deleteApprovalWorkflowMutation,
|
deleteApprovalWorkflowMutation,
|
||||||
replaceApprovalWorkflowStepsMutation,
|
deleteApprovalWorkflowStepMutation,
|
||||||
updateApprovalWorkflowMutation
|
reorderApprovalWorkflowStepsMutation,
|
||||||
|
updateApprovalWorkflowMutation,
|
||||||
|
updateApprovalWorkflowStepMutation
|
||||||
} from '../mutations';
|
} from '../mutations';
|
||||||
import {
|
import { approvalWorkflowByIdOptions, approvalWorkflowsQueryOptions } from '../queries';
|
||||||
approvalWorkflowByIdOptions,
|
|
||||||
approvalWorkflowsQueryOptions
|
|
||||||
} from '../queries';
|
|
||||||
import type {
|
import type {
|
||||||
ApprovalStepMutationPayload,
|
ApprovalStepMutationPayload,
|
||||||
|
ApprovalStepRecord,
|
||||||
|
ApprovalStepUpdatePayload,
|
||||||
ApprovalWorkflowDetail,
|
ApprovalWorkflowDetail,
|
||||||
ApprovalWorkflowMutationPayload
|
ApprovalWorkflowMutationPayload
|
||||||
} from '../types';
|
} 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;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
entityType: string;
|
entityType: string;
|
||||||
|
description: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type StepState = {
|
type StepFormState = {
|
||||||
stepNumber: string;
|
stepNumber: string;
|
||||||
roleCode: string;
|
roleCode: string;
|
||||||
roleName: string;
|
roleName: string;
|
||||||
|
approvalMode: ApprovalStepMutationPayload['approvalMode'];
|
||||||
isRequired: boolean;
|
isRequired: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function toWorkflowState(workflow?: ApprovalWorkflowDetail): WorkflowState {
|
function toWorkflowState(workflow?: ApprovalWorkflowDetail): WorkflowFormState {
|
||||||
return {
|
return {
|
||||||
code: workflow?.code ?? '',
|
code: workflow?.code ?? '',
|
||||||
name: workflow?.name ?? '',
|
name: workflow?.name ?? '',
|
||||||
entityType: workflow?.entityType ?? 'quotation',
|
entityType: workflow?.entityType ?? 'quotation',
|
||||||
|
description: workflow?.description ?? '',
|
||||||
isActive: workflow?.isActive ?? true
|
isActive: workflow?.isActive ?? true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function toStepState(workflow?: ApprovalWorkflowDetail): StepState[] {
|
function toStepState(step?: ApprovalStepRecord, stepNumber = 1): StepFormState {
|
||||||
return (
|
return {
|
||||||
workflow?.steps.map((step) => ({
|
stepNumber: String(step?.stepNumber ?? stepNumber),
|
||||||
stepNumber: String(step.stepNumber),
|
roleCode: step?.roleCode ?? 'sales_manager',
|
||||||
roleCode: step.roleCode,
|
roleName: step?.roleName ?? 'Sales Manager',
|
||||||
roleName: step.roleName,
|
approvalMode: step?.approvalMode ?? 'sequential',
|
||||||
isRequired: step.isRequired
|
isRequired: step?.isRequired ?? true
|
||||||
})) ?? [{ stepNumber: '1', roleCode: 'sales_manager', roleName: 'Sales Manager', isRequired: true }]
|
};
|
||||||
);
|
}
|
||||||
|
|
||||||
|
function friendlyRoleName(roleCode: string) {
|
||||||
|
return roleCode
|
||||||
|
.split('_')
|
||||||
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||||
|
.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
function WorkflowDialog({
|
function WorkflowDialog({
|
||||||
@@ -75,7 +97,7 @@ function WorkflowDialog({
|
|||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
workflow?: ApprovalWorkflowDetail;
|
workflow?: ApprovalWorkflowDetail;
|
||||||
}) {
|
}) {
|
||||||
const [state, setState] = React.useState<WorkflowState>(toWorkflowState(workflow));
|
const [state, setState] = React.useState<WorkflowFormState>(toWorkflowState(workflow));
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
...createApprovalWorkflowMutation,
|
...createApprovalWorkflowMutation,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -99,12 +121,16 @@ function WorkflowDialog({
|
|||||||
}
|
}
|
||||||
}, [open, workflow]);
|
}, [open, workflow]);
|
||||||
|
|
||||||
|
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
async function onSubmit(event: React.FormEvent) {
|
async function onSubmit(event: React.FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const payload: ApprovalWorkflowMutationPayload = {
|
const payload: ApprovalWorkflowMutationPayload = {
|
||||||
code: state.code,
|
code: state.code,
|
||||||
name: state.name,
|
name: state.name,
|
||||||
entityType: state.entityType,
|
entityType: state.entityType,
|
||||||
|
description: state.description.trim() || null,
|
||||||
isActive: state.isActive
|
isActive: state.isActive
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -116,26 +142,73 @@ function WorkflowDialog({
|
|||||||
await createMutation.mutateAsync(payload);
|
await createMutation.mutateAsync(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent className='sm:max-w-2xl'>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{workflow ? 'Edit Workflow' : 'Create Workflow'}</DialogTitle>
|
<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>
|
</DialogHeader>
|
||||||
<form onSubmit={onSubmit} className='grid gap-4'>
|
|
||||||
<Input placeholder='Workflow code' value={state.code} onChange={(e) => setState((current) => ({ ...current, code: e.target.value }))} />
|
<form onSubmit={onSubmit} className='space-y-4'>
|
||||||
<Input placeholder='Workflow name' value={state.name} onChange={(e) => setState((current) => ({ ...current, name: e.target.value }))} />
|
<div className='grid gap-4 md:grid-cols-2'>
|
||||||
<Input placeholder='Entity type' value={state.entityType} onChange={(e) => setState((current) => ({ ...current, entityType: e.target.value }))} />
|
<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 className='flex items-center justify-between rounded-lg border px-4 py-3'>
|
||||||
<div>
|
<div>
|
||||||
<div className='font-medium'>Active</div>
|
<div className='font-medium'>Active on creation</div>
|
||||||
<div className='text-muted-foreground text-sm'>Inactive workflows stay available in audit history only.</div>
|
<div className='text-muted-foreground text-sm'>
|
||||||
|
Keep this off if you want to finish step configuration first.
|
||||||
</div>
|
</div>
|
||||||
<Switch checked={state.isActive} onCheckedChange={(checked) => setState((current) => ({ ...current, isActive: checked }))} />
|
|
||||||
</div>
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={state.isActive}
|
||||||
|
onCheckedChange={(checked) => setState((current) => ({ ...current, isActive: checked }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
|
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
@@ -150,20 +223,30 @@ function WorkflowDialog({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StepsDialog({
|
function StepDialog({
|
||||||
workflow,
|
|
||||||
open,
|
open,
|
||||||
onOpenChange
|
onOpenChange,
|
||||||
|
workflow,
|
||||||
|
step
|
||||||
}: {
|
}: {
|
||||||
workflow: ApprovalWorkflowDetail;
|
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
|
workflow: ApprovalWorkflowDetail;
|
||||||
|
step?: ApprovalStepRecord;
|
||||||
}) {
|
}) {
|
||||||
const [steps, setSteps] = React.useState<StepState[]>(toStepState(workflow));
|
const [state, setState] = React.useState<StepFormState>(toStepState(step, workflow.steps.length + 1));
|
||||||
const replaceMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
...replaceApprovalWorkflowStepsMutation,
|
...createApprovalWorkflowStepMutation,
|
||||||
onSuccess: () => {
|
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);
|
onOpenChange(false);
|
||||||
},
|
},
|
||||||
onError: (error) => toast.error(error instanceof Error ? error.message : 'Update failed')
|
onError: (error) => toast.error(error instanceof Error ? error.message : 'Update failed')
|
||||||
@@ -171,73 +254,142 @@ function StepsDialog({
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (open) {
|
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) {
|
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||||
setSteps((current) =>
|
|
||||||
current.map((step, stepIndex) =>
|
|
||||||
stepIndex === index ? { ...step, [field]: value } : step
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onSubmit(event: React.FormEvent) {
|
async function onSubmit(event: React.FormEvent) {
|
||||||
event.preventDefault();
|
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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className='sm:max-w-4xl'>
|
<DialogContent className='sm:max-w-2xl'>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Manage Steps</DialogTitle>
|
<DialogTitle>{step ? 'Edit Step' : 'Add Step'}</DialogTitle>
|
||||||
<DialogDescription>Each step resolves to a CRM business role and runs strictly in order.</DialogDescription>
|
<DialogDescription>
|
||||||
|
Non-sequential approval modes are stored now for future runtime support.
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={onSubmit} className='space-y-4'>
|
<form onSubmit={onSubmit} className='space-y-4'>
|
||||||
{steps.map((step, index) => (
|
{!step ? (
|
||||||
<div key={`${index}-${step.roleCode}`} className='grid gap-3 rounded-lg border p-4 md:grid-cols-4'>
|
<div className='space-y-2'>
|
||||||
<Input placeholder='Step no.' value={step.stepNumber} onChange={(e) => updateStep(index, 'stepNumber', e.target.value)} />
|
<div className='text-sm font-medium'>Step Number</div>
|
||||||
<Input placeholder='Role code' value={step.roleCode} onChange={(e) => updateStep(index, 'roleCode', e.target.value)} />
|
<Input
|
||||||
<Input placeholder='Role name' value={step.roleName} onChange={(e) => updateStep(index, 'roleName', e.target.value)} />
|
type='number'
|
||||||
<div className='flex items-center justify-between rounded-lg border px-3 py-2'>
|
min={1}
|
||||||
<span className='text-sm font-medium'>Required</span>
|
value={state.stepNumber}
|
||||||
<Switch checked={step.isRequired} onCheckedChange={(checked) => updateStep(index, 'isRequired', checked)} />
|
onChange={(event) =>
|
||||||
</div>
|
setState((current) => ({ ...current, stepNumber: event.target.value }))
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<Button
|
|
||||||
type='button'
|
|
||||||
variant='outline'
|
|
||||||
onClick={() =>
|
|
||||||
setSteps((current) => [
|
|
||||||
...current,
|
|
||||||
{
|
|
||||||
stepNumber: String(current.length + 1),
|
|
||||||
roleCode: '',
|
|
||||||
roleName: '',
|
|
||||||
isRequired: true
|
|
||||||
}
|
}
|
||||||
])
|
/>
|
||||||
|
</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' />
|
{BUSINESS_ROLES.map((role) => (
|
||||||
Add Step
|
<option key={role} value={role}>
|
||||||
</Button>
|
{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>
|
<DialogFooter>
|
||||||
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
|
<Button type='button' variant='outline' onClick={() => onOpenChange(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type='submit' isLoading={replaceMutation.isPending}>
|
<Button type='submit' isLoading={isPending}>
|
||||||
Save Steps
|
{step ? 'Save Step' : 'Add Step'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
@@ -246,47 +398,241 @@ function StepsDialog({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function WorkflowDetailActions({ workflowId }: { workflowId: string }) {
|
function WorkflowCard({ workflowId }: { workflowId: string }) {
|
||||||
const { data } = useSuspenseQuery(approvalWorkflowByIdOptions(workflowId));
|
const { data } = useSuspenseQuery(approvalWorkflowByIdOptions(workflowId));
|
||||||
const workflow = data.workflow;
|
const workflow = data.workflow;
|
||||||
const [workflowDialogOpen, setWorkflowDialogOpen] = React.useState(false);
|
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({
|
const deleteMutation = useMutation({
|
||||||
...deleteApprovalWorkflowMutation,
|
...deleteApprovalWorkflowMutation,
|
||||||
onSuccess: () => toast.success('Workflow deleted'),
|
onSuccess: () => toast.success('Workflow deleted'),
|
||||||
onError: (error) => toast.error(error instanceof Error ? error.message : 'Delete failed')
|
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 (
|
return (
|
||||||
<>
|
<div className='rounded-xl border p-5'>
|
||||||
<div className='flex gap-2'>
|
<div className='flex flex-wrap items-start justify-between gap-4'>
|
||||||
<Button variant='outline' onClick={() => setWorkflowDialogOpen(true)}>
|
<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
|
Edit Workflow
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant='outline' onClick={() => setStepsDialogOpen(true)}>
|
<Button variant='outline' onClick={() => cloneMutation.mutate(workflow.id)} isLoading={cloneMutation.isPending}>
|
||||||
Manage Steps
|
Clone
|
||||||
</Button>
|
</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
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => setCreateStepOpen(true)}
|
||||||
|
disabled={!workflow.usage.canEdit}
|
||||||
|
>
|
||||||
|
<Icons.add className='mr-2 h-4 w-4' />
|
||||||
|
Add Step
|
||||||
|
</Button>
|
||||||
</div>
|
</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 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='font-medium'>Step {step.stepNumber}</div>
|
||||||
<div className='text-muted-foreground mt-1 text-sm'>{step.roleName}</div>
|
<div className='text-muted-foreground mt-1 text-sm'>{step.roleName}</div>
|
||||||
<div className='mt-2 flex gap-2'>
|
</div>
|
||||||
<Badge variant='outline'>{step.roleCode}</Badge>
|
|
||||||
<Badge variant={step.isRequired ? 'secondary' : 'outline'}>
|
<Badge variant={step.isRequired ? 'secondary' : 'outline'}>
|
||||||
{step.isRequired ? 'Required' : 'Optional'}
|
{step.isRequired ? 'Required' : 'Optional'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</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>
|
||||||
))}
|
))}
|
||||||
</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='space-y-6'>
|
||||||
<div className='flex items-center justify-between gap-4 rounded-lg border p-4'>
|
<div className='flex items-center justify-between gap-4 rounded-lg border p-4'>
|
||||||
<div>
|
<div>
|
||||||
<div className='font-medium'>Approval Workflow Admin</div>
|
<div className='font-medium'>Approval Workflow Builder</div>
|
||||||
<div className='text-muted-foreground text-sm'>Configure sequential CRM approval workflows and business-role steps.</div>
|
<div className='text-muted-foreground text-sm'>
|
||||||
|
Configure approval workflows, clone locked flows, and manage ordered steps without touching seed code.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => setCreateOpen(true)}>
|
<Button onClick={() => setCreateOpen(true)}>
|
||||||
<Icons.add className='mr-2 h-4 w-4' />
|
<Icons.add className='mr-2 h-4 w-4' />
|
||||||
@@ -309,30 +657,23 @@ export function ApprovalWorkflowSettings() {
|
|||||||
|
|
||||||
{data.items.length === 0 ? (
|
{data.items.length === 0 ? (
|
||||||
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm'>
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
data.items.map((workflow) => (
|
<div className='space-y-4'>
|
||||||
<div key={workflow.id} className='rounded-xl border p-5'>
|
{data.items.map((workflow) => (
|
||||||
<div className='flex flex-wrap items-start justify-between gap-4'>
|
<React.Suspense
|
||||||
<div>
|
key={workflow.id}
|
||||||
<div className='text-xl font-semibold'>{workflow.name}</div>
|
fallback={
|
||||||
<div className='text-muted-foreground text-sm'>
|
<div className='text-muted-foreground rounded-lg border p-5 text-sm'>
|
||||||
{workflow.code} • {workflow.entityType}
|
Loading workflow detail...
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
<div className='flex gap-2'>
|
>
|
||||||
<Badge variant={workflow.isActive ? 'secondary' : 'outline'}>
|
<WorkflowCard workflowId={workflow.id} />
|
||||||
{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} />
|
|
||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<WorkflowDialog open={createOpen} onOpenChange={setCreateOpen} />
|
<WorkflowDialog open={createOpen} onOpenChange={setCreateOpen} />
|
||||||
|
|||||||
@@ -1,23 +1,32 @@
|
|||||||
import { mutationOptions } from '@tanstack/react-query';
|
import { mutationOptions } from '@tanstack/react-query';
|
||||||
import { quotationDocumentKeys } from '@/features/crm/quotations/document/queries';
|
import { quotationDocumentKeys } from '@/features/crm/quotations/document/queries';
|
||||||
|
import { quotationKeys } from '@/features/crm/quotations/api/queries';
|
||||||
import { getQueryClient } from '@/lib/query-client';
|
import { getQueryClient } from '@/lib/query-client';
|
||||||
import {
|
import {
|
||||||
|
activateApprovalWorkflow,
|
||||||
approveApproval,
|
approveApproval,
|
||||||
cancelApproval,
|
cancelApproval,
|
||||||
|
cloneApprovalWorkflow,
|
||||||
createApprovalWorkflow,
|
createApprovalWorkflow,
|
||||||
|
createApprovalWorkflowStep,
|
||||||
|
deactivateApprovalWorkflow,
|
||||||
deleteApprovalWorkflow,
|
deleteApprovalWorkflow,
|
||||||
|
deleteApprovalWorkflowStep,
|
||||||
rejectApproval,
|
rejectApproval,
|
||||||
replaceApprovalWorkflowSteps,
|
reorderApprovalWorkflowSteps,
|
||||||
returnApproval,
|
returnApproval,
|
||||||
submitApproval,
|
submitApproval,
|
||||||
submitQuotationForApproval,
|
submitQuotationForApproval,
|
||||||
updateApprovalWorkflow
|
updateApprovalWorkflow,
|
||||||
|
updateApprovalWorkflowStep
|
||||||
} from './service';
|
} from './service';
|
||||||
import { approvalKeys, approvalWorkflowKeys } from './queries';
|
import { approvalKeys, approvalWorkflowKeys } from './queries';
|
||||||
import { quotationKeys } from '@/features/crm/quotations/api/queries';
|
|
||||||
import type {
|
import type {
|
||||||
ApprovalActionPayload,
|
ApprovalActionPayload,
|
||||||
ApprovalStepMutationPayload,
|
ApprovalStepMutationPayload,
|
||||||
|
ApprovalStepReorderPayload,
|
||||||
|
ApprovalStepUpdatePayload,
|
||||||
|
ApprovalWorkflowFilters,
|
||||||
ApprovalWorkflowMutationPayload,
|
ApprovalWorkflowMutationPayload,
|
||||||
SubmitApprovalPayload
|
SubmitApprovalPayload
|
||||||
} from './types';
|
} from './types';
|
||||||
@@ -30,12 +39,20 @@ async function invalidateApprovalDetail(id: string) {
|
|||||||
await getQueryClient().invalidateQueries({ queryKey: approvalKeys.detail(id) });
|
await getQueryClient().invalidateQueries({ queryKey: approvalKeys.detail(id) });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function invalidateApprovalWorkflows() {
|
async function invalidateApprovalWorkflowLists(filters?: ApprovalWorkflowFilters) {
|
||||||
await getQueryClient().invalidateQueries({ queryKey: approvalWorkflowKeys.lists() });
|
const queryClient = getQueryClient();
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: approvalWorkflowKeys.lists() }),
|
||||||
|
...(filters ? [queryClient.invalidateQueries({ queryKey: approvalWorkflowKeys.list(filters) })] : [])
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function invalidateApprovalWorkflowDetail(id: string) {
|
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) {
|
async function invalidateQuotationQueries(quotationId: string) {
|
||||||
@@ -60,7 +77,7 @@ async function invalidateRelatedEntityQueries(approvalId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const submitApprovalMutation = mutationOptions({
|
export const submitApprovalMutation = mutationOptions({
|
||||||
mutationFn: (data: SubmitApprovalPayload) => submitApproval(data),
|
mutationFn: (values: SubmitApprovalPayload) => submitApproval(values),
|
||||||
onSettled: async (_data, error) => {
|
onSettled: async (_data, error) => {
|
||||||
if (!error) {
|
if (!error) {
|
||||||
await invalidateApprovalLists();
|
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),
|
mutationFn: ({ id, remark }: { id: string; remark?: string }) => submitQuotationForApproval(id, remark),
|
||||||
onSettled: async (_data, error, variables) => {
|
onSettled: async (_data, error, variables) => {
|
||||||
if (!error) {
|
if (!error) {
|
||||||
@@ -77,22 +94,19 @@ export const submitQuotationApprovalMutation = mutationOptions({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const submitQuotationApprovalMutation = submitQuotationForApprovalMutation;
|
||||||
|
|
||||||
export const cancelApprovalMutation = mutationOptions({
|
export const cancelApprovalMutation = mutationOptions({
|
||||||
mutationFn: (id: string) => cancelApproval(id),
|
mutationFn: (id: string) => cancelApproval(id),
|
||||||
onSettled: async (_data, error, id) => {
|
onSettled: async (_data, error, id) => {
|
||||||
if (!error) {
|
if (!error) {
|
||||||
await Promise.all([
|
await Promise.all([invalidateApprovalLists(), invalidateApprovalDetail(id), invalidateRelatedEntityQueries(id)]);
|
||||||
invalidateApprovalLists(),
|
|
||||||
invalidateApprovalDetail(id),
|
|
||||||
invalidateRelatedEntityQueries(id)
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const approveApprovalMutation = mutationOptions({
|
export const approveApprovalMutation = mutationOptions({
|
||||||
mutationFn: ({ id, values }: { id: string; values: ApprovalActionPayload }) =>
|
mutationFn: ({ id, values }: { id: string; values: ApprovalActionPayload }) => approveApproval(id, values),
|
||||||
approveApproval(id, values),
|
|
||||||
onSettled: async (_data, error, variables) => {
|
onSettled: async (_data, error, variables) => {
|
||||||
if (!error) {
|
if (!error) {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -105,8 +119,7 @@ export const approveApprovalMutation = mutationOptions({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const rejectApprovalMutation = mutationOptions({
|
export const rejectApprovalMutation = mutationOptions({
|
||||||
mutationFn: ({ id, values }: { id: string; values: ApprovalActionPayload }) =>
|
mutationFn: ({ id, values }: { id: string; values: ApprovalActionPayload }) => rejectApproval(id, values),
|
||||||
rejectApproval(id, values),
|
|
||||||
onSettled: async (_data, error, variables) => {
|
onSettled: async (_data, error, variables) => {
|
||||||
if (!error) {
|
if (!error) {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -119,8 +132,7 @@ export const rejectApprovalMutation = mutationOptions({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const returnApprovalMutation = mutationOptions({
|
export const returnApprovalMutation = mutationOptions({
|
||||||
mutationFn: ({ id, values }: { id: string; values: ApprovalActionPayload }) =>
|
mutationFn: ({ id, values }: { id: string; values: ApprovalActionPayload }) => returnApproval(id, values),
|
||||||
returnApproval(id, values),
|
|
||||||
onSettled: async (_data, error, variables) => {
|
onSettled: async (_data, error, variables) => {
|
||||||
if (!error) {
|
if (!error) {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -136,7 +148,7 @@ export const createApprovalWorkflowMutation = mutationOptions({
|
|||||||
mutationFn: (data: ApprovalWorkflowMutationPayload) => createApprovalWorkflow(data),
|
mutationFn: (data: ApprovalWorkflowMutationPayload) => createApprovalWorkflow(data),
|
||||||
onSettled: async (_data, error) => {
|
onSettled: async (_data, error) => {
|
||||||
if (!error) {
|
if (!error) {
|
||||||
await invalidateApprovalWorkflows();
|
await invalidateApprovalWorkflowLists();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -152,7 +164,7 @@ export const updateApprovalWorkflowMutation = mutationOptions({
|
|||||||
onSettled: async (_data, error, variables) => {
|
onSettled: async (_data, error, variables) => {
|
||||||
if (!error) {
|
if (!error) {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
invalidateApprovalWorkflows(),
|
invalidateApprovalWorkflowLists(),
|
||||||
invalidateApprovalWorkflowDetail(variables.id)
|
invalidateApprovalWorkflowDetail(variables.id)
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -164,24 +176,103 @@ export const deleteApprovalWorkflowMutation = mutationOptions({
|
|||||||
onSettled: async (_data, error, id) => {
|
onSettled: async (_data, error, id) => {
|
||||||
if (!error) {
|
if (!error) {
|
||||||
const queryClient = getQueryClient();
|
const queryClient = getQueryClient();
|
||||||
await invalidateApprovalWorkflows();
|
await invalidateApprovalWorkflowLists();
|
||||||
queryClient.removeQueries({ queryKey: approvalWorkflowKeys.detail(id) });
|
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: ({
|
mutationFn: ({
|
||||||
workflowId,
|
workflowId,
|
||||||
steps
|
values
|
||||||
}: {
|
}: {
|
||||||
workflowId: string;
|
workflowId: string;
|
||||||
steps: ApprovalStepMutationPayload[];
|
values: ApprovalStepMutationPayload;
|
||||||
}) => replaceApprovalWorkflowSteps(workflowId, steps),
|
}) => createApprovalWorkflowStep(workflowId, values),
|
||||||
onSettled: async (_data, error, variables) => {
|
onSettled: async (_data, error, variables) => {
|
||||||
if (!error) {
|
if (!error) {
|
||||||
await Promise.all([
|
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)
|
invalidateApprovalWorkflowDetail(variables.workflowId)
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import { queryOptions } from '@tanstack/react-query';
|
|||||||
import {
|
import {
|
||||||
getApprovalById,
|
getApprovalById,
|
||||||
getApprovalWorkflowById,
|
getApprovalWorkflowById,
|
||||||
|
getApprovalWorkflowSteps,
|
||||||
getApprovalWorkflows,
|
getApprovalWorkflows,
|
||||||
getApprovals
|
getApprovals
|
||||||
} from './service';
|
} from './service';
|
||||||
import type { ApprovalFilters } from './types';
|
import type { ApprovalFilters, ApprovalWorkflowFilters } from './types';
|
||||||
|
|
||||||
export const approvalKeys = {
|
export const approvalKeys = {
|
||||||
all: ['crm-approvals'] as const,
|
all: ['crm-approvals'] as const,
|
||||||
@@ -18,9 +19,10 @@ export const approvalKeys = {
|
|||||||
export const approvalWorkflowKeys = {
|
export const approvalWorkflowKeys = {
|
||||||
all: ['crm-approval-workflows'] as const,
|
all: ['crm-approval-workflows'] as const,
|
||||||
lists: () => [...approvalWorkflowKeys.all, 'list'] 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,
|
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) =>
|
export const approvalsQueryOptions = (filters: ApprovalFilters) =>
|
||||||
@@ -35,10 +37,10 @@ export const approvalByIdOptions = (id: string) =>
|
|||||||
queryFn: () => getApprovalById(id)
|
queryFn: () => getApprovalById(id)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const approvalWorkflowsQueryOptions = () =>
|
export const approvalWorkflowsQueryOptions = (filters: ApprovalWorkflowFilters = {}) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: approvalWorkflowKeys.list(),
|
queryKey: approvalWorkflowKeys.list(filters),
|
||||||
queryFn: () => getApprovalWorkflows()
|
queryFn: () => getApprovalWorkflows(filters)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const approvalWorkflowByIdOptions = (id: string) =>
|
export const approvalWorkflowByIdOptions = (id: string) =>
|
||||||
@@ -46,3 +48,9 @@ export const approvalWorkflowByIdOptions = (id: string) =>
|
|||||||
queryKey: approvalWorkflowKeys.detail(id),
|
queryKey: approvalWorkflowKeys.detail(id),
|
||||||
queryFn: () => getApprovalWorkflowById(id)
|
queryFn: () => getApprovalWorkflowById(id)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const approvalWorkflowStepsOptions = (workflowId: string) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: approvalWorkflowKeys.steps(workflowId),
|
||||||
|
queryFn: () => getApprovalWorkflowSteps(workflowId)
|
||||||
|
});
|
||||||
|
|||||||
@@ -63,7 +63,11 @@ function mapWorkflowRecord(row: typeof crmApprovalWorkflows.$inferSelect): Appro
|
|||||||
code: row.code,
|
code: row.code,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
entityType: row.entityType,
|
entityType: row.entityType,
|
||||||
|
description: row.description ?? null,
|
||||||
|
isSystem: row.isSystem,
|
||||||
isActive: row.isActive,
|
isActive: row.isActive,
|
||||||
|
createdBy: row.createdBy,
|
||||||
|
updatedBy: row.updatedBy,
|
||||||
createdAt: row.createdAt.toISOString(),
|
createdAt: row.createdAt.toISOString(),
|
||||||
updatedAt: row.updatedAt.toISOString(),
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
deletedAt: row.deletedAt?.toISOString() ?? null
|
deletedAt: row.deletedAt?.toISOString() ?? null
|
||||||
@@ -78,6 +82,7 @@ function mapStepRecord(row: typeof crmApprovalSteps.$inferSelect): ApprovalStepR
|
|||||||
stepNumber: row.stepNumber,
|
stepNumber: row.stepNumber,
|
||||||
roleCode: row.roleCode,
|
roleCode: row.roleCode,
|
||||||
roleName: row.roleName,
|
roleName: row.roleName,
|
||||||
|
approvalMode: row.approvalMode as ApprovalStepRecord['approvalMode'],
|
||||||
isRequired: row.isRequired,
|
isRequired: row.isRequired,
|
||||||
createdAt: row.createdAt.toISOString(),
|
createdAt: row.createdAt.toISOString(),
|
||||||
updatedAt: row.updatedAt.toISOString(),
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
@@ -285,7 +290,17 @@ export async function listApprovalWorkflows(
|
|||||||
|
|
||||||
return workflows.map((workflow) => ({
|
return workflows.map((workflow) => ({
|
||||||
...mapWorkflowRecord(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 {
|
return {
|
||||||
...mapWorkflowRecord(workflow),
|
...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(),
|
code: payload.code.trim(),
|
||||||
name: payload.name.trim(),
|
name: payload.name.trim(),
|
||||||
entityType: payload.entityType.trim(),
|
entityType: payload.entityType.trim(),
|
||||||
isActive: payload.isActive ?? true
|
description: payload.description ?? null,
|
||||||
|
isSystem: false,
|
||||||
|
isActive: payload.isActive ?? true,
|
||||||
|
createdBy: organizationId,
|
||||||
|
updatedBy: organizationId
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@@ -333,8 +362,10 @@ export async function updateApprovalWorkflow(
|
|||||||
code: payload.code?.trim() ?? current.code,
|
code: payload.code?.trim() ?? current.code,
|
||||||
name: payload.name?.trim() ?? current.name,
|
name: payload.name?.trim() ?? current.name,
|
||||||
entityType: payload.entityType?.trim() ?? current.entityType,
|
entityType: payload.entityType?.trim() ?? current.entityType,
|
||||||
|
description: payload.description !== undefined ? payload.description ?? null : current.description,
|
||||||
isActive: payload.isActive ?? current.isActive,
|
isActive: payload.isActive ?? current.isActive,
|
||||||
updatedAt: new Date()
|
updatedAt: new Date(),
|
||||||
|
updatedBy: organizationId
|
||||||
})
|
})
|
||||||
.where(eq(crmApprovalWorkflows.id, id))
|
.where(eq(crmApprovalWorkflows.id, id))
|
||||||
.returning();
|
.returning();
|
||||||
@@ -374,6 +405,7 @@ export async function createApprovalStep(
|
|||||||
stepNumber: payload.stepNumber,
|
stepNumber: payload.stepNumber,
|
||||||
roleCode: payload.roleCode.trim(),
|
roleCode: payload.roleCode.trim(),
|
||||||
roleName: payload.roleName.trim(),
|
roleName: payload.roleName.trim(),
|
||||||
|
approvalMode: payload.approvalMode ?? 'sequential',
|
||||||
isRequired: payload.isRequired ?? true
|
isRequired: payload.isRequired ?? true
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
@@ -393,6 +425,7 @@ export async function updateApprovalStep(
|
|||||||
stepNumber: payload.stepNumber ?? current.stepNumber,
|
stepNumber: payload.stepNumber ?? current.stepNumber,
|
||||||
roleCode: payload.roleCode?.trim() ?? current.roleCode,
|
roleCode: payload.roleCode?.trim() ?? current.roleCode,
|
||||||
roleName: payload.roleName?.trim() ?? current.roleName,
|
roleName: payload.roleName?.trim() ?? current.roleName,
|
||||||
|
approvalMode: payload.approvalMode ?? current.approvalMode,
|
||||||
isRequired: payload.isRequired ?? current.isRequired,
|
isRequired: payload.isRequired ?? current.isRequired,
|
||||||
updatedAt: new Date()
|
updatedAt: new Date()
|
||||||
})
|
})
|
||||||
@@ -451,6 +484,7 @@ export async function replaceApprovalWorkflowSteps(
|
|||||||
stepNumber: step.stepNumber ?? index + 1,
|
stepNumber: step.stepNumber ?? index + 1,
|
||||||
roleCode: step.roleCode.trim(),
|
roleCode: step.roleCode.trim(),
|
||||||
roleName: step.roleName.trim(),
|
roleName: step.roleName.trim(),
|
||||||
|
approvalMode: step.approvalMode ?? 'sequential',
|
||||||
isRequired: step.isRequired ?? true
|
isRequired: step.isRequired ?? true
|
||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,9 +5,13 @@ import type {
|
|||||||
ApprovalFilters,
|
ApprovalFilters,
|
||||||
ApprovalListResponse,
|
ApprovalListResponse,
|
||||||
ApprovalStepMutationPayload,
|
ApprovalStepMutationPayload,
|
||||||
|
ApprovalStepReorderPayload,
|
||||||
|
ApprovalStepUpdatePayload,
|
||||||
ApprovalWorkflowDetailResponse,
|
ApprovalWorkflowDetailResponse,
|
||||||
|
ApprovalWorkflowFilters,
|
||||||
ApprovalWorkflowListResponse,
|
ApprovalWorkflowListResponse,
|
||||||
ApprovalWorkflowMutationPayload,
|
ApprovalWorkflowMutationPayload,
|
||||||
|
ApprovalWorkflowStepListResponse,
|
||||||
MutationSuccessResponse,
|
MutationSuccessResponse,
|
||||||
SubmitApprovalPayload
|
SubmitApprovalPayload
|
||||||
} from './types';
|
} from './types';
|
||||||
@@ -74,11 +78,18 @@ export async function submitQuotationForApproval(id: string, remark?: string) {
|
|||||||
|
|
||||||
const SETTINGS_BASE = '/crm/settings/approval-workflows';
|
const SETTINGS_BASE = '/crm/settings/approval-workflows';
|
||||||
|
|
||||||
export async function getApprovalWorkflows() {
|
export async function getApprovalWorkflows(
|
||||||
return apiClient<ApprovalWorkflowListResponse>(SETTINGS_BASE);
|
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}`);
|
return apiClient<ApprovalWorkflowDetailResponse>(`${SETTINGS_BASE}/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,12 +116,61 @@ export async function deleteApprovalWorkflow(id: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function replaceApprovalWorkflowSteps(
|
export async function cloneApprovalWorkflow(id: string) {
|
||||||
workflowId: string,
|
return apiClient<MutationSuccessResponse>(`${SETTINGS_BASE}/${id}/clone`, {
|
||||||
data: ApprovalStepMutationPayload[]
|
method: 'POST'
|
||||||
) {
|
});
|
||||||
return apiClient<MutationSuccessResponse>(`${SETTINGS_BASE}/${workflowId}/steps`, {
|
}
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify({ steps: data })
|
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)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
export interface ApprovalWorkflowRecord {
|
||||||
id: string;
|
id: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
entityType: string;
|
entityType: string;
|
||||||
|
description: string | null;
|
||||||
|
isSystem: boolean;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
createdBy: string;
|
||||||
|
updatedBy: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
deletedAt: string | null;
|
deletedAt: string | null;
|
||||||
@@ -17,6 +32,7 @@ export interface ApprovalStepRecord {
|
|||||||
stepNumber: number;
|
stepNumber: number;
|
||||||
roleCode: string;
|
roleCode: string;
|
||||||
roleName: string;
|
roleName: string;
|
||||||
|
approvalMode: 'sequential' | 'any_one' | 'all_required';
|
||||||
isRequired: boolean;
|
isRequired: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -25,10 +41,12 @@ export interface ApprovalStepRecord {
|
|||||||
|
|
||||||
export interface ApprovalWorkflowListItem extends ApprovalWorkflowRecord {
|
export interface ApprovalWorkflowListItem extends ApprovalWorkflowRecord {
|
||||||
stepCount: number;
|
stepCount: number;
|
||||||
|
usage: ApprovalWorkflowUsage;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApprovalWorkflowDetail extends ApprovalWorkflowRecord {
|
export interface ApprovalWorkflowDetail extends ApprovalWorkflowRecord {
|
||||||
steps: ApprovalStepRecord[];
|
steps: ApprovalStepRecord[];
|
||||||
|
usage: ApprovalWorkflowUsage;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApprovalRequestRecord {
|
export interface ApprovalRequestRecord {
|
||||||
@@ -95,6 +113,11 @@ export interface ApprovalFilters {
|
|||||||
sort?: string;
|
sort?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ApprovalWorkflowFilters {
|
||||||
|
entityType?: string;
|
||||||
|
activeOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SubmitApprovalPayload {
|
export interface SubmitApprovalPayload {
|
||||||
workflowId?: string;
|
workflowId?: string;
|
||||||
workflowCode?: string;
|
workflowCode?: string;
|
||||||
@@ -111,6 +134,7 @@ export interface ApprovalWorkflowMutationPayload {
|
|||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
entityType: string;
|
entityType: string;
|
||||||
|
description?: string | null;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,17 +142,27 @@ export interface ApprovalStepMutationPayload {
|
|||||||
stepNumber: number;
|
stepNumber: number;
|
||||||
roleCode: string;
|
roleCode: string;
|
||||||
roleName: string;
|
roleName: string;
|
||||||
|
approvalMode: 'sequential' | 'any_one' | 'all_required';
|
||||||
isRequired?: boolean;
|
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 {
|
export interface ApprovalListResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
time: string;
|
time: string;
|
||||||
message: string;
|
message: string;
|
||||||
totalItems: number;
|
|
||||||
offset: number;
|
|
||||||
limit: number;
|
|
||||||
items: ApprovalListItem[];
|
items: ApprovalListItem[];
|
||||||
|
totalItems: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApprovalDetailResponse {
|
export interface ApprovalDetailResponse {
|
||||||
@@ -152,6 +186,13 @@ export interface ApprovalWorkflowDetailResponse {
|
|||||||
workflow: ApprovalWorkflowDetail;
|
workflow: ApprovalWorkflowDetail;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ApprovalWorkflowStepListResponse {
|
||||||
|
success: boolean;
|
||||||
|
time: string;
|
||||||
|
message: string;
|
||||||
|
items: ApprovalStepRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface MutationSuccessResponse {
|
export interface MutationSuccessResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
|
|||||||
@@ -89,6 +89,10 @@ export const PERMISSIONS = {
|
|||||||
crmApprovalWorkflowCreate: 'crm.approval.workflow.create',
|
crmApprovalWorkflowCreate: 'crm.approval.workflow.create',
|
||||||
crmApprovalWorkflowUpdate: 'crm.approval.workflow.update',
|
crmApprovalWorkflowUpdate: 'crm.approval.workflow.update',
|
||||||
crmApprovalWorkflowDelete: 'crm.approval.workflow.delete',
|
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',
|
crmDashboardRead: 'crm.dashboard.read',
|
||||||
crmDashboardExport: 'crm.dashboard.export',
|
crmDashboardExport: 'crm.dashboard.export',
|
||||||
crmReportRead: 'crm.report.read',
|
crmReportRead: 'crm.report.read',
|
||||||
@@ -453,6 +457,10 @@ const DEFAULT_ROLE_DEFINITIONS: Record<string, Omit<CrmRoleProfileDefinition, 'c
|
|||||||
PERMISSIONS.crmApprovalWorkflowCreate,
|
PERMISSIONS.crmApprovalWorkflowCreate,
|
||||||
PERMISSIONS.crmApprovalWorkflowUpdate,
|
PERMISSIONS.crmApprovalWorkflowUpdate,
|
||||||
PERMISSIONS.crmApprovalWorkflowDelete,
|
PERMISSIONS.crmApprovalWorkflowDelete,
|
||||||
|
PERMISSIONS.crmApprovalWorkflowClone,
|
||||||
|
PERMISSIONS.crmApprovalWorkflowActivate,
|
||||||
|
PERMISSIONS.crmApprovalWorkflowDeactivate,
|
||||||
|
PERMISSIONS.crmApprovalWorkflowStepManage,
|
||||||
PERMISSIONS.crmDocumentTemplateRead,
|
PERMISSIONS.crmDocumentTemplateRead,
|
||||||
PERMISSIONS.crmDocumentTemplateCreate,
|
PERMISSIONS.crmDocumentTemplateCreate,
|
||||||
PERMISSIONS.crmDocumentTemplateUpdate,
|
PERMISSIONS.crmDocumentTemplateUpdate,
|
||||||
@@ -613,6 +621,10 @@ export const CRM_PERMISSION_GROUPS: PermissionGroup[] = [
|
|||||||
{ key: PERMISSIONS.crmApprovalWorkflowCreate, label: 'Create approval workflows' },
|
{ key: PERMISSIONS.crmApprovalWorkflowCreate, label: 'Create approval workflows' },
|
||||||
{ key: PERMISSIONS.crmApprovalWorkflowUpdate, label: 'Update approval workflows' },
|
{ key: PERMISSIONS.crmApprovalWorkflowUpdate, label: 'Update approval workflows' },
|
||||||
{ key: PERMISSIONS.crmApprovalWorkflowDelete, label: 'Delete 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.crmDocumentTemplateRead, label: 'Read document templates' },
|
||||||
{ key: PERMISSIONS.crmDocumentTemplateCreate, label: 'Create document templates' },
|
{ key: PERMISSIONS.crmDocumentTemplateCreate, label: 'Create document templates' },
|
||||||
{ key: PERMISSIONS.crmDocumentTemplateUpdate, label: 'Update document templates' },
|
{ key: PERMISSIONS.crmDocumentTemplateUpdate, label: 'Update document templates' },
|
||||||
|
|||||||
Reference in New Issue
Block a user