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