- {isLoadingCostumer && (
+
+ {isLoadingProjectFlock && (
)}
- {!isLoadingCostumer && isResponseSuccess(projectFlock) && (
+ {!isLoadingProjectFlock && isResponseSuccess(projectFlock) && (
)}
diff --git a/src/app/production/project-flock/detail/page.tsx b/src/app/production/project-flock/detail/page.tsx
index 6cf694c0..91d4dfd5 100644
--- a/src/app/production/project-flock/detail/page.tsx
+++ b/src/app/production/project-flock/detail/page.tsx
@@ -2,7 +2,7 @@
import ProjectFlockForm from '@/components/pages/production/project-flock/form/ProjectFlockForm';
import { isResponseError, isResponseSuccess } from '@/lib/api-helper';
-import { ProjectFlockApi } from '@/services/api/production';
+import { ProjectFlockApi } from '@/services/api/production/project-flock';
import { useRouter, useSearchParams } from 'next/navigation';
import useSWR from 'swr';
@@ -37,11 +37,11 @@ const ProjectFlockDetail = () => {
}
return (
-
+
{isLoadingProjectFlock && (
)}
- {!isLoadingProjectFlock && isResponseSuccess(projectFlock) && (
+ {isResponseSuccess(projectFlock) && (
, 'className'> {
+ tabs: TabItem[];
+ variant?: 'bordered' | 'lifted' | 'boxed';
+ size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
+ placement?: 'top' | 'bottom';
+ /** Tab yang aktif secara default (uncontrolled mode) */
+ defaultActiveId?: string;
+ /** Tab yang aktif (controlled mode, dikontrol parent) */
+ activeTabId?: string;
+ className?:
+ | string
+ | {
+ wrapper?: string;
+ tab?: string;
+ content?: string;
+ };
+ onTabChange?: (tabId: string) => void;
+}
+
+const Tabs = ({
+ tabs,
+ variant,
+ size = 'md',
+ placement = 'top',
+ defaultActiveId,
+ activeTabId: controlledActiveId,
+ className,
+ onTabChange,
+ ...props
+}: TabsProps) => {
+ // State internal hanya dipakai kalau `activeTabId` (controlled) tidak diset
+ const [uncontrolledActiveId, setUncontrolledActiveId] = useState(
+ defaultActiveId || tabs[0]?.id || ''
+ );
+
+ const isControlled = controlledActiveId !== undefined;
+ const activeTabId = isControlled ? controlledActiveId : uncontrolledActiveId;
+
+ const handleTabChange = (tabId: string) => {
+ if (tabId === activeTabId) return;
+ if (!isControlled) setUncontrolledActiveId(tabId);
+ onTabChange?.(tabId);
+ };
+
+ const { wrapper: wrapperClassName, tab: tabClassName } =
+ typeof className === 'object'
+ ? className
+ : { wrapper: className, tab: undefined };
+
+ const getTabsClasses = () => {
+ const variantClasses: Record = {
+ bordered: 'tabs-bordered',
+ lifted: 'tabs-lift',
+ boxed: 'tabs-box',
+ };
+
+ const sizeClasses: Record = {
+ xs: 'tabs-xs',
+ sm: 'tabs-sm',
+ md: '',
+ lg: 'tabs-lg',
+ xl: 'tabs-xl',
+ };
+
+ const placementClasses: Record = {
+ top: '',
+ bottom: 'tabs-bottom',
+ };
+
+ return cn(
+ 'tabs',
+ variant && variantClasses[variant],
+ sizeClasses[size],
+ placementClasses[placement],
+ wrapperClassName
+ );
+ };
+
+ const getTabClasses = (isActive: boolean, isDisabled?: boolean) =>
+ cn(
+ 'tab',
+ {
+ 'tab-active': isActive,
+ 'tab-disabled': isDisabled,
+ },
+ tabClassName
+ );
+
+ const activeContent = tabs.find((tab) => tab.id === activeTabId)?.content;
+
+ return (
+
+
+ {tabs.map(({ id, label, disabled }) => (
+
+ ))}
+
+
+ {activeContent &&
{activeContent}
}
+
+ );
+};
+
+export default Tabs;
diff --git a/src/components/helper/form/FormHeader.tsx b/src/components/helper/form/FormHeader.tsx
index ebc1d7ae..de7ec882 100644
--- a/src/components/helper/form/FormHeader.tsx
+++ b/src/components/helper/form/FormHeader.tsx
@@ -2,15 +2,27 @@ import Button from '@/components/Button';
import { Icon } from '@iconify/react';
interface FormHeaderProps {
- type: 'add' | 'edit' | 'detail';
+ type?: 'add' | 'edit' | 'detail';
title: string;
- backUrl: string;
+ backUrl?: string;
+ onBackClick?: () => void;
}
-export const FormHeader = ({ type, title, backUrl }: FormHeaderProps) => {
+export const FormHeader = ({
+ type,
+ title,
+ backUrl,
+ onBackClick,
+}: FormHeaderProps) => {
return (
-
);
diff --git a/src/components/input/PatternInput.tsx b/src/components/input/PatternInput.tsx
new file mode 100644
index 00000000..9af1b68e
--- /dev/null
+++ b/src/components/input/PatternInput.tsx
@@ -0,0 +1,90 @@
+'use client';
+
+import { ChangeEvent } from 'react';
+import {
+ PatternFormat,
+ NumberFormatBase,
+ NumberFormatBaseProps,
+ OnValueChange,
+} from 'react-number-format';
+import TextInput, { TextInputProps } from '@/components/input/TextInput';
+
+interface PatternInputProps extends Omit {
+ /**
+ * Format pattern, contoh: "##/##/####", "(###) ###-####", "####-####-####"
+ */
+ format: string;
+ /** Mask karakter kosong, misal "_" */
+ mask?: string;
+ /** Menampilkan mask walau value kosong */
+ allowEmptyFormatting?: boolean;
+ /** Placeholder karakter format, default: "#" */
+ patternChar?: string;
+ /** Jika true, izinkan huruf (A-Z) selain angka */
+ inputVehicleNumber?: boolean;
+ type?: 'text' | 'password' | 'tel';
+}
+
+/**
+ * PatternInput – tetap backward-compatible dengan Storybook
+ * tapi bisa menerima huruf jika `allowCharacters={true}`
+ */
+const PatternInput = ({
+ type = 'text',
+ format,
+ mask = '_',
+ allowEmptyFormatting = false,
+ patternChar = '#',
+ inputVehicleNumber = false,
+ onChange,
+ ...restProps
+}: PatternInputProps) => {
+ const handleValueChange: OnValueChange = (values, { event }) => {
+ const newEvent = event as ChangeEvent | undefined;
+ if (newEvent) {
+ newEvent.target.value = values.value.toUpperCase();
+ onChange?.(newEvent);
+ }
+ };
+
+ if (inputVehicleNumber) {
+ return (
+ {
+ const clean = value.replace(/[^a-z0-9]/gi, '').toUpperCase();
+
+ const match = clean.match(/^([A-Z]{0,2})(\d{0,4})([A-Z]{0,3})$/);
+ if (!match) return clean;
+ const [, prefix, number, suffix] = match;
+ return [prefix, number, suffix].filter(Boolean).join(' ');
+ }}
+ removeFormatting={(val) => val.replace(/\s+/g, '')}
+ isValidInputCharacter={(char) => /^[a-z0-9]$/i.test(char)}
+ getCaretBoundary={(val) =>
+ Array(val.length + 1)
+ .fill(true)
+ .map(Boolean)
+ }
+ onValueChange={handleValueChange}
+ />
+ );
+ }
+
+ return (
+
+ );
+};
+
+export default PatternInput;
diff --git a/src/components/input/SelectInput.tsx b/src/components/input/SelectInput.tsx
index 7887004f..d35e7589 100644
--- a/src/components/input/SelectInput.tsx
+++ b/src/components/input/SelectInput.tsx
@@ -1,22 +1,23 @@
'use client';
import { ComponentType, ReactNode, useEffect, useMemo, useState } from 'react';
-import useSWR from 'swr';
-
import Select, {
OptionProps,
GroupBase,
InputActionMeta,
MultiValue,
SingleValue,
+ components as ReactSelectComponents,
+ ControlProps,
} from 'react-select';
import CreatableSelect from 'react-select/creatable';
import makeAnimated from 'react-select/animated';
import { useDebounce } from 'use-debounce';
import { cn, getByPath } from '@/lib/helper';
+import useSWR from 'swr';
import { httpClientFetcher } from '@/services/http/client';
-import { isResponseSuccess } from '@/lib/api-helper';
import { BaseApiResponse } from '@/types/api/api-general';
+import { isResponseSuccess } from '@/lib/api-helper';
export interface OptionType {
value: string | number;
@@ -53,6 +54,8 @@ interface SelectInputBaseProps {
openMenu?: boolean;
delay?: number;
onInputChange?: (search: string) => void;
+ startAdornment?: ReactNode;
+ menuPortalTarget?: HTMLElement | null;
}
interface SelectInputProps extends SelectInputBaseProps {
@@ -63,6 +66,33 @@ interface SelectInputProps extends SelectInputBaseProps {
const animatedComponents = makeAnimated();
+const CustomControl = <
+ Option,
+ IsMulti extends boolean,
+ Group extends GroupBase