feat: add search functionality to FormSelect and MultiselectPaginatedSelect, and update task assignment display logic

This commit is contained in:
Yashwin 2026-05-14 12:47:52 +05:30
parent c516ea18bc
commit ec10281af9
9 changed files with 264 additions and 110 deletions

View File

@ -33,9 +33,9 @@ export const Layout = ({
};
return (
<div className="relative w-full h-screen overflow-hidden bg-[#f6f9ff]">
<div className="relative w-full h-screen overflow-hidden bg-[#F9F9F9]">
{/* Background */}
<div className="absolute top-0 left-[-80px] w-full md:left-[-80px] md:w-[1440px] h-full bg-[#f6f9ff] z-0" />
<div className="absolute top-0 left-[-80px] w-full md:left-[-80px] md:w-[1440px] h-full bg-[#F9F9F9] z-0" />
{/* Content Wrapper */}
<div className="absolute inset-0 flex gap-0 md:gap-2 lg:gap-3 p-0 md:p-2 lg:p-3 max-w-full md:max-w-full lg:max-w-[1280px] xl:max-w-none h-full mx-auto lg:mx-auto xl:mx-0 z-10">

View File

@ -17,6 +17,7 @@ interface FormSelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>,
options: SelectOption[];
placeholder?: string;
onValueChange?: (value: string) => void;
isSearchable?: boolean;
}
export const FormSelect = ({
@ -30,9 +31,11 @@ export const FormSelect = ({
id,
value,
onValueChange,
isSearchable = false,
...props
}: FormSelectProps): ReactElement => {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [searchTerm, setSearchTerm] = useState<string>('');
const [selectedValue, setSelectedValue] = useState<string>(value as string || '');
const [dropdownStyle, setDropdownStyle] = useState<{ top?: string; bottom?: string; left: string; width: string }>({ left: '0', width: '0' });
const dropdownRef = useRef<HTMLDivElement>(null);
@ -60,6 +63,10 @@ export const FormSelect = ({
setIsOpen(false);
};
if (!isOpen) {
setSearchTerm('');
}
if (isOpen && buttonRef.current) {
document.addEventListener('mousedown', handleClickOutside);
window.addEventListener('scroll', handleScroll, true);
@ -109,6 +116,11 @@ export const FormSelect = ({
const fieldId = id || `select-${label.toLowerCase().replace(/\s+/g, '-')}`;
const hasError = Boolean(error);
const filteredOptions = options.filter(opt =>
opt.label.toLowerCase().includes(searchTerm.toLowerCase())
);
const selectedOption = options.find((opt) => opt.value === selectedValue);
const handleSelect = (optionValue: string) => {
@ -161,21 +173,39 @@ export const FormSelect = ({
className="fixed bg-white border border-[rgba(0,0,0,0.2)] rounded-md shadow-lg z-[250] max-h-60 overflow-y-auto"
style={dropdownStyle}
>
{isSearchable && (
<div className="p-2 border-b border-[rgba(0,0,0,0.05)] sticky top-0 bg-white z-10">
<input
type="text"
className="w-full px-3 py-1.5 text-sm border border-[rgba(0,0,0,0.1)] rounded-md focus:outline-none focus:border-[#112868]"
placeholder="Search..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onClick={(e) => e.stopPropagation()}
/>
</div>
)}
<ul role="listbox" className="py-1.5">
{options.map((option) => (
<li key={option.value} role="option" aria-selected={selectedValue === option.value}>
<button
type="button"
onClick={() => handleSelect(option.value)}
className={cn(
'w-full px-3 py-2 text-left text-sm text-[#0e1b2a] hover:bg-gray-50 transition-colors',
selectedValue === option.value && 'bg-gray-50'
)}
>
{option.label}
</button>
{filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<li key={option.value} role="option" aria-selected={selectedValue === option.value}>
<button
type="button"
onClick={() => handleSelect(option.value)}
className={cn(
'w-full px-3 py-2 text-left text-sm text-[#0e1b2a] hover:bg-gray-50 transition-colors',
selectedValue === option.value && 'bg-gray-50'
)}
>
{option.label}
</button>
</li>
))
) : (
<li className="px-3 py-4 text-center text-sm text-[#9aa6b2]">
No results found
</li>
))}
)}
</ul>
</div>,
document.body

View File

@ -1,7 +1,7 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import type { ReactElement } from "react";
import { ChevronDown, Loader2, X } from "lucide-react";
import { ChevronDown, Loader2, X, Search } from "lucide-react";
import { cn } from "@/lib/utils";
interface MultiselectPaginatedSelectOption {
@ -20,6 +20,7 @@ interface MultiselectPaginatedSelectProps {
onLoadOptions: (
page: number,
limit: number,
search?: string,
) => Promise<{
options: MultiselectPaginatedSelectOption[];
pagination: {
@ -35,6 +36,7 @@ interface MultiselectPaginatedSelectProps {
id?: string;
multiple?: boolean;
disabled?: boolean;
isSearchable?: boolean;
}
export const MultiselectPaginatedSelect = ({
@ -51,6 +53,7 @@ export const MultiselectPaginatedSelect = ({
id,
multiple = true,
disabled = false,
isSearchable = false,
}: MultiselectPaginatedSelectProps): ReactElement => {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [options, setOptions] = useState<MultiselectPaginatedSelectOption[]>(
@ -58,6 +61,7 @@ export const MultiselectPaginatedSelect = ({
);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isLoadingMore, setIsLoadingMore] = useState<boolean>(false);
const [searchTerm, setSearchTerm] = useState<string>("");
const [pagination, setPagination] = useState<{
page: number;
limit: number;
@ -76,6 +80,7 @@ export const MultiselectPaginatedSelect = ({
const buttonRef = useRef<HTMLButtonElement>(null);
const dropdownMenuRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLUListElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const [dropdownStyle, setDropdownStyle] = useState<{
top?: string;
bottom?: string;
@ -85,7 +90,7 @@ export const MultiselectPaginatedSelect = ({
// Load initial options
const loadOptions = useCallback(
async (page: number = 1, append: boolean = false) => {
async (page: number = 1, append: boolean = false, search: string = "") => {
try {
if (page === 1) {
setIsLoading(true);
@ -93,7 +98,7 @@ export const MultiselectPaginatedSelect = ({
setIsLoadingMore(true);
}
const result = await onLoadOptions(page, pagination.limit);
const result = await onLoadOptions(page, pagination.limit, search);
if (append) {
setOptions((prev) => [...prev, ...result.options]);
@ -116,10 +121,25 @@ export const MultiselectPaginatedSelect = ({
useEffect(() => {
if (isOpen) {
if (options.length === 0) {
loadOptions(1, false);
loadOptions(1, false, searchTerm);
}
// Focus search input if searchable
if (isSearchable) {
setTimeout(() => searchInputRef.current?.focus(), 0);
}
}
}, [isOpen, options.length, loadOptions]);
}, [isOpen, options.length, loadOptions, isSearchable]);
// Debounced search
useEffect(() => {
if (!isOpen || !isSearchable) return;
const timer = setTimeout(() => {
loadOptions(1, false, searchTerm);
}, 300);
return () => clearTimeout(timer);
}, [searchTerm, isOpen, isSearchable, loadOptions]);
// Handle scroll for infinite loading
useEffect(() => {
@ -131,7 +151,7 @@ export const MultiselectPaginatedSelect = ({
const isNearBottom = scrollTop + clientHeight >= scrollHeight - 50;
if (isNearBottom && pagination.hasMore && !isLoadingMore && !isLoading) {
loadOptions(pagination.page + 1, true);
loadOptions(pagination.page + 1, true, searchTerm);
}
};
@ -139,7 +159,7 @@ export const MultiselectPaginatedSelect = ({
return () => {
scrollContainer.removeEventListener("scroll", handleScroll);
};
}, [isOpen, pagination, isLoadingMore, isLoading, loadOptions]);
}, [isOpen, pagination, isLoadingMore, isLoading, loadOptions, searchTerm]);
// Handle click outside
useEffect(() => {
@ -183,7 +203,7 @@ export const MultiselectPaginatedSelect = ({
const rect = buttonRef.current.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
const dropdownHeight = Math.min(240, 240);
const dropdownHeight = isSearchable ? 300 : 240;
const shouldOpenUp =
spaceBelow < dropdownHeight && spaceAbove > spaceBelow;
@ -207,7 +227,7 @@ export const MultiselectPaginatedSelect = ({
document.removeEventListener("mousedown", handleClickOutside);
window.removeEventListener("scroll", handleScroll, true);
};
}, [isOpen]);
}, [isOpen, isSearchable]);
const fieldId =
id || `multiselect-${label.toLowerCase().replace(/\s+/g, "-")}`;
@ -314,10 +334,25 @@ export const MultiselectPaginatedSelect = ({
createPortal(
<div
ref={dropdownMenuRef}
className="fixed bg-white border border-[rgba(0,0,0,0.2)] rounded-md shadow-lg z-[250] max-h-60 overflow-hidden flex flex-col"
className="fixed bg-white border border-[rgba(0,0,0,0.2)] rounded-md shadow-lg z-[250] max-h-80 overflow-hidden flex flex-col"
style={dropdownStyle}
data-dropdown-menu="true"
>
{isSearchable && (
<div className="px-3 pt-3 pb-2 border-b border-gray-100">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400" />
<input
ref={searchInputRef}
type="text"
className="w-full pl-8 pr-3 py-1.5 text-sm bg-gray-50 border border-gray-200 rounded focus:outline-none focus:border-[#112868] transition-colors"
placeholder="Search..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
)}
{isLoading && allOptions.length === 0 ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-5 h-5 text-[#112868] animate-spin" />

View File

@ -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 (
<Controller
name={`steps.${index}.assignee_role` as const}
name={`steps.${index}.assignee_role_ids` as const}
control={control}
render={({ field }) => (
<MultiselectPaginatedSelect
key={`role-select-${index}`}
label="Select Role(s)"
required
value={
Array.isArray(field.value)
? field.value
: field.value
? [field.value]
: []
}
isSearchable
value={field.value || []}
onValueChange={field.onChange}
onLoadOptions={loadRoles}
error={(errors.steps as any)?.[index]?.assignee_role?.message}
error={(errors.steps as any)?.[index]?.assignee_role_ids?.message}
disabled={isEdit}
/>
)}
@ -199,16 +233,18 @@ const StepAssigneeFields = ({
if (assigneeType === "user") {
return (
<Controller
name={`steps.${index}.assignee_id` as const}
name={`steps.${index}.assignee_user_ids` as const}
control={control}
render={({ field }) => (
<FormSelect
label="Select User"
<MultiselectPaginatedSelect
key={`user-select-${index}`}
label="Select User(s)"
required
options={userOptions}
value={field.value}
isSearchable
value={field.value || []}
onValueChange={field.onChange}
error={(errors.steps as any)?.[index]?.assignee_id?.message}
onLoadOptions={loadUsers}
error={(errors.steps as any)?.[index]?.assignee_user_ids?.message}
disabled={isEdit}
/>
)}
@ -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<any[]>([]);
const {
@ -243,6 +276,7 @@ export const WorkflowDefinitionModal = ({
control,
handleSubmit,
setValue,
setError,
formState: { errors },
reset,
} = useForm<any>({
@ -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}
/>
)}

View File

@ -76,11 +76,7 @@ const TaskCard = ({ task }: { task: WorkflowTask }) => {
/>
<span className="text-[12px] text-[#64748B] font-medium leading-[16px] truncate">
{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")}
</span>
</div>

View File

@ -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 (
<span className="text-gray-600">
{Array.isArray(role) ? role.join(", ") : (role || "-")}
{user || (roleIds && roleIds.length > 0 ? `${roleIds.length} roles` : "-")}
</span>
);
},

View File

@ -1235,6 +1235,7 @@ const ViewDocument = (): ReactElement => {
value={workflowDefinitionId}
onValueChange={setWorkflowDefinitionId}
placeholder="Select active workflow definition"
isSearchable
/>
{/* <div className="flex flex-col gap-1.5 px-0.5">
<div className="flex items-center gap-2">
@ -1558,7 +1559,7 @@ const ViewDocument = (): ReactElement => {
<td className="px-4 py-3 whitespace-nowrap">
<div className="flex items-center gap-1.5 text-xs text-gray-600">
<User className="w-3 h-3 text-gray-400" />
{task.assigned_to}
{task.assigned_to_name || "-"}
</div>
</td>
<td className="px-4 py-3 whitespace-nowrap">

View File

@ -75,6 +75,15 @@ export const userService = {
const response = await apiClient.put<UpdateUserResponse>(`/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<DeleteUserResponse> => {
// const response = await apiClient.delete<DeleteUserResponse>(`/users/${id}`);
// return response.data;

View File

@ -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;