feat: enhance form handling and loading states across components

This commit is contained in:
faisolavolut 2025-02-20 08:26:43 +07:00
parent 66e2cdb966
commit 643659a26c
10 changed files with 1197 additions and 66 deletions

View File

@ -219,6 +219,7 @@ export function Popover({
</PopoverTrigger>
<PopoverContent
className={cx(
"pointer-events-auto",
popoverClassName
? popoverClassName
: cx(

View File

@ -18,7 +18,9 @@ export const Field: React.FC<{
name: string;
isBetter?: boolean;
tooltip?: string;
valueKey?: string;
onLoad?: () => Promise<any> | any;
onDelete?: (item: any) => Promise<any> | any;
type?:
| "rating"
| "color"
@ -73,6 +75,8 @@ export const Field: React.FC<{
allowNew,
unique = true,
tooltip,
valueKey,
onDelete,
}) => {
let result = null;
const field = useLocal({
@ -182,6 +186,7 @@ export const Field: React.FC<{
"single-checkbox",
"radio",
"checkbox",
"multi-upload",
].includes(type) &&
css`
border: 0px !important;
@ -197,7 +202,7 @@ export const Field: React.FC<{
<div
// ref={prefixRef}
className={cx(
"px-1 py-1 items-center flex flex-row flex-grow rounded-l-md h-full",
"px-1 py-1 items-center flex flex-row flex-grow rounded-l-md h-full prefix",
css`
height: 2.13rem;
`
@ -231,6 +236,8 @@ export const Field: React.FC<{
mode={"upload"}
type="multi"
disabled={is_disable}
valueKey={valueKey}
onDelete={onDelete}
/>
</>
) : ["dropdown"].includes(type) ? (
@ -354,7 +361,7 @@ export const Field: React.FC<{
<div
// ref={suffixRef}
className={cx(
"px-1 py-1 items-center flex flex-row flex-grow rounded-r-md h-full",
"px-1 py-1 items-center flex flex-row flex-grow rounded-r-md h-full suffix",
css`
height: 2.13rem;
`,

View File

@ -253,6 +253,111 @@ export const FilePreview = ({
</>
);
};
export const FilePreviewBetter = ({
url,
disabled,
filename,
}: {
url: any;
disabled?: boolean;
filename?: string;
}) => {
const file: any = extractFileInfo(filename || url);
const color = colorOfExtension(file.extension);
let content = (
<div
className={cx(
"flex items-center justify-center w-8 h-8 rounded-lg ",
css`
background: ${color?.background};
border: 1px solid ${color?.color};
color: ${color?.color};
border-radius: 3px;
text-transform: uppercase;
padding: 0px 5px;
font-size: 9px;
margin-right: 5px;
`,
"flex items-center"
)}
>
{file.extension}
</div>
);
if (
[".png", ".jpeg", ".jpg", ".webp"].find((e) => file?.fullname.endsWith(e))
) {
content = (
<div className="rounded-lg flex-grow overflow-hidden">
<img
onClick={() => {
let _url = siteurl(url || "");
window.open(_url, "_blank");
}}
className={cx(
"rounded-md w-8 h-8 object-cover",
css`
&:hover {
outline: 2px solid #1c4ed8;
}
`,
css`
background-image: linear-gradient(
45deg,
#ccc 25%,
transparent 25%
),
linear-gradient(135deg, #ccc 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #ccc 75%),
linear-gradient(135deg, transparent 75%, #ccc 75%);
background-size: 25px 25px; /* Must be a square */
background-position: 0 0, 12.5px 0, 12.5px -12.5px, 0px 12.5px; /* Must be half of one side of the square */
`
)}
src={url}
/>
</div>
);
}
return (
<>
{file.extension && (
<div
className={cx(
"flex max-w-full rounded items-center px-1 cursor-pointer flex-grow hover:bg-gray-100 gap-x-1 justify-between",
"pr-2",
css`
&:hover {
// border: 1px solid #1c4ed8;
// outline: 1px solid #1c4ed8;
}
&:hover {
// border-bottom: 1px solid #1c4ed8;
// outline: 1px solid #1c4ed8;
}
`,
disabled ? "bg-transparent" : "bg-white"
)}
onClick={() => {
window.open(url, "_blank");
}}
>
<div className="flex flex-row gap-x-1 items-center">
<div className=" flex flex-row items-center">{content}</div>
<div className="text-xs filename line-clamp-1 break-all">
{file?.name}
</div>
</div>
<div className="ml-2">
<ExternalLink size="12px" />
</div>
</div>
)}
</>
);
};
function darkenColor(color: string, factor: number = 0.5): string {
const rgb = hexToRgb(color);
const r = Math.floor(rgb.r * factor);
@ -314,6 +419,26 @@ const getFileName = (url: string) => {
return { name, extension, fullname };
};
const extractFileInfo = (url: string) => {
let fileName = url.split("/").pop();
if (fileName) {
let parts = fileName.split(".");
let extension = parts.length > 1 ? parts.pop() : "";
let name = parts.join(".");
return {
name: name,
fullname: fileName,
extension: extension,
};
} else {
return {
name: null,
fullname: null,
extension: null,
};
}
};
export const ImgThumb = ({
className,
url,
@ -361,3 +486,35 @@ export const ImgThumb = ({
</div>
);
};
const getRandomColorPair = () => {
const colors = [
{ color: "#dc2626", background: "#fbd5d5" },
{ color: "#2563eb", background: "#dbeafe" },
{ color: "#16a34a", background: "#dcfce7" },
{ color: "#6b7280", background: "#f3f4f6" },
{ color: "#7c3aed", background: "#ede9fe" },
{ color: "#f97316", background: "#ffedd5" },
{ color: "#0d9488", background: "#ccfbf1" },
{ color: "#9333ea", background: "#e9d5ff" },
{ color: "#eab308", background: "#fef9c3" },
];
return colors[Math.floor(Math.random() * colors.length)];
};
const colorOfExtension = (extension: string) => {
const colorMap: any = {
pdf: { color: "#dc2626", background: "#fbd5d5" },
doc: { color: "#2563eb", background: "#dbeafe" },
docx: { color: "#2563eb", background: "#dbeafe" },
xls: { color: "#16a34a", background: "#dcfce7" },
xlsx: { color: "#16a34a", background: "#dcfce7" },
txt: { color: "#6b7280", background: "#f3f4f6" },
zip: { color: "#7c3aed", background: "#ede9fe" },
rar: { color: "#7c3aed", background: "#ede9fe" },
mp4: { color: "#f97316", background: "#ffedd5" },
mp3: { color: "#0d9488", background: "#ccfbf1" },
};
return colorMap[extension] || getRandomColorPair();
};

View File

@ -8,6 +8,8 @@ export const TypeUpload: React.FC<any> = ({
mode,
type,
disabled,
valueKey = "url",
onDelete,
}) => {
if (type === "multi") {
return (
@ -20,6 +22,8 @@ export const TypeUpload: React.FC<any> = ({
fm={fm}
on_change={on_change}
mode={mode}
valueKey={valueKey}
onDelete={onDelete}
/>
</>
);

View File

@ -1,24 +1,24 @@
import get from "lodash.get";
import { Loader2, Paperclip, Trash2, Upload } from "lucide-react";
import { Upload } from "lucide-react";
import { ChangeEvent, FC } from "react";
import * as XLSX from "xlsx";
import { useLocal } from "@/lib/utils/use-local";
import { siteurl } from "@/lib/utils/siteurl";
import { FilePreview } from "./FilePreview";
import { Spinner } from "../../ui/spinner";
import { FilePreviewBetter } from "./FilePreview";
import { MdDelete } from "react-icons/md";
export const FieldUploadMulti: FC<{
field: any;
fm: any;
on_change: (e: any) => void | Promise<void>;
mode?: "upload";
}> = ({ field, fm, on_change, mode }) => {
valueKey?: string;
onDelete?: (e: any) => any | Promise<any>;
}> = ({ field, fm, on_change, mode, valueKey = "url", onDelete }) => {
const styling = "mini";
const disabled = field?.disabled || false;
let value: any = fm.data?.[field.name];
// let type_upload =
const input = useLocal({
value: 0 as any,
value: [] as any[],
display: false as any,
ref: null as any,
drop: false as boolean,
@ -32,32 +32,6 @@ export const FieldUploadMulti: FC<{
try {
file = event.target?.files?.[0];
} catch (ex) {}
const upload_single = async (file: File) => {
return { url: `/dog.jpg` };
const formData = new FormData();
formData.append("file", file);
const url = "/api/upload";
const response = await fetch(url, {
method: "POST",
body: formData,
});
if (response.ok) {
const contentType: any = response.headers.get("content-type");
let result;
if (contentType.includes("application/json")) {
result = await response.json();
} else if (contentType.includes("text/plain")) {
result = await response.text();
} else {
result = await response.blob();
}
if (Array.isArray(result)) {
return `_file${get(result, "[0]")}`;
}
}
throw new Error("Upload Failed");
};
if (event.target.files) {
const list = [] as any[];
@ -70,13 +44,13 @@ export const FieldUploadMulti: FC<{
list.push({
name: file.name,
data: file,
[valueKey]: `${URL.createObjectURL(file)}`,
});
}
}
fm.data[field.name] = list;
fm.render();
on_change(fm.data?.[field.name]);
if (typeof on_change === "function") on_change(fm.data?.[field.name]);
input.fase = "start";
input.render();
}
@ -85,7 +59,111 @@ export const FieldUploadMulti: FC<{
input.ref.value = null;
}
};
return (
<div className="flex-grow flex-col flex w-full h-full items-stretch relative">
{!disabled ? (
<>
{" "}
<div className="flex flex-wrap py-1 pb-2">
<div className=" relative flex focus-within:border focus-within:border-primary border border-gray-300 rounded-md ">
<div
className={cx(
"hover:bg-gray-50 text-gray-900 text-md rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-1.5 ",
css`
input[type="file"],
input[type="file"]::-webkit-file-upload-button {
cursor: pointer;
}
`,
disabled && "bg-gray-50"
)}
>
{!disabled && (
<input
ref={(ref) => {
if (ref) input.ref = ref;
}}
type="file"
multiple={true}
// accept={field.prop.upload?.accept}
accept={"file/**"}
onChange={on_upload}
className={cx(
"absolute w-full h-full cursor-pointer top-0 left-0 opacity-0"
)}
/>
)}
{!disabled ? (
<div
onClick={() => {
if (input.ref) {
input.ref.click();
}
}}
className="items-center flex text-base px-1 outline-none rounded cursor-pointer flex-row justify-center"
>
<div className="flex flex-row items-center px-2">
<Upload className="h-4 w-4" />
</div>
<div className="flex flex-row items-center text-sm">
Add File
</div>
</div>
) : (
<div className="flex flex-row items-center px-1.5 text-sm">
-
</div>
)}
</div>
</div>
</div>
</>
) : (
<></>
)}
<div className="flex flex-wrap gap-2">
{Array.isArray(value) && value?.length ? (
<>
{value.map((e: any, idx: number) => {
return (
<div className="flex flex-col" key={`files-${name}-${idx}`}>
<div className="flex flex-row items-center w-64 p-2 border rounded-lg shadow-sm bg-white">
<div className="flex flex-grow flex-row items-center">
<div className="flex flex-grow">
<FilePreviewBetter
url={e?.[valueKey]}
filename={e?.name}
disabled={disabled}
/>
</div>
<div
className="hover:bg-gray-100 p-2 rounded-lg cursor-pointer"
onClick={() => {
fm.data[field.name] = value.filter(
(_, i) => i !== idx
);
fm.render();
if (typeof on_change === "function")
on_change(fm.data?.[field.name]);
if (typeof onDelete === "function") onDelete(e);
}}
>
<MdDelete className="w-4 h-4 text-red-500" />
</div>
</div>
</div>
</div>
);
})}
</>
) : (
<></>
)}
</div>
</div>
);
return (
<div className="flex-grow flex-col flex w-full h-full items-stretch p-1">
<div

View File

@ -0,0 +1,836 @@
import {
FC,
KeyboardEvent,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { useLocal } from "@/lib/utils/use-local";
import { TypeaheadOptions } from "./typeahead-opt";
import { Badge } from "../../ui/badge";
import { GoChevronDown } from "react-icons/go";
import { IoCloseOutline } from "react-icons/io5";
import { X } from "lucide-react";
import uniqBy from "lodash.uniqby";
type OptItem = { value: string; label: string; tag?: string };
export const TypeaheadBetter: FC<{
fitur?: "search-add";
value?: string[] | null;
placeholder?: string;
required?: boolean;
options?: (arg: {
search: string;
existing: OptItem[];
}) => (string | OptItem)[] | Promise<(string | OptItem)[]>;
onSelect?: (arg: { search: string; item?: null | OptItem }) => string | false;
onChange?: (selected: string[]) => void;
unique?: boolean;
allowNew?: boolean;
className?: string;
popupClassName?: string;
localSearch?: boolean;
autoPopupWidth?: boolean;
focusOpen?: boolean;
disabled?: boolean;
mode?: "multi" | "single";
note?: string;
disabledSearch?: boolean;
onInit?: (e: any) => void;
isBetter?: boolean;
}> = ({
value,
fitur,
note,
options: options_fn,
onSelect,
unique,
allowNew: allow_new,
focusOpen: on_focus_open,
localSearch: local_search,
autoPopupWidth: auto_popup_width,
placeholder,
mode,
disabled,
onChange,
className,
popupClassName,
disabledSearch,
onInit,
isBetter = false,
}) => {
const maxLength = 4;
const [searchTerm, setSearchTerm] = useState("");
const [debouncedTerm, setDebouncedTerm] = useState("");
const local = useLocal({
value: [] as string[],
open: false,
options: [] as OptItem[],
loaded: false,
loading: false,
selectBetter: {
all: false,
partial: [] as any[],
},
search: {
input: "",
timeout: null as any,
searching: false,
promise: null as any,
result: null as null | OptItem[],
},
unique: typeof unique === "undefined" ? true : unique,
allow_new: typeof allow_new === "undefined" ? false : allow_new,
on_focus_open: typeof on_focus_open === "undefined" ? true : on_focus_open,
local_search: typeof local_search === "undefined" ? true : local_search,
mode: typeof mode === "undefined" ? "multi" : mode,
auto_popup_width:
typeof auto_popup_width === "undefined" ? false : auto_popup_width,
select: null as null | OptItem,
});
const input = useRef<HTMLInputElement>(null);
let select_found = false;
let options = [...(local.search.result || local.options)];
const added = new Set<string>();
if (local.mode === "multi") {
options = options.filter((e) => {
if (!added.has(e.value)) added.add(e.value);
else return false;
if (local.select && local.select.value === e.value) select_found = true;
if (local.unique) {
if (local.value.includes(e.value)) {
return false;
}
}
return true;
});
if (Array.isArray(value) && value?.length) {
if (!select_found) {
local.select = options[0];
}
}
}
useEffect(() => {
if (!value) return;
if (options.length === 0) {
loadOptions().then(() => {
if (typeof value === "object" && value) {
local.value = value;
local.render();
} else if (typeof value === "string") {
local.value = [value];
local.render();
}
});
} else {
if (typeof value === "object" && value) {
local.value = value;
local.render();
} else {
local.value = [];
local.render();
}
}
}, [value]);
const select = useCallback(
(arg: { search: string; item?: null | OptItem }) => {
if (!local.allow_new) {
let found = null;
if (!arg.item) {
found = options.find((e) => e.value === arg.search);
} else {
found = options.find((e) => e.value === arg.item?.value);
}
if (!found) {
return false;
}
}
if (local.unique) {
let found = local.value.find((e) => {
return e === arg.item?.value || arg.search === e;
});
if (found) {
return false;
}
}
if (local.mode === "single") {
local.value = [];
}
if (typeof onSelect === "function") {
const result = onSelect(arg);
if (result) {
local.value.push(result);
local.render();
if (typeof onChange === "function") {
onChange(local.value);
}
return result;
} else {
return false;
}
} else {
let val = false as any;
if (arg.item) {
local.value.push(arg.item.value);
val = arg.item.value;
} else {
if (!arg.search) return false;
local.value.push(arg.search);
val = arg.search;
}
if (typeof onChange === "function") {
onChange(local.value);
}
local.render();
return val;
}
return true;
},
[onSelect, local.value, options]
);
const keydown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Backspace") {
if (local.value.length > 0 && e.currentTarget.selectionStart === 0) {
local.value.pop();
local.render();
}
}
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
const selected = select({
search: local.search.input,
item: local.select,
});
if (local.mode === "single") {
local.open = false;
}
if (typeof selected === "string") {
if (!allow_new) resetSearch();
if (local.mode === "single") {
const item = options.find((item) => item.value === selected);
if (item) {
local.search.input = item.label;
}
}
}
local.render();
return;
}
if (options.length > 0) {
local.open = true;
if (e.key === "ArrowDown") {
e.preventDefault();
e.stopPropagation();
const idx = options.findIndex((item) => {
if (item.value === local.select?.value) return true;
});
if (idx >= 0) {
if (idx + 1 <= options.length - 1) {
local.select = options[idx + 1];
} else {
local.select = options[0];
}
} else {
local.select = options[0];
}
local.render();
}
if (e.key === "ArrowUp") {
e.preventDefault();
e.stopPropagation();
const idx = options.findIndex((item) => {
if (item.value === local.select?.value) return true;
});
if (idx >= 0) {
if (idx - 1 >= 0) {
local.select = options[idx - 1];
} else {
local.select = options[options.length - 1];
}
} else {
local.select = options[0];
}
local.render();
}
}
},
[local.value, local.select, select, options, local.search.input]
);
const loadOptions = useCallback(async () => {
if (typeof options_fn === "function" && !local.loading) {
local.loading = true;
local.loaded = false;
local.render();
const res = options_fn({
search: local.search.input,
existing: options,
});
if (res) {
const applyOptions = (result: (string | OptItem)[]) => {
local.options = result.map((item) => {
if (typeof item === "string") return { value: item, label: item };
return item;
});
local.render();
};
if (res instanceof Promise) {
const result = await res;
applyOptions(result);
} else {
applyOptions(res);
}
local.loaded = true;
local.loading = false;
local.render();
}
}
}, [options_fn]);
useEffect(() => {
if (typeof onInit === "function") {
onInit({
reload: async () => {
if (typeof options_fn === "function" && !local.loading) {
local.loading = true;
local.loaded = false;
local.render();
const res = options_fn({
search: local.search.input,
existing: options,
});
if (res) {
const applyOptions = (result: (string | OptItem)[]) => {
local.options = result.map((item) => {
if (typeof item === "string")
return { value: item, label: item };
return item;
});
local.render();
};
if (res instanceof Promise) {
const result = await res;
applyOptions(result);
} else {
applyOptions(res);
}
local.loaded = true;
local.loading = false;
local.render();
}
}
},
});
}
}, []);
// Debounce effect
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedTerm(searchTerm); // Update debounced term after 1 second
}, 100);
return () => clearTimeout(timer); // Clear timeout if user types again
}, [searchTerm]);
// Function to handle search
useEffect(() => {
if (debouncedTerm) {
performSearch(debouncedTerm);
}
}, [debouncedTerm]);
const performSearch = (value: any) => {
if (typeof onSelect === "function") {
const result = onSelect({
search: value,
item: {
label: value,
value: value,
},
});
if (result) {
local.value.push(result);
local.render();
if (typeof onChange === "function") {
onChange(local.value);
}
return result;
} else {
return false;
}
}
// Lakukan pencarian, panggil API, atau filter data di sini
};
const resetSearch = () => {
local.search.searching = false;
local.search.input = "";
local.search.promise = null;
local.search.result = null;
local.select = null;
clearTimeout(local.search.timeout);
};
if (local.mode === "single" && local.value.length > 1) {
local.value = [local.value.pop() || ""];
}
if (local.value.length === 0) {
if (local.mode === "single") {
if (!local.open && !allow_new) {
local.select = null;
local.search.input = "";
}
}
}
const valueLabel = uniqBy(
local.value?.map((value) => {
if (local.mode === "single") {
const item = options.find((item) => item.value === value);
if (!local.open && !allow_new) {
local.select = item || null;
local.search.input = item?.tag || item?.label || "";
}
return item;
}
const item = local.options.find((e) => e.value === value);
return item;
}),
"value"
);
let inputval = local.search.input;
if (!local.open && local.mode === "single" && local.value?.length > 0) {
const found = options.find((e) => e.value === local.value[0]);
if (found) {
inputval = found.tag || found.label;
} else {
inputval = local.value[0];
}
}
useEffect(() => {
if (allow_new && local.open) {
local.search.input = local.value[0];
local.render();
}
}, [local.open]);
return (
<div className="flex flex-row flex-grow w-full relative">
<div
className={cx(
allow_new
? "cursor-text"
: local.mode === "single"
? "cursor-pointer"
: "cursor-text",
"text-black flex relative flex-wrap py-0 items-center w-full h-full flex-1 ",
className
)}
onClick={() => {
if (!disabled) input.current?.focus();
}}
>
{local.mode === "multi" ? (
<div
className={cx(
css`
margin-top: 5px;
margin-bottom: -3px;
display: flex;
flex-wrap: wrap;
`
)}
>
{valueLabel.map((e, idx) => {
return (
<Badge
key={idx}
variant={"outline"}
className={cx(
"space-x-1 mr-2 mb-2 bg-white",
!disabled &&
" cursor-pointer hover:bg-red-100 hover:border-red-100"
)}
onClick={(ev) => {
if (!disabled) {
ev.stopPropagation();
ev.preventDefault();
local.value = local.value.filter(
(val) => e?.value !== val
);
local.render();
input.current?.focus();
if (typeof onChange === "function") {
onChange(local.value);
}
}
}}
>
<div className="text-xs">
{e?.tag || e?.label || <>&nbsp;</>}
</div>
{!disabled && <IoCloseOutline size={12} />}
</Badge>
);
})}
</div>
) : (
<></>
)}
<TypeaheadOptions
fitur={fitur}
popup={true}
onOpenChange={(open) => {
if (!open) {
local.select = null;
}
local.open = open;
local.render();
if (!open) {
resetSearch();
}
}}
onRemove={(data) => {
local.value = local.value.filter((val) => data?.value !== val);
local.render();
input.current?.focus();
if (typeof onChange === "function") {
onChange(local.value);
}
}}
onSelectAll={(data: boolean) => {
local.value = data ? options.map((e) => e?.value) : [];
local.render();
input.current?.focus();
if (typeof onChange === "function") {
onChange(local.value);
}
}}
init={local}
isBetter={isBetter}
loading={local.loading}
showEmpty={!allow_new}
className={popupClassName}
open={local.open}
options={options}
searching={local.search.searching}
searchText={local.search.input}
onSearch={async (e) => {
const val = e.currentTarget.value;
if (!local.open) {
local.open = true;
}
local.search.input = val;
local.render();
if (local.search.promise) {
await local.search.promise;
}
local.search.searching = true;
local.render();
if (allow_new) {
setSearchTerm(val);
}
if (local.search.searching) {
if (local.local_search) {
if (!local.loaded) {
await loadOptions();
}
const search = local.search.input.toLowerCase();
if (search) {
local.search.result = options.filter((e) =>
e.label.toLowerCase().includes(search)
);
if (
local.search.result.length > 0 &&
!local.search.result.find(
(e) => e.value === local.select?.value
)
) {
}
} else {
local.search.result = null;
}
local.search.searching = false;
local.render();
} else {
clearTimeout(local.search.timeout);
local.search.timeout = setTimeout(async () => {
const result = options_fn?.({
search: local.search.input,
existing: options,
});
if (result) {
if (result instanceof Promise) {
local.search.promise = result;
local.search.result = (await result).map((item) => {
if (typeof item === "string")
return { value: item, label: item };
return item;
});
local.search.searching = false;
local.search.promise = null;
} else {
local.search.result = result.map((item) => {
if (typeof item === "string")
return { value: item, label: item };
return item;
});
local.search.searching = false;
}
if (
local.search.result.length > 0 &&
!local.search.result.find(
(e) => e.value === local.select?.value
)
) {
}
local.render();
}
}, 100);
}
}
}}
onSelect={(value) => {
if (!isBetter) local.open = false;
resetSearch();
const item = options.find((item) => item.value === value);
if (item) {
let search = local.search.input;
if (local.mode === "single") {
local.search.input = item.tag || item.label;
} else {
local.search.input = "";
}
select({
search,
item,
});
}
local.render();
}}
width={
local.auto_popup_width ? input.current?.offsetWidth : undefined
}
isMulti={local.mode === "multi"}
selected={({ item, options, idx }) => {
// console.log(local.select);
if (isBetter) {
const val = local.value?.length ? local.value : [];
let isSelect = options.find((e) => {
return (
e?.value === item?.value &&
val.find((ex) => ex === item?.value)
);
});
return isSelect ? true : false;
} else if (item.value === local.select?.value) {
return true;
}
return false;
}}
>
<div
className={cx(
allow_new ? "cursor-text" : "cursor-pointer",
"single flex-1 flex-grow flex flex-row"
)}
onClick={(e) => {
e.stopPropagation();
if (!disabled) {
if (!local.open) {
if (local.on_focus_open) {
loadOptions();
local.open = true;
local.render();
// if (allow_new) {
// local.search.input = inputval;
// local.render();
// }
}
}
if (local.mode === "single") {
if (input && input.current) input.current.select();
}
}
}}
>
{isBetter ? (
<div className="h-9 flex-grow flex flex-row items-start">
<div className="flex flex-grow"></div>
<div className="h-9 flex flex-row items-center px-2">
<GoChevronDown size={14} />
</div>
</div>
) : (
<input
placeholder={
local.mode === "multi"
? placeholder
: valueLabel[0]?.label || placeholder
}
type="text"
ref={input}
value={inputval}
onChange={async (e) => {
const val = e.currentTarget.value;
if (!local.open) {
local.open = true;
}
local.search.input = val;
local.render();
if (local.search.promise) {
await local.search.promise;
}
local.search.searching = true;
local.render();
if (allow_new) {
setSearchTerm(val);
}
if (local.search.searching) {
if (local.local_search) {
if (!local.loaded) {
await loadOptions();
}
const search = local.search.input.toLowerCase();
if (search) {
local.search.result = options.filter((e) =>
e.label.toLowerCase().includes(search)
);
if (
local.search.result.length > 0 &&
!local.search.result.find(
(e) => e.value === local.select?.value
)
) {
local.select = local.search.result[0];
}
} else {
local.search.result = null;
}
local.search.searching = false;
local.render();
} else {
clearTimeout(local.search.timeout);
local.search.timeout = setTimeout(async () => {
const result = options_fn?.({
search: local.search.input,
existing: options,
});
if (result) {
if (result instanceof Promise) {
local.search.promise = result;
local.search.result = (await result).map((item) => {
if (typeof item === "string")
return { value: item, label: item };
return item;
});
local.search.searching = false;
local.search.promise = null;
} else {
local.search.result = result.map((item) => {
if (typeof item === "string")
return { value: item, label: item };
return item;
});
local.search.searching = false;
}
if (
local.search.result.length > 0 &&
!local.search.result.find(
(e) => e.value === local.select?.value
)
) {
local.select = local.search.result[0];
}
local.render();
}
}, 100);
}
}
}}
disabled={!disabled ? disabledSearch : disabled}
spellCheck={false}
className={cx(
"text-black flex h-9 w-full border-input bg-transparent px-3 py-1 text-base border-none shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground md:text-sm focus:outline-none focus:ring-0",
local.mode === "single" ? "cursor-pointer" : ""
)}
style={{
pointerEvents: disabledSearch ? "none" : "auto", // Mencegah input menangkap klik saat disabled
}}
onKeyDown={keydown}
/>
)}
</div>
</TypeaheadOptions>
</div>
{local.mode === "single" && fitur !== "search-add" && (
<>
<div
className={cx(
"typeahead-arrow absolute z-10 inset-0 left-auto flex items-center ",
" justify-center w-6 mr-1 my-2 bg-transparant",
disabled ? "hidden" : "cursor-pointer"
)}
onClick={() => {
if (!disabled) {
local.value = [];
local.render();
if (typeof onChange === "function") onChange(local.value);
}
}}
>
{inputval ? <X size={14} /> : <GoChevronDown size={14} />}
</div>
</>
)}
</div>
);
};

View File

@ -41,6 +41,7 @@ export const ListBetter: React.FC<any> = ({
paging: 1,
maxPage: 1,
count: 0 as any,
ready: false,
addRow: (row: any) => {
setData((prev) => [...prev, row]);
local.data.push(row);
@ -83,10 +84,13 @@ export const ListBetter: React.FC<any> = ({
{"Loading..."}
</>
);
local.ready = false;
local.render();
if (typeof onCount === "function") {
const res = await onCount();
local.count = res;
local.maxPage = Math.ceil(res / take);
local.paging = 1;
local.render();
}
if (Array.isArray(onLoad)) {
@ -108,6 +112,8 @@ export const ListBetter: React.FC<any> = ({
toast.dismiss();
}, 100);
}
local.ready = true;
local.render();
},
reload: async () => {
toast.info(
@ -176,9 +182,10 @@ export const ListBetter: React.FC<any> = ({
{"Loading..."}
</>
);
local.ready = false;
local.render();
if (typeof onCount === "function") {
const res = await onCount();
console.log(res, take, Math.ceil(res / take));
setMaxPage(Math.ceil(res / take));
local.maxPage = Math.ceil(res / take);
local.count = res;
@ -213,6 +220,8 @@ export const ListBetter: React.FC<any> = ({
if (typeof onInit === "function") {
onInit(local);
}
local.ready = true;
local.render();
setTimeout(() => {
toast.dismiss();
}, 100);
@ -246,25 +255,33 @@ export const ListBetter: React.FC<any> = ({
className="w-full h-full flex flex-col gap-y-4 p-4"
reload={reload}
>
<div className="flex-grow flex flex-col gap-y-4">
{Array.isArray(local.data) && local.data?.length ? (
local.data?.map((e, idx) => {
return (
<div
className="flex flex-col w-full"
key={`items-${name}-${idx}`}
ref={local.data?.length === idx + 1 ? lastPostRef : null}
>
{typeof content === "function"
? content({ item: e, idx, tbl: local })
: content}
</div>
);
})
) : (
<></>
)}
</div>
{!local.ready ? (
<>
<div className="flex-grow h-full flex-grow flex flex-row items-center justify-center">
<div className="spinner-better"></div>
</div>
</>
) : (
<div className="flex-grow flex flex-col gap-y-4">
{Array.isArray(local.data) && local.data?.length ? (
local.data?.map((e, idx) => {
return (
<div
className="flex flex-col w-full"
key={`items-${name}-${idx}`}
ref={local.data?.length === idx + 1 ? lastPostRef : null}
>
{typeof content === "function"
? content({ item: e, idx, tbl: local })
: content}
</div>
);
})
) : (
<></>
)}
</div>
)}
</ScrollArea>
</div>
</>

View File

@ -6,8 +6,10 @@ import { cn } from "@/lib/utils";
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> & {
classNameIndicator?: any; // Trigger to force height update
}
>(({ className, value, classNameIndicator, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
@ -17,7 +19,10 @@ const Progress = React.forwardRef<
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all indicator"
className={cn(
"h-full w-full flex-1 bg-linear-progress transition-all indicator rounded-full",
classNameIndicator
)}
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>

View File

@ -2,7 +2,7 @@ import get from "lodash.get";
import api from "./axios";
type apixType = {
port: "portal" | "recruitment" | "mpp" | "public";
port: "portal" | "recruitment" | "mpp" | "public" | "onboarding";
path: string;
method?: "get" | "delete" | "post" | "put";
data?: any;
@ -36,6 +36,8 @@ export const apix = async ({
? process.env.NEXT_PUBLIC_API_RECRUITMENT
: port === "mpp"
? process.env.NEXT_PUBLIC_API_MPP
: port === "onboarding"
? process.env.NEXT_PUBLIC_API_ONBOARDING
: port === "public"
? process.env.NEXT_PUBLIC_BASE_URL
: ""
@ -49,10 +51,20 @@ export const apix = async ({
const requestData =
type === "form" && data
? Object.entries(data as any).reduce((formData, [key, value]) => {
formData.append(
key.includes("certificate") ? key : key.replace(/\[\d+\]/, ""),
value as any
);
if (Array.isArray(value) && value?.length) {
value.map((item: any) => {
formData.append(key, item as any);
});
} else if (value instanceof FormData) {
value.forEach((value, key) => {
formData.append(key, value);
});
} else {
formData.append(
key.includes("certificate") ? key : key.replace(/\[\d+\]/, ""),
value as any
);
}
return formData;
}, new FormData())
: data;

14
utils/convetForm.ts Normal file
View File

@ -0,0 +1,14 @@
export const convertForm = ({
data,
task,
}: {
data: any[];
task: (item: any, form: any) => void;
}) => {
const form = new FormData();
if (Array.isArray(data) && data?.length) {
data.map((item: any) => {
task(item, form);
});
}
};