+ )}
{isLoading && allOptions.length === 0 ? (
diff --git a/src/components/shared/WorkflowDefinitionModal.tsx b/src/components/shared/WorkflowDefinitionModal.tsx
index 44fd47c..6c7a310 100644
--- a/src/components/shared/WorkflowDefinitionModal.tsx
+++ b/src/components/shared/WorkflowDefinitionModal.tsx
@@ -30,8 +30,8 @@ const stepSchema = z
sequence: z.number().int().min(1),
step_type: z.enum(["initial", "task", "approval", "terminal"]),
assignee_type: z.enum(["role", "user", "originator"]).optional(),
- assignee_role: z.union([z.string(), z.array(z.string())]).optional(),
- assignee_id: z.string().optional(),
+ assignee_role_ids: z.array(z.string().uuid()).optional(),
+ assignee_user_ids: z.array(z.string().uuid()).optional(),
available_actions: z.array(z.string()).optional(),
requires_signature: z.boolean().default(false),
requires_comment: z.boolean().default(false),
@@ -74,22 +74,19 @@ const stepSchema = z
// Conditional validation based on assignee type
if (data.assignee_type === "role") {
- if (
- !data.assignee_role ||
- (Array.isArray(data.assignee_role) && data.assignee_role.length === 0)
- ) {
+ if (!data.assignee_role_ids || data.assignee_role_ids.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "At least one role is required",
- path: ["assignee_role"],
+ path: ["assignee_role_ids"],
});
}
} else if (data.assignee_type === "user") {
- if (!data.assignee_id) {
+ if (!data.assignee_user_ids || data.assignee_user_ids.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
- message: "User selection is required",
- path: ["assignee_id"],
+ message: "At least one user is required",
+ path: ["assignee_user_ids"],
});
}
}
@@ -136,6 +133,18 @@ const workflowSchema = z
const hasTerminalStep = data.steps.some(
(step: any) => step.step_type === "terminal",
);
+ const hasInitialStep = data.steps.some(
+ (step: any) => step.step_type === "initial",
+ );
+
+ if (!hasInitialStep) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Workflow must have exactly one initial step",
+ path: ["steps"],
+ });
+ }
+
if (!hasTerminalStep) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
@@ -143,6 +152,35 @@ const workflowSchema = z
path: ["steps"],
});
}
+
+ // Connectivity Validation
+ if (hasInitialStep && hasTerminalStep && data.transitions) {
+ const transitionFromCodes = new Set(data.transitions.map(t => t.from_step_code));
+ const transitionToCodes = new Set(data.transitions.map(t => t.to_step_code));
+
+ data.steps.forEach((step: any, index: number) => {
+ const isInitial = step.step_type === 'initial';
+ const isTerminal = step.step_type === 'terminal';
+
+ // 1. Every non-terminal step must have an outgoing transition
+ if (!isTerminal && !transitionFromCodes.has(step.step_code)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `Step '${step.name}' must have at least one outgoing transition`,
+ path: ["steps", index, "step_code"],
+ });
+ }
+
+ // 2. Every non-initial step must have an incoming transition (be reachable)
+ if (!isInitial && !transitionToCodes.has(step.step_code)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `Step '${step.name}' is unreachable (no transitions lead to it)`,
+ path: ["steps", index, "step_code"],
+ });
+ }
+ });
+ }
}
}
});
@@ -163,7 +201,7 @@ const StepAssigneeFields = ({
errors,
isEdit,
loadRoles,
- userOptions,
+ loadUsers,
}: any) => {
const assigneeType = useWatch({
control,
@@ -173,22 +211,18 @@ const StepAssigneeFields = ({
if (assigneeType === "role") {
return (
(
)}
@@ -199,16 +233,18 @@ const StepAssigneeFields = ({
if (assigneeType === "user") {
return (
(
-
)}
@@ -233,9 +269,6 @@ export const WorkflowDefinitionModal = ({
const [isSubmitting, setIsSubmitting] = useState(false);
const isEdit = !!definition;
- const [userOptions, setUserOptions] = useState<
- { value: string; label: string }[]
- >([]);
const [availableModules, setAvailableModules] = useState([]);
const {
@@ -243,6 +276,7 @@ export const WorkflowDefinitionModal = ({
control,
handleSubmit,
setValue,
+ setError,
formState: { errors },
reset,
} = useForm({
@@ -296,24 +330,6 @@ export const WorkflowDefinitionModal = ({
useEffect(() => {
if (isOpen) {
- const fetchInitialData = async () => {
- try {
- const response = tenantId
- ? await userService.getByTenant(tenantId, 1, 1000)
- : await userService.getAll(1, 1000);
- if (response.success) {
- setUserOptions(
- response.data.map((u: any) => ({
- value: u.id,
- label: `${u.first_name} ${u.last_name} (${u.email})`,
- })),
- );
- }
- } catch (err) {
- console.error("Failed to fetch users:", err);
- }
- };
-
const fetchModules = async () => {
try {
const response = await moduleService.getMyModules(tenantId);
@@ -325,7 +341,6 @@ export const WorkflowDefinitionModal = ({
}
};
- fetchInitialData();
fetchModules();
if (definition) {
@@ -343,8 +358,8 @@ export const WorkflowDefinitionModal = ({
sequence: s.sequence,
step_type: s.step_type,
assignee_type: s.assignee.type,
- assignee_role: s.assignee.role ?? undefined,
- assignee_id: s.assignee.id ?? undefined,
+ assignee_role_ids: s.assignee.role_ids ?? undefined,
+ assignee_user_ids: s.assignee.user_ids ?? s.assignee.ids ?? undefined,
available_actions: s.available_actions || [],
requires_signature: s.requires_signature || false,
requires_comment: s.requires_comment || false,
@@ -411,8 +426,6 @@ export const WorkflowDefinitionModal = ({
if (step.step_type === "terminal") {
const {
assignee_type,
- assignee_role,
- assignee_id,
available_actions,
...rest
} = step;
@@ -423,15 +436,15 @@ export const WorkflowDefinitionModal = ({
// Clean up assignee fields based on assignee_type
if (step.assignee_type === "originator") {
- delete cleanedStep.assignee_role;
- delete cleanedStep.assignee_id;
+ delete cleanedStep.assignee_role_ids;
+ delete cleanedStep.assignee_user_ids;
} else if (step.assignee_type === "role") {
- delete cleanedStep.assignee_id;
+ delete cleanedStep.assignee_user_ids;
} else if (step.assignee_type === "user") {
- delete cleanedStep.assignee_role;
- // Ensure ID is not an empty string which fails UUID validation
- if (cleanedStep.assignee_id === "") {
- delete cleanedStep.assignee_id;
+ delete cleanedStep.assignee_role_ids;
+ // Ensure IDs are not empty
+ if (!cleanedStep.assignee_user_ids || cleanedStep.assignee_user_ids.length === 0) {
+ delete cleanedStep.assignee_user_ids;
}
}
@@ -479,10 +492,42 @@ export const WorkflowDefinitionModal = ({
}
}
} catch (err: any) {
- showToast.error(
- err?.response?.data?.error?.message ||
- `Failed to ${isEdit ? "update" : "create"} workflow`,
- );
+ const backendErrors = err?.response?.data?.details;
+ if (Array.isArray(backendErrors)) {
+ backendErrors.forEach((detail: any) => {
+ const path = detail.path;
+ // Map backend path (e.g., "steps.2") to frontend field (e.g., "steps.2.assignee_user_ids")
+ // if it's a generic step error, or just use the path if it's already specific.
+
+ if (path.startsWith('steps.') && !path.includes('.', 6)) {
+ // It's a generic step error like "steps.2"
+ // We'll attach it to the assignee fields so it's visible
+ const index = path.split('.')[1];
+ const assigneeType = watchedSteps[parseInt(index)]?.assignee_type;
+ const fieldName = assigneeType === 'role' ? 'assignee_role_ids' : 'assignee_user_ids';
+ setError(`${path}.${fieldName}`, {
+ type: 'manual',
+ message: detail.message
+ });
+ setActiveTab('steps');
+ } else {
+ setError(path, {
+ type: 'manual',
+ message: detail.message
+ });
+
+ // Switch to relevant tab
+ if (path.startsWith('steps')) setActiveTab('steps');
+ if (path.startsWith('transitions')) setActiveTab('transitions');
+ }
+ });
+ showToast.error("Please fix the validation errors");
+ } else {
+ showToast.error(
+ err?.response?.data?.error?.message ||
+ `Failed to ${isEdit ? "update" : "create"} workflow`,
+ );
+ }
} finally {
setIsSubmitting(false);
}
@@ -517,16 +562,16 @@ export const WorkflowDefinitionModal = ({
};
};
- const loadRoles = async (page: number, limit: number) => {
+ const loadRoles = async (page: number, limit: number, search?: string) => {
const response = tenantId
- ? await roleService.getByTenant(tenantId, page, limit)
- : await roleService.getAll(page, limit);
+ ? await roleService.getByTenant(tenantId, page, limit, null, search)
+ : await roleService.getAll(page, limit, null, search);
if (response.success) {
return {
options: response.data.map((r: any) => ({
- value: r.code,
- label: r.name,
+ value: r.id,
+ label: `${r.name} (${r.code})`,
})),
pagination: response.pagination,
};
@@ -537,6 +582,38 @@ export const WorkflowDefinitionModal = ({
};
};
+ const loadUsers = async (_page: number, _limit: number, search?: string) => {
+ try {
+ const response = await userService.getDropdown({
+ search
+ });
+
+ if (response.success) {
+ const options = response.data.map((u: any) => ({
+ value: u.id,
+ label: `${u.name} (${u.email})`
+ }));
+
+ return {
+ options,
+ pagination: {
+ page: 1,
+ limit: options.length,
+ total: options.length,
+ totalPages: 1,
+ hasMore: false
+ }
+ };
+ }
+ } catch (err) {
+ console.error("Failed to fetch users for dropdown:", err);
+ }
+ return {
+ options: [],
+ pagination: { page: 1, limit, total: 0, totalPages: 0, hasMore: false }
+ };
+ };
+
const tabClasses = (tab: typeof activeTab) =>
cn(
"px-6 py-3 text-sm font-medium border-b-2 transition-colors focus:outline-none",
@@ -812,8 +889,8 @@ export const WorkflowDefinitionModal = ({
// When switching TO terminal, clear all hidden fields
if (val === "terminal") {
setValue(`steps.${index}.assignee_type`, undefined);
- setValue(`steps.${index}.assignee_role`, []);
- setValue(`steps.${index}.assignee_id`, "");
+ setValue(`steps.${index}.assignee_role_ids`, []);
+ setValue(`steps.${index}.assignee_user_ids`, []);
setValue(`steps.${index}.available_actions`, []);
}
}}
@@ -840,8 +917,8 @@ export const WorkflowDefinitionModal = ({
value={field.value}
onValueChange={(val) => {
field.onChange(val);
- setValue(`steps.${index}.assignee_role`, []);
- setValue(`steps.${index}.assignee_id`, "");
+ setValue(`steps.${index}.assignee_role_ids`, []);
+ setValue(`steps.${index}.assignee_user_ids`, []);
}}
error={
(errors.steps as any)?.[index]?.assignee_type
@@ -860,7 +937,7 @@ export const WorkflowDefinitionModal = ({
errors={errors}
isEdit={isEdit}
loadRoles={loadRoles}
- userOptions={userOptions}
+ loadUsers={loadUsers}
/>
)}
diff --git a/src/pages/tenant/Dashboard.tsx b/src/pages/tenant/Dashboard.tsx
index 6e5295e..042d8c0 100644
--- a/src/pages/tenant/Dashboard.tsx
+++ b/src/pages/tenant/Dashboard.tsx
@@ -76,11 +76,7 @@ const TaskCard = ({ task }: { task: WorkflowTask }) => {
/>
{task.step.name} •{" "}
- {(() => {
- const role = task.assignment?.assigned_role;
- if (role) return Array.isArray(role) ? role.join(", ") : role;
- return task.assignment?.assigned_to_name || "Unassigned";
- })()}
+ {task.assignment?.assigned_to_name || (task.assignment?.assigned_role_ids?.length ? `${task.assignment.assigned_role_ids.length} roles` : "Unassigned")}
diff --git a/src/pages/tenant/Tasks.tsx b/src/pages/tenant/Tasks.tsx
index 5eb1345..26e64da 100644
--- a/src/pages/tenant/Tasks.tsx
+++ b/src/pages/tenant/Tasks.tsx
@@ -136,13 +136,14 @@ const Tasks = (): ReactElement => {
),
},
{
- key: "assigned_role",
+ key: "assignment",
label: "Assigned To",
render: (task) => {
- const role = task.assignment.assigned_role;
+ const user = task.assignment.assigned_to_name;
+ const roleIds = task.assignment.assigned_role_ids;
return (
);
},
diff --git a/src/pages/tenant/ViewDocument.tsx b/src/pages/tenant/ViewDocument.tsx
index 986c732..4635ca7 100644
--- a/src/pages/tenant/ViewDocument.tsx
+++ b/src/pages/tenant/ViewDocument.tsx
@@ -1235,6 +1235,7 @@ const ViewDocument = (): ReactElement => {
value={workflowDefinitionId}
onValueChange={setWorkflowDefinitionId}
placeholder="Select active workflow definition"
+ isSearchable
/>
{/*
@@ -1558,7 +1559,7 @@ const ViewDocument = (): ReactElement => {
- {task.assigned_to}
+ {task.assigned_to_name || "-"}
|
diff --git a/src/services/user-service.ts b/src/services/user-service.ts
index 14e9461..9ade88c 100644
--- a/src/services/user-service.ts
+++ b/src/services/user-service.ts
@@ -75,6 +75,15 @@ export const userService = {
const response = await apiClient.put(`/users/${id}`, data);
return response.data;
},
+ getDropdown: async (
+ params: { search?: string }
+ ) => {
+ const query = new URLSearchParams();
+ if (params.search) query.append('search', params.search);
+
+ const response = await apiClient.get(`/tenants/current/users/dropdown?${query.toString()}`);
+ return response.data;
+ },
// delete: async (id: string): Promise => {
// const response = await apiClient.delete(`/users/${id}`);
// return response.data;
diff --git a/src/types/workflow.ts b/src/types/workflow.ts
index 205d5b9..47d7f98 100644
--- a/src/types/workflow.ts
+++ b/src/types/workflow.ts
@@ -8,7 +8,10 @@ export interface WorkflowStep {
assignee: {
type: 'role' | 'user' | 'originator';
id?: string | null;
+ ids?: string[] | null;
+ user_ids?: string[] | null;
role?: string[] | string | null;
+ role_ids?: string[] | null;
};
available_actions: string[];
requires_signature?: boolean;
@@ -120,7 +123,9 @@ export interface WorkflowInstance {
tasks: Array<{
id: string;
step: string;
- assigned_to: string;
+ assigned_user_ids?: string[] | null;
+ assigned_to_name?: string | null;
+ assigned_role_ids?: string[] | null;
status: string;
action_taken: string | null;
completed_at: string | null;
@@ -172,9 +177,9 @@ export interface WorkflowTask {
name: string;
};
assignment: {
- assigned_to: string | null;
+ assigned_user_ids?: string[] | null;
assigned_to_name: string | null;
- assigned_role: string | string[];
+ assigned_role_ids?: string[] | null;
assigned_at: string;
};
status: string;
|