Compare commits
2 Commits
c516ea18bc
...
da289b14de
| Author | SHA1 | Date | |
|---|---|---|---|
| da289b14de | |||
| ec10281af9 |
@ -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">
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|||||||
@ -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: _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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user