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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@
import * as React from 'react';
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
import { toast } from 'sonner';
import { BUSINESS_ROLES } from '@/lib/auth/rbac';
import { Icons } from '@/components/icons';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -16,54 +17,75 @@ import {
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import {
activateApprovalWorkflowMutation,
cloneApprovalWorkflowMutation,
createApprovalWorkflowMutation,
createApprovalWorkflowStepMutation,
deactivateApprovalWorkflowMutation,
deleteApprovalWorkflowMutation,
replaceApprovalWorkflowStepsMutation,
updateApprovalWorkflowMutation
deleteApprovalWorkflowStepMutation,
reorderApprovalWorkflowStepsMutation,
updateApprovalWorkflowMutation,
updateApprovalWorkflowStepMutation
} from '../mutations';
import {
approvalWorkflowByIdOptions,
approvalWorkflowsQueryOptions
} from '../queries';
import { approvalWorkflowByIdOptions, approvalWorkflowsQueryOptions } from '../queries';
import type {
ApprovalStepMutationPayload,
ApprovalStepRecord,
ApprovalStepUpdatePayload,
ApprovalWorkflowDetail,
ApprovalWorkflowMutationPayload
} from '../types';
type WorkflowState = {
const APPROVAL_MODES = [
{ value: 'sequential', label: 'Sequential', description: 'Runs step-by-step in current runtime.' },
{ value: 'any_one', label: 'Any One', description: 'Stored for future runtime support.' },
{ value: 'all_required', label: 'All Required', description: 'Stored for future runtime support.' }
] as const;
type WorkflowFormState = {
code: string;
name: string;
entityType: string;
description: string;
isActive: boolean;
};
type StepState = {
type StepFormState = {
stepNumber: string;
roleCode: string;
roleName: string;
approvalMode: ApprovalStepMutationPayload['approvalMode'];
isRequired: boolean;
};
function toWorkflowState(workflow?: ApprovalWorkflowDetail): WorkflowState {
function toWorkflowState(workflow?: ApprovalWorkflowDetail): WorkflowFormState {
return {
code: workflow?.code ?? '',
name: workflow?.name ?? '',
entityType: workflow?.entityType ?? 'quotation',
description: workflow?.description ?? '',
isActive: workflow?.isActive ?? true
};
}
function toStepState(workflow?: ApprovalWorkflowDetail): StepState[] {
return (
workflow?.steps.map((step) => ({
stepNumber: String(step.stepNumber),
roleCode: step.roleCode,
roleName: step.roleName,
isRequired: step.isRequired
})) ?? [{ stepNumber: '1', roleCode: 'sales_manager', roleName: 'Sales Manager', isRequired: true }]
);
function toStepState(step?: ApprovalStepRecord, stepNumber = 1): StepFormState {
return {
stepNumber: String(step?.stepNumber ?? stepNumber),
roleCode: step?.roleCode ?? 'sales_manager',
roleName: step?.roleName ?? 'Sales Manager',
approvalMode: step?.approvalMode ?? 'sequential',
isRequired: step?.isRequired ?? true
};
}
function friendlyRoleName(roleCode: string) {
return roleCode
.split('_')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
function WorkflowDialog({
@@ -75,7 +97,7 @@ function WorkflowDialog({
onOpenChange: (open: boolean) => void;
workflow?: ApprovalWorkflowDetail;
}) {
const [state, setState] = React.useState<WorkflowState>(toWorkflowState(workflow));
const [state, setState] = React.useState<WorkflowFormState>(toWorkflowState(workflow));
const createMutation = useMutation({
...createApprovalWorkflowMutation,
onSuccess: () => {
@@ -99,12 +121,16 @@ function WorkflowDialog({
}
}, [open, workflow]);
const isPending = createMutation.isPending || updateMutation.isPending;
async function onSubmit(event: React.FormEvent) {
event.preventDefault();
const payload: ApprovalWorkflowMutationPayload = {
code: state.code,
name: state.name,
entityType: state.entityType,
description: state.description.trim() || null,
isActive: state.isActive
};
@@ -116,26 +142,73 @@ function WorkflowDialog({
await createMutation.mutateAsync(payload);
}
const isPending = createMutation.isPending || updateMutation.isPending;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogContent className='sm:max-w-2xl'>
<DialogHeader>
<DialogTitle>{workflow ? 'Edit Workflow' : 'Create Workflow'}</DialogTitle>
<DialogDescription>Sequential workflow only. No parallel or conditional logic is introduced here.</DialogDescription>
<DialogDescription>
Configure workflow metadata. Runtime still executes steps sequentially for now.
</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className='grid gap-4'>
<Input placeholder='Workflow code' value={state.code} onChange={(e) => setState((current) => ({ ...current, code: e.target.value }))} />
<Input placeholder='Workflow name' value={state.name} onChange={(e) => setState((current) => ({ ...current, name: e.target.value }))} />
<Input placeholder='Entity type' value={state.entityType} onChange={(e) => setState((current) => ({ ...current, entityType: e.target.value }))} />
<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;

View File

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