554 lines
16 KiB
JavaScript
554 lines
16 KiB
JavaScript
import React, { useEffect, useRef, useState } from "react";
|
|
import Label from "../Label";
|
|
import { useDebounce } from "../../../utils/appUtils";
|
|
import { useEmployeesName } from "../../../hooks/useEmployees";
|
|
import { useProjectBothName } from "../../../hooks/useProjects";
|
|
import EmployeeRepository from "../../../repositories/EmployeeRepository";
|
|
|
|
const SelectEmployeeServerSide = ({
|
|
label = "Select",
|
|
placeholder = "Select Employee",
|
|
required = false,
|
|
value = null,
|
|
onChange,
|
|
valueKey = "id",
|
|
isFullObject = false,
|
|
isMultiple = false,
|
|
projectId = null,
|
|
isAllEmployee = false,
|
|
}) => {
|
|
const [searchText, setSearchText] = useState("");
|
|
const debounce = useDebounce(searchText, 300);
|
|
const [forcedSelected, setForcedSelected] = useState(null);
|
|
|
|
const { data, isLoading } = useEmployeesName(
|
|
projectId,
|
|
debounce,
|
|
isAllEmployee
|
|
);
|
|
|
|
const options = data?.data ?? [];
|
|
const [open, setOpen] = useState(false);
|
|
const dropdownRef = useRef(null);
|
|
|
|
const getDisplayName = (emp) => {
|
|
if (!emp) return "";
|
|
return `${emp.firstName || ""} ${emp.lastName || ""}`.trim();
|
|
};
|
|
|
|
let selectedSingle = null;
|
|
if (!isMultiple) {
|
|
if (isFullObject && value) selectedSingle = value;
|
|
else if (!isFullObject && value)
|
|
selectedSingle =
|
|
options.find((o) => o[valueKey] === value) || forcedSelected;
|
|
}
|
|
|
|
let selectedList = [];
|
|
if (isMultiple && Array.isArray(value)) {
|
|
if (isFullObject) selectedList = value;
|
|
else {
|
|
selectedList = options.filter((opt) => value.includes(opt[valueKey]));
|
|
}
|
|
}
|
|
|
|
const displayText = !isMultiple
|
|
? getDisplayName(selectedSingle) || placeholder
|
|
: selectedList.length > 0
|
|
? selectedList.map((e) => getDisplayName(e)).join(", ")
|
|
: placeholder;
|
|
|
|
useEffect(() => {
|
|
const handleClickOutside = (e) => {
|
|
if (dropdownRef.current && !dropdownRef.current.contains(e.target)) {
|
|
setOpen(false);
|
|
}
|
|
};
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
}, []);
|
|
|
|
const handleSelect = (option) => {
|
|
if (!isMultiple) {
|
|
if (isFullObject) onChange(option);
|
|
else onChange(option[valueKey]);
|
|
setOpen(false);
|
|
} else {
|
|
let updated = [];
|
|
const exists = selectedList.some((e) => e[valueKey] === option[valueKey]);
|
|
updated = exists
|
|
? selectedList.filter((e) => e[valueKey] !== option[valueKey])
|
|
: [...selectedList, option];
|
|
if (isFullObject) onChange(updated);
|
|
else onChange(updated.map((x) => x[valueKey]));
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!value || isFullObject) return;
|
|
|
|
const exists = options.some((o) => o[valueKey] === value);
|
|
if (exists) return;
|
|
|
|
const loadSingleEmployee = async () => {
|
|
try {
|
|
const emp = await EmployeeRepository.getEmployeeName(
|
|
null,
|
|
null,
|
|
true,
|
|
value
|
|
);
|
|
setForcedSelected(emp.data[0]);
|
|
} catch (err) {
|
|
console.error("Failed to load selected employee", err);
|
|
}
|
|
};
|
|
|
|
loadSingleEmployee();
|
|
}, [value, options, isFullObject, valueKey]);
|
|
|
|
return (
|
|
<div className="mb-3 position-relative" ref={dropdownRef}>
|
|
{label && (
|
|
<Label className="form-label" required={required}>
|
|
{label}
|
|
</Label>
|
|
)}
|
|
|
|
{/* MAIN BUTTON */}
|
|
<button
|
|
type="button"
|
|
className={`select2-icons form-select d-flex align-items-center justify-content-between ${
|
|
open ? "show" : ""
|
|
}`}
|
|
onClick={() => setOpen((prev) => !prev)}
|
|
>
|
|
<span className={`text-truncate ${!displayText ? "text-muted" : ""}`}>
|
|
{displayText}
|
|
</span>
|
|
</button>
|
|
|
|
{open && (
|
|
<ul
|
|
className="dropdown-menu w-100 shadow-sm show animate__fadeIn h-64 overflow-auto rounded"
|
|
style={{
|
|
position: "absolute",
|
|
top: "100%",
|
|
left: 0,
|
|
zIndex: 1050,
|
|
marginTop: "4px",
|
|
borderRadius: "0.375rem",
|
|
padding: 0,
|
|
}}
|
|
>
|
|
<li className="p-1 sticky-top bg-white" style={{ zIndex: 10 }}>
|
|
<input
|
|
type="search"
|
|
value={searchText}
|
|
onChange={(e) => setSearchText(e.target.value)}
|
|
className="form-control form-control-sm"
|
|
placeholder="Search..."
|
|
/>
|
|
</li>
|
|
|
|
{isLoading && (
|
|
<li className="dropdown-item text-muted text-center">Loading...</li>
|
|
)}
|
|
|
|
{!isLoading && options.length === 0 && (
|
|
<li className="dropdown-item text-muted text-center">
|
|
No results found
|
|
</li>
|
|
)}
|
|
|
|
{!isLoading &&
|
|
options.map((option) => {
|
|
const isActive = isMultiple
|
|
? selectedList.some((x) => x[valueKey] === option[valueKey])
|
|
: selectedSingle &&
|
|
selectedSingle[valueKey] === option[valueKey];
|
|
|
|
return (
|
|
<li key={option[valueKey]} className="px-1 rounded">
|
|
<button
|
|
type="button"
|
|
className={`dropdown-item rounded ${
|
|
isActive ? "active" : ""
|
|
}`}
|
|
onClick={() => handleSelect(option)}
|
|
>
|
|
{getDisplayName(option)}
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default SelectEmployeeServerSide;
|
|
|
|
export const SelectProjectField = ({
|
|
label = "Select",
|
|
placeholder = "Select Project",
|
|
required = false,
|
|
value = null,
|
|
onChange,
|
|
valueKey = "id",
|
|
isFullObject = false,
|
|
isMultiple = false,
|
|
isAllProject = false,
|
|
disabled,
|
|
className,
|
|
errors,
|
|
}) => {
|
|
const [searchText, setSearchText] = useState("");
|
|
const debounce = useDebounce(searchText, 300);
|
|
|
|
const { data, isLoading } = useProjectBothName(debounce);
|
|
|
|
const options = data ?? [];
|
|
const [open, setOpen] = useState(false);
|
|
const dropdownRef = useRef(null);
|
|
|
|
const getDisplayName = (project) => {
|
|
if (!project) return "";
|
|
return `${project.name || ""}`.trim();
|
|
};
|
|
|
|
let selectedSingle = null;
|
|
|
|
if (!isMultiple) {
|
|
if (isFullObject && value) selectedSingle = value;
|
|
else if (!isFullObject && value)
|
|
selectedSingle = options.find((o) => o[valueKey] === value);
|
|
}
|
|
|
|
let selectedList = [];
|
|
|
|
if (isMultiple && Array.isArray(value)) {
|
|
if (isFullObject) selectedList = value;
|
|
else {
|
|
selectedList = options.filter((opt) => value.includes(opt[valueKey]));
|
|
}
|
|
}
|
|
|
|
/** Main button label */
|
|
const displayText = !isMultiple
|
|
? getDisplayName(selectedSingle) || placeholder
|
|
: selectedList.length > 0
|
|
? selectedList.map((e) => getDisplayName(e)).join(", ")
|
|
: placeholder;
|
|
|
|
/** -----------------------------
|
|
* HANDLE OUTSIDE CLICK
|
|
* ----------------------------- */
|
|
useEffect(() => {
|
|
const handleClickOutside = (e) => {
|
|
if (dropdownRef.current && !dropdownRef.current.contains(e.target)) {
|
|
setOpen(false);
|
|
}
|
|
};
|
|
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
}, []);
|
|
|
|
/** -----------------------------
|
|
* HANDLE SELECT
|
|
* ----------------------------- */
|
|
const handleSelect = (option) => {
|
|
if (!isMultiple) {
|
|
// SINGLE SELECT
|
|
if (isFullObject) onChange(option);
|
|
else onChange(option[valueKey]);
|
|
} else {
|
|
// MULTIPLE SELECT
|
|
let updated = [];
|
|
|
|
const exists = selectedList.some((e) => e[valueKey] === option[valueKey]);
|
|
|
|
if (exists) {
|
|
// remove
|
|
updated = selectedList.filter((e) => e[valueKey] !== option[valueKey]);
|
|
} else {
|
|
// add
|
|
updated = [...selectedList, option];
|
|
}
|
|
|
|
if (isFullObject) onChange(updated);
|
|
else onChange(updated.map((x) => x[valueKey]));
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="mb-3 position-relative" ref={dropdownRef}>
|
|
{label && (
|
|
<Label className="form-label" required={required}>
|
|
{label}
|
|
</Label>
|
|
)}
|
|
|
|
{/* MAIN BUTTON */}
|
|
<button
|
|
type="button"
|
|
className={`select2-icons form-select d-flex align-items-center justify-content-between ${
|
|
open ? "show" : ""
|
|
}`}
|
|
onClick={() => setOpen((prev) => !prev)}
|
|
disabled={disabled}
|
|
>
|
|
<span className={`text-truncate ${!displayText ? "text-muted" : ""}`}>
|
|
{displayText}
|
|
</span>
|
|
</button>
|
|
|
|
{errors?.projectId && (
|
|
<div className="danger-text">{errors.projectId.message}</div>
|
|
)}
|
|
|
|
{open && (
|
|
<ul
|
|
className="dropdown-menu w-100 shadow-sm show animate__fadeIn h-64 overflow-auto rounded"
|
|
style={{
|
|
position: "absolute",
|
|
top: "100%",
|
|
left: 0,
|
|
zIndex: 1050,
|
|
marginTop: "2px",
|
|
borderRadius: "0.375rem",
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
<div className="p-1">
|
|
<input
|
|
type="search"
|
|
value={searchText}
|
|
onChange={(e) => setSearchText(e.target.value)}
|
|
className="form-control form-control-sm"
|
|
placeholder="Search..."
|
|
/>
|
|
</div>
|
|
<div className="overflow-auto px-1" style={{ maxHeight: "200px" }}>
|
|
|
|
{isLoading && (
|
|
<li className="dropdown-item text-muted text-center">Loading...</li>
|
|
)}
|
|
|
|
{!isLoading && options.length === 0 && (
|
|
<li className="dropdown-item text-muted text-center">
|
|
No results found
|
|
</li>
|
|
)}
|
|
|
|
{!isLoading &&
|
|
options.map((option) => {
|
|
const isActive = isMultiple
|
|
? selectedList.some((x) => x[valueKey] === option[valueKey])
|
|
: selectedSingle &&
|
|
selectedSingle[valueKey] === option[valueKey];
|
|
|
|
return (
|
|
<li key={option[valueKey]} className="px-1 rounded w-full">
|
|
<button
|
|
type="button"
|
|
className={`dropdown-item rounded d-block text-truncate w-100 ${
|
|
isActive ? "active" : ""
|
|
}`}
|
|
onClick={() => handleSelect(option)}
|
|
>
|
|
{getDisplayName(option)}
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</div>
|
|
</ul>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const SelectFieldSearch = ({
|
|
label = "Select",
|
|
placeholder = "Select",
|
|
required = false,
|
|
value = null,
|
|
onChange,
|
|
valueKey = "id",
|
|
labelKey = "name",
|
|
disabled = false,
|
|
isFullObject = false,
|
|
isMultiple = false,
|
|
hookParams,
|
|
useFetchHook,
|
|
errors = null,
|
|
}) => {
|
|
const [searchText, setSearchText] = useState("");
|
|
const debounce = useDebounce(searchText, 300);
|
|
|
|
const { data, isLoading } = useFetchHook(...hookParams, debounce);
|
|
const options = data?.data ?? [];
|
|
const [open, setOpen] = useState(false);
|
|
const dropdownRef = useRef(null);
|
|
|
|
const getDisplayName = (entity) =>
|
|
entity ? `${entity[labelKey] || ""}`.trim() : "";
|
|
|
|
/** ----------------------------- SELECTED OPTION ----------------------------- */
|
|
let selectedSingle = null;
|
|
if (!isMultiple) {
|
|
if (isFullObject && value) selectedSingle = value;
|
|
else if (!isFullObject && value)
|
|
selectedSingle = options.find((o) => o[valueKey] === value);
|
|
}
|
|
|
|
let selectedList = [];
|
|
if (isMultiple && Array.isArray(value)) {
|
|
selectedList = isFullObject
|
|
? value
|
|
: options.filter((opt) => value.includes(opt[valueKey]));
|
|
}
|
|
|
|
const displayText = !isMultiple
|
|
? getDisplayName(selectedSingle) || placeholder
|
|
: selectedList.length
|
|
? selectedList.map((e) => getDisplayName(e)).join(", ")
|
|
: placeholder;
|
|
|
|
/** ----------------------------- OUTSIDE CLICK ----------------------------- */
|
|
useEffect(() => {
|
|
const handleClickOutside = (e) => {
|
|
if (dropdownRef.current && !dropdownRef.current.contains(e.target))
|
|
setOpen(false);
|
|
};
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
}, []);
|
|
|
|
/** ----------------------------- MERGED OPTIONS ----------------------------- */
|
|
const [mergedOptions, setMergedOptions] = useState(options);
|
|
|
|
useEffect(() => {
|
|
let finalList = [...options];
|
|
|
|
if (!isMultiple && value && !isFullObject && typeof value === "object") {
|
|
const exists = options.some((o) => o[valueKey] === value[valueKey]);
|
|
if (!exists) finalList.unshift(value);
|
|
}
|
|
|
|
if (isMultiple && Array.isArray(value)) {
|
|
value.forEach((val) => {
|
|
const id = isFullObject ? val[valueKey] : val;
|
|
const exists = options.some((o) => o[valueKey] === id);
|
|
if (!exists && typeof val === "object") finalList.unshift(val);
|
|
});
|
|
}
|
|
|
|
// Only update if different to avoid infinite loop
|
|
const oldKeys = mergedOptions.map((o) => o[valueKey]).join(",");
|
|
const newKeys = finalList.map((o) => o[valueKey]).join(",");
|
|
if (oldKeys !== newKeys) setMergedOptions(finalList);
|
|
}, [options, value, isMultiple, isFullObject, valueKey, mergedOptions]);
|
|
|
|
/** ----------------------------- HANDLE SELECT ----------------------------- */
|
|
const handleSelect = (option) => {
|
|
if (!isMultiple) {
|
|
onChange(isFullObject ? option : option[valueKey]);
|
|
setOpen(false)
|
|
} else {
|
|
const exists = selectedList.some((e) => e[valueKey] === option[valueKey]);
|
|
const updated = exists
|
|
? selectedList.filter((e) => e[valueKey] !== option[valueKey])
|
|
: [...selectedList, option];
|
|
onChange(isFullObject ? updated : updated.map((x) => x[valueKey]));
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="mb-3 position-relative" ref={dropdownRef}>
|
|
{label && (
|
|
<Label className="form-label" required={required}>
|
|
{label}
|
|
</Label>
|
|
)}
|
|
|
|
<button
|
|
type="button"
|
|
className={`select2-icons form-select d-flex align-items-center justify-content-between ${
|
|
open ? "show" : ""
|
|
}`}
|
|
disabled={disabled}
|
|
onClick={() => setOpen((prev) => !prev)}
|
|
>
|
|
<span className={`text-truncate ${!displayText ? "text-muted" : ""}`}>
|
|
{displayText}
|
|
</span>
|
|
</button>
|
|
|
|
{errors && <div className="danger-text">{errors.message}</div>}
|
|
|
|
{open && (
|
|
<ul
|
|
className="dropdown-menu w-100 shadow-sm show animate__fadeIn h-64 overflow-auto rounded rounded-top-0 overflow-x-hidden"
|
|
style={{
|
|
position: "absolute",
|
|
top: "100%",
|
|
left: 0,
|
|
zIndex: 1050,
|
|
marginTop: "1px",
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
<div className="p-1">
|
|
<input
|
|
type="search"
|
|
value={searchText}
|
|
onChange={(e) => setSearchText(e.target.value)}
|
|
className="form-control form-control-sm"
|
|
placeholder="Search..."
|
|
disabled={disabled}
|
|
/>
|
|
</div>
|
|
<div className="overflow-auto px-1" style={{ maxHeight: "200px" }}>
|
|
{isLoading && (
|
|
<li className="dropdown-item text-muted text-center">
|
|
Loading...
|
|
</li>
|
|
)}
|
|
{!isLoading && mergedOptions.length === 0 && (
|
|
<li className="dropdown-item text-muted text-center">
|
|
No results found
|
|
</li>
|
|
)}
|
|
|
|
{!isLoading &&
|
|
mergedOptions.map((option) => {
|
|
const isActive = isMultiple
|
|
? selectedList.some((x) => x[valueKey] === option[valueKey])
|
|
: selectedSingle &&
|
|
selectedSingle[valueKey] === option[valueKey];
|
|
|
|
return (
|
|
<li key={option[valueKey]}>
|
|
<button
|
|
type="button"
|
|
className={`dropdown-item rounded ${
|
|
isActive ? "active" : ""
|
|
}`}
|
|
onClick={() => handleSelect(option)}
|
|
>
|
|
{getDisplayName(option)}
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</div>
|
|
</ul>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|