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

View File

@@ -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 }))} />
<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>
<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>
<Switch checked={state.isActive} onCheckedChange={(checked) => setState((current) => ({ ...current, isActive: checked }))} />
</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 on creation</div>
<div className='text-muted-foreground text-sm'>
Keep this off if you want to finish step configuration first.
</div>
</div>
<Switch
checked={state.isActive}
onCheckedChange={(checked) => setState((current) => ({ ...current, isActive: checked }))}
/>
</div>
) : 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)} />
{!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)
}))
}
>
{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>
))}
<Button
type='button'
variant='outline'
onClick={() =>
setSteps((current) => [
...current,
{
stepNumber: String(current.length + 1),
roleCode: '',
roleName: '',
isRequired: true
}
])
}
>
<Icons.add className='mr-2 h-4 w-4' />
Add Step
</Button>
<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='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 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>
<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>
))
))}
</div>
)}
<WorkflowDialog open={createOpen} onOpenChange={setCreateOpen} />

View File

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

View File

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

View File

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

View File

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

View File

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