feat: add search functionality to FormSelect and MultiselectPaginatedSelect, and update task assignment display logic
This commit is contained in:
parent
c516ea18bc
commit
ec10281af9
@ -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">
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
},
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user