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 ( return (
<div className="relative w-full h-screen overflow-hidden bg-[#f6f9ff]"> <div className="relative w-full h-screen overflow-hidden bg-[#F9F9F9]">
{/* Background */} {/* 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 */} {/* 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"> <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[]; options: SelectOption[];
placeholder?: string; placeholder?: string;
onValueChange?: (value: string) => void; onValueChange?: (value: string) => void;
isSearchable?: boolean;
} }
export const FormSelect = ({ export const FormSelect = ({
@ -30,9 +31,11 @@ export const FormSelect = ({
id, id,
value, value,
onValueChange, onValueChange,
isSearchable = false,
...props ...props
}: FormSelectProps): ReactElement => { }: FormSelectProps): ReactElement => {
const [isOpen, setIsOpen] = useState<boolean>(false); const [isOpen, setIsOpen] = useState<boolean>(false);
const [searchTerm, setSearchTerm] = useState<string>('');
const [selectedValue, setSelectedValue] = useState<string>(value as 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 [dropdownStyle, setDropdownStyle] = useState<{ top?: string; bottom?: string; left: string; width: string }>({ left: '0', width: '0' });
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
@ -60,6 +63,10 @@ export const FormSelect = ({
setIsOpen(false); setIsOpen(false);
}; };
if (!isOpen) {
setSearchTerm('');
}
if (isOpen && buttonRef.current) { if (isOpen && buttonRef.current) {
document.addEventListener('mousedown', handleClickOutside); document.addEventListener('mousedown', handleClickOutside);
window.addEventListener('scroll', handleScroll, true); window.addEventListener('scroll', handleScroll, true);
@ -109,6 +116,11 @@ export const FormSelect = ({
const fieldId = id || `select-${label.toLowerCase().replace(/\s+/g, '-')}`; const fieldId = id || `select-${label.toLowerCase().replace(/\s+/g, '-')}`;
const hasError = Boolean(error); const hasError = Boolean(error);
const filteredOptions = options.filter(opt =>
opt.label.toLowerCase().includes(searchTerm.toLowerCase())
);
const selectedOption = options.find((opt) => opt.value === selectedValue); const selectedOption = options.find((opt) => opt.value === selectedValue);
const handleSelect = (optionValue: string) => { 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" 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} 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"> <ul role="listbox" className="py-1.5">
{options.map((option) => ( {filteredOptions.length > 0 ? (
<li key={option.value} role="option" aria-selected={selectedValue === option.value}> filteredOptions.map((option) => (
<button <li key={option.value} role="option" aria-selected={selectedValue === option.value}>
type="button" <button
onClick={() => handleSelect(option.value)} type="button"
className={cn( onClick={() => handleSelect(option.value)}
'w-full px-3 py-2 text-left text-sm text-[#0e1b2a] hover:bg-gray-50 transition-colors', className={cn(
selectedValue === option.value && 'bg-gray-50' '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> {option.label}
</button>
</li>
))
) : (
<li className="px-3 py-4 text-center text-sm text-[#9aa6b2]">
No results found
</li> </li>
))} )}
</ul> </ul>
</div>, </div>,
document.body document.body

View File

@ -1,7 +1,7 @@
import { useState, useRef, useEffect, useCallback } from "react"; import { useState, useRef, useEffect, useCallback } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import type { ReactElement } from "react"; 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"; import { cn } from "@/lib/utils";
interface MultiselectPaginatedSelectOption { interface MultiselectPaginatedSelectOption {
@ -20,6 +20,7 @@ interface MultiselectPaginatedSelectProps {
onLoadOptions: ( onLoadOptions: (
page: number, page: number,
limit: number, limit: number,
search?: string,
) => Promise<{ ) => Promise<{
options: MultiselectPaginatedSelectOption[]; options: MultiselectPaginatedSelectOption[];
pagination: { pagination: {
@ -35,6 +36,7 @@ interface MultiselectPaginatedSelectProps {
id?: string; id?: string;
multiple?: boolean; multiple?: boolean;
disabled?: boolean; disabled?: boolean;
isSearchable?: boolean;
} }
export const MultiselectPaginatedSelect = ({ export const MultiselectPaginatedSelect = ({
@ -51,6 +53,7 @@ export const MultiselectPaginatedSelect = ({
id, id,
multiple = true, multiple = true,
disabled = false, disabled = false,
isSearchable = false,
}: MultiselectPaginatedSelectProps): ReactElement => { }: MultiselectPaginatedSelectProps): ReactElement => {
const [isOpen, setIsOpen] = useState<boolean>(false); const [isOpen, setIsOpen] = useState<boolean>(false);
const [options, setOptions] = useState<MultiselectPaginatedSelectOption[]>( const [options, setOptions] = useState<MultiselectPaginatedSelectOption[]>(
@ -58,6 +61,7 @@ export const MultiselectPaginatedSelect = ({
); );
const [isLoading, setIsLoading] = useState<boolean>(false); const [isLoading, setIsLoading] = useState<boolean>(false);
const [isLoadingMore, setIsLoadingMore] = useState<boolean>(false); const [isLoadingMore, setIsLoadingMore] = useState<boolean>(false);
const [searchTerm, setSearchTerm] = useState<string>("");
const [pagination, setPagination] = useState<{ const [pagination, setPagination] = useState<{
page: number; page: number;
limit: number; limit: number;
@ -76,6 +80,7 @@ export const MultiselectPaginatedSelect = ({
const buttonRef = useRef<HTMLButtonElement>(null); const buttonRef = useRef<HTMLButtonElement>(null);
const dropdownMenuRef = useRef<HTMLDivElement>(null); const dropdownMenuRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLUListElement>(null); const scrollContainerRef = useRef<HTMLUListElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const [dropdownStyle, setDropdownStyle] = useState<{ const [dropdownStyle, setDropdownStyle] = useState<{
top?: string; top?: string;
bottom?: string; bottom?: string;
@ -85,7 +90,7 @@ export const MultiselectPaginatedSelect = ({
// Load initial options // Load initial options
const loadOptions = useCallback( const loadOptions = useCallback(
async (page: number = 1, append: boolean = false) => { async (page: number = 1, append: boolean = false, search: string = "") => {
try { try {
if (page === 1) { if (page === 1) {
setIsLoading(true); setIsLoading(true);
@ -93,7 +98,7 @@ export const MultiselectPaginatedSelect = ({
setIsLoadingMore(true); setIsLoadingMore(true);
} }
const result = await onLoadOptions(page, pagination.limit); const result = await onLoadOptions(page, pagination.limit, search);
if (append) { if (append) {
setOptions((prev) => [...prev, ...result.options]); setOptions((prev) => [...prev, ...result.options]);
@ -116,10 +121,25 @@ export const MultiselectPaginatedSelect = ({
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
if (options.length === 0) { 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 // Handle scroll for infinite loading
useEffect(() => { useEffect(() => {
@ -131,7 +151,7 @@ export const MultiselectPaginatedSelect = ({
const isNearBottom = scrollTop + clientHeight >= scrollHeight - 50; const isNearBottom = scrollTop + clientHeight >= scrollHeight - 50;
if (isNearBottom && pagination.hasMore && !isLoadingMore && !isLoading) { 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 () => { return () => {
scrollContainer.removeEventListener("scroll", handleScroll); scrollContainer.removeEventListener("scroll", handleScroll);
}; };
}, [isOpen, pagination, isLoadingMore, isLoading, loadOptions]); }, [isOpen, pagination, isLoadingMore, isLoading, loadOptions, searchTerm]);
// Handle click outside // Handle click outside
useEffect(() => { useEffect(() => {
@ -183,7 +203,7 @@ export const MultiselectPaginatedSelect = ({
const rect = buttonRef.current.getBoundingClientRect(); const rect = buttonRef.current.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom; const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top; const spaceAbove = rect.top;
const dropdownHeight = Math.min(240, 240); const dropdownHeight = isSearchable ? 300 : 240;
const shouldOpenUp = const shouldOpenUp =
spaceBelow < dropdownHeight && spaceAbove > spaceBelow; spaceBelow < dropdownHeight && spaceAbove > spaceBelow;
@ -207,7 +227,7 @@ export const MultiselectPaginatedSelect = ({
document.removeEventListener("mousedown", handleClickOutside); document.removeEventListener("mousedown", handleClickOutside);
window.removeEventListener("scroll", handleScroll, true); window.removeEventListener("scroll", handleScroll, true);
}; };
}, [isOpen]); }, [isOpen, isSearchable]);
const fieldId = const fieldId =
id || `multiselect-${label.toLowerCase().replace(/\s+/g, "-")}`; id || `multiselect-${label.toLowerCase().replace(/\s+/g, "-")}`;
@ -314,10 +334,25 @@ export const MultiselectPaginatedSelect = ({
createPortal( createPortal(
<div <div
ref={dropdownMenuRef} 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} style={dropdownStyle}
data-dropdown-menu="true" 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 ? ( {isLoading && allOptions.length === 0 ? (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-8">
<Loader2 className="w-5 h-5 text-[#112868] animate-spin" /> <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), sequence: z.number().int().min(1),
step_type: z.enum(["initial", "task", "approval", "terminal"]), step_type: z.enum(["initial", "task", "approval", "terminal"]),
assignee_type: z.enum(["role", "user", "originator"]).optional(), assignee_type: z.enum(["role", "user", "originator"]).optional(),
assignee_role: z.union([z.string(), z.array(z.string())]).optional(), assignee_role_ids: z.array(z.string().uuid()).optional(),
assignee_id: z.string().optional(), assignee_user_ids: z.array(z.string().uuid()).optional(),
available_actions: z.array(z.string()).optional(), available_actions: z.array(z.string()).optional(),
requires_signature: z.boolean().default(false), requires_signature: z.boolean().default(false),
requires_comment: z.boolean().default(false), requires_comment: z.boolean().default(false),
@ -74,22 +74,19 @@ const stepSchema = z
// Conditional validation based on assignee type // Conditional validation based on assignee type
if (data.assignee_type === "role") { if (data.assignee_type === "role") {
if ( if (!data.assignee_role_ids || data.assignee_role_ids.length === 0) {
!data.assignee_role ||
(Array.isArray(data.assignee_role) && data.assignee_role.length === 0)
) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
message: "At least one role is required", message: "At least one role is required",
path: ["assignee_role"], path: ["assignee_role_ids"],
}); });
} }
} else if (data.assignee_type === "user") { } else if (data.assignee_type === "user") {
if (!data.assignee_id) { if (!data.assignee_user_ids || data.assignee_user_ids.length === 0) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
message: "User selection is required", message: "At least one user is required",
path: ["assignee_id"], path: ["assignee_user_ids"],
}); });
} }
} }
@ -136,6 +133,18 @@ const workflowSchema = z
const hasTerminalStep = data.steps.some( const hasTerminalStep = data.steps.some(
(step: any) => step.step_type === "terminal", (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) { if (!hasTerminalStep) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
@ -143,6 +152,35 @@ const workflowSchema = z
path: ["steps"], 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, errors,
isEdit, isEdit,
loadRoles, loadRoles,
userOptions, loadUsers,
}: any) => { }: any) => {
const assigneeType = useWatch({ const assigneeType = useWatch({
control, control,
@ -173,22 +211,18 @@ const StepAssigneeFields = ({
if (assigneeType === "role") { if (assigneeType === "role") {
return ( return (
<Controller <Controller
name={`steps.${index}.assignee_role` as const} name={`steps.${index}.assignee_role_ids` as const}
control={control} control={control}
render={({ field }) => ( render={({ field }) => (
<MultiselectPaginatedSelect <MultiselectPaginatedSelect
key={`role-select-${index}`}
label="Select Role(s)" label="Select Role(s)"
required required
value={ isSearchable
Array.isArray(field.value) value={field.value || []}
? field.value
: field.value
? [field.value]
: []
}
onValueChange={field.onChange} onValueChange={field.onChange}
onLoadOptions={loadRoles} onLoadOptions={loadRoles}
error={(errors.steps as any)?.[index]?.assignee_role?.message} error={(errors.steps as any)?.[index]?.assignee_role_ids?.message}
disabled={isEdit} disabled={isEdit}
/> />
)} )}
@ -199,16 +233,18 @@ const StepAssigneeFields = ({
if (assigneeType === "user") { if (assigneeType === "user") {
return ( return (
<Controller <Controller
name={`steps.${index}.assignee_id` as const} name={`steps.${index}.assignee_user_ids` as const}
control={control} control={control}
render={({ field }) => ( render={({ field }) => (
<FormSelect <MultiselectPaginatedSelect
label="Select User" key={`user-select-${index}`}
label="Select User(s)"
required required
options={userOptions} isSearchable
value={field.value} value={field.value || []}
onValueChange={field.onChange} 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} disabled={isEdit}
/> />
)} )}
@ -233,9 +269,6 @@ export const WorkflowDefinitionModal = ({
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const isEdit = !!definition; const isEdit = !!definition;
const [userOptions, setUserOptions] = useState<
{ value: string; label: string }[]
>([]);
const [availableModules, setAvailableModules] = useState<any[]>([]); const [availableModules, setAvailableModules] = useState<any[]>([]);
const { const {
@ -243,6 +276,7 @@ export const WorkflowDefinitionModal = ({
control, control,
handleSubmit, handleSubmit,
setValue, setValue,
setError,
formState: { errors }, formState: { errors },
reset, reset,
} = useForm<any>({ } = useForm<any>({
@ -296,24 +330,6 @@ export const WorkflowDefinitionModal = ({
useEffect(() => { useEffect(() => {
if (isOpen) { 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 () => { const fetchModules = async () => {
try { try {
const response = await moduleService.getMyModules(tenantId); const response = await moduleService.getMyModules(tenantId);
@ -325,7 +341,6 @@ export const WorkflowDefinitionModal = ({
} }
}; };
fetchInitialData();
fetchModules(); fetchModules();
if (definition) { if (definition) {
@ -343,8 +358,8 @@ export const WorkflowDefinitionModal = ({
sequence: s.sequence, sequence: s.sequence,
step_type: s.step_type, step_type: s.step_type,
assignee_type: s.assignee.type, assignee_type: s.assignee.type,
assignee_role: s.assignee.role ?? undefined, assignee_role_ids: s.assignee.role_ids ?? undefined,
assignee_id: s.assignee.id ?? undefined, assignee_user_ids: s.assignee.user_ids ?? s.assignee.ids ?? undefined,
available_actions: s.available_actions || [], available_actions: s.available_actions || [],
requires_signature: s.requires_signature || false, requires_signature: s.requires_signature || false,
requires_comment: s.requires_comment || false, requires_comment: s.requires_comment || false,
@ -411,8 +426,6 @@ export const WorkflowDefinitionModal = ({
if (step.step_type === "terminal") { if (step.step_type === "terminal") {
const { const {
assignee_type, assignee_type,
assignee_role,
assignee_id,
available_actions, available_actions,
...rest ...rest
} = step; } = step;
@ -423,15 +436,15 @@ export const WorkflowDefinitionModal = ({
// Clean up assignee fields based on assignee_type // Clean up assignee fields based on assignee_type
if (step.assignee_type === "originator") { if (step.assignee_type === "originator") {
delete cleanedStep.assignee_role; delete cleanedStep.assignee_role_ids;
delete cleanedStep.assignee_id; delete cleanedStep.assignee_user_ids;
} else if (step.assignee_type === "role") { } else if (step.assignee_type === "role") {
delete cleanedStep.assignee_id; delete cleanedStep.assignee_user_ids;
} else if (step.assignee_type === "user") { } else if (step.assignee_type === "user") {
delete cleanedStep.assignee_role; delete cleanedStep.assignee_role_ids;
// Ensure ID is not an empty string which fails UUID validation // Ensure IDs are not empty
if (cleanedStep.assignee_id === "") { if (!cleanedStep.assignee_user_ids || cleanedStep.assignee_user_ids.length === 0) {
delete cleanedStep.assignee_id; delete cleanedStep.assignee_user_ids;
} }
} }
@ -479,10 +492,42 @@ export const WorkflowDefinitionModal = ({
} }
} }
} catch (err: any) { } catch (err: any) {
showToast.error( const backendErrors = err?.response?.data?.details;
err?.response?.data?.error?.message || if (Array.isArray(backendErrors)) {
`Failed to ${isEdit ? "update" : "create"} workflow`, 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 { } finally {
setIsSubmitting(false); 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 const response = tenantId
? await roleService.getByTenant(tenantId, page, limit) ? await roleService.getByTenant(tenantId, page, limit, null, search)
: await roleService.getAll(page, limit); : await roleService.getAll(page, limit, null, search);
if (response.success) { if (response.success) {
return { return {
options: response.data.map((r: any) => ({ options: response.data.map((r: any) => ({
value: r.code, value: r.id,
label: r.name, label: `${r.name} (${r.code})`,
})), })),
pagination: response.pagination, 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) => const tabClasses = (tab: typeof activeTab) =>
cn( cn(
"px-6 py-3 text-sm font-medium border-b-2 transition-colors focus:outline-none", "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 // When switching TO terminal, clear all hidden fields
if (val === "terminal") { if (val === "terminal") {
setValue(`steps.${index}.assignee_type`, undefined); setValue(`steps.${index}.assignee_type`, undefined);
setValue(`steps.${index}.assignee_role`, []); setValue(`steps.${index}.assignee_role_ids`, []);
setValue(`steps.${index}.assignee_id`, ""); setValue(`steps.${index}.assignee_user_ids`, []);
setValue(`steps.${index}.available_actions`, []); setValue(`steps.${index}.available_actions`, []);
} }
}} }}
@ -840,8 +917,8 @@ export const WorkflowDefinitionModal = ({
value={field.value} value={field.value}
onValueChange={(val) => { onValueChange={(val) => {
field.onChange(val); field.onChange(val);
setValue(`steps.${index}.assignee_role`, []); setValue(`steps.${index}.assignee_role_ids`, []);
setValue(`steps.${index}.assignee_id`, ""); setValue(`steps.${index}.assignee_user_ids`, []);
}} }}
error={ error={
(errors.steps as any)?.[index]?.assignee_type (errors.steps as any)?.[index]?.assignee_type
@ -860,7 +937,7 @@ export const WorkflowDefinitionModal = ({
errors={errors} errors={errors}
isEdit={isEdit} isEdit={isEdit}
loadRoles={loadRoles} 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"> <span className="text-[12px] text-[#64748B] font-medium leading-[16px] truncate">
{task.step.name} {" "} {task.step.name} {" "}
{(() => { {task.assignment?.assigned_to_name || (task.assignment?.assigned_role_ids?.length ? `${task.assignment.assigned_role_ids.length} roles` : "Unassigned")}
const role = task.assignment?.assigned_role;
if (role) return Array.isArray(role) ? role.join(", ") : role;
return task.assignment?.assigned_to_name || "Unassigned";
})()}
</span> </span>
</div> </div>

View File

@ -136,13 +136,14 @@ const Tasks = (): ReactElement => {
), ),
}, },
{ {
key: "assigned_role", key: "assignment",
label: "Assigned To", label: "Assigned To",
render: (task) => { render: (task) => {
const role = task.assignment.assigned_role; const user = task.assignment.assigned_to_name;
const roleIds = task.assignment.assigned_role_ids;
return ( return (
<span className="text-gray-600"> <span className="text-gray-600">
{Array.isArray(role) ? role.join(", ") : (role || "-")} {user || (roleIds && roleIds.length > 0 ? `${roleIds.length} roles` : "-")}
</span> </span>
); );
}, },

View File

@ -1235,6 +1235,7 @@ const ViewDocument = (): ReactElement => {
value={workflowDefinitionId} value={workflowDefinitionId}
onValueChange={setWorkflowDefinitionId} onValueChange={setWorkflowDefinitionId}
placeholder="Select active workflow definition" placeholder="Select active workflow definition"
isSearchable
/> />
{/* <div className="flex flex-col gap-1.5 px-0.5"> {/* <div className="flex flex-col gap-1.5 px-0.5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -1558,7 +1559,7 @@ const ViewDocument = (): ReactElement => {
<td className="px-4 py-3 whitespace-nowrap"> <td className="px-4 py-3 whitespace-nowrap">
<div className="flex items-center gap-1.5 text-xs text-gray-600"> <div className="flex items-center gap-1.5 text-xs text-gray-600">
<User className="w-3 h-3 text-gray-400" /> <User className="w-3 h-3 text-gray-400" />
{task.assigned_to} {task.assigned_to_name || "-"}
</div> </div>
</td> </td>
<td className="px-4 py-3 whitespace-nowrap"> <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); const response = await apiClient.put<UpdateUserResponse>(`/users/${id}`, data);
return response.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> => { // delete: async (id: string): Promise<DeleteUserResponse> => {
// const response = await apiClient.delete<DeleteUserResponse>(`/users/${id}`); // const response = await apiClient.delete<DeleteUserResponse>(`/users/${id}`);
// return response.data; // return response.data;

View File

@ -8,7 +8,10 @@ export interface WorkflowStep {
assignee: { assignee: {
type: 'role' | 'user' | 'originator'; type: 'role' | 'user' | 'originator';
id?: string | null; id?: string | null;
ids?: string[] | null;
user_ids?: string[] | null;
role?: string[] | string | null; role?: string[] | string | null;
role_ids?: string[] | null;
}; };
available_actions: string[]; available_actions: string[];
requires_signature?: boolean; requires_signature?: boolean;
@ -120,7 +123,9 @@ export interface WorkflowInstance {
tasks: Array<{ tasks: Array<{
id: string; id: string;
step: string; step: string;
assigned_to: string; assigned_user_ids?: string[] | null;
assigned_to_name?: string | null;
assigned_role_ids?: string[] | null;
status: string; status: string;
action_taken: string | null; action_taken: string | null;
completed_at: string | null; completed_at: string | null;
@ -172,9 +177,9 @@ export interface WorkflowTask {
name: string; name: string;
}; };
assignment: { assignment: {
assigned_to: string | null; assigned_user_ids?: string[] | null;
assigned_to_name: string | null; assigned_to_name: string | null;
assigned_role: string | string[]; assigned_role_ids?: string[] | null;
assigned_at: string; assigned_at: string;
}; };
status: string; status: string;