task-f.3
This commit is contained in:
29
src/features/crm/approval/schemas/approval-step.schema.ts
Normal file
29
src/features/crm/approval/schemas/approval-step.schema.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { z } from 'zod';
|
||||
import { BUSINESS_ROLES } from '@/lib/auth/rbac';
|
||||
|
||||
export const APPROVAL_MODES = ['sequential', 'any_one', 'all_required'] as const;
|
||||
|
||||
export const approvalStepSchema = z.object({
|
||||
stepNumber: z.number().int().min(1),
|
||||
roleCode: z.enum(BUSINESS_ROLES),
|
||||
roleName: z.string().trim().min(1),
|
||||
approvalMode: z.enum(APPROVAL_MODES).default('sequential'),
|
||||
isRequired: z.boolean().default(true)
|
||||
});
|
||||
|
||||
export const createApprovalStepSchema = approvalStepSchema;
|
||||
|
||||
export const updateApprovalStepSchema = z.object({
|
||||
roleCode: z.enum(BUSINESS_ROLES).optional(),
|
||||
roleName: z.string().trim().min(1).optional(),
|
||||
approvalMode: z.enum(APPROVAL_MODES).optional(),
|
||||
isRequired: z.boolean().optional()
|
||||
});
|
||||
|
||||
export const reorderApprovalStepsSchema = z.object({
|
||||
orderedStepIds: z.array(z.string().min(1)).min(1)
|
||||
});
|
||||
|
||||
export type CreateApprovalStepInput = z.infer<typeof createApprovalStepSchema>;
|
||||
export type UpdateApprovalStepInput = z.infer<typeof updateApprovalStepSchema>;
|
||||
export type ReorderApprovalStepsInput = z.infer<typeof reorderApprovalStepsSchema>;
|
||||
@@ -0,0 +1,58 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
function normalizeWorkflowCode(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '');
|
||||
}
|
||||
|
||||
const nullableTrimmedString = z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.nullable()
|
||||
.transform((value) => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
});
|
||||
|
||||
export const approvalWorkflowFilterSchema = z.object({
|
||||
entityType: z.string().trim().optional(),
|
||||
activeOnly: z
|
||||
.union([z.literal('true'), z.literal('false')])
|
||||
.optional()
|
||||
.transform((value) => value === 'true')
|
||||
});
|
||||
|
||||
export const createApprovalWorkflowSchema = z.object({
|
||||
code: z
|
||||
.string()
|
||||
.min(1)
|
||||
.transform((value) => normalizeWorkflowCode(value))
|
||||
.refine((value) => value.length > 0, {
|
||||
message: 'code is required'
|
||||
}),
|
||||
name: z.string().trim().min(1),
|
||||
entityType: z.string().trim().min(1),
|
||||
description: nullableTrimmedString,
|
||||
isActive: z.boolean().optional()
|
||||
});
|
||||
|
||||
export const updateApprovalWorkflowSchema = z.object({
|
||||
code: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.transform((value) => (value ? normalizeWorkflowCode(value) : value)),
|
||||
name: z.string().trim().min(1).optional(),
|
||||
entityType: z.string().trim().min(1).optional(),
|
||||
description: nullableTrimmedString.optional()
|
||||
});
|
||||
|
||||
export type ApprovalWorkflowFiltersInput = z.infer<typeof approvalWorkflowFilterSchema>;
|
||||
export type CreateApprovalWorkflowInput = z.infer<typeof createApprovalWorkflowSchema>;
|
||||
export type UpdateApprovalWorkflowInput = z.infer<typeof updateApprovalWorkflowSchema>;
|
||||
767
src/features/crm/approval/server/workflow-builder.service.ts
Normal file
767
src/features/crm/approval/server/workflow-builder.service.ts
Normal file
@@ -0,0 +1,767 @@
|
||||
import { and, asc, count, desc, eq, inArray, isNull, sql } from 'drizzle-orm';
|
||||
import {
|
||||
crmApprovalMatrices,
|
||||
crmApprovalRequests,
|
||||
crmApprovalSteps,
|
||||
crmApprovalWorkflows
|
||||
} from '@/db/schema';
|
||||
import { db } from '@/lib/db';
|
||||
import { AuthError } from '@/lib/auth/session';
|
||||
import type {
|
||||
CreateApprovalStepInput,
|
||||
ReorderApprovalStepsInput,
|
||||
UpdateApprovalStepInput
|
||||
} from '../schemas/approval-step.schema';
|
||||
import type {
|
||||
ApprovalWorkflowFiltersInput,
|
||||
CreateApprovalWorkflowInput,
|
||||
UpdateApprovalWorkflowInput
|
||||
} from '../schemas/approval-workflow.schema';
|
||||
|
||||
export interface ApprovalWorkflowUsage {
|
||||
totalRequestCount: number;
|
||||
activeRequestCount: number;
|
||||
activeMatrixCount: number;
|
||||
isLocked: boolean;
|
||||
lockedReason: string | null;
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
canDeactivate: boolean;
|
||||
}
|
||||
|
||||
export interface ApprovalWorkflowRecord {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
code: string;
|
||||
name: string;
|
||||
entityType: string;
|
||||
description: string | null;
|
||||
isSystem: boolean;
|
||||
isActive: boolean;
|
||||
createdBy: string;
|
||||
updatedBy: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
}
|
||||
|
||||
export interface ApprovalStepRecord {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
workflowId: string;
|
||||
stepNumber: number;
|
||||
roleCode: string;
|
||||
roleName: string;
|
||||
approvalMode: 'sequential' | 'any_one' | 'all_required';
|
||||
isRequired: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
}
|
||||
|
||||
export interface ApprovalWorkflowListItem extends ApprovalWorkflowRecord {
|
||||
stepCount: number;
|
||||
usage: ApprovalWorkflowUsage;
|
||||
}
|
||||
|
||||
export interface ApprovalWorkflowDetail extends ApprovalWorkflowRecord {
|
||||
steps: ApprovalStepRecord[];
|
||||
usage: ApprovalWorkflowUsage;
|
||||
}
|
||||
|
||||
export interface ApprovalWorkflowListFilters {
|
||||
entityType?: string;
|
||||
activeOnly?: boolean;
|
||||
}
|
||||
|
||||
function mapWorkflowRecord(row: typeof crmApprovalWorkflows.$inferSelect): ApprovalWorkflowRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
organizationId: row.organizationId,
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
entityType: row.entityType,
|
||||
description: row.description ?? null,
|
||||
isSystem: row.isSystem,
|
||||
isActive: row.isActive,
|
||||
createdBy: row.createdBy,
|
||||
updatedBy: row.updatedBy,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
deletedAt: row.deletedAt?.toISOString() ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function mapStepRecord(row: typeof crmApprovalSteps.$inferSelect): ApprovalStepRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
organizationId: row.organizationId,
|
||||
workflowId: row.workflowId,
|
||||
stepNumber: row.stepNumber,
|
||||
roleCode: row.roleCode,
|
||||
roleName: row.roleName,
|
||||
approvalMode: row.approvalMode as ApprovalStepRecord['approvalMode'],
|
||||
isRequired: row.isRequired,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
deletedAt: row.deletedAt?.toISOString() ?? null
|
||||
};
|
||||
}
|
||||
|
||||
async function assertWorkflow(id: string, organizationId: string) {
|
||||
const [workflow] = await db
|
||||
.select()
|
||||
.from(crmApprovalWorkflows)
|
||||
.where(
|
||||
and(
|
||||
eq(crmApprovalWorkflows.id, id),
|
||||
eq(crmApprovalWorkflows.organizationId, organizationId),
|
||||
isNull(crmApprovalWorkflows.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!workflow) {
|
||||
throw new AuthError('Approval workflow not found', 404);
|
||||
}
|
||||
|
||||
return workflow;
|
||||
}
|
||||
|
||||
async function assertStep(stepId: string, organizationId: string) {
|
||||
const [step] = await db
|
||||
.select()
|
||||
.from(crmApprovalSteps)
|
||||
.where(
|
||||
and(
|
||||
eq(crmApprovalSteps.id, stepId),
|
||||
eq(crmApprovalSteps.organizationId, organizationId),
|
||||
isNull(crmApprovalSteps.deletedAt)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!step) {
|
||||
throw new AuthError('Approval step not found', 404);
|
||||
}
|
||||
|
||||
return step;
|
||||
}
|
||||
|
||||
async function listWorkflowStepsInternal(workflowId: string, organizationId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(crmApprovalSteps)
|
||||
.where(
|
||||
and(
|
||||
eq(crmApprovalSteps.workflowId, workflowId),
|
||||
eq(crmApprovalSteps.organizationId, organizationId),
|
||||
isNull(crmApprovalSteps.deletedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(crmApprovalSteps.stepNumber), asc(crmApprovalSteps.createdAt));
|
||||
}
|
||||
|
||||
async function ensureWorkflowCodeUnique(
|
||||
organizationId: string,
|
||||
code: string,
|
||||
excludedWorkflowId?: string
|
||||
) {
|
||||
const [existing] = await db
|
||||
.select({ id: crmApprovalWorkflows.id })
|
||||
.from(crmApprovalWorkflows)
|
||||
.where(
|
||||
and(
|
||||
eq(crmApprovalWorkflows.organizationId, organizationId),
|
||||
eq(crmApprovalWorkflows.code, code),
|
||||
isNull(crmApprovalWorkflows.deletedAt),
|
||||
...(excludedWorkflowId ? [sql`${crmApprovalWorkflows.id} <> ${excludedWorkflowId}`] : [])
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
throw new AuthError('Approval workflow code already exists', 409);
|
||||
}
|
||||
}
|
||||
|
||||
async function getWorkflowUsageMap(workflowIds: string[], organizationId: string) {
|
||||
if (workflowIds.length === 0) {
|
||||
return new Map<string, ApprovalWorkflowUsage>();
|
||||
}
|
||||
|
||||
const [allRequests, activeRequests, activeMatrices] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
workflowId: crmApprovalRequests.workflowId,
|
||||
count: count()
|
||||
})
|
||||
.from(crmApprovalRequests)
|
||||
.where(
|
||||
and(
|
||||
eq(crmApprovalRequests.organizationId, organizationId),
|
||||
inArray(crmApprovalRequests.workflowId, workflowIds),
|
||||
isNull(crmApprovalRequests.deletedAt)
|
||||
)
|
||||
)
|
||||
.groupBy(crmApprovalRequests.workflowId),
|
||||
db
|
||||
.select({
|
||||
workflowId: crmApprovalRequests.workflowId,
|
||||
count: count()
|
||||
})
|
||||
.from(crmApprovalRequests)
|
||||
.where(
|
||||
and(
|
||||
eq(crmApprovalRequests.organizationId, organizationId),
|
||||
inArray(crmApprovalRequests.workflowId, workflowIds),
|
||||
eq(crmApprovalRequests.status, 'pending'),
|
||||
isNull(crmApprovalRequests.deletedAt)
|
||||
)
|
||||
)
|
||||
.groupBy(crmApprovalRequests.workflowId),
|
||||
db
|
||||
.select({
|
||||
workflowId: crmApprovalMatrices.workflowId,
|
||||
count: count()
|
||||
})
|
||||
.from(crmApprovalMatrices)
|
||||
.where(
|
||||
and(
|
||||
eq(crmApprovalMatrices.organizationId, organizationId),
|
||||
inArray(crmApprovalMatrices.workflowId, workflowIds),
|
||||
eq(crmApprovalMatrices.isActive, true),
|
||||
isNull(crmApprovalMatrices.deletedAt)
|
||||
)
|
||||
)
|
||||
.groupBy(crmApprovalMatrices.workflowId)
|
||||
]);
|
||||
|
||||
const requestMap = new Map(allRequests.map((row) => [row.workflowId, Number(row.count)]));
|
||||
const activeRequestMap = new Map(activeRequests.map((row) => [row.workflowId, Number(row.count)]));
|
||||
const activeMatrixMap = new Map(activeMatrices.map((row) => [row.workflowId, Number(row.count)]));
|
||||
|
||||
return new Map(
|
||||
workflowIds.map((workflowId) => {
|
||||
const totalRequestCount = requestMap.get(workflowId) ?? 0;
|
||||
const activeRequestCount = activeRequestMap.get(workflowId) ?? 0;
|
||||
const activeMatrixCount = activeMatrixMap.get(workflowId) ?? 0;
|
||||
const isLocked = totalRequestCount > 0;
|
||||
const lockedReason = isLocked
|
||||
? 'This workflow has already been used by approval requests. Clone it before editing.'
|
||||
: null;
|
||||
|
||||
return [
|
||||
workflowId,
|
||||
{
|
||||
totalRequestCount,
|
||||
activeRequestCount,
|
||||
activeMatrixCount,
|
||||
isLocked,
|
||||
lockedReason,
|
||||
canEdit: !isLocked,
|
||||
canDelete: totalRequestCount === 0 && activeMatrixCount === 0,
|
||||
canDeactivate: activeRequestCount === 0 && activeMatrixCount === 0
|
||||
} satisfies ApprovalWorkflowUsage
|
||||
];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function assertWorkflowEditable(workflowId: string, organizationId: string) {
|
||||
const usage = (await getWorkflowUsageMap([workflowId], organizationId)).get(workflowId);
|
||||
if (usage?.isLocked) {
|
||||
throw new AuthError(usage.lockedReason ?? 'Workflow is locked', 400);
|
||||
}
|
||||
return usage;
|
||||
}
|
||||
|
||||
async function assertWorkflowCanDeactivate(workflowId: string, organizationId: string) {
|
||||
const usage = (await getWorkflowUsageMap([workflowId], organizationId)).get(workflowId);
|
||||
if (!usage) {
|
||||
throw new AuthError('Approval workflow not found', 404);
|
||||
}
|
||||
if (!usage.canDeactivate) {
|
||||
throw new AuthError(
|
||||
'Workflow cannot be deactivated while active matrices or pending approval requests still reference it',
|
||||
400
|
||||
);
|
||||
}
|
||||
return usage;
|
||||
}
|
||||
|
||||
async function assertWorkflowCanDelete(workflowId: string, organizationId: string) {
|
||||
const usage = (await getWorkflowUsageMap([workflowId], organizationId)).get(workflowId);
|
||||
if (!usage) {
|
||||
throw new AuthError('Approval workflow not found', 404);
|
||||
}
|
||||
if (!usage.canDelete) {
|
||||
throw new AuthError(
|
||||
'Workflow cannot be deleted because it is used by approval requests or active approval matrices',
|
||||
400
|
||||
);
|
||||
}
|
||||
return usage;
|
||||
}
|
||||
|
||||
async function assertWorkflowHasRequiredSteps(workflowId: string, organizationId: string) {
|
||||
const steps = await listWorkflowStepsInternal(workflowId, organizationId);
|
||||
if (steps.length === 0) {
|
||||
throw new AuthError('Active workflow must have at least one step', 400);
|
||||
}
|
||||
if (!steps.some((step) => step.isRequired)) {
|
||||
throw new AuthError('Active workflow must have at least one required step', 400);
|
||||
}
|
||||
}
|
||||
|
||||
async function nextCloneCode(organizationId: string, baseCode: string) {
|
||||
const normalizedBase = `${baseCode}_copy`;
|
||||
const existingCodes = await db
|
||||
.select({ code: crmApprovalWorkflows.code })
|
||||
.from(crmApprovalWorkflows)
|
||||
.where(
|
||||
and(
|
||||
eq(crmApprovalWorkflows.organizationId, organizationId),
|
||||
isNull(crmApprovalWorkflows.deletedAt)
|
||||
)
|
||||
);
|
||||
|
||||
const existing = new Set(existingCodes.map((row) => row.code));
|
||||
if (!existing.has(normalizedBase)) {
|
||||
return normalizedBase;
|
||||
}
|
||||
|
||||
let index = 2;
|
||||
while (existing.has(`${normalizedBase}_${index}`)) {
|
||||
index += 1;
|
||||
}
|
||||
return `${normalizedBase}_${index}`;
|
||||
}
|
||||
|
||||
export async function listApprovalWorkflows(
|
||||
organizationId: string,
|
||||
filters: ApprovalWorkflowListFilters = {}
|
||||
): Promise<ApprovalWorkflowListItem[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(crmApprovalWorkflows)
|
||||
.where(
|
||||
and(
|
||||
eq(crmApprovalWorkflows.organizationId, organizationId),
|
||||
isNull(crmApprovalWorkflows.deletedAt),
|
||||
...(filters.entityType ? [eq(crmApprovalWorkflows.entityType, filters.entityType)] : []),
|
||||
...(filters.activeOnly ? [eq(crmApprovalWorkflows.isActive, true)] : [])
|
||||
)
|
||||
)
|
||||
.orderBy(
|
||||
asc(crmApprovalWorkflows.entityType),
|
||||
desc(crmApprovalWorkflows.isActive),
|
||||
asc(crmApprovalWorkflows.name)
|
||||
);
|
||||
|
||||
const workflowIds = rows.map((row) => row.id);
|
||||
const [steps, usageMap] = await Promise.all([
|
||||
workflowIds.length
|
||||
? db
|
||||
.select({
|
||||
workflowId: crmApprovalSteps.workflowId
|
||||
})
|
||||
.from(crmApprovalSteps)
|
||||
.where(
|
||||
and(
|
||||
eq(crmApprovalSteps.organizationId, organizationId),
|
||||
inArray(crmApprovalSteps.workflowId, workflowIds),
|
||||
isNull(crmApprovalSteps.deletedAt)
|
||||
)
|
||||
)
|
||||
: Promise.resolve([]),
|
||||
getWorkflowUsageMap(workflowIds, organizationId)
|
||||
]);
|
||||
|
||||
const stepCountMap = new Map<string, number>();
|
||||
for (const step of steps) {
|
||||
stepCountMap.set(step.workflowId, (stepCountMap.get(step.workflowId) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return rows.map((row) => ({
|
||||
...mapWorkflowRecord(row),
|
||||
stepCount: stepCountMap.get(row.id) ?? 0,
|
||||
usage: usageMap.get(row.id) ?? {
|
||||
totalRequestCount: 0,
|
||||
activeRequestCount: 0,
|
||||
activeMatrixCount: 0,
|
||||
isLocked: false,
|
||||
lockedReason: null,
|
||||
canEdit: true,
|
||||
canDelete: true,
|
||||
canDeactivate: true
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getApprovalWorkflow(
|
||||
id: string,
|
||||
organizationId: string
|
||||
): Promise<ApprovalWorkflowDetail> {
|
||||
const workflow = await assertWorkflow(id, organizationId);
|
||||
const [steps, usageMap] = await Promise.all([
|
||||
listWorkflowStepsInternal(id, organizationId),
|
||||
getWorkflowUsageMap([id], organizationId)
|
||||
]);
|
||||
|
||||
return {
|
||||
...mapWorkflowRecord(workflow),
|
||||
steps: steps.map(mapStepRecord),
|
||||
usage: usageMap.get(id) ?? {
|
||||
totalRequestCount: 0,
|
||||
activeRequestCount: 0,
|
||||
activeMatrixCount: 0,
|
||||
isLocked: false,
|
||||
lockedReason: null,
|
||||
canEdit: true,
|
||||
canDelete: true,
|
||||
canDeactivate: true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function createApprovalWorkflow(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: CreateApprovalWorkflowInput
|
||||
): Promise<ApprovalWorkflowRecord> {
|
||||
await ensureWorkflowCodeUnique(organizationId, payload.code);
|
||||
|
||||
const [created] = await db
|
||||
.insert(crmApprovalWorkflows)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
organizationId,
|
||||
code: payload.code,
|
||||
name: payload.name.trim(),
|
||||
entityType: payload.entityType.trim(),
|
||||
description: payload.description ?? null,
|
||||
isSystem: false,
|
||||
isActive: payload.isActive ?? true,
|
||||
createdBy: userId,
|
||||
updatedBy: userId
|
||||
})
|
||||
.returning();
|
||||
|
||||
return mapWorkflowRecord(created);
|
||||
}
|
||||
|
||||
export async function updateApprovalWorkflow(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: UpdateApprovalWorkflowInput
|
||||
): Promise<ApprovalWorkflowRecord> {
|
||||
await assertWorkflowEditable(id, organizationId);
|
||||
const current = await assertWorkflow(id, organizationId);
|
||||
const nextCode = payload.code ?? current.code;
|
||||
|
||||
await ensureWorkflowCodeUnique(organizationId, nextCode, id);
|
||||
|
||||
const [updated] = await db
|
||||
.update(crmApprovalWorkflows)
|
||||
.set({
|
||||
code: nextCode,
|
||||
name: payload.name?.trim() ?? current.name,
|
||||
entityType: payload.entityType?.trim() ?? current.entityType,
|
||||
description: payload.description !== undefined ? payload.description : current.description,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: userId
|
||||
})
|
||||
.where(eq(crmApprovalWorkflows.id, id))
|
||||
.returning();
|
||||
|
||||
return mapWorkflowRecord(updated);
|
||||
}
|
||||
|
||||
export async function cloneApprovalWorkflow(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
userId: string
|
||||
): Promise<ApprovalWorkflowDetail> {
|
||||
const current = await assertWorkflow(id, organizationId);
|
||||
const currentSteps = await listWorkflowStepsInternal(id, organizationId);
|
||||
const cloneCode = await nextCloneCode(organizationId, current.code);
|
||||
|
||||
const clonedWorkflow = await db.transaction(async (tx) => {
|
||||
const [workflow] = await tx
|
||||
.insert(crmApprovalWorkflows)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
organizationId,
|
||||
code: cloneCode,
|
||||
name: `${current.name} Copy`,
|
||||
entityType: current.entityType,
|
||||
description: current.description,
|
||||
isSystem: false,
|
||||
isActive: false,
|
||||
createdBy: userId,
|
||||
updatedBy: userId
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (currentSteps.length > 0) {
|
||||
await tx.insert(crmApprovalSteps).values(
|
||||
currentSteps.map((step) => ({
|
||||
id: crypto.randomUUID(),
|
||||
organizationId,
|
||||
workflowId: workflow.id,
|
||||
stepNumber: step.stepNumber,
|
||||
roleCode: step.roleCode,
|
||||
roleName: step.roleName,
|
||||
approvalMode: step.approvalMode,
|
||||
isRequired: step.isRequired
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
return workflow;
|
||||
});
|
||||
|
||||
return getApprovalWorkflow(clonedWorkflow.id, organizationId);
|
||||
}
|
||||
|
||||
export async function activateApprovalWorkflow(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
userId: string
|
||||
): Promise<ApprovalWorkflowRecord> {
|
||||
await assertWorkflowHasRequiredSteps(id, organizationId);
|
||||
const [updated] = await db
|
||||
.update(crmApprovalWorkflows)
|
||||
.set({
|
||||
isActive: true,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: userId
|
||||
})
|
||||
.where(eq(crmApprovalWorkflows.id, id))
|
||||
.returning();
|
||||
|
||||
return mapWorkflowRecord(updated);
|
||||
}
|
||||
|
||||
export async function deactivateApprovalWorkflow(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
userId: string
|
||||
): Promise<ApprovalWorkflowRecord> {
|
||||
await assertWorkflowCanDeactivate(id, organizationId);
|
||||
const [updated] = await db
|
||||
.update(crmApprovalWorkflows)
|
||||
.set({
|
||||
isActive: false,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: userId
|
||||
})
|
||||
.where(eq(crmApprovalWorkflows.id, id))
|
||||
.returning();
|
||||
|
||||
return mapWorkflowRecord(updated);
|
||||
}
|
||||
|
||||
export async function softDeleteApprovalWorkflow(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
userId: string
|
||||
): Promise<{ before: ApprovalWorkflowDetail; after: ApprovalWorkflowRecord }> {
|
||||
const before = await getApprovalWorkflow(id, organizationId);
|
||||
await assertWorkflowCanDelete(id, organizationId);
|
||||
const [updated] = await db
|
||||
.update(crmApprovalWorkflows)
|
||||
.set({
|
||||
deletedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
updatedBy: userId
|
||||
})
|
||||
.where(eq(crmApprovalWorkflows.id, id))
|
||||
.returning();
|
||||
|
||||
return {
|
||||
before,
|
||||
after: mapWorkflowRecord(updated)
|
||||
};
|
||||
}
|
||||
|
||||
export async function listApprovalWorkflowSteps(
|
||||
workflowId: string,
|
||||
organizationId: string
|
||||
): Promise<ApprovalStepRecord[]> {
|
||||
await assertWorkflow(workflowId, organizationId);
|
||||
const steps = await listWorkflowStepsInternal(workflowId, organizationId);
|
||||
return steps.map(mapStepRecord);
|
||||
}
|
||||
|
||||
export async function createApprovalWorkflowStep(
|
||||
workflowId: string,
|
||||
organizationId: string,
|
||||
payload: CreateApprovalStepInput
|
||||
): Promise<ApprovalStepRecord[]> {
|
||||
await assertWorkflowEditable(workflowId, organizationId);
|
||||
const workflow = await assertWorkflow(workflowId, organizationId);
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
const existingSteps = await tx
|
||||
.select()
|
||||
.from(crmApprovalSteps)
|
||||
.where(
|
||||
and(
|
||||
eq(crmApprovalSteps.workflowId, workflowId),
|
||||
eq(crmApprovalSteps.organizationId, organizationId),
|
||||
isNull(crmApprovalSteps.deletedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(crmApprovalSteps.stepNumber));
|
||||
|
||||
const maxStepNumber = existingSteps.length + 1;
|
||||
if (payload.stepNumber > maxStepNumber) {
|
||||
throw new AuthError(`stepNumber must be between 1 and ${maxStepNumber}`, 400);
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
existingSteps
|
||||
.filter((step) => step.stepNumber >= payload.stepNumber)
|
||||
.sort((left, right) => right.stepNumber - left.stepNumber)
|
||||
.map((step) =>
|
||||
tx
|
||||
.update(crmApprovalSteps)
|
||||
.set({
|
||||
stepNumber: step.stepNumber + 1,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(crmApprovalSteps.id, step.id))
|
||||
)
|
||||
);
|
||||
|
||||
await tx.insert(crmApprovalSteps).values({
|
||||
id: crypto.randomUUID(),
|
||||
organizationId,
|
||||
workflowId,
|
||||
stepNumber: payload.stepNumber,
|
||||
roleCode: payload.roleCode,
|
||||
roleName: payload.roleName.trim(),
|
||||
approvalMode: payload.approvalMode,
|
||||
isRequired: payload.isRequired
|
||||
});
|
||||
});
|
||||
|
||||
if (workflow.isActive) {
|
||||
await assertWorkflowHasRequiredSteps(workflowId, organizationId);
|
||||
}
|
||||
|
||||
return listApprovalWorkflowSteps(workflowId, organizationId);
|
||||
}
|
||||
|
||||
export async function updateApprovalWorkflowStep(
|
||||
stepId: string,
|
||||
organizationId: string,
|
||||
payload: UpdateApprovalStepInput
|
||||
): Promise<ApprovalStepRecord> {
|
||||
const current = await assertStep(stepId, organizationId);
|
||||
await assertWorkflowEditable(current.workflowId, organizationId);
|
||||
|
||||
const [updated] = await db
|
||||
.update(crmApprovalSteps)
|
||||
.set({
|
||||
roleCode: payload.roleCode ?? current.roleCode,
|
||||
roleName: payload.roleName?.trim() ?? current.roleName,
|
||||
approvalMode: payload.approvalMode ?? current.approvalMode,
|
||||
isRequired: payload.isRequired ?? current.isRequired,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(crmApprovalSteps.id, stepId))
|
||||
.returning();
|
||||
|
||||
const workflow = await assertWorkflow(current.workflowId, organizationId);
|
||||
if (workflow.isActive) {
|
||||
await assertWorkflowHasRequiredSteps(current.workflowId, organizationId);
|
||||
}
|
||||
|
||||
return mapStepRecord(updated);
|
||||
}
|
||||
|
||||
export async function deleteApprovalWorkflowStep(
|
||||
stepId: string,
|
||||
organizationId: string
|
||||
): Promise<{ before: ApprovalStepRecord; after: ApprovalStepRecord; steps: ApprovalStepRecord[] }> {
|
||||
const current = await assertStep(stepId, organizationId);
|
||||
await assertWorkflowEditable(current.workflowId, organizationId);
|
||||
const workflow = await assertWorkflow(current.workflowId, organizationId);
|
||||
|
||||
const [updated] = await db
|
||||
.update(crmApprovalSteps)
|
||||
.set({
|
||||
deletedAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(crmApprovalSteps.id, stepId))
|
||||
.returning();
|
||||
|
||||
const remainingSteps = await listWorkflowStepsInternal(current.workflowId, organizationId);
|
||||
await Promise.all(
|
||||
remainingSteps.map((step, index) =>
|
||||
db
|
||||
.update(crmApprovalSteps)
|
||||
.set({
|
||||
stepNumber: index + 1,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(crmApprovalSteps.id, step.id))
|
||||
)
|
||||
);
|
||||
|
||||
if (workflow.isActive) {
|
||||
await assertWorkflowHasRequiredSteps(current.workflowId, organizationId);
|
||||
}
|
||||
|
||||
return {
|
||||
before: mapStepRecord(current),
|
||||
after: mapStepRecord(updated),
|
||||
steps: await listApprovalWorkflowSteps(current.workflowId, organizationId)
|
||||
};
|
||||
}
|
||||
|
||||
export async function reorderApprovalWorkflowSteps(
|
||||
workflowId: string,
|
||||
organizationId: string,
|
||||
payload: ReorderApprovalStepsInput
|
||||
): Promise<ApprovalStepRecord[]> {
|
||||
await assertWorkflowEditable(workflowId, organizationId);
|
||||
const existingSteps = await listWorkflowStepsInternal(workflowId, organizationId);
|
||||
|
||||
if (existingSteps.length !== payload.orderedStepIds.length) {
|
||||
throw new AuthError('orderedStepIds must include every active step exactly once', 400);
|
||||
}
|
||||
|
||||
const existingIds = new Set(existingSteps.map((step) => step.id));
|
||||
for (const stepId of payload.orderedStepIds) {
|
||||
if (!existingIds.has(stepId)) {
|
||||
throw new AuthError('orderedStepIds contains an unknown step', 400);
|
||||
}
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await Promise.all(
|
||||
payload.orderedStepIds.map((stepId, index) =>
|
||||
tx
|
||||
.update(crmApprovalSteps)
|
||||
.set({
|
||||
stepNumber: index + 1,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(crmApprovalSteps.id, stepId))
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
const workflow = await assertWorkflow(workflowId, organizationId);
|
||||
if (workflow.isActive) {
|
||||
await assertWorkflowHasRequiredSteps(workflowId, organizationId);
|
||||
}
|
||||
|
||||
return listApprovalWorkflowSteps(workflowId, organizationId);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import * as React from 'react';
|
||||
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { BUSINESS_ROLES } from '@/lib/auth/rbac';
|
||||
import { Icons } from '@/components/icons';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -16,54 +17,75 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
activateApprovalWorkflowMutation,
|
||||
cloneApprovalWorkflowMutation,
|
||||
createApprovalWorkflowMutation,
|
||||
createApprovalWorkflowStepMutation,
|
||||
deactivateApprovalWorkflowMutation,
|
||||
deleteApprovalWorkflowMutation,
|
||||
replaceApprovalWorkflowStepsMutation,
|
||||
updateApprovalWorkflowMutation
|
||||
deleteApprovalWorkflowStepMutation,
|
||||
reorderApprovalWorkflowStepsMutation,
|
||||
updateApprovalWorkflowMutation,
|
||||
updateApprovalWorkflowStepMutation
|
||||
} from '../mutations';
|
||||
import {
|
||||
approvalWorkflowByIdOptions,
|
||||
approvalWorkflowsQueryOptions
|
||||
} from '../queries';
|
||||
import { approvalWorkflowByIdOptions, approvalWorkflowsQueryOptions } from '../queries';
|
||||
import type {
|
||||
ApprovalStepMutationPayload,
|
||||
ApprovalStepRecord,
|
||||
ApprovalStepUpdatePayload,
|
||||
ApprovalWorkflowDetail,
|
||||
ApprovalWorkflowMutationPayload
|
||||
} from '../types';
|
||||
|
||||
type WorkflowState = {
|
||||
const APPROVAL_MODES = [
|
||||
{ value: 'sequential', label: 'Sequential', description: 'Runs step-by-step in current runtime.' },
|
||||
{ value: 'any_one', label: 'Any One', description: 'Stored for future runtime support.' },
|
||||
{ value: 'all_required', label: 'All Required', description: 'Stored for future runtime support.' }
|
||||
] as const;
|
||||
|
||||
type WorkflowFormState = {
|
||||
code: string;
|
||||
name: string;
|
||||
entityType: string;
|
||||
description: string;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
type StepState = {
|
||||
type StepFormState = {
|
||||
stepNumber: string;
|
||||
roleCode: string;
|
||||
roleName: string;
|
||||
approvalMode: ApprovalStepMutationPayload['approvalMode'];
|
||||
isRequired: boolean;
|
||||
};
|
||||
|
||||
function toWorkflowState(workflow?: ApprovalWorkflowDetail): WorkflowState {
|
||||
function toWorkflowState(workflow?: ApprovalWorkflowDetail): WorkflowFormState {
|
||||
return {
|
||||
code: workflow?.code ?? '',
|
||||
name: workflow?.name ?? '',
|
||||
entityType: workflow?.entityType ?? 'quotation',
|
||||
description: workflow?.description ?? '',
|
||||
isActive: workflow?.isActive ?? true
|
||||
};
|
||||
}
|
||||
|
||||
function toStepState(workflow?: ApprovalWorkflowDetail): StepState[] {
|
||||
return (
|
||||
workflow?.steps.map((step) => ({
|
||||
stepNumber: String(step.stepNumber),
|
||||
roleCode: step.roleCode,
|
||||
roleName: step.roleName,
|
||||
isRequired: step.isRequired
|
||||
})) ?? [{ stepNumber: '1', roleCode: 'sales_manager', roleName: 'Sales Manager', isRequired: true }]
|
||||
);
|
||||
function toStepState(step?: ApprovalStepRecord, stepNumber = 1): StepFormState {
|
||||
return {
|
||||
stepNumber: String(step?.stepNumber ?? stepNumber),
|
||||
roleCode: step?.roleCode ?? 'sales_manager',
|
||||
roleName: step?.roleName ?? 'Sales Manager',
|
||||
approvalMode: step?.approvalMode ?? 'sequential',
|
||||
isRequired: step?.isRequired ?? true
|
||||
};
|
||||
}
|
||||
|
||||
function friendlyRoleName(roleCode: string) {
|
||||
return roleCode
|
||||
.split('_')
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function WorkflowDialog({
|
||||
@@ -75,7 +97,7 @@ function WorkflowDialog({
|
||||
onOpenChange: (open: boolean) => void;
|
||||
workflow?: ApprovalWorkflowDetail;
|
||||
}) {
|
||||
const [state, setState] = React.useState<WorkflowState>(toWorkflowState(workflow));
|
||||
const [state, setState] = React.useState<WorkflowFormState>(toWorkflowState(workflow));
|
||||
const createMutation = useMutation({
|
||||
...createApprovalWorkflowMutation,
|
||||
onSuccess: () => {
|
||||
@@ -99,12 +121,16 @@ function WorkflowDialog({
|
||||
}
|
||||
}, [open, workflow]);
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
async function onSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
const payload: ApprovalWorkflowMutationPayload = {
|
||||
code: state.code,
|
||||
name: state.name,
|
||||
entityType: state.entityType,
|
||||
description: state.description.trim() || null,
|
||||
isActive: state.isActive
|
||||
};
|
||||
|
||||
@@ -116,26 +142,73 @@ function WorkflowDialog({
|
||||
await createMutation.mutateAsync(payload);
|
||||
}
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogContent className='sm:max-w-2xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{workflow ? 'Edit Workflow' : 'Create Workflow'}</DialogTitle>
|
||||
<DialogDescription>Sequential workflow only. No parallel or conditional logic is introduced here.</DialogDescription>
|
||||
<DialogDescription>
|
||||
Configure workflow metadata. Runtime still executes steps sequentially for now.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={onSubmit} className='grid gap-4'>
|
||||
<Input placeholder='Workflow code' value={state.code} onChange={(e) => setState((current) => ({ ...current, code: e.target.value }))} />
|
||||
<Input placeholder='Workflow name' value={state.name} onChange={(e) => setState((current) => ({ ...current, name: e.target.value }))} />
|
||||
<Input placeholder='Entity type' value={state.entityType} onChange={(e) => setState((current) => ({ ...current, entityType: e.target.value }))} />
|
||||
<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} />
|
||||
|
||||
@@ -1,23 +1,32 @@
|
||||
import { mutationOptions } from '@tanstack/react-query';
|
||||
import { quotationDocumentKeys } from '@/features/crm/quotations/document/queries';
|
||||
import { quotationKeys } from '@/features/crm/quotations/api/queries';
|
||||
import { getQueryClient } from '@/lib/query-client';
|
||||
import {
|
||||
activateApprovalWorkflow,
|
||||
approveApproval,
|
||||
cancelApproval,
|
||||
cloneApprovalWorkflow,
|
||||
createApprovalWorkflow,
|
||||
createApprovalWorkflowStep,
|
||||
deactivateApprovalWorkflow,
|
||||
deleteApprovalWorkflow,
|
||||
deleteApprovalWorkflowStep,
|
||||
rejectApproval,
|
||||
replaceApprovalWorkflowSteps,
|
||||
reorderApprovalWorkflowSteps,
|
||||
returnApproval,
|
||||
submitApproval,
|
||||
submitQuotationForApproval,
|
||||
updateApprovalWorkflow
|
||||
updateApprovalWorkflow,
|
||||
updateApprovalWorkflowStep
|
||||
} from './service';
|
||||
import { approvalKeys, approvalWorkflowKeys } from './queries';
|
||||
import { quotationKeys } from '@/features/crm/quotations/api/queries';
|
||||
import type {
|
||||
ApprovalActionPayload,
|
||||
ApprovalStepMutationPayload,
|
||||
ApprovalStepReorderPayload,
|
||||
ApprovalStepUpdatePayload,
|
||||
ApprovalWorkflowFilters,
|
||||
ApprovalWorkflowMutationPayload,
|
||||
SubmitApprovalPayload
|
||||
} from './types';
|
||||
@@ -30,12 +39,20 @@ async function invalidateApprovalDetail(id: string) {
|
||||
await getQueryClient().invalidateQueries({ queryKey: approvalKeys.detail(id) });
|
||||
}
|
||||
|
||||
async function invalidateApprovalWorkflows() {
|
||||
await getQueryClient().invalidateQueries({ queryKey: approvalWorkflowKeys.lists() });
|
||||
async function invalidateApprovalWorkflowLists(filters?: ApprovalWorkflowFilters) {
|
||||
const queryClient = getQueryClient();
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: approvalWorkflowKeys.lists() }),
|
||||
...(filters ? [queryClient.invalidateQueries({ queryKey: approvalWorkflowKeys.list(filters) })] : [])
|
||||
]);
|
||||
}
|
||||
|
||||
async function invalidateApprovalWorkflowDetail(id: string) {
|
||||
await getQueryClient().invalidateQueries({ queryKey: approvalWorkflowKeys.detail(id) });
|
||||
const queryClient = getQueryClient();
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: approvalWorkflowKeys.detail(id) }),
|
||||
queryClient.invalidateQueries({ queryKey: approvalWorkflowKeys.steps(id) })
|
||||
]);
|
||||
}
|
||||
|
||||
async function invalidateQuotationQueries(quotationId: string) {
|
||||
@@ -60,7 +77,7 @@ async function invalidateRelatedEntityQueries(approvalId: string) {
|
||||
}
|
||||
|
||||
export const submitApprovalMutation = mutationOptions({
|
||||
mutationFn: (data: SubmitApprovalPayload) => submitApproval(data),
|
||||
mutationFn: (values: SubmitApprovalPayload) => submitApproval(values),
|
||||
onSettled: async (_data, error) => {
|
||||
if (!error) {
|
||||
await invalidateApprovalLists();
|
||||
@@ -68,7 +85,7 @@ export const submitApprovalMutation = mutationOptions({
|
||||
}
|
||||
});
|
||||
|
||||
export const submitQuotationApprovalMutation = mutationOptions({
|
||||
export const submitQuotationForApprovalMutation = mutationOptions({
|
||||
mutationFn: ({ id, remark }: { id: string; remark?: string }) => submitQuotationForApproval(id, remark),
|
||||
onSettled: async (_data, error, variables) => {
|
||||
if (!error) {
|
||||
@@ -77,22 +94,19 @@ export const submitQuotationApprovalMutation = mutationOptions({
|
||||
}
|
||||
});
|
||||
|
||||
export const submitQuotationApprovalMutation = submitQuotationForApprovalMutation;
|
||||
|
||||
export const cancelApprovalMutation = mutationOptions({
|
||||
mutationFn: (id: string) => cancelApproval(id),
|
||||
onSettled: async (_data, error, id) => {
|
||||
if (!error) {
|
||||
await Promise.all([
|
||||
invalidateApprovalLists(),
|
||||
invalidateApprovalDetail(id),
|
||||
invalidateRelatedEntityQueries(id)
|
||||
]);
|
||||
await Promise.all([invalidateApprovalLists(), invalidateApprovalDetail(id), invalidateRelatedEntityQueries(id)]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const approveApprovalMutation = mutationOptions({
|
||||
mutationFn: ({ id, values }: { id: string; values: ApprovalActionPayload }) =>
|
||||
approveApproval(id, values),
|
||||
mutationFn: ({ id, values }: { id: string; values: ApprovalActionPayload }) => approveApproval(id, values),
|
||||
onSettled: async (_data, error, variables) => {
|
||||
if (!error) {
|
||||
await Promise.all([
|
||||
@@ -105,8 +119,7 @@ export const approveApprovalMutation = mutationOptions({
|
||||
});
|
||||
|
||||
export const rejectApprovalMutation = mutationOptions({
|
||||
mutationFn: ({ id, values }: { id: string; values: ApprovalActionPayload }) =>
|
||||
rejectApproval(id, values),
|
||||
mutationFn: ({ id, values }: { id: string; values: ApprovalActionPayload }) => rejectApproval(id, values),
|
||||
onSettled: async (_data, error, variables) => {
|
||||
if (!error) {
|
||||
await Promise.all([
|
||||
@@ -119,8 +132,7 @@ export const rejectApprovalMutation = mutationOptions({
|
||||
});
|
||||
|
||||
export const returnApprovalMutation = mutationOptions({
|
||||
mutationFn: ({ id, values }: { id: string; values: ApprovalActionPayload }) =>
|
||||
returnApproval(id, values),
|
||||
mutationFn: ({ id, values }: { id: string; values: ApprovalActionPayload }) => returnApproval(id, values),
|
||||
onSettled: async (_data, error, variables) => {
|
||||
if (!error) {
|
||||
await Promise.all([
|
||||
@@ -136,7 +148,7 @@ export const createApprovalWorkflowMutation = mutationOptions({
|
||||
mutationFn: (data: ApprovalWorkflowMutationPayload) => createApprovalWorkflow(data),
|
||||
onSettled: async (_data, error) => {
|
||||
if (!error) {
|
||||
await invalidateApprovalWorkflows();
|
||||
await invalidateApprovalWorkflowLists();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -152,7 +164,7 @@ export const updateApprovalWorkflowMutation = mutationOptions({
|
||||
onSettled: async (_data, error, variables) => {
|
||||
if (!error) {
|
||||
await Promise.all([
|
||||
invalidateApprovalWorkflows(),
|
||||
invalidateApprovalWorkflowLists(),
|
||||
invalidateApprovalWorkflowDetail(variables.id)
|
||||
]);
|
||||
}
|
||||
@@ -164,24 +176,103 @@ export const deleteApprovalWorkflowMutation = mutationOptions({
|
||||
onSettled: async (_data, error, id) => {
|
||||
if (!error) {
|
||||
const queryClient = getQueryClient();
|
||||
await invalidateApprovalWorkflows();
|
||||
await invalidateApprovalWorkflowLists();
|
||||
queryClient.removeQueries({ queryKey: approvalWorkflowKeys.detail(id) });
|
||||
queryClient.removeQueries({ queryKey: approvalWorkflowKeys.steps(id) });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const replaceApprovalWorkflowStepsMutation = mutationOptions({
|
||||
export const cloneApprovalWorkflowMutation = mutationOptions({
|
||||
mutationFn: (id: string) => cloneApprovalWorkflow(id),
|
||||
onSettled: async (_data, error) => {
|
||||
if (!error) {
|
||||
await invalidateApprovalWorkflowLists();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const activateApprovalWorkflowMutation = mutationOptions({
|
||||
mutationFn: (id: string) => activateApprovalWorkflow(id),
|
||||
onSettled: async (_data, error, id) => {
|
||||
if (!error) {
|
||||
await Promise.all([invalidateApprovalWorkflowLists(), invalidateApprovalWorkflowDetail(id)]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const deactivateApprovalWorkflowMutation = mutationOptions({
|
||||
mutationFn: (id: string) => deactivateApprovalWorkflow(id),
|
||||
onSettled: async (_data, error, id) => {
|
||||
if (!error) {
|
||||
await Promise.all([invalidateApprovalWorkflowLists(), invalidateApprovalWorkflowDetail(id)]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const createApprovalWorkflowStepMutation = mutationOptions({
|
||||
mutationFn: ({
|
||||
workflowId,
|
||||
steps
|
||||
values
|
||||
}: {
|
||||
workflowId: string;
|
||||
steps: ApprovalStepMutationPayload[];
|
||||
}) => replaceApprovalWorkflowSteps(workflowId, steps),
|
||||
values: ApprovalStepMutationPayload;
|
||||
}) => createApprovalWorkflowStep(workflowId, values),
|
||||
onSettled: async (_data, error, variables) => {
|
||||
if (!error) {
|
||||
await Promise.all([
|
||||
invalidateApprovalWorkflows(),
|
||||
invalidateApprovalWorkflowLists(),
|
||||
invalidateApprovalWorkflowDetail(variables.workflowId)
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const updateApprovalWorkflowStepMutation = mutationOptions({
|
||||
mutationFn: ({
|
||||
workflowId,
|
||||
stepId,
|
||||
values
|
||||
}: {
|
||||
workflowId: string;
|
||||
stepId: string;
|
||||
values: ApprovalStepUpdatePayload;
|
||||
}) => updateApprovalWorkflowStep(workflowId, stepId, values),
|
||||
onSettled: async (_data, error, variables) => {
|
||||
if (!error) {
|
||||
await Promise.all([
|
||||
invalidateApprovalWorkflowLists(),
|
||||
invalidateApprovalWorkflowDetail(variables.workflowId)
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const deleteApprovalWorkflowStepMutation = mutationOptions({
|
||||
mutationFn: ({ workflowId, stepId }: { workflowId: string; stepId: string }) =>
|
||||
deleteApprovalWorkflowStep(workflowId, stepId),
|
||||
onSettled: async (_data, error, variables) => {
|
||||
if (!error) {
|
||||
await Promise.all([
|
||||
invalidateApprovalWorkflowLists(),
|
||||
invalidateApprovalWorkflowDetail(variables.workflowId)
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const reorderApprovalWorkflowStepsMutation = mutationOptions({
|
||||
mutationFn: ({
|
||||
workflowId,
|
||||
values
|
||||
}: {
|
||||
workflowId: string;
|
||||
values: ApprovalStepReorderPayload;
|
||||
}) => reorderApprovalWorkflowSteps(workflowId, values),
|
||||
onSettled: async (_data, error, variables) => {
|
||||
if (!error) {
|
||||
await Promise.all([
|
||||
invalidateApprovalWorkflowLists(),
|
||||
invalidateApprovalWorkflowDetail(variables.workflowId)
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -2,10 +2,11 @@ import { queryOptions } from '@tanstack/react-query';
|
||||
import {
|
||||
getApprovalById,
|
||||
getApprovalWorkflowById,
|
||||
getApprovalWorkflowSteps,
|
||||
getApprovalWorkflows,
|
||||
getApprovals
|
||||
} from './service';
|
||||
import type { ApprovalFilters } from './types';
|
||||
import type { ApprovalFilters, ApprovalWorkflowFilters } from './types';
|
||||
|
||||
export const approvalKeys = {
|
||||
all: ['crm-approvals'] as const,
|
||||
@@ -18,9 +19,10 @@ export const approvalKeys = {
|
||||
export const approvalWorkflowKeys = {
|
||||
all: ['crm-approval-workflows'] as const,
|
||||
lists: () => [...approvalWorkflowKeys.all, 'list'] as const,
|
||||
list: () => [...approvalWorkflowKeys.lists(), 'all'] as const,
|
||||
list: (filters: ApprovalWorkflowFilters = {}) => [...approvalWorkflowKeys.lists(), filters] as const,
|
||||
details: () => [...approvalWorkflowKeys.all, 'detail'] as const,
|
||||
detail: (id: string) => [...approvalWorkflowKeys.details(), id] as const
|
||||
detail: (id: string) => [...approvalWorkflowKeys.details(), id] as const,
|
||||
steps: (workflowId: string) => [...approvalWorkflowKeys.detail(workflowId), 'steps'] as const
|
||||
};
|
||||
|
||||
export const approvalsQueryOptions = (filters: ApprovalFilters) =>
|
||||
@@ -35,10 +37,10 @@ export const approvalByIdOptions = (id: string) =>
|
||||
queryFn: () => getApprovalById(id)
|
||||
});
|
||||
|
||||
export const approvalWorkflowsQueryOptions = () =>
|
||||
export const approvalWorkflowsQueryOptions = (filters: ApprovalWorkflowFilters = {}) =>
|
||||
queryOptions({
|
||||
queryKey: approvalWorkflowKeys.list(),
|
||||
queryFn: () => getApprovalWorkflows()
|
||||
queryKey: approvalWorkflowKeys.list(filters),
|
||||
queryFn: () => getApprovalWorkflows(filters)
|
||||
});
|
||||
|
||||
export const approvalWorkflowByIdOptions = (id: string) =>
|
||||
@@ -46,3 +48,9 @@ export const approvalWorkflowByIdOptions = (id: string) =>
|
||||
queryKey: approvalWorkflowKeys.detail(id),
|
||||
queryFn: () => getApprovalWorkflowById(id)
|
||||
});
|
||||
|
||||
export const approvalWorkflowStepsOptions = (workflowId: string) =>
|
||||
queryOptions({
|
||||
queryKey: approvalWorkflowKeys.steps(workflowId),
|
||||
queryFn: () => getApprovalWorkflowSteps(workflowId)
|
||||
});
|
||||
|
||||
@@ -63,7 +63,11 @@ function mapWorkflowRecord(row: typeof crmApprovalWorkflows.$inferSelect): Appro
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
entityType: row.entityType,
|
||||
description: row.description ?? null,
|
||||
isSystem: row.isSystem,
|
||||
isActive: row.isActive,
|
||||
createdBy: row.createdBy,
|
||||
updatedBy: row.updatedBy,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
deletedAt: row.deletedAt?.toISOString() ?? null
|
||||
@@ -78,6 +82,7 @@ function mapStepRecord(row: typeof crmApprovalSteps.$inferSelect): ApprovalStepR
|
||||
stepNumber: row.stepNumber,
|
||||
roleCode: row.roleCode,
|
||||
roleName: row.roleName,
|
||||
approvalMode: row.approvalMode as ApprovalStepRecord['approvalMode'],
|
||||
isRequired: row.isRequired,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
@@ -285,7 +290,17 @@ export async function listApprovalWorkflows(
|
||||
|
||||
return workflows.map((workflow) => ({
|
||||
...mapWorkflowRecord(workflow),
|
||||
stepCount: steps.filter((step) => step.workflowId === workflow.id).length
|
||||
stepCount: steps.filter((step) => step.workflowId === workflow.id).length,
|
||||
usage: {
|
||||
totalRequestCount: 0,
|
||||
activeRequestCount: 0,
|
||||
activeMatrixCount: 0,
|
||||
isLocked: false,
|
||||
lockedReason: null,
|
||||
canEdit: true,
|
||||
canDelete: true,
|
||||
canDeactivate: true
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -298,7 +313,17 @@ export async function getApprovalWorkflow(
|
||||
|
||||
return {
|
||||
...mapWorkflowRecord(workflow),
|
||||
steps
|
||||
steps,
|
||||
usage: {
|
||||
totalRequestCount: 0,
|
||||
activeRequestCount: 0,
|
||||
activeMatrixCount: 0,
|
||||
isLocked: false,
|
||||
lockedReason: null,
|
||||
canEdit: true,
|
||||
canDelete: true,
|
||||
canDeactivate: true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -314,7 +339,11 @@ export async function createApprovalWorkflow(
|
||||
code: payload.code.trim(),
|
||||
name: payload.name.trim(),
|
||||
entityType: payload.entityType.trim(),
|
||||
isActive: payload.isActive ?? true
|
||||
description: payload.description ?? null,
|
||||
isSystem: false,
|
||||
isActive: payload.isActive ?? true,
|
||||
createdBy: organizationId,
|
||||
updatedBy: organizationId
|
||||
})
|
||||
.returning();
|
||||
|
||||
@@ -333,8 +362,10 @@ export async function updateApprovalWorkflow(
|
||||
code: payload.code?.trim() ?? current.code,
|
||||
name: payload.name?.trim() ?? current.name,
|
||||
entityType: payload.entityType?.trim() ?? current.entityType,
|
||||
description: payload.description !== undefined ? payload.description ?? null : current.description,
|
||||
isActive: payload.isActive ?? current.isActive,
|
||||
updatedAt: new Date()
|
||||
updatedAt: new Date(),
|
||||
updatedBy: organizationId
|
||||
})
|
||||
.where(eq(crmApprovalWorkflows.id, id))
|
||||
.returning();
|
||||
@@ -374,6 +405,7 @@ export async function createApprovalStep(
|
||||
stepNumber: payload.stepNumber,
|
||||
roleCode: payload.roleCode.trim(),
|
||||
roleName: payload.roleName.trim(),
|
||||
approvalMode: payload.approvalMode ?? 'sequential',
|
||||
isRequired: payload.isRequired ?? true
|
||||
})
|
||||
.returning();
|
||||
@@ -393,6 +425,7 @@ export async function updateApprovalStep(
|
||||
stepNumber: payload.stepNumber ?? current.stepNumber,
|
||||
roleCode: payload.roleCode?.trim() ?? current.roleCode,
|
||||
roleName: payload.roleName?.trim() ?? current.roleName,
|
||||
approvalMode: payload.approvalMode ?? current.approvalMode,
|
||||
isRequired: payload.isRequired ?? current.isRequired,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
@@ -451,6 +484,7 @@ export async function replaceApprovalWorkflowSteps(
|
||||
stepNumber: step.stepNumber ?? index + 1,
|
||||
roleCode: step.roleCode.trim(),
|
||||
roleName: step.roleName.trim(),
|
||||
approvalMode: step.approvalMode ?? 'sequential',
|
||||
isRequired: step.isRequired ?? true
|
||||
}))
|
||||
)
|
||||
|
||||
@@ -5,9 +5,13 @@ import type {
|
||||
ApprovalFilters,
|
||||
ApprovalListResponse,
|
||||
ApprovalStepMutationPayload,
|
||||
ApprovalStepReorderPayload,
|
||||
ApprovalStepUpdatePayload,
|
||||
ApprovalWorkflowDetailResponse,
|
||||
ApprovalWorkflowFilters,
|
||||
ApprovalWorkflowListResponse,
|
||||
ApprovalWorkflowMutationPayload,
|
||||
ApprovalWorkflowStepListResponse,
|
||||
MutationSuccessResponse,
|
||||
SubmitApprovalPayload
|
||||
} from './types';
|
||||
@@ -74,11 +78,18 @@ export async function submitQuotationForApproval(id: string, remark?: string) {
|
||||
|
||||
const SETTINGS_BASE = '/crm/settings/approval-workflows';
|
||||
|
||||
export async function getApprovalWorkflows() {
|
||||
return apiClient<ApprovalWorkflowListResponse>(SETTINGS_BASE);
|
||||
export async function getApprovalWorkflows(
|
||||
filters: ApprovalWorkflowFilters = {}
|
||||
): Promise<ApprovalWorkflowListResponse> {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (filters.entityType) searchParams.set('entityType', filters.entityType);
|
||||
if (filters.activeOnly) searchParams.set('activeOnly', 'true');
|
||||
|
||||
const query = searchParams.toString();
|
||||
return apiClient<ApprovalWorkflowListResponse>(`${SETTINGS_BASE}${query ? `?${query}` : ''}`);
|
||||
}
|
||||
|
||||
export async function getApprovalWorkflowById(id: string) {
|
||||
export async function getApprovalWorkflowById(id: string): Promise<ApprovalWorkflowDetailResponse> {
|
||||
return apiClient<ApprovalWorkflowDetailResponse>(`${SETTINGS_BASE}/${id}`);
|
||||
}
|
||||
|
||||
@@ -105,12 +116,61 @@ export async function deleteApprovalWorkflow(id: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function replaceApprovalWorkflowSteps(
|
||||
workflowId: string,
|
||||
data: ApprovalStepMutationPayload[]
|
||||
) {
|
||||
return apiClient<MutationSuccessResponse>(`${SETTINGS_BASE}/${workflowId}/steps`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ steps: data })
|
||||
export async function cloneApprovalWorkflow(id: string) {
|
||||
return apiClient<MutationSuccessResponse>(`${SETTINGS_BASE}/${id}/clone`, {
|
||||
method: 'POST'
|
||||
});
|
||||
}
|
||||
|
||||
export async function activateApprovalWorkflow(id: string) {
|
||||
return apiClient<MutationSuccessResponse>(`${SETTINGS_BASE}/${id}/activate`, {
|
||||
method: 'POST'
|
||||
});
|
||||
}
|
||||
|
||||
export async function deactivateApprovalWorkflow(id: string) {
|
||||
return apiClient<MutationSuccessResponse>(`${SETTINGS_BASE}/${id}/deactivate`, {
|
||||
method: 'POST'
|
||||
});
|
||||
}
|
||||
|
||||
export async function getApprovalWorkflowSteps(workflowId: string) {
|
||||
return apiClient<ApprovalWorkflowStepListResponse>(`${SETTINGS_BASE}/${workflowId}/steps`);
|
||||
}
|
||||
|
||||
export async function createApprovalWorkflowStep(
|
||||
workflowId: string,
|
||||
data: ApprovalStepMutationPayload
|
||||
) {
|
||||
return apiClient<MutationSuccessResponse>(`${SETTINGS_BASE}/${workflowId}/steps`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateApprovalWorkflowStep(
|
||||
workflowId: string,
|
||||
stepId: string,
|
||||
data: ApprovalStepUpdatePayload
|
||||
) {
|
||||
return apiClient<MutationSuccessResponse>(`${SETTINGS_BASE}/${workflowId}/steps/${stepId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteApprovalWorkflowStep(workflowId: string, stepId: string) {
|
||||
return apiClient<MutationSuccessResponse>(`${SETTINGS_BASE}/${workflowId}/steps/${stepId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
export async function reorderApprovalWorkflowSteps(
|
||||
workflowId: string,
|
||||
data: ApprovalStepReorderPayload
|
||||
) {
|
||||
return apiClient<MutationSuccessResponse>(`${SETTINGS_BASE}/${workflowId}/steps/reorder`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
export interface ApprovalWorkflowUsage {
|
||||
totalRequestCount: number;
|
||||
activeRequestCount: number;
|
||||
activeMatrixCount: number;
|
||||
isLocked: boolean;
|
||||
lockedReason: string | null;
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
canDeactivate: boolean;
|
||||
}
|
||||
|
||||
export interface ApprovalWorkflowRecord {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
code: string;
|
||||
name: string;
|
||||
entityType: string;
|
||||
description: string | null;
|
||||
isSystem: boolean;
|
||||
isActive: boolean;
|
||||
createdBy: string;
|
||||
updatedBy: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
@@ -17,6 +32,7 @@ export interface ApprovalStepRecord {
|
||||
stepNumber: number;
|
||||
roleCode: string;
|
||||
roleName: string;
|
||||
approvalMode: 'sequential' | 'any_one' | 'all_required';
|
||||
isRequired: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -25,10 +41,12 @@ export interface ApprovalStepRecord {
|
||||
|
||||
export interface ApprovalWorkflowListItem extends ApprovalWorkflowRecord {
|
||||
stepCount: number;
|
||||
usage: ApprovalWorkflowUsage;
|
||||
}
|
||||
|
||||
export interface ApprovalWorkflowDetail extends ApprovalWorkflowRecord {
|
||||
steps: ApprovalStepRecord[];
|
||||
usage: ApprovalWorkflowUsage;
|
||||
}
|
||||
|
||||
export interface ApprovalRequestRecord {
|
||||
@@ -95,6 +113,11 @@ export interface ApprovalFilters {
|
||||
sort?: string;
|
||||
}
|
||||
|
||||
export interface ApprovalWorkflowFilters {
|
||||
entityType?: string;
|
||||
activeOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface SubmitApprovalPayload {
|
||||
workflowId?: string;
|
||||
workflowCode?: string;
|
||||
@@ -111,6 +134,7 @@ export interface ApprovalWorkflowMutationPayload {
|
||||
code: string;
|
||||
name: string;
|
||||
entityType: string;
|
||||
description?: string | null;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
@@ -118,17 +142,27 @@ export interface ApprovalStepMutationPayload {
|
||||
stepNumber: number;
|
||||
roleCode: string;
|
||||
roleName: string;
|
||||
approvalMode: 'sequential' | 'any_one' | 'all_required';
|
||||
isRequired?: boolean;
|
||||
}
|
||||
|
||||
export interface ApprovalStepUpdatePayload {
|
||||
roleCode?: string;
|
||||
roleName?: string;
|
||||
approvalMode?: 'sequential' | 'any_one' | 'all_required';
|
||||
isRequired?: boolean;
|
||||
}
|
||||
|
||||
export interface ApprovalStepReorderPayload {
|
||||
orderedStepIds: string[];
|
||||
}
|
||||
|
||||
export interface ApprovalListResponse {
|
||||
success: boolean;
|
||||
time: string;
|
||||
message: string;
|
||||
totalItems: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
items: ApprovalListItem[];
|
||||
totalItems: number;
|
||||
}
|
||||
|
||||
export interface ApprovalDetailResponse {
|
||||
@@ -152,6 +186,13 @@ export interface ApprovalWorkflowDetailResponse {
|
||||
workflow: ApprovalWorkflowDetail;
|
||||
}
|
||||
|
||||
export interface ApprovalWorkflowStepListResponse {
|
||||
success: boolean;
|
||||
time: string;
|
||||
message: string;
|
||||
items: ApprovalStepRecord[];
|
||||
}
|
||||
|
||||
export interface MutationSuccessResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
|
||||
Reference in New Issue
Block a user